封仁华的工程笔记

把大模型前缀缓存命中率从 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% 的调用,我却把它当成全量。教训是:先确认测量可信,再谈结论。

错误二:从代码推断运行时行为。 我读了截断逻辑就断定它不会触发,没有验证。教训是:日志说什么才算数。

这两次误判有一个共同点——我都在用自己的理解替代观测。而缓存命中率恰恰是一个"错了也不会报错"的指标,只有把它测出来,问题才存在。

如果重来一次,我会在第一天就装探针,而不是先猜三天。