Claude Code 团队复盘构建经验:提示词缓存决定一切
Lessons from building Claude Code: Prompt caching is everything
Anthropic Claude Code 团队成员 Thariq Shihipar 发文分享该 Agent 围绕提示词缓存构建的经验:缓存按前缀逐字节匹配,前缀中任何改动都会使其后全部失效。
作者来自 Claude Code 团队,把缓存命中率当事故监控,给出的排序、fork 与工具加载模式可直接迁移到自己的 Agent 工程。
工程界常说“cache rules everything around me”,这条规则对 agent 同样适用。
像 Claude Code 这样长时间运行的 agent 产品之所以可行,靠的是 prompt caching,它让我们能够复用之前往返计算的结果,并显著降低延迟和成本。
在 Claude Code,我们整个 harness 都是围绕 prompt caching 构建的。高 prompt cache 命中率能降低成本,并帮助我们为订阅计划设定更宽松的速率限制,因此我们对 prompt cache 命中率设置了告警,一旦过低就宣布 SEV。
这些是我们在规模化优化 prompt caching 过程中学到的(往往反直觉的)经验。
为缓存布局你的 prompt

Prompt caching 通过前缀匹配工作——API 会缓存从请求开头到每个 cache_control 断点之间的所有内容。这意味着你放置内容的顺序极其重要,你希望尽可能多的请求共享同一个前缀。
如何组织 prompt 不仅影响缓存命中,也影响输出质量——prompt engineering 基础是这门技艺的另一半。
最好的做法是静态内容在前,动态内容在后。对 Claude Code 来说,这看起来像:
- 静态 system prompt 与工具(全局缓存)
- CLAUDE.md(在项目内缓存)
- 会话上下文(在会话内缓存)
- 对话消息
这样我们就能最大化共享缓存命中的会话数量。
但这种方法可能出奇地脆弱。我们曾因各种原因打破过这种顺序,包括:在静态 system prompt 中放入详细的 timestamp、以非确定性的方式打乱工具顺序定义,以及更新工具的参数(例如 Agent 工具可以调用哪些 agent)。
用消息来传递更新
有时你放入 prompt 的信息会过时,例如时间变化或用户修改了文件。你可能会想更新 prompt,但这会导致缓存未命中,并可能给用户带来相当高的成本。
考虑是否可以在 agent 的下一轮中通过消息传入这些信息。在 Claude Code 中,我们在下一条用户消息或工具结果中加入一个 <system-reminder> 标签,向模型提供更新后的信息,这有助于保留缓存。
不要在会话中途更换模型
Prompt cache 是模型独有的,这会让 prompt caching 的账算起来相当反直觉。
例如,如果你与 Opus 的对话已经进行到 100k tokens,想提一个相当容易回答的问题,切换到 Haiku 实际上会比让 Opus 回答更贵,因为我们需要为 Haiku 重建 prompt cache。
如果你需要切换模型,最好的方式是通过 subagent;沿用上面的例子,你可以部署一个 subagent,让 Opus 为另一个模型准备一条关于待完成任务的“交接”消息。我们经常用 Claude Code 的 Explore agent 这样做,它们使用的是 Haiku。
绝不要在会话中途添加或移除工具
在对话中途更改工具集是人们破坏提示缓存最常见的方式之一。这看起来很直观——你应该只给模型你认为它现在需要的工具。但由于工具是缓存前缀的一部分,添加或移除工具会使整个对话的缓存失效。
使用 Plan Mode 围绕缓存进行设计
Plan Mode 是围绕缓存约束设计功能的一个很好的例子。直观的做法是:当用户进入 plan mode 时,替换工具集,只包含只读工具,但那样会破坏缓存。
相反,我们始终在请求中保留所有工具,并将 EnterPlanMode 和 ExitPlanMode 本身作为工具使用。当用户开启 Plan Mode 时,agent 会收到一条系统消息,说明它处于 Plan Mode 以及相关指令:探索代码库、不要编辑文件,并在计划完成时调用 ExitPlanMode。工具定义从不改变。
这还有一个额外的好处:因为 EnterPlanMode 是模型可以自行调用的工具,所以当它检测到难题时,可以自主进入 plan mode,而不会造成任何缓存中断。
使用工具搜索来延迟加载而非移除
同样的原则也适用于我们的工具搜索工具。Claude Code 可以加载数十个 MCP 工具,将所有这些工具都包含在每个请求中会很昂贵,但在对话中途移除它们会破坏缓存。
我们的解决方案:defer_loading。我们不移除工具,而是发送轻量级存根(仅包含工具名称,带有 defer_loading: true),模型可以在需要时通过工具搜索来“发现”它们。完整的工具 schema 仅在模型选择它们时才加载。这保持了缓存前缀的稳定,因为相同的存根始终以相同的顺序存在。
你也可以通过我们的 API 使用工具搜索工具来简化这一过程。
在不破坏缓存的情况下进行压缩

压缩是指当上下文窗口用尽时发生的情况。我们总结到目前为止的对话,并用该摘要继续一个新会话。
压缩与提示缓存的交互方式很容易出错。要压缩对话,你必须将完整对话发送给模型,以便它撰写摘要。最简单的方法是使用一个单独的 API 调用,带有自己的系统提示(类似“总结这个”)且不附加任何工具,但这正是成本陷阱所在。提示缓存仅在请求的前缀与已缓存内容从头开始逐字节匹配时才适用。你的主对话缓存在一个系统提示和工具集下;总结调用使用不同的系统提示且没有工具,因此前缀在第一个 token 处就出现分歧,缓存完全不适用。你最终要为发送的整个对话支付全额、未缓存的输入费率——而且对话越长(即你越需要压缩),那一次调用就越昂贵。
解决方案:缓存安全的派生
当我们运行压缩时,我们使用与父对话完全相同的系统提示、用户上下文、系统上下文和工具定义。我们在前面加上父对话的消息,然后在末尾将压缩提示作为新的用户消息追加。
从 API 的角度来看,这个请求几乎与父级的最后一次请求完全相同——相同的前缀、相同的工具、相同的对话历史——因此缓存的前缀会被复用。唯一新增的 token 是压缩提示本身。
不过,这确实意味着我们需要保存一个“压缩缓冲区”,以便在上下文窗口中有足够的空间来容纳压缩消息和摘要输出的 token。
压缩处理起来很棘手,但幸运的是,你不需要自己去摸索这些经验——基于我们从 Claude Code 中获得的经验,我们已将压缩功能直接构建到 API 中,因此你可以在自己的应用中应用这些模式。
经验教训
以下是我们发现的、在构建 agent 时优化提示缓存的一些有用模式:
- 提示缓存是一种前缀匹配。前缀中任何位置的任何更改都会使其后的所有内容失效。围绕这一约束来设计你的整个系统。把顺序搞对,大部分缓存就能免费生效。
- 使用消息而不是修改系统提示。你可能会想通过编辑系统提示来实现诸如进入计划模式、更改日期等操作,但实际上更好的做法是在对话过程中将这些内容插入到消息中。
- 不要在对话中途更改工具或模型。使用工具来建模状态转换(如计划模式),而不是更改工具集。推迟工具加载,而不是移除工具。
- 像监控正常运行时间一样监控你的缓存命中率。我们对缓存中断进行告警,并将其视为事故。几个百分点的缓存未命中率就可能对成本和延迟产生巨大影响。
- 分叉操作需要共享父级的前缀。如果你需要运行旁路计算(压缩、摘要、技能执行),请使用完全相同的缓存安全参数,这样你就能在父级前缀上获得缓存命中。
Claude Code 从第一天起就是围绕提示缓存构建的;在构建 agent 时,为了获得最佳效果,我们建议你也这样做。
立即开始使用 Claude Code。
本文由 Claude Code 团队的技术人员 Thariq Shihipar 撰写。
来源:Anthropic:Claude.dev 开发者博客 · claude.dev