构建Claude Code的经验教训:Prompt 缓存就是一切
Anthropic分享Claude Code开发经验:通过提示词缓存优化降低60-90%成本,涵盖提示结构设计、缓存命中率监控、工具延迟加载及上下文压缩等关键策略。
"Cache Rules Everything Around Me",这条工程格言同样适用于 AI Agent。
像Claude Code这样的长时间运行的Agentic产品,之所以能够实现,得益于提示缓存(prompt caching)技术,这项技术允许我们复用之前交互轮次中的计算结果,从而显著降低延迟和成本。关于什么是提示词缓存和我们在技术上是如何实现的,请看这篇文章。
在 Claude Code,我们围绕提示缓存构建了整个技术框架。较高的提示缓存命中率不但能帮助我们降低成本,并帮助我们为订阅计划提供更好的响应速度和更少的速率限制。因此,我们对提示缓存命中率设置了监控告警,一旦命中率过低,就会宣布进入故障事件(SEV)处理流程。
以下是我们在大规模优化提示缓存过程中总结出的经验教训(这些经验往往是反直觉的)。
为缓存而布局你的提示词
提示缓存通过前缀匹配的方式来工作,API会缓存从请求开头到每cache_control 块之间的所有内容。这意味着你放置内容的顺序至关重要,你需要让尽可能多的请求共享相同的前缀。
最好的方法是,就是前面放置静态内容,后面放置动态内容,对Claude来说就像下面这样的
静态系统提示词和工具定义(全局缓存,跨会话复用)
CLAUDE.md 配置(项目级缓存)
会话上下文(会话级缓存)
对话消息(逐轮追加)
通过这种方式,我们最大化了不同会话之间共享缓存命中的数量。但它也是出乎意料地脆弱!我们曾因以下原因破坏过这个顺序:在静态系统提示中放入了详细的时间戳、非确定性地打乱工具定义的顺序、更新工具的参数(例如 AgentTool 可以调用哪些 agent)等等。
用消息来传递更新内容
有时候你放在提示词中的信息可能会过时,例如时间发生变化或用户修改了某个文件。你可能会想要直接更新提示词,但这样做会导致缓存未命中,最终可能给用户带来相当高的成本。
考虑一下你是否可以在下一轮对话中通过消息来传递这些信息。在 Claude Code 中,我们会在下一条用户消息或工具结果中添加一<system-reminder>标签,其中包含给模型的更新信息(例如"现在是星期三"),这样有助于保留缓存。
永远不要在会话中途添加或移除工具
在对话中途更改工具集是人们破坏提示缓存的最常见方式之一。这看起来很合理,我们应该只给模型提供我们认为它当前需要的工具。但因为工具是缓存前缀的一部分,添加或移除一个工具会导致整个对话的缓存失效。
Plan模式,围绕cache设计
Plan模式是一个围绕缓存约束来进行功能设计的绝佳案例。直觉上的做法是:当用户进入Plan模式时,将工具集替换为只包含只读工具。但这样做会破坏缓存。
相反,我们始终在请求中保留所有工具,并EnterPlanMode ExitPlanMode本身也设计为工具。当用户开启 Plan模式时,Agent会收到一条系统消息,说明它正处于Plan模式以及相应的指令,浏览代码库、不要编辑文件、计划完成后调用 ExitPlanMode。工具定义始终不变。
这还带来了一个额外的好处:因EnterPlanMode是模型自身可以调用的工具,它可以在检测到困难问题时自主进入Plan模式,而不会造成任何缓存中断。
工具搜索功能,延迟加载而非移除
同样的原则也适用于我们的工具搜索功能。Claude Code 可能加载了数十个MCP 工具,将它们全部包含在每个请求中会很昂贵。但在对话中途移除它们又会破坏缓存。
我们的解决方案是:延迟加载。我们不移除工具,而是发送轻量级的数据,它仅包含工具名称,并标记 defer_loading: true,模型可以在需要时通过 ToolSearch工具来"发现"它们。完整的工具 schema 只在模型选择使用时才被加载。这样可以保持缓存前缀的稳定性:相同的存根始终以相同的顺序存在。
幸运的是,你可以通过我们的 API 使用工具搜索工具来简化这一过程。
上下文分支:压缩
压缩操作将会放生在上下文窗口耗尽时。我们会将之前的对话进行总结,然后用该摘要开启一个新的会话继续对话。
令人意外的是,压缩在提示缓存方面存在许多反直觉的边界情况。
特别是,当我们进行压缩时,需要将整个对话发送给模型来生成摘要。如果这是一个使用不同系统提示且没有工具的单独 API调用(这是最简单的实现方式),那么主对话的缓存前缀将完全无法匹配。你需要为所有这些输入 token 支付全价,从而大幅增加用户的成本。
解决方案:缓存安全的上下文分支
当我们执行压缩时,我们使用与父对话完全相同的系统提示、用户上下文、系统上下文和工具定义。我们将父对话的消息放在前面,然后将压缩提示作为新的用户消息追加到末尾。
从 API 的角度来看,这个请求与父对话的最后一个请求几乎完全相同,相同的前缀、相同的工具、相同的历史记录,因此前缀缓存得以复用。唯一新增的token只是压缩提示本身。
这意味着我们需要保存一个"压缩缓冲区",这样我们的上下文窗口中就有足够的空间来容纳压缩消息和生成摘要的输出 token。
压缩确实棘手,但幸运的是,用户不需要自己去踩这些坑,基于我们在 Claude Code 中积累的经验,我们已经将压缩功能直接内置到了 API 中,这样用户就可以在自己的应用中直接应用这些模式。
学到什么
提示缓存是前缀匹配。前缀中任何位置的任何改动都会使其之后的所有缓存失效。围绕这一约束来设计我们的整个系统。把顺序搞对了,大部分缓存就能自然生效。
用消息代替系统提示词的修改。用户可能会倾向于通过编辑系统提示来实现诸如进入 plan 模式、更改日期等操作,但实际上更好的做法是在对话过程中将这些信息插入到消息中。
不要在对话中途更改工具或模型。使用工具来建模状态转换(如 plan 模式),而不是更改工具集。用延迟加载工具来替代移除工具。
像监控正常运行时间一样监控你的缓存命中率。我们对缓存中断设置了告警,并将其视为故障事件处理。仅仅几个百分点的缓存未命中率就可能对成本和延迟产生巨大影响。
分支操作需要共享父对话的前缀。如果用户需要运行附属计算(压缩、摘要、技能执行),请使用相同的缓存安全参数,这样用户就能命中父对话前缀的缓存。
译者评注
像 Claude Code 这样的长时间运行 Agent 产品之所以可行,是因为 prompt 缓存允许Claude复用之前轮次的计算结果,从而大幅降低延迟和成本。而prompt缓存底层的技术是KVCache,是所有的大语言模型供应商都有的技术,只是在实现上存在一些差异。
我们在构建Agent应用的时候,也要充分学习Claude Code的经验,将prompt缓存放在第一位,应用应当围绕缓存进行设计。其中prompt顺序需要我们在开发Agent时候需要注意,在上下文耗尽时候用该摘要开始新的会话,并且压缩时复用父对话的系统提示词和工具定义,从而保持缓存前缀一致性,也是值得我们学习和借鉴的。
原文:Lessons from Building Claude Code: Prompt Caching Is Everything