运维 | 缓存命中率 70% 的秘密:一个没被送出的请求头近期更新
运维 | 缓存命中率 70% 的秘密:一个没被送出的请求头
有些缓存不是失效了,是从来没被找到过。
有些便宜不是省出来的,是把同一段话别再说第二遍。
写在前面
我给自己的 AI 助手接了一个自建 LLM 网关,主模型走的是上游的编码套餐。用了一段时间,账单上的数字一直比预期高:输入 token 一大片,输出却少得可怜——这说明钱主要不是花在"生成"上,而是花在"反复读同一段上下文"上。
真正让我起疑的是月度统计里的一个指标:前缀缓存命中率,只有 70% 上下。
对于长会话,这个数字本该是 95% 以上。缓存的原理很朴素——同一次对话,第二轮请求的前缀和第一轮几乎完全一样,命中缓存的部分按输入价的几十分之一计费。70% 意味着每三轮里就有一整轮在按原价重读历史。
症状:命中率在 90% 和 0% 之间跳
翻网关的调用日志,同一个会话里连续二十次调用,命中率长这样:
#9 41,410 tok 命中 89%
#10 42,202 tok 命中 0%
#11 43,610 tok 命中 90%
#12 45,617 tok 命中 79%
#13 47,826 tok 命中 4%
#14 48,237 tok 命中 99%相邻两次调用的间隔只有 4 到 43 秒。这个时间尺度直接排除了缓存过期(TTL)的可能——几十秒内不可能有什么"自然失效"。而如果前缀稳定,第二次本该命中第一次的全部内容,不该在 0% 和 90% 之间随机摆动。
第一个判断是错的
我先按上游返回的请求 ID 形态给调用分了层,发现命中率和 ID 格式高度相关:某种形态的 ID 平均命中 93%,另一种只有 34%。于是我写下结论:"某一家上游实现不做缓存。"
这个判断后来被自己的数据打掉了。修好之后同一条链路的 ID 形态照样在变,命中率却稳定在 99%。同一个上游会根据流式与端点吐出不同格式的 ID,拿它当后端指纹是靠不住的。
真正能定论的只有对照实验:把"变量"控制到只剩一个,看它是否改变结果。
决定性的十次调用
我构造了一段约 5.8K token 的固定前缀,通过网关连打五次,中间只改最后一行——先不带任何会话标识头打一轮,再带一个固定的会话标识头打一轮。
不带 x-opencode-session:5/5 全部 cached=0 命中 0.0%
带 x-opencode-session:从第 2 次起 4/4 命中 99.1%十次调用,一次没差。同一段前缀、同一个网关、同一把密钥,唯一的不同就是那个请求头。
这一刻问题从"上游缓存行不行"变成了"我们到底有没有把会话号送出去"。
根因:每一轮都换了一个新会话
打开上游中继的实现看,会话号的解析顺序是:
- 客户端请求头里的
X-Opencode-Session(或相近的几种别名); - 上下文里已有的会话号;
- 都没有——当场生成一个 UUID。
第三行就是全部答案。我的主链路走的是自建网关(provider: custom + 自己的入口地址),而助手发会话标识头只会认"上游家族名字"或"上游官方域名"这两个条件。自建入口两个都不满足,于是这个头从来没被发出去过;网关拿不到,只能每请求现生成一个 UUID 冒充会话号。
上游据此认为"这是二十个互不相干的会话",把请求均匀撒到不同后端。而前缀缓存是跟着后端实例走的——每一台都只有自己见过的那一小段,命中率自然退化成各家实现的平均值。
修复:一行配置
助手本身留了官方开关,不用改代码:给这条路由的供应商标明"亲和头名字",它就会每轮带上会话级哈希(值由会话 ID 派生,不含对话内容)。
hermes config set providers.<你的网关条目>.session_affinity_header x-opencode-session
# 配置只在进程启动时读,必须重启网关;验收看 pid 是否真的变了重启后的同一个会话,连续六次调用:
call#1 113,996 tok 命中 99%
call#2 114,525 tok 命中 100%
call#3 115,045 tok 命中 100%
call#4 116,515 tok 命中 99%
call#5 117,905 tok 命中 99%
call#6 119,662 tok 命中 100%输入涨到十二万 token,命中率不再抖。
| 项目 | 修前 | 修后 |
|---|---|---|
| 当日加权命中率 | 72.2% | 99% 以上 |
| 近七天加权命中率 | 68.0% | 观察中 |
| 同一会话内波动 | 0% ↔ 90% 随机 | 稳定 99~100% |
| 未命中输入的量级 | 占总输入约三成 | 剩个位数百分比 |
折算成钱:月池的消耗量大概砍掉四成。如果它维持住,这个月不用再担心提前见底——而在修之前,按当时的日均消耗外推,额度会比账单周期早十来天用完。
顺带澄清一个不相关的开关
排查过程中看到网关后台有个"User-Agent 透传"开关,很容易误会成同类问题:开了之后,上游看到的是客户端真实的 User-Agent;不开则统一发网关自己的标识。
读源码确认了两件事:一是这个开关默认就是关闭,二是我关心的缓存跟它完全无关——缓存只跟"会话钉在哪台后端"有关。开启它反而会把流量指纹搞得更杂(同一个密钥下时而 Python SDK、时而另一个客户端),对缓存零收益。所以保持关闭。
顺带一提,这类"看起来相关其实无关"的开关,判断方法只有一个:读它的实现,而不是读它的名字。
给它装一只眼睛
修好只是一次性动作,能退化才是常态——换个客户端、改一次配置、重启忘了带参数,命中率就悄悄掉回去了。所以我加了一个每晚跑的脚本:只读日志,统计当日与近七天的加权命中率,附上"修复前后"的分段对比,跌破阈值才发告警。
它自己踩过一个坑,也值得记一笔:日志时间戳带毫秒(11:10:26,880),第一版解析时没去掉 ,880,strptime 全行失败、脚本静默返回空输出——而这类"无事发生"的脚本,在无人值守的任务里看起来和"一切正常"一模一样。
命令返回成功,状态显示正常,不代表事情真的发生了。
写在最后
这次花费最多时间的不是修复,是证伪自己的第一个结论。ID 形态与命中率的相关性是真的,但那只是相关;如果停在那里,我会去换上游、去加额度、去解释"上游就是不行"——全都在错误的路上。
省下这笔钱的,不是更高级的模型,也不是更聪明的提示词,而是一个几十字节的请求头:让上游知道,这二十次请求,其实是一句话。
2026 年 11 月。排查与修复过程由 Hermes Agent 辅助完成。
最后更新于 2026-11-11 「部分内容存在时效性,如有失效请留言反馈」
除注明外为 袅残烟的冥想盆 原创文章,转载请注明出处。
本作品采用 知识共享署名-相同方式共享 4.0 国际许可协议 进行许可。