运维 | 缓存命中率 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%

十次调用,一次没差。同一段前缀、同一个网关、同一把密钥,唯一的不同就是那个请求头。

这一刻问题从"上游缓存行不行"变成了"我们到底有没有把会话号送出去"。

根因:每一轮都换了一个新会话

打开上游中继的实现看,会话号的解析顺序是:

  1. 客户端请求头里的 X-Opencode-Session(或相近的几种别名);
  2. 上下文里已有的会话号;
  3. 都没有——当场生成一个 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 辅助完成。