跳到主要内容

不属于你的那次变慢:对话中途的 KV 缓存逐出

· 阅读需 11 分钟
Tian Pan
Software Engineer

一段对话在同一个 Claude 会话里跑了四十分钟。十一轮回合,每轮平均首字延迟(TTFT)800ms,每轮都很便宜——因为那段 28,000 token 的前缀命中了提示词缓存。第十二轮到来,TTFT 飙到 3.4 秒。对话的形态没变,模型没切换,网络也正常。缓存输入 token 从 27,800 掉到 0。下一轮的 prefill 账单从第一个 token 起就全额计费。

你去追踪里找原因,没有任何一条日志写着"另一个租户的突发流量把你逐出了缓存"。对这次毛刺最诚实的解读是:在同一片 GPU 池的某处,另一个客户的 prompt 让调度器认为,丢掉你这段温热的前缀是代价最小的选择。你无法重放这一轮,无法证明那次逐出。那一刻的缓存状态是陌生人流量的函数,而那些流量不在你的追踪里,因为它们本来就不属于你。

这就是"提示词缓存"这四个字所掩盖的失败模式。命中时折扣是真的,但缓存是共享基础设施,你的逐请求指标却把它当作私有状态来看。你的 SLO 承诺了一个延迟预算,而这个预算依赖于一个客户端从未拥有、服务商也不保证的状态。长对话让这道裂缝可见,因为它们正是从缓存命中里获益最多、也最能感知缓存消失的工作负载。

你看不见的那块缓存

服务商层面的提示词缓存就是 KV 缓存:prefill 阶段产生的 key 和 value 张量,被保存在 GPU 显存里,让下一个带相同前缀的请求可以跳过 prefill 计算。Anthropic 对缓存读的定价是基础输入价的 10%;OpenAI 也是同样。命中时账算得很清楚:一段 28,000 token 的系统提示原价每轮 0.09 美元,命中缓存后只要 0.009 美元。把这个数字乘以每天数百轮、上千段对话,缓存就是"产品能跑得起来"和"产品在持续失血"的分水岭。

麻烦在于:缓存所在的那块内存不归你所有。GPU 池是共享的。缓存里装的是最近经过的各种前缀,容量有限。一旦压力上升——某个大客户在做发布、某个区域流量峰值、一批长 prompt 集中到达——条目就被逐出。服务商有逐出策略,但不公开。实际行为像是带时间维度的 LRU:Anthropic 文档里写着五分钟的最小 TTL,每次命中刷新;OpenAI 描述的是闲置五到十分钟后自动清理,但在低峰时段可以拉长到一个小时。两家都把真正的决策留给内部调度器,而调度器响应的是你看不到的负载。

Anthropic 在 2026 年三月的那次改动让边界变得更刺眼:默认 TTL 被悄悄从一小时降到五分钟。围绕旧窗口构建的团队发现自己代码没动一行,缓存命中率却塌了。教训不在于那个具体数字,而在于:缓存契约其实不是契约——它是一个供应商在经济账要求他转动时随手调整的旋钮,你代码握住的只是一份希望:下一轮到来时,前缀还是温的。

重新 prefill 究竟要付多少代价

第十二轮缓存未命中时,你不是多付一点。你是为整段对话里每一个 token 重新 prefill——从系统提示的第一个 token,到对话历史的最后一个 token,一次性付清。在 Claude Sonnet 的基础输入费率下,28,000 token 的前缀大约要为这一轮多付 0.084 美元的输入费。在多数生产环境里,TTFT 和 prefill 长度近似线性,所以原本冷启动只要 200ms 的前缀,现在变成这一轮首字延迟的硬底线。

用户感知到的事情比账单更糟。十一轮亚秒级 TTFT 把用户训练得期待"秒回",第十二轮要等四秒才出第一个 token。对话节奏直接断裂。如果是语音智能体,这种沉默令人不安;如果是聊天界面,用户会在上一条还没回来时就开始打下一条;如果是无人值守跑在 CI 里的智能体,你围绕缓存路径慷慨设置的超时这时会触发,编排器从头重启对话——重试时再付一次 prefill,并且更没有理由相信缓存会及时变温。

这笔代价在你度量它之前都是隐形的。多数 token 仪表盘把缓存与非缓存输入合在一个桶里,或者把缓存 token 当作一个"越多越好"的优化指标,而不是要捍卫的命中率基线。正确的视角是:对话内每一轮的命中率,以轮次为索引绘制,再按一天中的小时切片。那些在第十轮之后掉下悬崖的对话,正在告诉你逐出是从哪里开始咬人的。如果你没有这张图,账单会在月底到来,而签字的工程师拿不出任何东西可以指。

服务商不会告诉你的逐出策略

服务商不会公开"哪段前缀先被丢"的规则。2026 年的推理调度研究——Continuum 的 KV 缓存 TTL 工作、vLLM 提出的尾部优化 LRU、MIRAGE 的动态重映射——把这些隐式策略写出来了,让你能猜到闭源服务商在做什么形状的事。常见的模式有:

  • 基于前缀块的 LRU。 标准基线。最久没命中过的前缀最便宜逐出,因为按近因来看它最不可能被复用。流量平稳时跑得很好;但在轮次间隔长的对话里会失败——一次思考停顿就让缓存觉得你"老了",哪怕你下一轮就在几秒之后。
  • 按 re-prefill 代价排优先级。 一些调度器倾向保留那些重算代价更高的前缀。30,000 token 的前缀比 2,000 token 的更值得留。如果你跑长对话,这对你有利;如果调度器偏向优化吞吐而不是尾延迟,这对你就不利。
  • 容量压力下的逐出。 GPU 显存吃紧时,调度器会激进地丢条目,给新到的 prefill 腾地方。压力信号由全租户总流量决定,不是你的。一个突发的邻居就能把你的前缀挤掉,哪怕你的对话还在进行。
  • 感知对话的留存策略。 前沿策略,比如 vLLM 的 T-LRU,会追踪哪些对话很可能很快再发请求,并把它们的前缀钉住不让逐出。这种策略在今天的生产里很少见,主流服务商也没在 API 层暴露。你能拿到的最强信号是轶事性的:在重负载下命中率依旧稳。

光读文档你学不到服务商用的是哪一种。你得给命中率打点埋点,把下降和服务商状态页、区域负载模式、自己的请求时序对起来。未命中那一刻的追踪不会告诉你原因;许多次未命中合起来的模式才会。

在不可保证的缓存上做设计

正确的姿态是:把缓存命中当作优化,而不是不变式。一旦缓存未命中就崩的代码,是误解了"缓存"这个词。能扛住"对话中途被逐出"这种失败模式的设计模式,要么让冷路径可接受,要么让热路径可观测。

  • 能钉住前缀就钉住。 Anthropic 在 cache control 块上提供五分钟和一小时两种 TTL。一小时这个选项之所以存在,恰恰是因为长对话在默认 TTL 下被频繁逐出。把它用在系统提示和那些会跨多轮反复读取的常驻上下文上。一次性付掉缓存写的溢价,然后把它摊销到整段对话里。Anthropic 五分钟写收基础输入价的 1.25 倍,一小时写收 2 倍——大约两次命中就回本。
  • 按缓存留存来给对话长度做预算。 如果你的平均对话是四十分钟而默认 TTL 是五分钟,那么每段安静期都是一次丢失前缀的机会。要么升级 TTL,要么让智能体在停顿期间用低成本的 keepalive 请求保持缓存温热。keepalive 不优雅——你为那些可能用不到的缓存写付钱——但它比悬崖式下跌可预测。
  • 构建一条优雅的冷启动回退路径。 当 TTFT 超过阈值,你就知道缓存未命中。客户端应该知道这时该做什么:给用户流式一条"还在想"的反馈而不是沉默,把下游超时调低避免重试堆积,把事件连同足够的对话元数据一起记下来,以便在聚合视角下找出逐出模式。冷路径不该是异常,它应当是一个有自己 SLO 的、被文档化的运行模式。
  • 为不能容忍未命中的对话预留容量。 两家主流服务商都提供专属容量层。卖点是吞吐;但对生产级智能体更有意思的属性是缓存的可预测性——专属层的逐出发生在更小、更可控的占用上。这种经济账对兴趣项目几乎跑不通,对 B2B 智能体常常跑得通,因为他们的终端用户不会在四秒静默后还耐心重试。
  • 以"对话内每一轮"为粒度度量命中率。 不是按请求,也不是按租户。按轮次、以对话中的位置为索引,让缓存悬崖在哪里掉下来一目了然。再把这张图和服务商所在区域的"一天中的小时"切片配在一起,吵闹邻居效应就会以日内周期模式浮现出来,而不是被当成噪声。

更深一层的动作是:别再把遥测里的"已缓存"当成一个二值变量。你需要的指标是每一轮"缓存输入 token / 总输入 token"的比值,并且画成分布而不是均值。90% 的均值看起来很好,直到你注意到底端十分位是 0%——而那个底端十分位正是某个人对话里最糟糕的那一轮,正是他会记住的那一轮。

架构其实在告诉你什么

多租户推理把对话延迟变成了"陌生人流量"的函数。再精巧的 prompt 工程也改不了这一点。KV 缓存是共享基础设施,藏在一个把共享这件事隐藏起来的 API 后面。你的客户端把缓存当作私有状态;服务商把它当作面对入站负载可以随意分配的可替代资源。两种视角的相遇之处,恰恰就是你的对话被收取一笔重新 prefill 费用、而你的应用没有任何语言能解释它的时刻。

对团队最诚实的表述是:缓存不是你的,你围绕它写的那份延迟契约,隐含地是一份和你推理服务商所有其他客户一起签的契约。你可以用预留容量或更长的 TTL 买到更多可预测性,可以用优雅降级吸收损失,量级够大的话还可以和服务商谈判把缓存钉住。你做不到的,是假装温路径就是唯一的路径。冷路径以别人突发流量的频率存在,那个没有把冷路径讲清楚的智能体,发出去的 UX 就是下一个吵闹邻居用来打碎它的工具。

References:Let's stay in touch and Follow me for more thoughts and updates