

1 min read
Claude Code的缓存命中,为什么能帮你省这么多钱?
当你打开中转站去查找你的对话记录时,会明显察觉到除了输入输出token,还会有一个缓存读取token,而且这个缓存读取token的大小经常会是远远大于你的输入输出token。 很显然,这个缓存命中机制能帮你省下一大笔钱,那么它是怎么做到的呢? 这篇文章,我会将Claude Code这套缓存命中机制讲清楚。 先说结论:C
views
| comments
当你打开中转站去查找你的对话记录时,会明显察觉到除了输入输出token,还会有一个缓存读取token,而且这个缓存读取token的大小经常会是远远大于你的输入输出token。
很显然,这个缓存命中机制能帮你省下一大笔钱,那么它是怎么做到的呢?
这篇文章,我会将Claude Code这套缓存命中机制讲清楚。
1.
cache_control 是 Claude Code 发给API的一个marker,大概长这样
2.
3.
当上下文太长时,Claude Code也不会粗暴地推倒重来,而是会通过
先说结论:Claude Code缓存的不是"答案",而是"前缀"
先纠正一个很容易误会的点。 Claude Code这里说的缓存命中,并不是传统意义上的"问题一样,直接返回旧答案"。 它主要命中的是输入侧的 prompt cache。 从源码里的usage统计就能看出来,Claude Code关心的是:
cache_creation_input_tokenscache_read_input_tokens
- 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为缓存命中做了一整套链路
- 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相关的内容
- 把静态身份,通用规则,固定行为这些放在边界前
- 把 session guidance,memory,AGENT.md/CLAUDE.md这些更容易改动的内容放在边界后
二、tool schema
如果说system prompt是第一大前缀,那么tool schema就是第二大前缀。 Claude Code的工具协议非常的厚,还带prompt,权限,UI渲染,语义标记等大量信息(这块后续也会更新)。但问题就来了:这些工具定义本身,也是会进入模型上下文的。 这就意味着,一旦 tool schema 有变更,那么缓存就可能失效。 Claude Code对此做了一个非常牛逼的处理: 它不会每次都重新构造tool schema,而是将schema分成两层:- session-stable base
- per-request overlay
- name:工具名称
- description:工具说明
- input_schema:工具参数
- strict:入参要不要严格约束
- eager_input_streaming:工具输入能不能更早流式输出
- defer_loading:这个工具先不要把完整的schema暴露给模型,而是仅告诉模型有这么个工具,等模型真正需要时,再通过
ToolSearch之类的机制将它展开。 - cache_control
- 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的上下文治理,不是"整个缓存失效重新算",而是:
- 尽量保住前缀
- 精确删掉尾部不再重要的旧结果引用
四、fork子代理如何共享缓存
如果是普通子代理,大家往往会想当然地:- 重新生成 system prompt
- 重新拼历史
- 带上新的任务说明
- 各自起一套上下文
- 直接复用父线程已经渲染好的 system prompt
- 保留父 assistant message 的完整结构
- 把所有相关 tool_result 都替换成完全一致的占位内容
- 只有最后那段指令文本不同
- 对模型来说,每个子代理任务不同
- 对缓存来说,它们又尽量像“同一个请求的大前缀”
五、怎么判断缓存是否命中
Claude Code专门做了cache break检测。 最直接的指标就是 usage:cache_creation_input_tokenscache_read_input_tokens
cache_creation_input_tokens > 0说明这次有一段新的输入前缀被写入缓存cache_read_input_tokens > 0说明这次真的从已有缓存里读到了前缀,发生了命中
- 请求前,记录当前 prompt/tool/model/header 的状态
- 请求后,看
cache_read_input_tokens是否显著下降 - 如果下降,再推断原因到底是:
- model 变了
- system prompt 变了
- tools 变了
- beta/header 变了
- TTL 过期了