构建 Claude Code 的经验教训:提示缓存就是一切
在 Claude Code 中优化提示缓存的最佳实践,包括如何最有效地构建提示、使用工具以及叠加压缩。
工程领域常说“缓存主宰我周围的一切”,同样的规则也适用于智能体。
像 Claude Code 这样的长时运行智能体产品之所以可行,得益于 提示词缓存,它让我们能够复用前几轮往返的计算结果,显著降低延迟和成本。
在 Claude Code,我们围绕提示词缓存构建了整个运行框架。较高的提示词缓存命中率能降低成本,并帮助我们为订阅计划制定更宽松的速率限制,因此我们会对提示词缓存命中率运行告警,命中率过低时会宣布 SEV。
以下是我们大规模优化提示词缓存时学到的(往往有违直觉的)经验教训。
为缓存设计你的提示词结构
提示词缓存通过前缀匹配工作——API 会缓存从请求开头到每个 cache_control 断点的所有内容。这意味着你放入内容的顺序至关重要,你希望尽可能多的请求共享一个前缀。
提示词的结构不仅影响缓存命中,也影响输出质量——提示词工程基础是这门学科的另一半。
实现这一点的最佳方式是静态内容在前,动态内容在后。对 Claude Code 来说,结构如下:
- 静态系统提示词 & 工具(全局缓存)
- CLAUDE.md(在项目内缓存)
- 会话上下文(在会话内缓存)
- 对话消息
这样我们就能最大化共享缓存命中的会话数量。
但这种方法可能出人意料地脆弱。我们曾因各种原因破坏过这种顺序,包括:在静态系统提示词中放入精确到秒的时间戳、以非确定性方式打乱工具顺序定义,以及更新工具的参数(例如 Agent 工具可以调用哪些智能体)。
用消息进行更新
有时你放入提示词的信息会过时,例如时间变化或用户修改了文件。更新提示词可能很诱人,但这会导致缓存未命中,最终可能让用户付出相当高的代价。
考虑是否可以在智能体的下一轮通过消息传递这些信息。在 Claude Code 中,我们在下一条用户消息或工具结果中添加 <system-reminder> 标签,向模型提供更新后的信息,这有助于保留缓存。
不要在会话中途更换模型
提示词缓存是模型特有的,这使得提示词缓存的计算相当反直觉。
例如,如果你与 Opus 的对话已进行到 10 万 token,而你想问一个相当容易回答的问题,切换到 Haiku 实际上比让 Opus 回答更昂贵,因为我们需要为 Haiku 重建提示词缓存。
如果需要切换模型,最佳方式是使用子智能体;延续上面的例子,你可以部署一个子智能体,让 Opus 准备一条“交接”消息,将它需要完成的任务传递给另一个模型。我们经常在 Claude Code 的 Explore 智能体中这样做,它们使用 Haiku。
绝不要在会话中途添加或移除工具
在对话中途更换工具集是人们破坏提示缓存最常见的方法之一。这似乎很直观——你只应提供模型当前所需的工具。但由于工具是缓存前缀的一部分,添加或删除工具会使整个对话的缓存失效。
利用计划模式围绕缓存进行设计
Plan Mode 是围绕缓存约束进行功能设计的一个绝佳例子。直观的做法是:当用户进入计划模式时,将工具集替换为仅包含只读工具,但这会破坏缓存。
相反,我们始终在请求中保留所有工具,并将 EnterPlanMode 和 ExitPlanMode 本身作为工具。当用户开启计划模式时,智能体会收到一条系统消息,说明它处于计划模式以及相应的指令:探索代码库、不要编辑文件,并在计划完成时调用 ExitPlanMode。工具定义从不改变。
这还有一个额外的好处:由于 EnterPlanMode 是模型可以自行调用的工具,它可以在检测到难题时自主进入计划模式,而不会破坏任何缓存。
使用工具搜索来延迟而非移除
同样的原则也适用于我们的工具搜索工具。Claude Code 可以加载数十个 MCP 工具,在每个请求中包含所有工具会很昂贵,但在对话中途移除它们会破坏缓存。
我们的解决方案是:defer_loading。我们不移除工具,而是发送轻量级存根(仅包含工具名称,带有 defer_loading: true),模型可以在需要时通过工具搜索“发现”它们。完整的工具架构仅在模型选择它们时才加载。这使缓存前缀保持稳定,因为相同的存根始终以相同的顺序出现。
你也可以通过我们的 API 使用工具搜索工具来简化这一过程。
不破坏缓存的压缩
Compaction(压缩)是在上下文窗口用尽时发生的事情。我们总结到目前为止的对话,并用该总结继续新的会话。
压缩与提示缓存的交互方式很容易出错。要压缩对话,你必须将完整对话发送给模型,以便它撰写摘要。最简单的方法是进行一次单独的 API 调用,使用自己的系统提示(类似“总结这个”)且不附带工具,但这正是成本陷阱所在。提示缓存仅在请求的前缀从头开始逐字节匹配已缓存内容时才适用。你的主要对话在一个系统提示和工具集下被缓存;总结调用使用不同的系统提示且没有工具,因此前缀在第一个标记处就分道扬镳,缓存完全不适用。你最终要为发送的整个对话支付完整的、未缓存的输入费率——而且对话越长(即你最初越需要压缩),那一次调用就越昂贵。
解决方案:缓存安全的分叉
当我们执行压缩时,我们使用与父对话完全相同的系统提示、用户上下文、系统上下文和工具定义。我们将父对话的消息前置,然后将压缩提示作为新的用户消息附加在末尾。
从 API 的角度来看,该请求与父请求的上一个请求几乎完全相同——相同的前缀、相同的工具、相同的历史记录——因此会复用缓存前缀。唯一的新 token 是压缩提示本身。
但这确实意味着我们需要保存一个“压缩缓冲区”,以便在上下文窗口中有足够的空间来包含压缩消息和摘要输出 token。
压缩很棘手,但幸运的是,你不需要亲自吸取这些教训——基于我们在 Claude Code 中的经验,我们直接在 API 中构建了 compaction,这样你就可以在自己的应用中应用这些模式。
经验教训
以下是我们在构建 agent 时发现的用于优化提示缓存的一些模式:
- 提示缓存是前缀匹配。前缀中任何位置的任何更改都会使其之后的所有内容失效。围绕这一约束设计你的整个系统。把顺序搞对,大部分缓存就能自动生效。
- 使用消息而不是修改系统提示。你可能会想编辑系统提示来执行诸如进入计划模式、更改日期等操作,但实际上在对话过程中将这些内容插入消息中会更好。
- 不要在对话中途更改工具或模型。使用工具来建模状态转换(如计划模式),而不是更改工具集。延迟加载工具,而不是移除工具。
- 像监控正常运行时间一样监控你的缓存命中率。我们会对缓存中断发出警报,并将其视为事故。几个百分点的缓存未命中率会显著影响成本和延迟。
- 分叉操作需要共享父请求的前缀。如果你需要运行一个旁路计算(压缩、摘要、技能执行),请使用相同的缓存安全参数,以便你能命中父请求的前缀。
Claude Code 从第一天起就围绕提示缓存构建;为了在构建 agent 时获得最佳效果,我们也建议你这样做。
立即开始使用 Claude Code。
本文由 Claude Code 团队的技术成员 Thariq Shihipar 撰写。