跳到主要内容
查看文档索引

Prompt 几乎没变,缓存却从 99% 掉到 0%:Agent 到底改坏了什么?

两组线上记录显示:内容看起来差不多,只要开头的顺序变了,Prompt 缓存就可能从 99% 直接掉到 0%。
实践 · 记录 · 分享

最近在 Strat Thread 的 Agent 里,我遇到一个很容易猜错原因的问题:同样是长对话,用的也是同一个模型,两次请求还不到一分钟。可一次缓存命中接近 99%,另一次却直接变成 0%。

第一反应很容易是:缓存过期了?请求换了一台机器?供应商刚好打了个喷嚏?但把 OpenLIT Trace 展开后,线索指向了应用自己。Trace 就像一张请求行程单,记下它去了哪里、花了多久、用了多少 Token。这张行程单显示:程序每轮都在重新拼装请求的开头。文字看起来还是那些文字,顺序却变了。

这篇文章先给结论:

Prompt Cache 不会猜“两段话意思差不多”。它只从头开始,检查 Token 顺序能连续对上多少。Token 可以先理解成模型读文字时切出的小块。逐步加载 Prompt 或 Tool 没问题;问题是加载新内容时,又把前面的旧内容改了一遍。

如果 Agent 的基础指令、Tool 定义、能力块和历史消息能保持稳定顺序,并让新内容只追加在尾部,缓存可以成为长期运行 Agent 最确定的一项成本优化。反过来,哪怕只在中间插入一个 Tool、时间戳或重新排序后的 JSON,后面几万 Token 的公共内容也可能失去复用资格。

两条线上代表性 Trace 的逐请求缓存 Token 命中率

上图是两条真实生产 Trace,不是受控的部署前后 A/B。紫色是一次不稳定前缀路径,绿色是一次稳定前缀路径。它们说明“现在的系统里两种结果都能出现”,不能单独证明某次代码修改已经产生收益。

OpenLIT 记录了什么

第一条 Trace 一共有五次模型请求:

请求Input TokensCached Input Tokens本次命中率
129,16800%
231,33700%
331,55831,23298.97%
431,63531,23298.73%
548,55600%

整条 Trace 的 Token 加权命中率是:

62,464 / 172,254 = 36.26%

它不是缓慢下降,而是 0% → 0% → 98.97% → 98.73% → 0%。第五次请求虽然包含大量与前面相似的指令和历史,缓存仍然完全失效。

OpenLIT 中一次 48,556 输入 Token、0 Cached Token 的请求;正文、会话与 Trace 标识已脱敏

第二条 Trace 也有五次模型请求:

请求Input TokensCached Input Tokens本次命中率
149,00248,64099.26%
249,18148,64098.90%
350,89648,64095.57%
451,40150,68898.61%
552,80650,68895.99%

整条 Trace 的 Token 加权命中率是:

247,296 / 253,286 = 97.64%

随着 Tool 调用和结果继续追加,Input Tokens 在增长,但绝大部分旧前缀仍然可复用。

OpenLIT 中一次 52,806 输入 Token、50,688 Cached Token 的请求;正文、会话与 Trace 标识已脱敏

这两条 Trace 发生在同一版本、同一分钟附近,所以最稳妥的解释是:当前实现可以走出稳定前缀,也可以走出破坏前缀的路径。它们适合用来定位机制,不适合包装成优化结果。

它缓存的不是答案

一次 LLM 推理可以粗略分为两段:

  1. Prefill:模型并行读取全部输入 Token,在每一层计算注意力需要的 Key 和 Value 等中间状态;
  2. Decode:模型逐 Token 生成输出,每一步读取之前已经形成的状态,再追加新状态。

Transformer 的注意力计算来自 Attention Is All You Need。对于长 Prompt,Prefill 不只是“把文字读一遍”,而是让输入经过多层大规模矩阵计算。上下文越长,重复处理相同前缀浪费的算力越明显。

如果下一次请求的开头与上一次完全一致,推理服务就有机会复用那段前缀已经计算出的状态,只为后面的新 Token 做 Prefill,再继续 Decode。OpenAI 的文档也明确说明,Prompt Cache 可能在 GPU 本地保存加密的 Key/Value 张量;具体服务端实现并不公开,因此把它理解为“KV Cache 或等价的前缀计算状态”更准确。

PagedAttention 论文进一步展示了为什么 KV 状态的内存管理如此重要:每个 Token 在不同层和注意力头都有 Key/Value 向量,缓存会随序列增长。把它们按块管理,可以减少碎片并共享公共前缀,但缓存仍然占用 GPU 内存,也需要路由、查找、淘汰和生命周期管理。

所以 Prompt Cache 不是普通的“答案缓存”。它不会拿上次的答案直接交卷,输出仍然要重新生成。它省下的,是相同开头已经做过的计算。就像一道长题的前半段已经算好,这次从新出现的那一行继续算。

它只认相同的开头,不猜意思

可以把每个请求想成一条从根开始的 Token 路径:

隐藏系统上下文
  -> Tool 定义
  -> 稳定开发者指令
  -> 能力块 A
  -> 用户消息
  -> Tool Call
  -> Tool Result
  -> 新用户消息

缓存要找的是“最长的相同开头”。这种查找在数据结构里可以用 Trie 或 Radix Tree 表示,但不懂这两个名字也不影响理解。想象两队人从起点开始一个个对身份:中间有一个人对不上,后面的人即使名字相同,位置和前文也已经变了。

以下变化都可能让前缀在中途分叉:

  • 把新加载的能力重新合并进第一条 system/developer 文本;
  • Tool 定义相同,但数组顺序或 JSON 序列化顺序变化;
  • 每轮都从空集合重新计算“当前已加载能力”;
  • 在稳定指令前加入当前时间、页面、会话 ID、语言或用户变量;
  • 压缩历史后用摘要替换原消息;
  • 更换模型、reasoning 选项、区域或 Provider 请求封装;
  • 使用不稳定的 prompt_cache_key,让相同前缀更容易被路由到没有缓存的机器。

这也解释了为什么缓存衰减经常是断崖,而不是斜坡:断点之前全部命中,断点之后全部需要重算。

逐步加载为什么容易破坏开头

渐进式能力加载原本是为了减少无关上下文:Agent 先看到基础能力,需要时再加载策略、行情、回测或运维 Tool。问题出在一个常见实现方式:

第 1 轮:基础 Prompt + Tool A
第 2 轮:重新生成基础 Prompt + Tool A + Tool B
第 3 轮:重新排序后生成基础 Prompt + Tool B + Tool A + Tool C

从产品语义看,Agent 只是逐步获得能力;从缓存视角看,每轮请求的前半段都可能被改写。Tool B 一旦插到 Tool A 前面,原本命中的历史、Tool Call 和 Tool Result 都会整体后移。

更稳的结构是:

稳定基础前缀 [显式缓存断点]
  + 第一轮追加的能力块 A
  + 第一轮消息与结果
  + 第二轮只追加能力块 B
  + 第二轮消息与结果

“渐进”应该描述能力如何追加,而不是描述整个 Prompt 如何重建。

我现在怎样保住这个开头

针对这类 Agent,我把缓存合同拆成七条:

  1. 稳定基础块永远在最前面。 身份、权限、安全、数据新鲜度、Tool 使用规则和核心交付规范使用确定性文本与确定性顺序。
  2. 在稳定基础块后设置显式断点。 GPT‑5.6 支持 prompt_cache_breakpoint。即使后面的用户内容变化,基础块也能成为独立可复用前缀。
  3. 能力块分开存放,只追加,不回填。 同一批加载时可以按固定规范排序;已经进入历史的能力块不再移动。
  4. 会话拥有已激活能力集合。 每轮不从空状态重新推导。只有明确的清理操作才重置,避免同一个 Tool 在不同位置反复出现。
  5. 缓存 Key 描述 Prompt 合同,不描述一次请求。 版本、语言、模式和受控分片可以进入 Key;随机 session ID、时间戳和原始用户标识不应该无条件进入。
  6. 请求封装保持一致。 模型、reasoning、Tool 目录、Provider 参数和重试路径都要纳入确定性指纹;旧模型路径不因为新模型缓存优化而被悄悄改写。
  7. 把写入和读取分开观测。 只看 cached_tokens 会高估收益。GPT‑5.6 还要记录 cache_write_tokens、普通输入、输出、延迟和最终费用。

OpenAI Prompt Caching 指南给出的原则与此一致:保持会话历史和 Tool 定义稳定;多轮 Agent 通过追加消息扩大可复用前缀;动态内容前设置显式断点;prompt_cache_key 影响路由,但不会把请求钉死在某台机器上,也不保证命中。

为什么缓存读取便宜

缓存读取便宜,不是因为 Token 消失了,而是因为最昂贵的重复 Prefill 计算已经在第一次请求完成。后续请求仍然要承担:

  • 新增后缀 Token 的 Prefill;
  • 全部输出 Token 的自回归 Decode;
  • 缓存内存、索引、路由和淘汰;
  • 未命中、写入以及跨机器溢出带来的成本。

截至 2026 年 8 月 30 日,OpenAI 的 GPT‑5.6 发布与价格说明列出的规则是:Cache Write 按普通输入的 1.25× 计费,Cache Read 按 0.1× 计费。以一段长度为 P 的稳定前缀为例:

不使用缓存,连续请求 2 次:2P
写入 1 次、完整读取 1 次:1.25P + 0.1P = 1.35P

不使用缓存,连续请求 10 次:10P
写入 1 次、完整读取 9 次:1.25P + 9 × 0.1P = 2.15P

第二次就开始抵消写入溢价;复用次数越多,单位请求的稳定前缀成本越接近读取价。但这只是输入前缀部分。动态后缀和输出不打折,低复用内容如果频繁写入,反而可能比完全不缓存更贵。

以当日 GPT‑5.6 Sol 的 $5 / 1M input tokens 为例,写入约为 $6.25 / 1M,读取约为 $0.50 / 1M。这不是永久价格,工程实现不应该硬编码它;应当从实际 Provider 账单和 usage 字段计算。

正确衡量缓存收益

最容易误导人的指标,是“把每次请求的百分比取平均”。10 Token 的 100% 命中和 100,000 Token 的 0% 命中,平均是 50%,但成本几乎完全由后者决定。

正确的 Token 加权命中率是:

sum(cached_input_tokens) / sum(input_tokens)

成本还需要展开为:

普通输入 × input_rate
+ 缓存读取 × cached_input_rate
+ 缓存写入 × cache_write_rate
+ 输出 × output_rate

延迟也要单独看。高命中通常减少 Prefill 时间,但排队、网络、Tool 执行、Decode 长度和推理强度都可能成为主要耗时,不能从命中率直接推导端到端加速比例。

正式验收至少应固定这些条件:相同模型与 Provider 路由、相同基础 Prompt、相同 Tool 目录与顺序、相同用户任务、相同请求数、一次不计入结果的预热,以及 baseline/candidate 各自独立的 Token 汇总。没有 cache_write_tokens 或最终 usage,就只能判定证据不完整,不能拿估算值补齐。

这次对比目前能证明什么

这次 OpenLIT 证据能证明三件事:

  1. 线上确实存在前缀被破坏后从接近 99% 突然降到 0% 的路径;
  2. 同一套业务也能形成约 97.64% Token 加权命中的稳定路径;
  3. 优化目标不是“让模型少看上下文”,而是让必要上下文以稳定、可追加、可观测的方式进入请求。

它还不能证明 Strat Thread 的候选改动已经把所有请求从紫色变成绿色。本文发布时,新的前缀合同仍未进入生产;绿色 Trace 是目标路径的真实参照,不是优化版本的验收结果。等候选版本上线后,才会用相同工作负载做受控比较,并同时核对命中、写入、总成本与延迟。

这条边界很重要。缓存优化最怕的不是命中率低,而是为了得到一张好看的图,把两条不同工作负载的曲线叫成“前后对比”。

最后一个判断

长对话 Agent 的缓存问题,说到底不是某个 Provider 参数没填对,而是程序怎样排列上下文的问题。

基础指令应该像不可变头部;能力像 append-only 事件;Tool 目录有稳定身份和顺序;动态值尽可能留在后缀;缓存断点表达变化频率;观测系统区分写入、读取和普通输入。

当这些边界清楚以后,渐进式加载和高缓存命中并不冲突。Agent 可以继续按需获得能力,同时保住此前几万 Token 已经完成的计算。真正昂贵的从来不是 Prompt 很长,而是同一段长 Prompt 被一遍遍当成第一次看到。

完 / 继续实践