Claude Code 围绕 Prompt Caching 构建,Anthropic 团队在官方 Blog 中分享了他们在Claude Code 建设中总结的缓存策略,如果你也在建设 Agent,你也应该从一开始就把缓存纳入设计。这篇文章是对原文的理解和总结~
背景:Prompt Caching 为什么如此重要
为什么重要:从 LLM 推理的两个阶段说起
LLM 每次处理请求时,内部分为两个阶段:
- Prefill(预填充) :并行处理输入的所有 token,为每个 token 计算它与其他所有 token 的注意力关系(attention),产生一组 KV Cache(Key-Value 缓存)。输入越长,这个阶段越慢、越贵。
- Decode(解码):基于 Prefill 产生的 KV Cache,逐个生成输出 token。每生成一个新 token,只需计算它与已有 KV 的关系,成本极低。
Prefill 是成本大头。一个 100k token 的提示词,Prefill 可能耗时数秒,而 Decode 生成每个 token 只需毫秒级。
在多轮对话中,每次新请求都会把之前的全部内容再发一遍------系统提示(System Prompt)、工具定义、历史消息拼在一起,形成一串很长的序列。这串序列从开头到某个位置的连续片段,就叫「前缀」------必须从开头开始,中间不能跳。如果上一轮和这一轮的前缀完全一样,那部分内容的 KV Cache 就可以直接复用,不用重新 Prefill。
Prompt Caching 的本质就是跨请求复用前缀的 KV Cache。 模型仍然「看到」完整文本,输出不会有任何差异------变的只是你等多久收到回复,以及 API 收你多少钱。
打个比方:每次做一套 100 题的卷子,你都必须从第 1 题看到第 100 题。但第一次做时,你把前 80 题的详细推导写在了草稿纸上。第二次考试时,你仍要看完整套卷子,但前 80 题直接复用草稿不用重新推导。
缓存就像考试时的草稿纸------你不是少看了题目,而是少做了重复推导。
在哪一层生效
在开发原生 Agent 的时候会调用 LLM API,你的代码大概长这样------没有任何缓存:
python
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 可能长这样:
python
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 的排序原则是:越不变的东西越靠前,越常变的东西越靠后。
具体排序如下:
- 静态系统提示词(System Prompt) + 工具定义(极少变动,全局共享)
- CLAUDE.md(项目范围内变动,跨会话共享)
- 会话上下文(同一次会话内共享)
- 对话消息(每轮都在增长)
这个顺序让尽可能多的会话共享同一段前缀。在 Claude Code 中,cache_control 断点被策略性地放在这些层级之间:系统提示词和工具定义之后是全局缓存(所有用户共享);CLAUDE.md 之后是项目级缓存(跨会话共享);会话上下文之后是会话级缓存(同一次对话内共享)。每一层断点都把前面稳定的内容「冻结」,只让后面动态增长的部分逐轮变化。
「Cache rules everything around me」------ 对 Agent 而言,缓存规则确实统治了一切。
用消息传递更新,而不是修改系统提示
通过分层 cache_control 确实极大程度上增加了缓存的命中,但是 Claude Code 团队自己也承认:这个约束比想象中脆弱------有时候确实有修改静态系统提示词(System Prompt)或者工具定义的诉求。
当会话中的信息发生变化------比如当前时间推进了、用户修改了一个文件------直觉的做法是更新系统提示词。但这样做会摧毁前缀,导致整个会话重新 Prefill。(还记得吗,系统提示词位于前缀的最开头,改一个字符就意味着从这个字符开始就不匹配了)。
Claude Code 的做法是:在下一条用户消息或工具结果中插入 标签,将增量信息通过消息流传递给模型。这样静态前缀(系统提示词、工具定义等)保持不变,缓存继续生效,模型依然能在最新的上下文中工作。
python
# 不好的做法:修改 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」。工具定义从头到尾一个字都没变,缓存前缀完全不受影响。
python
# 工具定义始终不变,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 只有压缩指令本身。
python
# 不好的做法:不同的 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': '请把以上对话总结成一段摘要'}
]
)
压缩不是「另起炉灶」,而是「在文件堆的顶部放一张文件总结」。前缀保持一致,缓存才能继续生效。
核心结论
- Prompt caching 是前缀匹配------任何变动都会使之后全部内容失效
- 静态内容前置、动态内容后置,是最大化缓存复用的唯一正确排序
- 用消息(如 )传递更新,永远不要修改系统提示词
- 不要中途切换模型;需要分派任务时用子代理
- 不要中途增删工具;用工具调用建模状态转换,用 defer_loading 替代移除
- 压缩 / fork 等旁路操作必须使用与父请求完全一致的缓存前缀