要点速览
- 对长生命周期代理产品而言,提示缓存 (Prompt Caching) 直接决定成本、延迟和订阅方案可提供的额度,必须像可用性一样被严密监控。
- 提示缓存是前缀匹配机制,因此系统提示、工具定义、项目上下文、会话上下文和消息的排列顺序必须围绕共享前缀来设计。
- 中途切换模型、增删工具,或直接修改系统提示,都会导致缓存命中率大幅下降;更好的做法是用消息、状态工具和延迟加载来表达变化。
- Plan mode、Tool Search 和 compaction 这些能力都需要从“缓存安全”角度来设计,否则用户成本会被隐性放大。
- 凡是需要 fork 出侧路计算的操作,都应该尽量复用父会话的前缀,这样才能安全继承已有缓存。
原文链接 原文:English Original 作者:Thariq (@trq212),Anthropic Claude Code 开发者
工程领域里常有人说一句话:“Cache Rules Everything Around Me”,而对代理来说,这条规则同样成立。
像 Claude Code 这样可长时间运行的代理式产品,之所以能在工程上可行,很大程度上依赖提示缓存。它让我们能够复用前几轮交互里的计算结果,从而显著降低延迟和成本。什么是提示缓存,它是如何工作的,又该怎样在技术上实现?可以看看 @RLanceMartin 关于提示缓存的文章,以及我们最新的自动缓存发布说明。
在 Claude Code 中,我们整个运行框架几乎都是围绕提示缓存搭建的。高提示缓存命中率既能降低成本,也能让我们的订阅计划提供更慷慨的速率限制,所以我们会对提示缓存命中率设置告警,一旦太低就会按 SEV 级别处理。
下面这些,就是我们在大规模优化提示缓存过程中学到的经验,其中不少都和直觉不太一样。
提示缓存通过前缀匹配 (prefix matching) 工作。API 会从请求开头开始缓存,一直到每个 cache_control 断点为止。这意味着你怎样排列内容极其重要。你要尽可能让更多请求共享同一个前缀。
最佳实践是:静态内容放前面,动态内容放后面。对 Claude Code 来说,大致是这样:
这样我们就能最大化不同会话之间共享缓存命中的概率。
但这件事其实脆弱得出乎意料。我们过去就因为很多原因破坏过这种顺序,例如:把一个非常细的时间戳塞进静态系统提示里;以非确定性方式打乱工具定义顺序;修改工具参数(例如 AgentTool 可调用哪些代理)等等。
有时你放进提示里的信息会过期,比如时间变化了,或者用户修改了某个文件。你可能会本能地想直接更新提示,但那会导致缓存未命中,对用户来说成本可能相当高。
你应该考虑,能不能把这些更新改为在下一轮通过消息传入。比如在 Claude Code 里,我们会在下一条用户消息或工具结果中加入一个 <system-reminder> 标签,把更新后的信息告诉模型(例如“现在已经是周三了”),这样可以尽量保住缓存。
提示缓存是模型级隔离的,这会让提示缓存的成本计算变得相当反直觉。
如果你已经在和 Opus 的一段对话中积累了 100k token,这时想问一个其实很简单的问题,切换到 Haiku 反而可能比继续让 Opus 回答更贵,因为我们必须为 Haiku 重新构建一遍提示缓存。
如果你确实需要切换模型,最佳做法是借助子代理 (subagents)。也就是说,由 Opus 先为另一个模型准备一条“交接”消息,再把任务转过去。我们在 Claude Code 中经常这么做,比如 Explore agents 就使用 Haiku。
在对话进行过程中改变工具集合,是人们最常见的提示缓存破坏方式之一。直觉上你会觉得,只把当前真正需要的工具提供给模型才合理。但因为工具本身属于被缓存的前缀,任何增删都会让整段对话的缓存失效。
Plan mode 就是一个围绕缓存约束设计功能的典型例子。最直观的做法似乎是:当用户进入 plan mode,就把工具集合切换成只读工具。但这样会直接打破缓存。
所以我们的做法是:始终把所有工具都保留在请求里,并把 EnterPlanMode 和 ExitPlanMode 本身也设计成工具。当用户切换到 plan mode 时,代理会收到一条系统消息,说明当前处于 plan mode,以及应该遵循的规则,比如探索代码库、不要编辑文件、在计划完成后调用 ExitPlanMode。工具定义本身从不变化。
这还有一个额外好处:因为 EnterPlanMode 是模型自己可以调用的工具,所以当它识别到一个棘手问题时,就可以自主进入 plan mode,而不会打破缓存。
同样的原则也适用于我们的 tool search 功能。Claude Code 可能会一次加载数十个 MCP 工具,如果每次请求都把它们全部带上,成本会很高。但如果在会话中途把它们删掉,又会破坏缓存。
我们的解决办法是 defer_loading。我们不移除工具,而是发送轻量级 stub,也就是只给出工具名并设置 defer_loading: true,模型在需要时再通过 ToolSearch 工具去“发现”它们。只有当模型选中某个工具时,完整 schema 才会真正加载进来。这样一来,被缓存的前缀始终保持稳定:同样的 stub 总是按同样顺序存在。
好在你可以直接通过我们的 API 使用 tool search tool,这能显著简化实现。
当你耗尽上下文窗口时,就会发生 compaction。我们会先把当前对话总结一下,然后带着这份摘要继续开启一个新会话。
出人意料的是,compaction 和提示缓存之间有很多容易让人误判的边界情况。
特别是,在做 compaction 时,我们需要把完整对话重新发给模型以生成摘要。如果这是一个单独的 API 调用,而且使用了不同的系统提示、也不带工具(这属于最简单的实现方式),那么它和主对话的缓存前缀根本对不上。结果就是:那些输入 token 全都要按全价重新计算,用户成本会明显飙升。
当我们执行 compaction 时,会复用和父对话完全相同的系统提示、用户上下文、系统上下文以及工具定义。我们先拼接上父对话已有的消息,再把 compaction 提示作为一条新的用户消息附在最后。
从 API 的视角看,这个请求几乎和父对话的上一轮请求一模一样:同样的前缀、同样的工具、同样的历史。因此,父对话的缓存前缀可以被直接复用。真正新增的 token,只有 compaction 提示本身。
不过,这也意味着我们需要预留一个“compaction buffer”,这样上下文窗口里才能始终留出足够空间,容纳 compaction 消息和摘要输出 token。
Compaction 确实很棘手。但幸运的是,你不必自己把这些坑全部踩一遍。基于我们从 Claude Code 中得到的经验,我们已经把 compaction 直接内建进 API 里了,所以你也可以把这些模式直接用在自己的应用中。
Claude Code 从第一天开始就是围绕提示缓存来构建的。如果你也在做代理产品,最好同样如此。