YinFeng's Blog

Back

Claude Code的缓存命中,为什么能帮你省这么多钱?Claude Code的缓存命中,为什么能帮你省这么多钱?
当你打开中转站去查找你的对话记录时,会明显察觉到除了输入输出token,还会有一个缓存读取token,而且这个缓存读取token的大小经常会是远远大于你的输入输出token。 很显然,这个缓存命中机制能帮你省下一大笔钱,那么它是怎么做到的呢? 这篇文章,我会将Claude Code这套缓存命中机制讲清楚。

先说结论:Claude Code缓存的不是"答案",而是"前缀"

先纠正一个很容易误会的点。 Claude Code这里说的缓存命中,并不是传统意义上的"问题一样,直接返回旧答案"。 它主要命中的是输入侧的 prompt cache。 从源码里的usage统计就能看出来,Claude Code关心的是:
  • cache_creation_input_tokens
  • cache_read_input_tokens
而不是"output cache"。 而结论中的"前缀"通常包括:
  • system prompt的静态层
  • tools/schema
  • 历史消息中较早的部分
  • 某些 cache_controlcache_reference 相关结构
这意味着这套缓存命中机制真正在做的是:
  • 把 system prompt,tools,历史消息这些前缀组织好
  • 告诉服务端哪些部分值得缓存
  • 尽量保证下一轮请求时,前缀和上一轮足够一致
  • 让服务端直接复用这一大段前缀的处理结果
所以,这套机制省的是"重新读老上下文"的成本,不是"重新生成回答"的成本。

让"前缀"保持字节级别的稳定

在读源码的过程中,我发现Claude Code做的大部分工作,都是尽量让"前缀"保持字节级别的稳定。 也就是说:发给模型的请求里,前面的一大段内容,在序列化之后要尽量完全一致。 所以,这里的"稳定"其实非常严格:
  • 文本要一样
  • 顺序要一样
  • 拼接方式要一样
  • schema序列化结果要一样
  • 哪个message上挂了缓存标记也要一样
那么,很多具体实现细节就会变的相当敏感:
  • system prompt的边界处理
  • 工具的排序
  • tool schema的缓存
  • beta header的稳定性
  • 子代理fork时是否重建system prompt
  • cache marker 放在哪些message上
总之,Claude Code是在努力维护一个尽可能长,尽可能稳定,尽可能可以复用的请求前缀

因此,为了保证"前缀"稳定,Claude Code为缓存命中做了一整套链路

  1. system prompt先被拆成稳定块和动态块
  2. tool schema尽量写成session-stable结构
  3. 历史messages被精确地放置cache marker
  4. 旧工具结果用 cache_reference 绑定
  5. 长上下文时用 cache_edits 删除不必要的旧引用
  6. fork子代理时共享前缀
  7. 请求前后再做 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_control
  • cache_reference
  • cache_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,意思是:
  • 这之前是公共前缀,这之后是当前轮新增内容
举个例子:
  1. 用户消息 A
  2. assistant/tool/result 若干
  3. 用户消息 B
  4. 在 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_tokens
  • cache_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之所以强,不只是它会调模型,而是它已经把“如何让模型在很多轮里持续高效工作”做成了一套系统工程。 而缓存命中,正是这套工程的核心之一。
Claude Code的缓存命中,为什么能帮你省这么多钱?
https://www.windchant.online/blog/20260412---claude-code/post
Author YinFeng
Published at 2026年4月13日