THE CACHE THAT COULD NEVER HIT

写进去了,
却永远命不中

一次 tool_result 五分钟 prompt cache 的真实排障记录:从四个断点被占满,到“明明有 cache write”却永远读不回来。

EnvironmentNODE GATEWAY
RouteOPENROUTER
Validation modelCLAUDE SONNET 4.6
00ABSTRACT

先别被 write 骗了。

工具循环里出现 cache_creation_input_tokens,只证明供应商接受了一个缓存断点;它不证明下一次请求能复用这个前缀。

我们的缓存确实写进去了。它也确实从设计上就不可能再被命中。

真正的排障分成两层。第一层更早:Anthropic 最多只接受四个显式缓存断点,而人格、近期日记、会话摘要和滚动历史已经把名额全部占满。工具结果想挂第五个断点时,根本没有位置。

腾出一个槽位后,第二层问题才露出来:为了给仅包含工具结果的消息挂标记,网关临时塞入一段“不可见”的文本。它只存在于当次请求副本,下一轮重建请求时便消失。缓存终于能写,却仍然不可能命中。

01FIRST FAILURE

工具结果最初连断点都分不到。

显式缓存断点不是无限资源。单次请求最多四个;原布局恰好已经用了四个,工具循环只能站在门外。

这解释了最初的现象:普通多轮对话能看到稳定前缀的 cache read,但同一轮里刚返回的工具结果没有进入可复用前缀。不是 TTL 选错,也不是供应商偶发抽风;缓存预算在工具执行之前就已经耗尽。

为什么先牺牲摘要断点?

系统约在 60k context 才生成会话摘要,而实际使用通常在触发前就更换会话。为一个很少出现的块永久占用四分之一断点预算,收益远小于缓存每轮都可能出现的工具结果。

因此我们保留摘要内容,但移除它自己的 cache_control。摘要没有被删除;它仍会被后面的滚动历史断点包含在完整前缀中。释放出来的第四个槽位专门留给当前消息或最新工具结果,并使用更便宜的 5 分钟 TTL。

第一次修复解决的是“能不能写”;它还没有回答“写完能不能读”。
02MECHANISM

断点缓存的是“截至这里的完整前缀”。

Prompt caching 不是给某一小段文字贴个便利贴。一个缓存断点代表:从请求开头一路到这个块为止的全部内容,都可以构成一个可复用的前缀。后续请求必须保持这段前缀一致,供应商才能读到它。

工具调用会在同一轮中制造多次模型请求:模型先请求工具,网关返回工具结果,模型可能再请求另一个工具,最后才生成正文。短命的 5 分钟断点正适合这些紧邻的迭代。

一次工具调用通常只有 write。必须还有下一次模型请求,刚写入的工具结果才有机会被 read。

假设模型先调用工具 A:带着 A 的结果发起第二次请求时,A 才首次进入缓存;如果模型随后顺序调用工具 B,第三次请求会再次携带 A,此时 A 才真正命中。只调用一次工具便直接生成最终回复,通常没有同轮复用机会;最多期待五分钟内的下一条用户消息保持相同前缀。

“调用两次”还必须是顺序调用。若模型在同一个 assistant turn 里并行发出 A、B 两个 tool_use,两份结果会一起首次进入下一次请求;如果该请求直接生成最终回复,依然只有 write,没有后续 read。

但它有一个硬条件:工具定义、system、历史消息、thinking 参数、消息顺序,以及断点之前的每一个内容块都必须稳定。任何“只在这一次请求里出现”的文本都会破坏前缀。

03SYMPTOM

837 tokens 写入了,下一轮却没读到。

为了把供应商波动和模型路由排除掉,我们固定了账号、模型、会话和工具顺序,让同一轮依次执行两个工具。第一次受控测试得到三次迭代:

ITERATIONEVENTCACHE READCACHE WRITE
01model → tool A08,268
02tool A → tool B8,268837
03tool B → final8,2681,653

第二次迭代新写了 837 tokens。若第三次迭代复用了这段工具结果,read 理应接近 8,268 + 837 = 9,105。实际仍是 8,268。

Expected read9,105
Actual read8,268
Missing prefix837

这一步很关键:如果只看会话级汇总,我们会看到“有读、有写”,然后误判缓存工作正常。只有保留每次模型迭代的 usage,断裂才会显形。

04ROOT CAUSE

请求副本里有一段幽灵文本。

原实现需要在“只包含 tool_result、没有 text block”的用户消息末尾放一个断点。它选择追加一段用于续写的说明文字:

BEFORE / REQUEST-ONLY CONTENTBUG
// markLastCacheBreakpoint() works on a cloned request
msg.content.push({
  type: 'text',
  text: '以上是工具执行结果,请继续基于结果完成当前回复。',
  cache_control: directive
});
ITERATION 02 / WRITTEN PREFIX
tool_result: “result text”
text: “以上是工具执行结果…”
ITERATION 03 / REBUILT PREFIX
tool_result: “result text”

loopMessages 本身没有被修改;下一次迭代会重新克隆它。临时文字因此只存在于第二次请求。第二次写入的前缀包含幽灵文本,第三次拿来匹配的前缀却没有它。

缓存系统没有失灵。它只是非常诚实地拒绝了两个不同的前缀。
05FIX

元数据应该寄生在稳定内容上。

修复原则只有一句:不要为了缓存制造新的语义内容。找到最后一个现存的工具结果,把断点直接挂在它上面。

AFTER / ANTHROPIC-SHAPED MESSAGESTABLE
for (let j = msg.content.length - 1; j >= 0; j--) {
  const block = msg.content[j];
  if (block && block.type === 'tool_result') {
    block.cache_control = { type: 'ephemeral' };
    return cloned;
  }
}

由于请求最终会转换成 OpenAI-compatible 格式并交给 OpenRouter,还要确保转换层不会把元数据丢掉:

OPENROUTER CONVERSIONROLE: TOOL
{
  role: 'tool',
  tool_call_id: tr.tool_use_id,
  content: [{
    type: 'text',
    text: result,
    cache_control: { type: 'ephemeral' }
  }]
}

我们同时加了两个回归测试:一个确认 marker 留在既有 tool_result 上、没有污染原消息;另一个确认 OpenRouter 转换后,role: tool 的 content 仍携带 cache_control

06PROOF

整数闭合,但只证明第一个结果命中。

部署修复后,我们在同一个保留会话里再次强制执行 websearch → recall → final。三次迭代的缓存 usage 如下:

ITERATIONEVENTCACHE READCACHE WRITE
01model → websearch7,3921,633
02websearch → recall9,025791
03recall → final9,8161,895
7,392+1,633=9,025
9,025+791=9,816

第一组等式证明初始请求新增的 1,633 tokens 被第二次请求复用。更关键的是第二组:包含 websearch 结果的第二次请求写入 791 tokens,第三次请求的 read 恰好增加 791;因此 websearch 结果确认命中一次

recall 结果则首次出现在第三次请求,并写入 1,895 tokens。由于模型随后直接生成最终回复,没有第四次请求,所以这部分只有 write,不能声称已被 read。

Confirmed result hit1
Write only1
Useful from

单次调用可能意义不大。顺序调用两次时,第一个工具结果才开始产生缓存收益;最后一个结果仍要等下一次请求。

这比“响应变快了”更可靠,也比供应商面板上的单个 cache 标识更有解释力。延迟会受网络、排队和输出长度影响;前缀 token 的闭合关系不会。

PRIVACY NOTE
测试会话被保留用于回归,但本文只记录工具顺序与 token 指标,不包含 recall 返回的私人记忆内容。
07FINAL LAYOUT

四个断点,按变化速度排布。

最终布局把昂贵、稳定的前缀放前面并使用 1 小时 TTL,把同一轮工具循环的尾部放最后并使用 5 分钟 TTL。会话摘要仍会注入,但不再浪费一个独立断点槽位;它会自然包含在后续滚动前缀里。

01
Personality / Profile
最稳定,跨多轮复用
1 HOUR
02
Recent diary / memo
约每日变化
1 HOUR
03
Rolling history
会话内增量变化
1 HOUR
04
Current / tool result tail
供后续模型迭代复用
5 MIN

长 TTL 必须出现在短 TTL 之前。除了符合前缀从稳定到易变的自然顺序,也避免混合 TTL 的请求被供应商拒绝或产生不可预期的缓存行为。

08FIELD CHECKLIST

下次排工具缓存,先看这八件事。

  • 先数整份请求里的显式缓存断点;四个名额用完后,工具结果没有第五个槽位。
  • 保留每次模型迭代的 usage,不要只看整轮合并总数。
  • 把 cache write 当作候选证据,不要把它当作命中证明。
  • 固定供应商、模型、会话、工具定义和 thinking 参数,先排除路由漂移。
  • 检查断点前是否出现临时提示、格式转换或顺序变化。
  • 尽量把 cache_control 附着在已经存在且会跨迭代保留的语义块上。
  • 确认协议转换层没有折叠 content,也没有丢失缓存元数据。
  • 上一轮 read + write = 下一轮 read 验证连续工具结果是否真正复用。
SECONDARY FINDING
缓存链修好后,测试还暴露出另一件独立的问题:搜索工具可能工作正常,但检索源未必能找到目标官方页面。缓存命中率和搜索召回率是两条完全不同的故障轴,不要混成一个问题排。
09REFERENCES

协议与实现参考。

  1. Anthropic — Prompt caching
  2. Anthropic — Tool use with prompt caching
  3. Anthropic — Tool runner and tool-result cache metadata
  4. OpenRouter — Prompt caching guide

All cache metrics were recorded from one fixed-model controlled session on 2026—07—17.