把大模型前缀缓存命中率从 58% 提升到 93%
摘要:一套长期在线的对话 Agent,模型账单始终偏高。排查后发现前缀缓存命中率只有 57.8%,意味着四成输入 token 按全价计费。本文记录从误判到定位、再到验证的完整过程,最终命中率提升到 93%,每轮未命中 token 从 7093 降到 1638。
1. 问题
这套系统跑在单台 4C4G 云主机上,日常对话上下文约四万 token。账单不该高到引起注意,但它确实引起了注意。
我打开服务商的用量后台,看到一栏此前没认真看过的指标:
| 指标 | 数值 |
|---|---|
| 输入 token(命中) | 57.8% |
| 输入 token(未命中) | 42.2% |
| 每轮未命中 token | 约 7,093 |
四成输入按全价计费。 而命中与未命中的价差是 50 倍,这意味着绝大部分成本花在了本可以避免的地方。
问题不在于"贵不贵",而在于"为什么该命中的没命中"。
2. 前缀缓存的工作机制
大模型按输入 token 计费。但同一段前缀的中间计算结果可以在服务端缓存:如果下一次请求的开头与之逐字节相同,这段就能直接复用,按折扣价计费。
关键在于"开头"和"逐字节":
- 从第 1 个 token 开始向后匹配,以固定大小的块为单位;
- 中途任何一处改动,从改动点往后全部失效;
- 只有在末尾追加,前面的部分才能完整复用。
因此对 Agent 这类多轮应用来说,缓存不是运气问题,而是一个可设计的工程约束:
每一轮请求,都应该是上一轮请求的超集,且公共前缀不变。
这条约束一旦被破坏,缓存收益就会断崖式下降,而系统不会报任何错误。
3. 诊断
3.1 第一次误判:数据口径不可信
最初的判断是"上下文压缩逻辑在改写历史"。依据是自建看板上几次命中率骤降。
我差点直接去改压缩代码。核对数据来源时发现一个问题:
| 数据源 | 样本量 |
|---|---|
| 自建看板 | 376 次调用 |
| 服务商后台 | 1,327 次调用 |
看板只覆盖了 28% 的请求,而且偏向"我在手动测试时"的调用,完全看不到机器人日常闲聊的形态。
结论作废。这件事之后我定了一条规矩:测量口径本身可疑时,不要下任何结论——不是"结论弱一点",而是不要下。
3.2 第二次误判:从代码推断行为
换方向,直接读框架的上下文管理实现。翻到截断逻辑,是按轮次保留最近 N 轮、丢弃更早的。
从代码注释看,它只在超出上限时触发。我当时判断:对话远未到上限,这段逻辑不会生效,问题不在这里。
这个判断也是错的。代码写了什么不重要,运行时做了什么才重要,而我跳过了验证这一步。
3.3 三层埋点
我改成在三个位置同时记录:
| 层次 | 位置 | 能回答的问题 |
|---|---|---|
| 插件钩子 | on_llm_request |
框架交给 provider 的上下文长什么样 |
| 上下文管理器 | 裁剪与压缩前后 | 这一轮实际被裁掉多少、剩多少 |
| 出站 payload | 请求序列化完成、发出之前 | 最终发给模型的 messages 到底是什么 |
第三层是关键。前两层都可能被后续逻辑再改一次,只有出站那一刻的内容才是模型真正看到的。
在出站处对每条 message 计算内容哈希,逐条记录下来:
_msgs = payloads.get("messages") or []
_info = []
for _m in _msgs:
_c = _m.get("content")
_s = _c if isinstance(_c, str) else json.dumps(_c, ensure_ascii=False, sort_keys=True, default=str)
_info.append([_m.get("role"), hashlib.sha1(_s.encode("utf-8", "ignore")).hexdigest()[:10], len(_s)])
把相邻两次请求的哈希序列做前缀比对,问题立刻显形:
第 1 次: 138 条
第 2 次: 140 条 前 137 条完全相同,第 138 条变了
第 3 次: 142 条 前 139 条完全相同,第 140 条变了
消息数在增长,说明窗口在动;但每次都有一条历史消息被改写,位置就在倒数第二条。前缀从这里断开。
4. 根因
截断参数被配成了"保留 20 轮、每次丢弃 2 轮"。这个组合的实际含义是:每一轮请求都在裁剪,而不是我以为的"仅在超限时裁一次"。
于是窗口每轮右移,历史消息的边界随之变化,缓存前缀持续失效。
根因只有一行配置,但它被两层"看起来合理"的推断掩盖了:数据看起来支持一个结论,代码看起来支持另一个结论,而两个都没经过运行时验证。
5. 修复
把参数改为"保留 100 轮、一次丢 50 轮":
| 参数 | 修改前 | 修改后 |
|---|---|---|
max_turns |
20 | 100 |
trim_turns |
2 | 50 |
效果是窗口大多数时候不动,只在真正撑满时批量裁剪一次。裁剪仍然会发生,但频率从"每轮"降到"每 25 轮左右"。
6. 结果
| 指标 | 优化前 | 优化后 |
|---|---|---|
| 缓存命中率 | 57.8% | 93.1% |
| 每轮未命中 token | 7,093 | 2,443 |
| 输入 token 单价(相对) | 100% | 约 4% |
命中率我没有用估算值,而是用服务商返回的 prompt_cache_hit_tokens / prompt_cache_miss_tokens 直接聚合——这是账单口径,不是我的推测。
7. 动态内容的安置
还有一处结构性问题需要处理。
Agent 每一轮都要往上下文里注入会变的内容:当前时间、状态、召回的记忆、行为倾向。这些天然与缓存冲突。
解法不是"不注入",而是把变化收敛到末尾:
[system 提示词] ← 不变,命中
[历史第 1 条 … 第 N-1 条] ← 不变,命中
[当轮用户消息 + 动态内容] ← 每轮变化,未命中
具体做法是动态内容只挂在当轮最后一条消息上,不重构历史。上一轮挂过的那条消息在进入历史时会被还原为纯文本,所以历史始终干净。
这样可命中的前缀是"除最后一条以外的全部"。动态部分约 1,600 token,总输入约四万,理论上限在 96% 左右——与实测的 93% 相符,差额来自截断轮次和新会话首轮。
8. 复盘
这次优化的技术含量很低:改动只有两个数字。真正花时间的是确认问题在哪,而这个过程中我犯了两个典型错误。
错误一:用覆盖不全的数据下结论。 本地计量只覆盖 28% 的调用,我却把它当成全量。教训是:先确认测量可信,再谈结论。
错误二:从代码推断运行时行为。 我读了截断逻辑就断定它不会触发,没有验证。教训是:日志说什么才算数。
这两次误判有一个共同点——我都在用自己的理解替代观测。而缓存命中率恰恰是一个"错了也不会报错"的指标,只有把它测出来,问题才存在。
如果重来一次,我会在第一天就装探针,而不是先猜三天。