前情提要
- 新开一个会话、什么都不做,用
/context一看,上下文就已经被占了将近 1.5 万 token- 第1节立了一条规矩:发给模型的历史只能往后追加,已经发出去的部分一个字都不改
- 第2节的 mini-cc 把模型的回复原样存下,包括 thinking 块
为什么只说了一句「你好」,就用了 1.5 万 token?因为每次请求都要带上一整包东西:系统提示词、几十个工具的说明、CLAUDE.md、Skill 目录……
这么大一包,每轮都发一遍,贵不贵?
贵。所以有了缓存。这一节讲的缓存,会解释后面很多「奇怪」的设计:CLAUDE.md 为什么不放进系统提示词、Skill 目录为什么只发新增的、MCP 说明为什么被标成「危险」。它们背后是同一个原因。
把这一节放在前面,后面几节就可以直接回头指着它说:「就是因为缓存」。
一、拆开一次请求

Claude Code 每次调用模型,发出去的请求大概是这样:
┌─ system(系统提示词,一个字符串数组)─────────────────────────────┐
│ [静态区] 身份 · 系统规则 · 怎么做任务 · 谨慎操作 · 怎么用工具 · 沟通风格 │ ← 所有用户一字不差
│ ── __SYSTEM_PROMPT_DYNAMIC_BOUNDARY__ ── │ ← 分界线,发送前删掉
│ [动态区] 会话指引 · 记忆 · 环境信息 · 语言 · 输出风格 · MCP 说明 … │ ← 每个会话不同
├─ tools(工具说明)──────────────────────────────────────────────┤
│ Read、Edit、Bash、Agent、Skill …… 每个工具的说明和参数格式 │
├─ messages ──────────────────────────────────────────────────────┤
│ [0] CLAUDE.md + 今天的日期 ← 每次请求临时贴上(第6节) │
│ [1] Skill 目录、可搜索的工具名单 … ← system-reminder 附件 │
│ [2] 你的第一句话 │
│ [3] 模型的回复 + 工具调用 │
│ [4] 工具结果 │
│ …… │
└──────────────────────────────────────────────────────────────────┘
三大块:system、tools、messages。前两块在一个会话里基本不变,第三块每轮都在变长。
光 Bash 一个工具的说明里,就有一整套 git 提交流程(第3节)。几十个工具的说明加起来,是固定开销里的大头。
每一次请求,都要把系统提示词、工具说明、整段历史重新发一遍。
二、前缀缓存:逐字相同才命中

模型 API 支持前缀缓存:这次请求的开头和上次一模一样,那一段就不用重新计算,按缓存价格计费,又便宜又快。
条件很苛刻:从第一个字开始,逐字相同。 中间改了一个字,从那个字往后的缓存全部作废。
第 1 轮:[system][tools][msg0][msg1]
第 2 轮:[system][tools][msg0][msg1][msg2][msg3]
└──────── 和上一轮一样 ──────┘└─ 新增 ─┘
缓存命中,便宜 原价计算
如果第 2 轮改了 msg0 里的一个字:
第 2 轮:[system][tools][msg0'][msg1][msg2][msg3]
└─ 命中 ─┘└──────── 全部作废,原价重算 ────────┘
所以排列的原则是:越不会变的,越往前放。 系统提示词最前,工具说明其次,消息历史最后。消息历史虽然每轮都在变长,但只要只往后加,前面的部分就不变。
缓存只认前缀:改一个字,后面全部重算。
三、静态区、分界线、动态区

Claude Code 对缓存严格到了什么程度?先看系统提示词。
src/constants/prompts.ts 里有一个特殊的字符串:
/**
* Boundary marker separating static (cross-org cacheable) content from dynamic content.
* Everything BEFORE this marker in the system prompt array can use scope: 'global'.
* 分界线:前面是静态内容,可以跨组织缓存;后面是动态内容。
*
* WARNING: Do not remove or reorder this marker without updating cache logic
* 警告:不要删除或移动这个标记,除非同步修改缓存逻辑
*/
export const SYSTEM_PROMPT_DYNAMIC_BOUNDARY = '__SYSTEM_PROMPT_DYNAMIC_BOUNDARY__'分界线把系统提示词切成两半:
| 内容 | 缓存范围 | |
|---|---|---|
| 静态区 | 身份、规则、怎么做任务、怎么用工具…… | 所有用户共享一份缓存 |
| 动态区 | 工作目录、记忆、语言、MCP 说明…… | 只在你自己的会话里缓存 |
静态区对全世界所有 Claude Code 用户一字不差,所以可以共享同一份缓存。这份缓存是热的,你的第一次请求就能命中。分界线本身在发送前会被删掉,模型永远看不到它。
这也解释了一个问题:CLAUDE.md 为什么不放进系统提示词? 每个人的 CLAUDE.md 都不一样,放进静态区,这份全世界共享的缓存就没法用了。第6节会看到它被放到了哪里,以及这个位置带来的副作用。
系统提示词切成两半:前一半全世界共享缓存,后一半才是你自己的。
四、静态区不许有 if
静态区每多一个「看情况」的判断,缓存就会分裂。有一段注释把这笔账算得很清楚:
这里的每一个条件判断都是一个运行时开关,放在静态区的话,会让缓存前缀的变体数量翻倍(2 的 N 次方)。
一个 if 分出 2 种版本,10 个 if 就是 1024 种,每种只能缓存自己的那份。本来全世界共享一份热缓存,现在被切成了一千多份冷缓存。