先别被 write 骗了。
工具循环里出现 cache_creation_input_tokens,只证明供应商接受了一个缓存断点;它不证明下一次请求能复用这个前缀。
真正的排障分成两层。第一层更早:Anthropic 最多只接受四个显式缓存断点,而人格、近期日记、会话摘要和滚动历史已经把名额全部占满。工具结果想挂第五个断点时,根本没有位置。
腾出一个槽位后,第二层问题才露出来:为了给仅包含工具结果的消息挂标记,网关临时塞入一段“不可见”的文本。它只存在于当次请求副本,下一轮重建请求时便消失。缓存终于能写,却仍然不可能命中。
工具结果最初连断点都分不到。
显式缓存断点不是无限资源。单次请求最多四个;原布局恰好已经用了四个,工具循环只能站在门外。
tool_result这解释了最初的现象:普通多轮对话能看到稳定前缀的 cache read,但同一轮里刚返回的工具结果没有进入可复用前缀。不是 TTL 选错,也不是供应商偶发抽风;缓存预算在工具执行之前就已经耗尽。
为什么先牺牲摘要断点?
系统约在 60k context 才生成会话摘要,而实际使用通常在触发前就更换会话。为一个很少出现的块永久占用四分之一断点预算,收益远小于缓存每轮都可能出现的工具结果。
因此我们保留摘要内容,但移除它自己的 cache_control。摘要没有被删除;它仍会被后面的滚动历史断点包含在完整前缀中。释放出来的第四个槽位专门留给当前消息或最新工具结果,并使用更便宜的 5 分钟 TTL。
断点缓存的是“截至这里的完整前缀”。
Prompt caching 不是给某一小段文字贴个便利贴。一个缓存断点代表:从请求开头一路到这个块为止的全部内容,都可以构成一个可复用的前缀。后续请求必须保持这段前缀一致,供应商才能读到它。
工具调用会在同一轮中制造多次模型请求:模型先请求工具,网关返回工具结果,模型可能再请求另一个工具,最后才生成正文。短命的 5 分钟断点正适合这些紧邻的迭代。
假设模型先调用工具 A:带着 A 的结果发起第二次请求时,A 才首次进入缓存;如果模型随后顺序调用工具 B,第三次请求会再次携带 A,此时 A 才真正命中。只调用一次工具便直接生成最终回复,通常没有同轮复用机会;最多期待五分钟内的下一条用户消息保持相同前缀。
“调用两次”还必须是顺序调用。若模型在同一个 assistant turn 里并行发出 A、B 两个 tool_use,两份结果会一起首次进入下一次请求;如果该请求直接生成最终回复,依然只有 write,没有后续 read。
但它有一个硬条件:工具定义、system、历史消息、thinking 参数、消息顺序,以及断点之前的每一个内容块都必须稳定。任何“只在这一次请求里出现”的文本都会破坏前缀。
837 tokens 写入了,下一轮却没读到。
为了把供应商波动和模型路由排除掉,我们固定了账号、模型、会话和工具顺序,让同一轮依次执行两个工具。第一次受控测试得到三次迭代:
| ITERATION | EVENT | CACHE READ | CACHE WRITE |
|---|---|---|---|
| 01 | model → tool A | 0 | 8,268 |
| 02 | tool A → tool B | 8,268 | 837 |
| 03 | tool B → final | 8,268 | 1,653 |
第二次迭代新写了 837 tokens。若第三次迭代复用了这段工具结果,read 理应接近 8,268 + 837 = 9,105。实际仍是 8,268。
这一步很关键:如果只看会话级汇总,我们会看到“有读、有写”,然后误判缓存工作正常。只有保留每次模型迭代的 usage,断裂才会显形。
请求副本里有一段幽灵文本。
原实现需要在“只包含 tool_result、没有 text block”的用户消息末尾放一个断点。它选择追加一段用于续写的说明文字:
// markLastCacheBreakpoint() works on a cloned request
msg.content.push({
type: 'text',
text: '以上是工具执行结果,请继续基于结果完成当前回复。',
cache_control: directive
});
loopMessages 本身没有被修改;下一次迭代会重新克隆它。临时文字因此只存在于第二次请求。第二次写入的前缀包含幽灵文本,第三次拿来匹配的前缀却没有它。
元数据应该寄生在稳定内容上。
修复原则只有一句:不要为了缓存制造新的语义内容。找到最后一个现存的工具结果,把断点直接挂在它上面。
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,还要确保转换层不会把元数据丢掉:
{
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。
整数闭合,但只证明第一个结果命中。
部署修复后,我们在同一个保留会话里再次强制执行 websearch → recall → final。三次迭代的缓存 usage 如下:
| ITERATION | EVENT | CACHE READ | CACHE WRITE |
|---|---|---|---|
| 01 | model → websearch | 7,392 | 1,633 |
| 02 | websearch → recall | 9,025 | 791 |
| 03 | recall → final | 9,816 | 1,895 |
第一组等式证明初始请求新增的 1,633 tokens 被第二次请求复用。更关键的是第二组:包含 websearch 结果的第二次请求写入 791 tokens,第三次请求的 read 恰好增加 791;因此 websearch 结果确认命中一次。
recall 结果则首次出现在第三次请求,并写入 1,895 tokens。由于模型随后直接生成最终回复,没有第四次请求,所以这部分只有 write,不能声称已被 read。
单次调用可能意义不大。顺序调用两次时,第一个工具结果才开始产生缓存收益;最后一个结果仍要等下一次请求。
这比“响应变快了”更可靠,也比供应商面板上的单个 cache 标识更有解释力。延迟会受网络、排队和输出长度影响;前缀 token 的闭合关系不会。
测试会话被保留用于回归,但本文只记录工具顺序与 token 指标,不包含 recall 返回的私人记忆内容。
四个断点,按变化速度排布。
最终布局把昂贵、稳定的前缀放前面并使用 1 小时 TTL,把同一轮工具循环的尾部放最后并使用 5 分钟 TTL。会话摘要仍会注入,但不再浪费一个独立断点槽位;它会自然包含在后续滚动前缀里。
最稳定,跨多轮复用
约每日变化
会话内增量变化
供后续模型迭代复用
长 TTL 必须出现在短 TTL 之前。除了符合前缀从稳定到易变的自然顺序,也避免混合 TTL 的请求被供应商拒绝或产生不可预期的缓存行为。
下次排工具缓存,先看这八件事。
- 先数整份请求里的显式缓存断点;四个名额用完后,工具结果没有第五个槽位。
- 保留每次模型迭代的 usage,不要只看整轮合并总数。
- 把 cache write 当作候选证据,不要把它当作命中证明。
- 固定供应商、模型、会话、工具定义和 thinking 参数,先排除路由漂移。
- 检查断点前是否出现临时提示、格式转换或顺序变化。
- 尽量把
cache_control附着在已经存在且会跨迭代保留的语义块上。 - 确认协议转换层没有折叠 content,也没有丢失缓存元数据。
- 用
上一轮 read + write = 下一轮 read验证连续工具结果是否真正复用。
缓存链修好后,测试还暴露出另一件独立的问题:搜索工具可能工作正常,但检索源未必能找到目标官方页面。缓存命中率和搜索召回率是两条完全不同的故障轴,不要混成一个问题排。