知识点思维导图
41 个知识节点
生产工程(08) - AI 应用成本控制:Token、缓存与模型分级
读完后,你应能完成以下任务:
- 绘制“生产工程(08) - AI 应用成本控制:Token、缓存与模型分级 / 先把账算明白:token 怎么变成钱”的关键对象与数据流,解释“这个公式解释了两个优化方向:少喂输入(prompt 别塞无关内容、RAG 别检索一大堆 chunk 全塞进去)和少出输出(让模型答得简洁、设 max_tokens 上限)。”,并用源码位置、日志或 Trace 标注证据。
- 为“生产工程(08) - AI 应用成本控制:Token、缓存与模型分级 / 缓存:性价比最高的降本手段”设计正常与异常输入,验证“命中缓存 = 零成本 + 零延迟,这是最划算的优化。”,输出首个偏差位置与回归测试结果。
- 实现“生产工程(08) - AI 应用成本控制:Token、缓存与模型分级 / LRU:缓存满了淘汰谁”的最小代码或配置,检验“缓存不能无限大,满了得淘汰。”,输出命令、结果与 Diff,并说明不适用边界。
一、AI 应用成本控制的真实应用场景
你的知识库问答上线一个月,财务来问:"这个 AI 助手怎么花了小两万?"
你拉日志一看,发现两件事:一是同样的问题("报销政策""请假流程")被反复问,每次都老老实实调了一遍模型;二是有些简单问题("你好""谢谢")也用了最贵的模型。这两样都是白烧钱。
模型调用按 token 收费,每一次调用都在花钱。成本控制不是上线后才考虑的事,而是设计时就要算的账。 这一篇讲最直接的几个降本手段,其中缓存是性价比最高的。
二、先把账算明白:token 怎么变成钱
模型按 token 收费,而且输入和输出通常不同价,输出更贵:
这个公式解释了两个优化方向:少喂输入(prompt 别塞无关内容、RAG 别检索一大堆 chunk 全塞进去)和少出输出(让模型答得简洁、设 max_tokens 上限)。输出贵,所以控制回答长度比压缩输入更划算。
token 数怎么来?真实项目用 tiktoken 精确分词,粗估可以按「中文约 1 字 1 token,英文约 4 字符 1 token」。
三、缓存:性价比最高的降本手段
很多问题是重复的。同一个问题第二次来,没必要再花钱调模型——上次的答案直接返回就行。命中缓存 = 零成本 + 零延迟,这是最划算的优化。
核心逻辑就一句:先查缓存,命中直接返回,未命中才调模型并把结果存进缓存。
四、LRU:缓存满了淘汰谁
缓存不能无限大,满了得淘汰。LRU(Least Recently Used)的策略是淘汰最久没被用到的,让热点问题常驻。用标准库 OrderedDict 实现得很干净:
move_to_end 把刚用过的移到队尾,popitem(last=False) 淘汰队首。这样频繁被问的问题永远在末尾、淘汰不掉,冷门问题自然被挤出去。
五、Prompt Cache、KV Cache、Prefix Cache 别混用
| 名称 | 发生在哪里 | 解决什么 |
|---|---|---|
| 业务缓存 | 应用层 | 相同问题直接返回上次答案 |
| Prompt Cache | 模型服务/供应商侧 | 相同前缀输入少计费或少计算 |
| KV Cache | 推理引擎内部 | 自回归生成时复用历史 key/value |
| Prefix Cache | 自部署推理优化 | 多请求共享相同 system prompt/长上下文前缀 |
做应用开发时,你最能直接控制的是业务缓存和 prompt 结构。KV Cache 通常是 vLLM/Ollama/模型服务内部优化,不要在业务代码里假装自己能手动管理。
六、缓存之外的降本手段
缓存解决重复问题,但还有其他场景:
| 手段 | 解决什么 | 怎么做 |
|---|---|---|
| 缓存 | 重复问题重复花钱 | LRU 缓存,命中零成本 |
| 模型分级 | 简单问题用了贵模型 | 简单问题走便宜小模型,复杂的才上大模型 |
| 控制 token | prompt 太长、回答太啰嗦 | 精简 prompt、设 max_tokens、RAG 控制 topK |
| 限流 | 单用户刷爆接口 | 按用户/IP 限速,防滥用和成本失控 |
| 降级 | 模型超时/超预算 | 触发阈值后返回缓存答案或兜底回复 |
这几个不是二选一,是组合拳。缓存性价比最高,先上;模型分级对成本影响大,量上来后必做。
七、工程上真正会踩的坑
- 缓存键没归一化。"报销几天" 和 "报销 几天" 因空格被当成两个不同问题,命中率虚低。缓存键要先归一化(去空格、统一大小写、甚至语义聚合)。
- 该过期的缓存不过期。知识库更新了,缓存还返回旧答案。时效性内容要给缓存加 TTL,或文档更新时主动清缓存。
- 缓存了不该缓存的。带用户隐私、带时效("今天天气")、个性化的回答不能跨用户缓存,否则张三看到李四的答案。缓存范围要按内容性质区分。
- 只顾省钱牺牲质量。一刀切全用小模型,复杂问题答不好,省了钱丢了体验。模型分级要按问题复杂度路由,不是全降级。
八、一句话面试答法
AI 应用成本怎么控制? 第一性价比是缓存:相同问题用 LRU 缓存,命中就零成本零延迟返回,不重复调模型,注意缓存键要归一化、时效内容要加 TTL。其次是模型分级,简单问题走便宜小模型、复杂的才上大模型。再就是控制 token——输出比输入贵,所以设 max_tokens、让回答简洁、RAG 控制 topK 别把一堆 chunk 全塞进 prompt。配合按用户限流防滥用、超预算时降级返回兜底答案。成本是设计时就要算的账,不是上线后才补。
九、动手实践:41 AI 应用成本控制:Token、缓存与模型分级
带 LRU 缓存的问答服务 + token 成本估算:相同问题命中缓存零成本返回,缓存满了按 LRU 淘汰最久未用的条目,最后统计实际花费 vs 缓存省下。
9.1 在线运行
零依赖,纯标准库。跑一批含重复问题的请求,演示缓存省钱和 LRU 淘汰。
9.2 预期输出
=== 问答过程 ===
[调用模型] 报销几天内提交 -> 花费 0.000055 元
[调用模型] 年假有几天 -> 花费 0.000049 元
[命中缓存] 报销几天内提交 -> 零成本返回
[命中缓存] 年假有几天 -> 零成本返回
[缓存淘汰] 容量已满,淘汰最久未用:'报销几天内提交'
[调用模型] 迟到扣钱吗 -> 花费 0.000049 元
[命中缓存] 年假有几天 -> 零成本返回
=== 成本统计 ===
总请求数:6(命中缓存 3,调用模型 3)
缓存命中率:50%
实际花费:0.000153 元
缓存省下:0.000153 元
6 次请求只真正调了 3 次模型,命中率 50%,省下的钱和花掉的一样多。
9.3 代码 ↔ 概念对应
| 概念 | 在 main.py 哪里 |
|---|---|
| token 估算 | estimate_tokens |
| token 换算成钱(区分输入/输出单价) | estimate_cost |
| LRU 缓存(OrderedDict 实现) | LRUCache |
| 命中后标记最近使用 | LRUCache.get 里 move_to_end |
| 容量满淘汰最久未用 | LRUCache.put 里 popitem(last=False) |
| 缓存优先、未命中才调模型 | QAService.ask |
| 成本/命中率统计 | QAService.report |
9.4 LRU 是怎么淘汰的
LRU = Least Recently Used,容量满时淘汰「最久没被用到」的条目,让热点问题常驻。用 OrderedDict 实现很优雅:
get命中时move_to_end,把它标记为最近使用(移到末尾)put超容量时popitem(last=False),删掉最前面的(最久未用)
输出里能看到:「报销」「年假」都被问过两次后,插入「迟到」时淘汰的是更久没被问的「报销」,而刚被问过的「年假」留了下来。
9.5 动手改
- 把
cache_capacity调大到 10,命中率会变高(不再频繁淘汰)。 - 在缓存里加 TTL(存入时间戳,超过 N 秒视为过期),体会「时效性缓存」。
- 把
estimate_tokens换成真实tiktoken分词(需pip install tiktoken),成本估算更准。
9.6 可运行源码:AI 应用成本控制:Token、缓存与模型分级
main.py
十、总结
- 先把账算明白:token 怎么变成钱:这个公式解释了两个优化方向:少喂输入(prompt 别塞无关内容、RAG 别检索一大堆 chunk 全塞进去)和少出输出(让模型答得简洁、设 max_tokens 上限)。
- 缓存:性价比最高的降本手段:命中缓存 = 零成本 + 零延迟,这是最划算的优化。
- LRU:缓存满了淘汰谁:缓存不能无限大,满了得淘汰。
- Prompt Cache、KV Cache、Prefix Cache 别混用:做应用开发时,你最能直接控制的是业务缓存和 prompt 结构。
- 缓存之外的降本手段:这几个不是二选一,是组合拳。
- 工程上真正会踩的坑:带用户隐私、带时效("今天天气")、个性化的回答不能跨用户缓存,否则张三看到李四的答案。
10.1 可运行实验:语义缓存与成本计算器
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>AA-17 在线实验</title>
<style>
:root{color-scheme:dark;font-family:Inter,system-ui,sans-serif}*{box-sizing:border-box}body{margin:0;background:#0f1211;color:#e7ece9;font-size:13px}.shell{padding:16px}.top{display:flex;justify-content:space-between;gap:16px;margin-bottom:14px}h1{margin:3px 0;font-size:18px}.id,.value{color:#68e0b5;font-family:ui-monospace,monospace}.summary{margin:4px 0;color:#a5afa9}.run{border:0;border-radius:6px;background:#68e0b5;color:#07110d;padding:8px 14px;font-weight:700}.grid{display:grid;grid-template-columns:minmax(220px,.8fr) minmax(0,1.8fr);gap:12px}.panel{border:1px solid #29322e;background:#141817;padding:12px}.control{display:grid;gap:5px;margin-bottom:11px}.head{display:flex;justify-content:space-between;gap:8px}select,input{width:100%;accent-color:#68e0b5;background:#0d100f;color:#e7ece9}.toggle{display:flex;justify-content:space-between;border-top:1px solid #29322e;padding-top:9px}.toggle input{width:18px}.metrics{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));gap:7px}.metric{border:1px solid #29322e;padding:8px}.metric b{display:block;color:#68e0b5;font-size:16px}.stages{display:flex;gap:6px;overflow:auto;margin:10px 0}.stage{border:1px solid #8a6230;padding:7px;min-width:90px}.stage.ok{border-color:#367a61}.stage.fail{border-color:#8b4545}table{width:100%;border-collapse:collapse}td{border-top:1px solid #29322e;padding:7px}.diagnosis{margin-top:9px;border-left:3px solid #68e0b5;background:#101412;padding:9px;line-height:1.5}.danger{border-color:#ef7f7f}@media(max-width:680px){.top,.grid{display:grid;grid-template-columns:1fr}.metrics{grid-template-columns:repeat(2,1fr)}}
</style>
</head>
<body>
<main class="shell">
<header class="top"><div><div class="id">AA-17 · DETERMINISTIC LAB</div><h1 id="title"></h1><p class="summary" id="summary"></p></div><button class="run" id="run">运行实验</button></header>
<section class="grid"><div class="panel"><div id="controls"></div><label class="toggle"><span>注入典型故障</span><input id="failure" type="checkbox"></label></div><div class="panel"><div class="metrics" id="metrics"></div><div class="stages" id="stages"></div><table><tbody id="rows"></tbody></table><div class="diagnosis" id="diagnosis"></div></div></section>
</main>
<script>
const scenario = { title: '语义缓存与成本计算器', summary: '比较精确与语义缓存,并验证版本、权限和知识库摘要是否进入缓存键。', controls: [
{ key: 'requests', label: '每日请求', type: 'range', min: 1000, max: 100000, step: 1000, value: 30000, suffix: ' 次' },
{ key: 'hitRate', label: '预计命中率', type: 'range', min: 0, max: 90, step: 5, value: 45, suffix: '%' },
{ key: 'key', label: '缓存键', type: 'select', value: 'full', options: [['query', '仅 Query'], ['model', 'Query + Model'], ['full', 'Query + Model + Prompt + ACL + KB 版本']] }
] };
const controls = document.querySelector('#controls');
const failure = document.querySelector('#failure');
document.querySelector('#title').textContent = scenario.title;
document.querySelector('#summary').textContent = scenario.summary;
function renderControl(control) {
const label = document.createElement('label'); label.className = 'control';
const head = document.createElement('span'); head.className = 'head'; head.innerHTML = '<span>' + control.label + '</span><span class="value" data-value="' + control.key + '"></span>'; label.appendChild(head);
const input = document.createElement(control.type === 'select' ? 'select' : 'input'); input.dataset.key = control.key;
if (control.type === 'select') control.options.forEach(option => { const item = document.createElement('option'); item.value = option[0]; item.textContent = option[1]; item.selected = option[0] === control.value; input.appendChild(item); });
else { input.type = 'range'; input.min = control.min; input.max = control.max; input.step = control.step || 1; input.value = control.value; }
input.addEventListener('input', updateValues); label.appendChild(input); return label;
}
function updateValues() { scenario.controls.forEach(control => { const input = controls.querySelector('[data-key="' + control.key + '"]'); document.querySelector('[data-value="' + control.key + '"]').textContent = control.type === 'select' ? input.options[input.selectedIndex].text : input.value + (control.suffix || ''); }); }
function readValues() { const values = {}; scenario.controls.forEach(control => { const input = controls.querySelector('[data-key="' + control.key + '"]'); values[control.key] = control.type === 'range' ? Number(input.value) : input.value; }); values.failure = failure.checked; return values; }
function stage(name, state, detail) { return { name, state, detail }; }
const aiStage = stage;
function clamp(value, minimum, maximum) { return Math.min(maximum, Math.max(minimum, value)); }
function simulate(values) { const fail = values.failure;
/** 每日由缓存命中的请求数量。 */
const hits = Math.round(values.requests * values.hitRate / 100);
/** 未命中时按每次 0.012 美元计算的每日模型费用。 */
const dailyCost = (values.requests - hits) * 0.012;
/** 缓存相对完全不缓存节省的费用。 */
const saved = hits * 0.012;
/** 缓存键缺少上下文版本时预计发生的错误复用数量。 */
const wrongHits = values.key === 'full' && !fail ? 0 : Math.max(1, Math.round(hits * (values.key === 'query' ? 0.08 : 0.025)));
return { metrics: [[hits.toLocaleString(), '每日命中'], ['$' + saved.toFixed(2), '每日节省'], ['$' + dailyCost.toFixed(2), '每日模型费'], [wrongHits, '错误复用']], stages: [aiStage('规范化 Query', 'ok', 'normalized'), aiStage('权限摘要', values.key === 'full' ? 'ok' : 'fail', values.key), aiStage('语义匹配', 'ok', values.hitRate + '%'), aiStage('版本校验', fail ? 'fail' : values.key === 'full' ? 'ok' : 'warn', fail ? 'KB changed' : values.key), aiStage('返回缓存', wrongHits ? 'fail' : 'ok', wrongHits)], rows: [['完整缓存键', 'tenant + ACL hash + normalized query + model + prompt + KB version'], ['语义阈值', '必须用坏案例集评测,不能只追求命中率'], ['失效策略', fail ? '知识库已发布 v5,但缓存仍绑定 v4,必须批量失效' : '索引与 Prompt 版本变化会生成新命名空间']], diagnosis: wrongHits ? '缓存节省了费用,但产生错误或越权复用,当前方案不可上线。' : '缓存键隔离完整,在保证正确性的前提下降低模型费用。', danger: wrongHits > 0 };
}
function render() { const result = simulate(readValues()); document.querySelector('#metrics').innerHTML = result.metrics.map(item => '<div class="metric"><b>' + item[0] + '</b><span>' + item[1] + '</span></div>').join(''); document.querySelector('#stages').innerHTML = result.stages.map(item => '<div class="stage ' + item.state + '"><b>' + item.name + '</b><div>' + item.detail + '</div></div>').join(''); document.querySelector('#rows').innerHTML = result.rows.map(item => '<tr><td>' + item[0] + '</td><td>' + item[1] + '</td></tr>').join(''); const diagnosis = document.querySelector('#diagnosis'); diagnosis.textContent = result.diagnosis; diagnosis.className = 'diagnosis' + (result.danger ? ' danger' : ''); }
scenario.controls.forEach(control => controls.appendChild(renderControl(control))); updateValues(); document.querySelector('#run').addEventListener('click', render); render();
</script>
</body>
</html>
学完自测
选择所有正确答案;提交后逐项核对判断依据。