构建 Claude Code 的经验教训:提示缓存就是一切
从 Claude Code 的实战经验中学习提示缓存的优化技巧

工程界有句老话:"Cache Rules Everything Around Me"(缓存统治一切,改编自 Wu-Tang Clan 经典歌曲),这条法则对 Agent 同样适用。
像 Claude Code 这样长时间运行的 Agent 产品,之所以能够成立,靠的就是提示缓存(prompt caching)——它让我们能够复用上一轮请求的计算结果,大幅降低延迟和成本。
什么是提示缓存?它是怎么工作的?技术上如何实现?更多细节可以阅读 @RLanceMartin 关于提示缓存和我们新推出的自动缓存功能的文章。
在 Claude Code,我们的整个挂载系统(harness)都是围绕提示缓存构建的。高的提示缓存命中率能降低成本,还能让我们为订阅计划提供更慷慨的速率限制(rate limits)。所以我们对提示缓存命中率设置了告警,一旦太低就会宣布 SEV(严重事件)。
以下是我们从大规模优化提示缓存过程中学到的(往往反直觉的)经验教训。
为缓存设计系统提示词的布局
提示缓存的工作原理是前缀匹配(prefix matching)——API 会缓存从请求开头到每个 cache_control 断点之间的所有内容。这意味着你放置内容的顺序至关重要,你希望尽可能多的请求共享同一个前缀。
最佳做法是:静态内容在前,动态内容在后。在 Claude Code 中,顺序如下:
- 静态系统提示词和工具定义(全局缓存)
- Claude.MD(项目级别缓存)
- 会话上下文(会话级别缓存)
- 对话消息
这样我们就能最大化不同会话之间的缓存命中率。
但这个顺序可能出乎意料地脆弱!我们曾经踩过的坑包括:在静态系统提示词里放了精确到秒的时间戳、工具定义的排列顺序随机化、修改了工具参数(比如 AgentTool 可以调用哪些子代理)等等。
用系统消息来传递更新信息
有时候系统提示词中的信息会过期,比如时间变了,或者用户修改了某个文件。你可能会想直接更新系统提示词,但这会导致缓存未命中(cache miss),对用户来说可能相当昂贵。
更好的做法是:在下一轮对话中发送一条系统消息,告诉模型更新后的信息(比如"现在是周三"),这样缓存就不会失效。
不要在会话中途切换模型
提示缓存是模型专属的,这让缓存的"数学"变得反直uitive。
假设你已经和 Opus 对话了 10 万个 token,然后想问一个比较简单的问题。看起来切到 Haiku 会更便宜?实际上恰恰相反——因为我们需要为 Haiku 重新构建提示缓存,切换的成本可能比直接让 Opus 回答还要高。
如果你确实需要切换模型,最好的方式是通过子代理(subagent):让 Opus 准备一个"交接"消息,把任务交给另一个模型去完成。我们在 Claude Code 的 Explore 代理(使用 Haiku)中就经常这么做。
永远不要在会话中途添加或移除工具
在对话过程中变更工具集是最常见的缓存破坏方式之一。直觉上你会觉得——应该只给模型当前需要的工具。但因为工具定义是缓存前缀的一部分,添加或移用任何一个工具都会使整个对话的缓存失效。
规划模式——围绕缓存约束来设计功能
规划模式(Plan Mode)是一个围绕缓存约束来设计功能的绝佳例子。直觉做法是:用户进入规划模式时,把工具集替换成只读工具。但这会破坏缓存。
我们的做法是:始终在请求中保留所有工具,然后把 EnterPlanMode 和 ExitPlanMode 本身也做成工具。用户开启规划模式时,代理会收到一条系统消息,说明当前处于规划模式以及相关指令——探索代码库、不要编辑文件、计划完成后调用 ExitPlanMode。工具定义始终不变。
这还有一个额外好处:因为 EnterPlanMode 是模型自己可以调用的工具,当它遇到难题时可以自主进入规划模式,而不会造成缓存中断。
工具搜索——延迟加载而非移除
同样的原则也适用于我们的工具搜索功能。Claude Code 可能加载了几十个 MCP 工具,如果每个请求都包含全部工具定义,开销会很大。但如果在对话中途移除它们,又会破坏缓存。
我们的解决方案是 defer_loading。不是移除工具,而是发送轻量的存根——只保留工具名称,加上 defer_loading: true——模型可以通过 ToolSearch 工具在需要时"发现"这些工具。完整的工具定义只有在模型实际选择时才加载。这样缓存前缀始终保持稳定:同样的存根,同样的顺序。
幸运的是,你可以直接通过我们的 API 使用工具搜索功能来简化这个流程。
上下文分叉——压缩
压缩(compaction)是当上下文窗口快用完时的处理方式:我们总结已有对话,然后在新会话中继续。
令人意外的是,压缩在提示缓存方面有很多反直觉的边界情况。
具体来说,压缩时我们需要把整个对话发送给模型来生成摘要。如果这是用不同的系统提示词、不带工具的单独 API 调用(最简单的实现方式),那和主对话的缓存前缀完全不匹配。你需要为所有这些输入 token 支付全价,成本急剧上升。
解决方案——缓存安全的分叉
执行压缩时,我们使用与父对话完全相同的系统提示词、用户上下文、系统上下文和工具定义。我们将父对话的消息前置,然后把压缩提示作为新的用户消息追加到末尾。
从 API 的角度看,这个请求和父对话的上一次请求几乎一模一样——相同的前缀、相同的工具、相同的历史记录——所以缓存前缀得以复用。唯一的新增 token 就是压缩提示本身。
不过这也意味着我们需要保留一个"压缩缓冲区",确保上下文窗口有足够的空间来容纳压缩消息和摘要的输出 token。
压缩确实很棘手,但幸运的是你不需要自己踩这些坑——基于我们在 Claude Code 中积累的经验,我们已经把压缩功能直接内置到了 API 中,你可以在自己的应用中直接使用这些模式。
经验总结
- 提示缓存是前缀匹配。 前缀中任何位置的变更都会使其后的所有内容失效。围绕这个约束来设计你的整个系统。把顺序搞对,大部分缓存就能自动生效。
- 用系统消息代替系统提示词变更。 你可能会想直接修改系统提示词来处理进入规划模式、更新日期等操作,但实际上在对话中插入系统消息效果更好。
- 不要在对话中途切换工具或模型。 用工具来建模状态转换(比如规划模式),而不是变更工具集。用延迟加载代替移除工具。
- 像监控可用性一样监控缓存命中率。 我们对缓存中断设置告警,当作严重事件处理。缓存命中率哪怕下降几个百分点,都会显著影响成本和延迟。
- 分叉操作需要共享父级前缀。 如果你需要执行一个辅助计算(压缩、摘要生成、技能执行),使用与父级相同的缓存安全参数,这样才能命中父级前缀的缓存。
Claude Code 从第一天起就是围绕提示缓存构建的。如果你在构建 Agent,也应该这样做。