agent 压缩上下文的那一下,经常是整段对话里最贵的一次请求。这时候上下文已经快满了,摘要请求要是没命中 prompt cache,就等于把十几万 token 按原价重新读一遍。
我在给自己的 SSH 客户端 Hatoba 做 AI 助手,第一版就踩了这个坑:压缩时换了一个不带工具的系统提示词,tools 也去掉了,历史里的工具调用还被压成了文本——从第一个 token 开始就和缓存对不上。
所以我翻了一圈 Claude Code、Codex、Qwen Code、OpenCode、Cline、Roo Code、pi、Gemini CLI 和 Aider 的源码、文档和 issue,看看大家是怎么处理的。
原理:前缀匹配,一个字节都不能变
prompt cache 是前缀匹配。Anthropic 的顺序是 tools → system → messages,前面任何一个字节变了,后面全部失效。
所以给摘要单独写一个系统提示词,或者把工具删掉,等于主动放弃整段缓存。
Anthropic 文档还写明了:改 tool_choice 也会让 messages 部分的缓存失效。也就是说,想用 tool_choice 禁止模型调工具,这条路同样走不通。
两派做法
看下来大家分成两派。
一派:把压缩请求当成主请求的分叉
做法是把上一次正常请求原样复制一份——工具、系统提示词、完整历史、thinking 设置都不动,只在最后多加一条总结指令。
- Claude Code 官方文档写的就是这种做法:指令附在最后一条 user 消息里,工具也留在请求里,只在文字上要求模型只输出文本、不要调工具。缓存还热的时候,
/compact基本只花输出的钱。 - Qwen Code 为这件事专门写了设计文档。主路径用同样的系统提示词、工具和完整历史,末尾加一句不要调用工具;模型如果还是调了工具、或者回了空,就丢掉结果,退回不复用缓存的独立请求再试一次。PR 里一个测试样例的缓存命中率从 0% 到了 96.7%。
- Codex 在 OpenAI、Azure、Bedrock 上走服务端压缩:请求带同样的工具、instructions 和 cache key,末尾加一个
compaction_trigger,由服务端返回一段加密的摘要。 - 旧版 Cline 更直接:把总结指令贴在下一次正常请求后面,让模型用
summarize_task工具交回摘要。
另一派:单独发一个摘要请求
用一段专门的短系统提示词,不带工具,历史要么压成一段文本,要么改写过。
OpenCode v1、现在的 Cline SDK、Roo Code、pi、Gemini CLI、Aider 都是这样。其中 Gemini CLI 还会再调一次模型,检查摘要有没有漏东西;Codex 给其他服务商用的本地压缩也把 tools 去掉了。
几个半路翻车的例子
比较有意思的是,这条路上翻过车的不少。
OpenCode V2:只发了半段历史
OpenCode V2 修过一次,PR 里明确说之前的压缩请求换了系统提示词和工具集,用不上热缓存,于是改成了共享。但它只把要被总结的那部分旧历史发出去。
有用户在 issue 里统计了 132 次压缩,缓存读取的中位数只有 4% 到 7%,按 Anthropic 计价花了 70.51 美元,如果都按缓存读取算只要 8.17 美元。原因维护者还没确认,报告人怀疑就是只发了半段历史——缓存断点和主循环对不上。
Claude Code:工具数量差了一个
Claude Code 自己也翻过车。有人在 issue 里抓包发现,开了 advisor 工具之后,压缩请求和主循环的工具数量差了一个(66 和 67),system 也有一块不一样,每次压缩都把整段对话重新写一遍缓存,后来才修掉。
Anthropic 自己的 SDK
Anthropic 自己 SDK 里之前的客户端压缩,摘要请求连 system 和 tools 都不带,现在已经删掉,换成了服务端压缩。
pi:故意不用缓存
pi 则是故意不用缓存:它给摘要请求关掉了缓存写入,代码注释的意思是一次性的摘要没必要写缓存。它也试过复用缓存的做法,第二天就回滚了,原因没写。
现在有个 PR 把它做成可选,而且要先满足几个条件:
- 模型和 thinking 设置一致;
- 距离上次请求没超过缓存有效期的 90%;
- 窗口放得下;
- 估算下来比单独请求便宜。
缓存已经过期的话,分叉请求读不到任何东西,还要多付一笔写缓存的钱——pi 加这些判断就是为了避开这种情况。
可以直接照着做的清单
想少踩坑的话,下面这几条可以直接照着做。
- 压缩请求就是上一次正常请求加一条指令。
tools、system、完整历史、模型、thinking 配置逐字节不动,指令放在最后。 - 不要靠去掉工具或者改
tool_choice来禁止调工具。 用文字要求,再加兜底:模型调了工具或者回了空,就丢掉结果,换独立请求重试。 - 发完整历史,别只发要被总结的那一段。
- 别为了省钱换小模型来压缩,缓存是按模型分的。Roo Code 干脆删了自定义压缩模型,理由是只有对话本来的模型才总结得准。
- 压缩请求是一次性的,就算要写缓存也用 5 分钟 TTL。 Claude Code 就是这么分的:主循环用 1 小时,压缩用 5 分钟。缓存已经冷了的话,可以像 pi 那样直接走不写缓存的独立请求。
- 阈值要给摘要输出留空间,用窗口减去一个预留量,别写死一个百分比:
- OpenCode V2:窗口减
max(10%, 16k); - pi:窗口减
16384; - Qwen:85% 且不超过窗口减
33k。
- OpenCode V2:窗口减
- 压缩之后的历史反正是冷的,系统提示词一定要冻结,至少
tools和system还能命中。 - 摘要用 user 消息注入,外面包一层说明——这是之前对话的摘要,不是新指令。摘要里很可能带着网页和命令输出的原文,不包这一层,它们就成了“用户说的话”。
- 清理旧的工具输出也是在改历史,从改动的位置开始缓存全部失效。要清就攒着一次清一批,Qwen 一次清到阈值的一半,免得每一轮都破坏前缀。
- 用数据验收。 看压缩请求的
cache_read_input_tokens;有条件就像 Codex 那样写个测试,直接比对压缩请求和上一次正常请求的 body。