摘要:Plan Mode:用工具表达状态转换
Claude Code 的 Plan Mode 是这个原则的经典实践。Claude Code 的解法是:始终保留全部工具定义,把进入 Plan Mode(EnterPlanMode) 和 退出 Plan Mode(ExitPlanMode)Claude Code 的做法是「cache-safe forking」:压缩调用使用与父会话完全相同的系统提示、用户上下文、系统上下文和工具定义,仅把压缩指令作为最后一条用户消息追加。
Claude Code 围绕 Prompt Caching 构建,Anthropic 团队在官方 Blog 中分享了他们在Claude Code 建设中总结的缓存策略,如果你也在建设 Agent,你也应该从一开始就把缓存纳入设计。这篇文章是对原文的理解和总结~
Claude Code 的 Plan Mode 是这个原则的经典实践。按理来说,Plan Mode 只能读,不能写,那么我在进入Plan Mode时应该只给模型保留「read_file」工具,防止它乱改代码。但如果真的这样做,就等于在会话中途替换了一套工具定义——前缀从第一行开始就变了,缓存彻底失效。
Claude Code 的解法是:始终保留全部工具定义,把进入 Plan Mode(EnterPlanMode) 和 退出 Plan Mode(ExitPlanMode)本身也设计成工具。当模型调用 EnterPlanMode 时,它会收到一条系统消息,说明「你现在处于计划模式,只能读取不能修改,完成后再调用 ExitPlanMode」。工具定义从头到尾一个字都没变,缓存前缀完全不受影响。
Claude Code 的做法是「cache-safe forking」:压缩调用使用与父会话完全相同的系统提示、用户上下文、系统上下文和工具定义,仅把压缩指令作为最后一条用户消息追加。从 API 的角度看,这个请求和父会话的上一个请求几乎一模一样——同样的前缀、同样的工具、同样的历史——所以缓存被完整复用。唯一新增的 token 只有压缩指令本身。
Claude Code 围绕 Prompt Caching 构建,Anthropic 团队在官方 Blog 中分享了他们在Claude Code 建设中总结的缓存策略,如果你也在建设 Agent,你也应该从一开始就把缓存纳入设计。这篇文章是对原文的理解和总结~
背景:Prompt Caching 为什么如此重要
为什么重要:从 LLM 推理的两个阶段说起
LLM 每次处理请求时,内部分为两个阶段:
Prefill 是成本大头。一个 100k token 的提示词,Prefill 可能耗时数秒,而 Decode 生成每个 token 只需毫秒级。
在多轮对话中,每次新请求都会把之前的全部内容再发一遍——系统提示(System Prompt)、工具定义、历史消息拼在一起,形成一串很长的序列。这串序列从开头到某个位置的连续片段,就叫「前缀」——必须从开头开始,中间不能跳。如果上一轮和这一轮的前缀完全一样,那部分内容的 KV Cache 就可以直接复用,不用重新 Prefill。
Prompt Caching 的本质就是跨请求复用前缀的 KV Cache。 模型仍然「看到」完整文本,输出不会有任何差异——变的只是你等多久收到回复,以及 API 收你多少钱。
打个比方:每次做一套 100 题的卷子,你都必须从第 1 题看到第 100 题。但第一次做时,你把前 80 题的详细推导写在了草稿纸上。第二次考试时,你仍要看完整套卷子,但前 80 题直接复用草稿不用重新推导。
在哪一层生效
在开发原生 Agent 的时候会调用 LLM API,你的代码大概长这样——没有任何缓存:
import requests response = requests.post( 'https://api.example.com/v1/chat/completions', json={ 'model': 'gpt-4', 'messages': [ {'role': 'system', 'content': 'You are a helpful assistant.'}, {'role': 'user', 'content': 'Hello!'} ] } )而 Claude Code 内部调用 Anthropic Claude API 可能长这样:
from anthropic import Anthropic client = Anthropic() response = client.messages.create( model='claude-3-5-sonnet-20241022', max_tokens=1024, system=[ {'type': 'text', 'text': 'You are a helpful assistant.'}, {'type': 'cache_control', 'cache_type': 'ephemeral'} # ← 缓存标记 ], messages=[...] )通过 cache_control 标记告诉 API 平台「前面的内容请缓存」。API 会缓存从请求开始到每个
cache_control断点之间的所有内容。最佳实践:五条缓存优化策略
以下是 Claude Code 团队基于这个约束总结,在harness中实践缓存的五条策略。
对提示词排序:越不变的越靠前
前缀匹配是严格的逐字节比对,第 5 页改了一个逗号,后面 95 页的 KV Cache 全部作废,那么提示词里各模块的排列顺序就直接决定了缓存效率。Claude Code 的排序原则是:越不变的东西越靠前,越常变的东西越靠后。
具体排序如下:
这个顺序让尽可能多的会话共享同一段前缀。在 Claude Code 中,cache_control 断点被策略性地放在这些层级之间:系统提示词和工具定义之后是全局缓存(所有用户共享);CLAUDE.md 之后是项目级缓存(跨会话共享);会话上下文之后是会话级缓存(同一次对话内共享)。每一层断点都把前面稳定的内容「冻结」,只让后面动态增长的部分逐轮变化。
用消息传递更新,而不是修改系统提示
通过分层 cache_control 确实极大程度上增加了缓存的命中,但是 Claude Code 团队自己也承认:这个约束比想象中脆弱——有时候确实有修改静态系统提示词(System Prompt)或者工具定义的诉求。
当会话中的信息发生变化——比如当前时间推进了、用户修改了一个文件——直觉的做法是更新系统提示词。但这样做会摧毁前缀,导致整个会话重新 Prefill。(还记得吗,系统提示词位于前缀的最开头,改一个字符就意味着从这个字符开始就不匹配了)。
Claude Code 的做法是:在下一条用户消息或工具结果中插入 标签,将增量信息通过消息流传递给模型。这样静态前缀(系统提示词、工具定义等)保持不变,缓存继续生效,模型依然能在最新的上下文中工作。
# 不好的做法:修改 system prompt(破坏缓存前缀) system = 'You are a helpful assistant. Current time: 2026-05-07 10:00' # 好的做法:在下一回合的消息中传递更新 messages = [ {'role': 'user', 'content': '帮我改这段代码'}, {'role': 'assistant', 'content': '...'}, {'role': 'user', 'content': '<system-reminder>\n用户修改了文件 app.py,当前时间 2026-05-07 10:00\n</system-reminder>\n\n继续优化'} ]不要在会话中途切换模型
Prompt Caching 是模型级别的。如果你在一个 100k token 的 Opus 对话中,想临时切到 Haiku 处理一个简单问题,直觉上 Haiku 更便宜——但实际上 Haiku 需要从零重建整个 prompt cache,反而比让 Opus 直接回答更贵。
正确的做法是使用子代理(subagent):让当前模型准备一份「交接摘要」,把任务分派给另一个模型的独立会话。子代理有自己的缓存前缀,不会干扰父会话的缓存。Claude Code 的 Explore agents(使用 Haiku)就是这样实现的。
不要在会话中途增删工具
工具定义位于缓存前缀的最前面,增加或删除任何一个工具都等于重写了前缀——整段对话的缓存全部作废。这看起来有点反直觉:难道不应该只给模型它当前需要的工具吗?但事实上缓存的约束比「整洁」更重要。
Plan Mode:用工具表达状态转换
Claude Code 的 Plan Mode 是这个原则的经典实践。按理来说,Plan Mode 只能读,不能写,那么我在进入Plan Mode时应该只给模型保留「read_file」工具,防止它乱改代码。但如果真的这样做,就等于在会话中途替换了一套工具定义——前缀从第一行开始就变了,缓存彻底失效。
Claude Code 的解法是:始终保留全部工具定义,把进入 Plan Mode(EnterPlanMode) 和 退出 Plan Mode(ExitPlanMode)本身也设计成工具。当模型调用 EnterPlanMode 时,它会收到一条系统消息,说明「你现在处于计划模式,只能读取不能修改,完成后再调用 ExitPlanMode」。工具定义从头到尾一个字都没变,缓存前缀完全不受影响。
# 工具定义始终不变,Plan Mode 本身也是一个工具 tools = [ {'name': 'read_file', 'description': '读取文件内容', 'input_schema': {...}}, {'name': 'write_file', 'description': '写入文件内容', 'input_schema': {...}}, { 'name': 'EnterPlanMode', 'description': '进入计划模式,此时只能读取不能修改文件', 'input_schema': {'type': 'object', 'properties': {}} }, { 'name': 'ExitPlanMode', 'description': '退出计划模式,恢复正常编码', 'input_schema': {'type': 'object', 'properties': {}} }, # ... 其他所有工具始终保留 ]这还有一个额外好处:因为 EnterPlanMode 是模型可以自主调用的工具,当它遇到困难问题时,可以自己进入计划模式思考,完全不需要用户干预,也不会造成缓存中断。
defer_loading:用存根替代移除
同样的原则也适用于 MCP 工具管理。Claude Code 可能加载了几十个 MCP 工具,全部展开放在请求里太贵,但中途移除又会破坏缓存。
Claude Code 的解决方案是
defer_loading:不移除工具,而是发送轻量存根(只有工具名,标记defer_loading: true)。模型需要时通过 「tool search」工具发现完整定义。这样缓存前缀始终稳定——相同的存根、相同的顺序、永远不变。安全压缩:fork 时必须复用父前缀
当对话越来越长,最终会触及上下文窗口的上限,这时需要「压缩」(compaction)——把之前的对话总结成一段摘要,然后用摘要替代原始消息继续对话。
最常见的陷阱是:新开一个 summarization 调用,用一条简洁的系统提示(比如「请总结以下内容」),并且不带任何工具。这样做的问题在于, summarization 调用的前缀从第一个 token 就和原会话不同(系统提示变了、工具没了),所以整段对话都无法命中缓存。你不仅要为 summarization 本身付费,还要为重新发送全部对话历史支付全额费用——而且对话越长,这笔费用越高。
Claude Code 的做法是「cache-safe forking」:压缩调用使用与父会话完全相同的系统提示、用户上下文、系统上下文和工具定义,仅把压缩指令作为最后一条用户消息追加。从 API 的角度看,这个请求和父会话的上一个请求几乎一模一样——同样的前缀、同样的工具、同样的历史——所以缓存被完整复用。唯一新增的 token 只有压缩指令本身。
# 不好的做法:不同的 system prompt(前缀从第一个 token 就分歧) summary = client.messages.create( model='claude-3-5-sonnet', system='请总结以下对话', # 前缀不同! messages=conversation_history, ) # 好的做法:cache-safe forking,复用完全相同的前缀 summary = client.messages.create( model='claude-3-5-sonnet', system=original_system, # 和父会话相同 tools=original_tools, # 和父会话相同 messages=[ *conversation_history, {'role': 'user', 'content': '请把以上对话总结成一段摘要'} ] )核心结论