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之所以强,不只是它会调模型,而是它已经把“如何让模型在很多轮里持续高效工作”做成了一套系统工程。

而缓存命中,正是这套工程的核心之一。

暂无评论

发送评论 编辑评论


				
上一篇