当你打开中转站去查找你的对话记录时,会明显察觉到除了输入输出token,还会有一个缓存读取token,而且这个缓存读取token的大小经常会是远远大于你的输入输出token。
很显然,这个缓存命中机制能帮你省下一大笔钱,那么它是怎么做到的呢?
这篇文章,我会将Claude Code这套缓存命中机制讲清楚。
先说结论:Claude Code缓存的不是”答案”,而是”前缀”
先纠正一个很容易误会的点。
Claude Code这里说的缓存命中,并不是传统意义上的”问题一样,直接返回旧答案”。
它主要命中的是输入侧的 prompt cache。
从源码里的usage统计就能看出来,Claude Code关心的是:
cache_creation_input_tokenscache_read_input_tokens
而不是”output cache”。
而结论中的”前缀”通常包括:
- system prompt的静态层
- tools/schema
- 历史消息中较早的部分
- 某些
cache_control、cache_reference相关结构
这意味着这套缓存命中机制真正在做的是:
- 把 system prompt,tools,历史消息这些前缀组织好
- 告诉服务端哪些部分值得缓存
- 尽量保证下一轮请求时,前缀和上一轮足够一致
- 让服务端直接复用这一大段前缀的处理结果
所以,这套机制省的是“重新读老上下文”的成本,不是”重新生成回答”的成本。
让”前缀”保持字节级别的稳定
在读源码的过程中,我发现Claude Code做的大部分工作,都是尽量让”前缀”保持字节级别的稳定。
也就是说:发给模型的请求里,前面的一大段内容,在序列化之后要尽量完全一致。
所以,这里的”稳定”其实非常严格:
- 文本要一样
- 顺序要一样
- 拼接方式要一样
- schema序列化结果要一样
- 哪个message上挂了缓存标记也要一样
那么,很多具体实现细节就会变的相当敏感:
- system prompt的边界处理
- 工具的排序
- tool schema的缓存
- beta header的稳定性
- 子代理fork时是否重建system prompt
- cache marker 放在哪些message上
总之,Claude Code是在努力维护一个尽可能长,尽可能稳定,尽可能可以复用的请求前缀。
因此,为了保证”前缀”稳定,Claude Code为缓存命中做了一整套链路
- system prompt先被拆成稳定块和动态块
- tool schema尽量写成session-stable结构
- 历史messages被精确地放置cache marker
- 旧工具结果用
cache_reference绑定 - 长上下文时用
cache_edits删除不必要的旧引用 - fork子代理时共享前缀
- 请求前后再做 cache break 检测
一、system prompt
Claude Code首先做的,就是把system prompt本身拆开
在源码中,有一个特别关键的常量:SYSTEM_PROMPT_DYNAMIC_BOUNDARY
它的作用非常直接:将system prompt分成静态和动态两部分
- 边界前:认为是静态,跨会话稳定,适合缓存的内容
- 边界后:认为是动态,和用户/session/runtime相关的内容
Claude Code并不会傻傻地将system prompt当成一整块大字符串来处理,而是:
- 把静态身份,通用规则,固定行为这些放在边界前
- 把 session guidance,memory,AGENT.md/CLAUDE.md这些更容易改动的内容放在边界后
边界前的稳定块承载主要缓存价值,边界后的动态块尽量不污染前缀。
本质上就是在做:system prompt的前缀分层缓存。
二、tool schema
如果说system prompt是第一大前缀,那么tool schema就是第二大前缀。
Claude Code的工具协议非常的厚,还带prompt,权限,UI渲染,语义标记等大量信息(这块后续也会更新)。但问题就来了:这些工具定义本身,也是会进入模型上下文的。
这就意味着,一旦 tool schema 有变更,那么缓存就可能失效。
Claude Code对此做了一个非常牛逼的处理:
它不会每次都重新构造tool schema,而是将schema分成两层:
- session-stable base
- per-request overlay
session-stable base 里面放的是:
- name:工具名称
- description:工具说明
- input_schema:工具参数
- strict:入参要不要严格约束
- eager_input_streaming:工具输入能不能更早流式输出
这些东西一旦确定,就尽量在整个session保持不变。
per-request overlay里放的是可能每轮变化的字段,比如:
- defer_loading:这个工具先不要把完整的schema暴露给模型,而是仅告诉模型有这么个工具,等模型真正需要时,再通过
ToolSearch之类的机制将它展开。 - cache_control
Claude Code这里专门做了一套session级缓存,它会将session-stable base层缓存到内存中,在当前session内复用,避免:
- tool prompt偏移
- 同名工具 schema不一致
- 部分配置的冷热变化
这里的设计思路和system prompt非常相似,也是通过分层的方式来维持前缀缓存的命中率。
三、message 历史
system prompt 和 tools稳定之后,下一层就是消息历史。
Claude Code这里的思路是:不将所有消息都缓存,而是通过一些marker去控制:
- 在哪里放缓存边界
- 边界之前的哪些旧结果可以通过引用复用
- 哪些旧引用应该被删掉
这里有三个概念非常重要:
cache_controlcache_referencecache_edits
1.cache_control是什么
cache_control 是 Claude Code 发给API的一个marker,大概长这样
{
type: 'ephemeral',
ttl?: '5m' | '1h',
scope?: 'global' | 'org'
}
你可以把它理解成一张”标签”,这些字段的意思是这样:
- type: ‘ephemeral’ 表示这是一个临时的prompt cache,是为了后续请求复用前缀,用人话来说就是:“这段前缀现在值得缓存,后面短时间内可以继续复用”。
- TTL:缓存存活时间
- scope:决定缓存的共享范围,global指的是”这段前缀足够通用,可以跨大范围复用”,org意思是”这段前缀只在当前环境范围内复用”。
这个 cache_control主要打在system prompt blocks 以及 messages 中,但是tool schema层预留了 cache_control 能力。
而且值得注意的是,注释中明确写到:每个请求只放一个message-level cache marker。
这是为了让缓存边界更加稳定,避免多个marker让局部生命周期更复杂。
通常情况,这个marker会放在最后一条message,意思是:
- 这之前是公共前缀,这之后是当前轮新增内容
举个例子:
- 用户消息 A
- assistant/tool/result 若干
- 用户消息 B
- 在 B 的最后一个内容块上挂 cache_control
那意思就是:
- 到 B 为止,这整段消息历史可以作为缓存前缀
- 后面新产生的 assistant 输出和新工具调用,作为”尾巴”追加在缓存前缀的后面。
2.cache_reference是什么
cache_reference 是给旧 tool_result打上的引用锚点。
它通常会设置成对应的 tool_use_id,作用是告诉服务端:
这个旧工具结果已经在缓存前缀里了,后面可以通过这个引用识别并复用。
这一步非常重要,因为在和Claude Code的对话里会有大量工具结果,而这些结果通常会很长,如果每一轮都把它们完整再塞一遍,成本会迅速膨胀。
有了cache_reference,系统就可以更稳定地把工具结果纳入缓存前缀,而不是每次都当新内容重复读。
3.cache_edits是什么
当上下文太长时,Claude Code也不会粗暴地推倒重来,而是会通过cache_edits做缓存编辑,
它的作用是:告诉服务端,哪些旧的 cache_reference不值得保留,可以删掉。
这意味着Claude Code的上下文治理,不是”整个缓存失效重新算”,而是:
- 尽量保住前缀
- 精确删掉尾部不再重要的旧结果引用
这是非常agent runtime的思路:在每轮对话中都尽量去维护最长的那段稳定前缀。
四、fork子代理如何共享缓存
如果是普通子代理,大家往往会想当然地:
- 重新生成 system prompt
- 重新拼历史
- 带上新的任务说明
- 各自起一套上下文
但Claude Code不怎么做,它在fork子代理时刻意追求:byte-identical API request prefixes,也就是:多个API请求在”前半段内容”上,必须做到字节级完全一致。
具体做法是:
- 直接复用父线程已经渲染好的 system prompt
- 保留父 assistant message 的完整结构
- 把所有相关 tool_result 都替换成完全一致的占位内容
- 只有最后那段指令文本不同
这样多个 fork child 会共享非常长的一段公共前缀,只有最后一点点变化。
- 对模型来说,每个子代理任务不同
- 对缓存来说,它们又尽量像“同一个请求的大前缀”
五、怎么判断缓存是否命中
Claude Code专门做了cache break检测。
最直接的指标就是 usage:
cache_creation_input_tokenscache_read_input_tokens
你可以粗暴理解成:
cache_creation_input_tokens > 0
说明这次有一段新的输入前缀被写入缓存cache_read_input_tokens > 0
说明这次真的从已有缓存里读到了前缀,发生了命中
更进一步,Claude Code 还有专门的 cache break detection 逻辑:
- 请求前,记录当前 prompt/tool/model/header 的状态
- 请求后,看
cache_read_input_tokens是否显著下降 - 如果下降,再推断原因到底是:
- model 变了
- system prompt 变了
- tools 变了
- beta/header 变了
- TTL 过期了
cache break的作用是帮你检测缓存是不是失效了,否则你只能看到模型明明在正常回答,但是越来越贵,越来越慢,而你不知道原因在哪,怎么去排查。
如果我人为修改代码,那么缓存还能命中吗
会,但是不会把整个缓存都打爆,更常见的是”部分命中”缓存,也就是变化点之前的公共前缀仍然命中,从变化点之后开始重新计算模型对尾部内容的处理。
换句话说,改代码通常只是尾部发生变化,但是大面积的前缀并不会被修改。
如何在实际使用中尽量避免缓存失效
说了这么多原理,如何避免才是至关重要的,毕竟我们并不是去真正设计一个harness,而是日常中去大量使用它。
实用的原则大概以下几条。
1. 尽量在同一个会话里持续推进
不要刚做两步就重开一个全新会话。同一会话的公共前缀越稳定,越容易持续命中。
2.不要频繁切模型
模型一变,基本就不是同一条 cache key 了。
3.不要频繁切影响请求形态的模式
例如 fast mode、某些 agentic mode、beta 开关。这些状态抖动很容易改变前缀协议形态。
4.MCP 和工具环境尽量一开始就定好
不要中途频繁连上/断开 MCP server,不然工具集合和提示信息可能会变化。
5.让变化尽量发生在“尾部”
正常改代码没问题。真正要避免的是不断回头重写很早期的上下文条件。
6.如果要 fork,就尽量让它们共享同一个稳定父上下文
不要人为给每个 fork child 注入风格不同的大前缀。
结语
在长生命周期 agent 里,最贵的不是一次回答,而是“每一轮都重新把同样的大前缀吃一遍”。
Claude Code之所以强,不只是它会调模型,而是它已经把“如何让模型在很多轮里持续高效工作”做成了一套系统工程。
而缓存命中,正是这套工程的核心之一。
