代码语言
选择后全站课程跟随

Cache 缓存

4,298 字 · 预计阅读 12 分钟

前情提要

  • 新开一个会话、什么都不做,用 /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 里有一个特殊的字符串:

typescript
/**
 * 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 种,每种只能缓存自己的那份。本来全世界共享一份热缓存,现在被切成了一千多份冷缓存。

本文是会员内容

全文 4,298 字,已试读约 30%。 开通专业版可阅读全文,并解锁全部课程与后续更新。

开通专业版

支持 7 天无理由退款 · 早鸟期前 500 名自动升级永久卡