第4章:模型调用与缓存经济学
约 11 分钟 · 更新于 2026-09-01
第4章:模型调用与缓存经济学
这是本教程的枢纽章。前三章埋的所有伏笔——哨兵常量、DANGEROUS_ 前缀、agent 列表搬家、fork 占位符、附件头部预计算——在这一章合流。核心判断:Claude Code 为 prompt cache 建了一整套基础设施,包括一个 727 行的"谁把缓存搞坏了"取证子系统。
一、先把 prompt cache 的规则说清楚
Anthropic API 的 prompt caching 是前缀缓存:在请求内容的某个位置打一个 cache_control 标记,标记之前的全部内容会被缓存;下次请求如果前缀逐字节相同,这部分就命中缓存。
三个后果,决定了这一章后面的一切:
- 命中是全有或全无的前缀匹配。 前 10000 个 token 里改了一个字符,后面全部重新计费。
- 写缓存比读缓存贵。 缓存创建(cache_creation_input_tokens)的单价高于普通输入 token,缓存读取(cache_read_input_tokens)则便宜得多。所以"频繁破坏又重建"比"根本不缓存"还糟。
- 缓存有 TTL。 默认 5 分钟,符合条件的可以要 1 小时。
一个 agent 会话的请求结构大概是:
text
[system prompt] [tool schemas] [message 1] [message 2] … [message N]
~5-15K token ~10-40K token ← 每轮追加
系统提示词和工具 schema 加起来经常是几万 token,而且每一轮都要重发。 缓存命中与否,直接决定这个会话的成本量级。
于是 Claude Code 的所有"绕远路"设计都有了统一解释:它们都在保护这个前缀。
二、三种缓存作用域
ts
export type CacheScope = 'global' | 'org'
export type SystemPromptBlock = {
text: string
cacheScope: CacheScope | null
}
[源码 src/utils/api.ts:80]
三个取值:
| cacheScope | 含义 | 谁能命中 |
|---|
| 'global' | 全局缓存 | 所有用户——只要那段文本逐字节一致 |
| 'org' | 组织级缓存 | 同一个组织内 |
| null | 不缓存(作为前缀的一部分被别的块的标记覆盖) | —— |
'global' 这个作用域是理解整个设计的钥匙。Claude Code 那份静态系统提示词,对全世界所有用户是同一份——所以它可以被服务端跨用户复用。这就是为什么第 2 章那条注释说工具列表"必须和一份线上 Statsig 配置保持同步":那份配置定义了"全局可缓存的标准工具集"是什么,本地一旦跟它不一致,全局缓存就命中不了。
代价是:这份静态部分里不能有任何用户/会话特有的东西。一个字都不行。于是需要一条边界。
三、哨兵:把边界写进数据
第 1 章引用过它,这里看它是怎么被消费的 [源码 src/utils/api.ts:362-405]:
ts
if (useGlobalCacheFeature) {
const boundaryIndex = systemPrompt.findIndex(s => s === SYSTEM_PROMPT_DYNAMIC_BOUNDARY)
if (boundaryIndex !== -1) {
let attributionHeader, systemPromptPrefix
const staticBlocks: string[] = []
const dynamicBlocks: string[] = []
for (let i = 0; i < systemPrompt.length; i++) {
const block = systemPrompt[i]
if (!block || block === SYSTEM_PROMPT_DYNAMIC_BOUNDARY) continue
if (block.startsWith('x-anthropic-billing-header')) attributionHeader = block
else if (CLI_SYSPROMPT_PREFIXES.has(block)) systemPromptPrefix = block
else if (i < boundaryIndex) staticBlocks.push(block) // ← 边界前
else dynamicBlocks.push(block) // ← 边界后
}
const result: SystemPromptBlock[] = []
if (attributionHeader) result.push({ text: attributionHeader, cacheScope: null })
if (systemPromptPrefix) result.push({ text: systemPromptPrefix, cacheScope: null })
const staticJoined = staticBlocks.join('\n\n')
if (staticJoined) result.push({ text: staticJoined, cacheScope: 'global' }) // ★
const dynamicJoined = dynamicBlocks.join('\n\n')
if (dynamicJoined) result.push({ text: dynamicJoined, cacheScope: null })
logEvent('tengu_sysprompt_boundary_found', {
blockCount: result.length,
staticBlockLength: staticJoined.length,
dynamicBlockLength: dynamicJoined.length,
})
return result
} else {
logEvent('tengu_sysprompt_missing_boundary_marker', { promptBlockCount: systemPrompt.length })
}
}
几个值得注意的细节:
① 哨兵是靠内容找的,不是靠下标。 findIndex(s => s === SYSTEM_PROMPT_DYNAMIC_BOUNDARY)——因为上游 getSystemPrompt() 返回的数组里有一堆 null 会被 .filter() 掉,位置不稳定,内容是稳定的。同理,那个 CLI 前缀块也是靠 CLI_SYSPROMPT_PREFIXES.has(block) 集合查找而不是靠下标 [源码 src/constants/system.ts:26]:
All possible CLI sysprompt prefix values, used by splitSysPromptPrefix to identify prefix blocks by content rather than position.
② 哨兵找不到会打点。 tengu_sysprompt_missing_boundary_marker——"有人把哨兵删了"是一个被监控的线上指标。这就是第 1 章那句 WARNING: Do not remove or reorder this marker 的执行层保障:光靠注释拦不住人,还得有告警。
③ 静态块 join 成一个块,动态块也 join 成一个块。 而不是每个 section 一个块。因为 buildSystemPromptBlocks 上面顶着一句:
ts
// IMPORTANT: Do not add any more blocks for caching or you will get a 400
[源码 src/services/api/claude.ts:3221]
API 对 cache_control 标记的数量有硬上限(4 个),超了直接 400。 所以 4 个块就是全部预算:归因头、CLI 前缀、静态、动态。
四、消息侧:只允许一个断点
系统提示词那边是 4 个标记的预算,消息这边只有 1 个。而且注释解释了为什么不能是 2 个——这段注释是本教程读到的最"里面"的一段:
ts
// Exactly one message-level cache_control marker per request. Mycro's
// turn-to-turn eviction (page_manager/index.rs: Index::insert) frees
// local-attention KV pages at any cached prefix position NOT in
// cache_store_int_token_boundaries. With two markers the second-to-last
// position is protected and its locals survive an extra turn even though
// nothing will ever resume from there — with one marker they're freed
// immediately.
const markerIndex = skipCacheWrite ? messages.length - 2 : messages.length - 1
[源码 src/services/api/claude.ts:3078]
翻译:
- Mycro 是 Anthropic 的推理服务(注释直接引用了它的源码路径 page_manager/index.rs)
- 它按 turn 淘汰 KV cache 的 page,会释放"不在缓存边界集合里"的局部注意力 page
- 如果客户端打两个标记,倒数第二个位置也会被保护,导致一批永远不会被用到的 KV page 白占一轮显存
客户端的 cache_control 放置策略,是针对服务端 KV cache 淘汰算法调过的。 这已经不是"用好 API"了,这是跨越客户端/服务端边界的联合优化。
后半句同样精彩:
For fire-and-forget forks (skipCacheWrite) we shift the marker to the second-to-last message: that's the last shared-prefix point, so the write is a no-op merge on mycro (entry already exists) and the fork doesn't leave its own tail in the KVCC.
fork 出去的一次性子任务,把标记往前挪一格——挪到"和父进程共享前缀的最后一个位置"。这样这次写缓存在服务端是个空操作(那个 entry 本来就存在),而 fork 自己的尾巴不会留在 KV cache 里污染。
可迁移的判断 ⑧
当一个外部服务的内部行为显著影响你的成本时,值得去搞清楚它的内部机制,并把结论写进注释。
这段注释的价值不在于优化本身(省的那点显存对单个用户毫无意义),在于它把一个隐式的、跨系统的耦合显式化了。没有它,下一个人看到"为什么只打一个标记"会觉得是疏漏,顺手加一个,然后在机队级别产生一个谁也解释不了的成本上升。
五、1 小时 TTL:一个被"闩住"的判断
ts
function should1hCacheTTL(querySource?: QuerySource): boolean {
// 3P Bedrock users get 1h TTL when opted in via env var
if (getAPIProvider() === 'bedrock' && isEnvTruthy(process.env.ENABLE_PROMPT_CACHING_1H_BEDROCK)) return true
// Latch eligibility in bootstrap state for session stability — prevents
// mid-session overage flips from changing the cache_control TTL, which
// would bust the server-side prompt cache (~20K tokens per flip).
let userEligible = getPromptCache1hEligible()
if (userEligible === null) {
userEligible = process.env.USER_TYPE === 'ant'
|| (isClaudeAISubscriber() && !currentLimits.isUsingOverage)
setPromptCache1hEligible(userEligible)
}
if (!userEligible) return false
// Cache allowlist in bootstrap state for session stability — prevents mixed
// TTLs when GrowthBook's disk cache updates mid-request
let allowlist = getPromptCache1hAllowlist()
if (allowlist === null) { … }
return querySource !== undefined && allowlist.some(pattern =>
pattern.endsWith('*') ? querySource.startsWith(pattern.slice(0, -1)) : querySource === pattern)
}
[源码 src/services/api/claude.ts:393]
两处 latch(闩锁),理由都是同一个:这个值在会话中途变了,cache_control 的内容就变了,前缀就断了。
- 用户用超了额度、开始走 overage 计费 → 资格从 true 变 false → 一次翻转烧掉约 20K token
- GrowthBook 的磁盘缓存在请求中途更新了 → 同一次请求里出现混合 TTL
解法不是"让它别变",而是进程启动时算一次,之后整个会话都用那个值。哪怕它已经过时了。
在缓存前缀面前,正确性让位于稳定性。 这是个很强的取舍,而且他们做得很自觉——两处都写了注释说明代价。
同一个模式在第 3 章出现过(clearBetaHeaderLatches 在 /clear 和 /compact 时才重置 [源码 src/constants/systemPromptSections.ts:60]),第 6 章还会再出现一次。
querySource 的 allowlist 机制也值得一提:1 小时 TTL 不是全局开的,而是按调用来源开——主线程和 SDK 开、子 agent 可能不开、摘要/标题/分类器这些短命调用肯定不开。因为 1h TTL 本身要额外付费,只有"会被反复命中的长会话"才划算。
六、727 行的"谁把缓存搞坏了"取证系统
这是本章最能说明问题的一处:Claude Code 有一个专门的子系统,用来检测缓存断裂并生成 diff 文件。
它记录的"上一次请求状态"是这样的 [源码 src/services/api/promptCacheBreakDetection.ts:28]:
ts
type PreviousState = {
systemHash: number
toolsHash: number
/** Hash of system blocks WITH cache_control intact. Catches scope/TTL flips
* (global↔org, 1h↔5m) that stripCacheControl erases from systemHash. */
cacheControlHash: number
toolNames: string[]
/** Per-tool schema hash. Diffed to name which tool's description changed
* when toolSchemasChanged but added=removed=0 (77% of tool breaks per
* BQ 2026-03-22). AgentTool/SkillTool embed dynamic agent/command lists. */
perToolHashes: Record<string, number>
systemCharCount: number
model: string
fastMode: boolean
/** 'tool_based' | 'system_prompt' | 'none' — flips when MCP tools are
* discovered/removed. */
globalCacheStrategy: string
/** Sorted beta header list. Diffed to show which headers were added/removed. */
betas: string[]
/** AFK_MODE_BETA_HEADER presence — should NOT break cache anymore
* (sticky-on latched in claude.ts). Tracked to verify the fix. */
autoModeActive: boolean
…
}
逐条读这些注释,等于读了一份缓存断裂的病理学报告:
| 记录的东西 | 它曾经导致过什么 |
|---|
| cacheControlHash | 作用域或 TTL 翻转(global↔org、1h↔5m)——普通 hash 看不见,因为它把 cache_control 剥掉了 |
| perToolHashes | 77% 的工具侧缓存断裂是"工具没增没减,但某个工具的描述变了"(AgentTool / SkillTool 内嵌动态列表) |
| globalCacheStrategy | MCP 工具被发现/移除时,全局缓存策略在三种模式间翻转 |
| betas | beta header 列表变了 |
| autoModeActive | "这个本来会破坏缓存,已经修了(在 claude.ts 里闩成 sticky-on),留着这个字段是为了验证修复有效" |
最后那条是最能说明团队状态的:他们保留了一个已修复问题的监控点,专门用来确认它没有复发。
而 perToolHashes 那个 77% 直接解释了第 1 章那个"agent 列表占 10.2% 缓存创建 token"的重构:工具描述里塞动态内容是缓存断裂的最大单一来源,所以要把动态列表从描述里搬出去。
检测到断裂之后干什么?写一个 diff 文件到临时目录:
ts
function getCacheBreakDiffPath(): string {
// → ~/.claude/tmp/cache-break-<4位随机>.diff
}
用 diff 包的 createPatch 生成前后对比。开发者可以直接打开这个文件看是哪一行变了。
可迁移的判断 ⑨
如果某个不可见的属性(缓存命中、幂等性、字节稳定性)是你系统的成本关键,就为它建专门的观测设施——而且要能定位到"是谁改的",不能只报"它坏了"。
Claude Code 的分层很清楚:(a) 逐维度 hash 定位是哪一类变了,(b) perToolHashes 这种细粒度 hash 定位到具体是哪个工具,(c) createPatch 输出可读的 diff 定位到哪一行。三层缺一层,排查成本就上一个量级。
七、模型侧的三个降级路径
缓存讲完,说说这一层还干了什么。模型调用有三条降级路径,在第 3 章的主循环里都能看到落点。
7.1 流式 → 非流式
ts
function getNonstreamingFallbackTimeoutMs(): number
export async function* executeNonStreamingRequest(…)
[源码 src/services/api/claude.ts:807, 818]
流式请求超时后退到非流式。主循环里对应的是 onStreamingFallback 回调和随之而来的孤儿消息墓碑:
ts
if (streamingFallbackOccured) {
// Yield tombstones for orphaned messages so they're removed from UI and transcript.
// These partial messages (especially thinking blocks) have invalid signatures
// that would cause "thinking blocks cannot be modified" API errors.
for (const msg of assistantMessages) {
yield { type: 'tombstone' as const, message: msg }
}
logEvent('tengu_orphaned_messages_tombstoned', { orphanedMessageCount: assistantMessages.length })
…
}
[源码 src/query.ts:712]
tombstone(墓碑)是一种专门的消息类型,作用是让 UI 和 transcript 把已经显示出来的半截消息撤回。这是流式系统里一个很少被正经处理的问题:已经吐给用户的东西,怎么收回。
7.2 模型 → fallback 模型
ts
if (innerError instanceof FallbackTriggeredError && fallbackModel) {
currentModel = fallbackModel
attemptWithFallback = true
yield* yieldMissingToolResultBlocks(assistantMessages, 'Model fallback triggered')
…
// Thinking signatures are model-bound: replaying a protected-thinking
// block (e.g. capybara) to an unprotected fallback (e.g. opus) 400s.
// Strip before retry so the fallback model gets clean history.
if (process.env.USER_TYPE === 'ant') messagesForQuery = stripSignatureBlocks(messagesForQuery)
yield createSystemMessage(
`Switched to ${renderModelName(innerError.fallbackModel)} due to high demand for ${renderModelName(innerError.originalModel)}`,
'warning',
)
continue
}
[源码 src/query.ts:894-950]
三件事值得注意:
- yieldMissingToolResultBlocks——已经吐出去的 tool_use block 必须补上对应的 tool_result,否则 API 会因为"有工具调用没有结果"而报错。这个函数在文件里被调用三次(fallback、异常、中断),是个专门修补孤儿的工具函数 [源码 src/query.ts:123]。
- thinking signature 是绑定模型的——换模型前必须剥掉,否则 400。
- 降级要告诉用户,而且用 'warning' 级别,"这样用户不用开 verbose 也能看到"。
7.3 429 / 529 的重试策略
ts
const DEFAULT_MAX_RETRIES = 10
const MAX_529_RETRIES = 3
// 529 = overloaded. Only foreground sources (the ones a human is waiting on)
// retry on 529. Everything else (summaries, titles, suggestions, classifiers)
const FOREGROUND_529_RETRY_SOURCES = new Set<QuerySource>([…])
[源码 src/services/api/withRetry.ts:52-62]
只有"有人在等"的请求才重试 529。 摘要、标题、建议、分类器这些后台调用直接放弃——注释里的词是 "no retry amplification"(不做重试放大)。
这是个很重要的分布式系统判断:服务过载时,后台任务的重试会把过载变成雪崩。 谁该重试的依据不是"这个请求重不重要",而是"失败了有没有人受影响"。
八、把前三章的伏笔收回来
现在可以给第 1 章那五个例子一个统一解释了:
| 设计 | 保护的是什么 | 章节 |
|---|
| SYSTEM_PROMPT_DYNAMIC_BOUNDARY | 系统提示词的 global 前缀块 | 本章 §3 |
| DANGEROUS_uncachedSystemPromptSection | 同上,通过让"每轮重算"变得难写 | 第 5 章 |
| agent 列表搬出工具描述 | 工具 schema 块(77% 的工具侧断裂来自描述变化) | 第 6 章 |
| fork 的固定占位符 | fork 子进程与父进程的消息前缀 | 第 12 章 |
| 附件头部预计算 | 消息尾部附件的字节稳定性 | 第 6 章 |
| 1h TTL 资格闩锁 | cache_control 标记本身的内容 | 本章 §5 |
| 只打一个消息级标记 | 服务端 KV page 的淘汰行为 | 本章 §4 |
七处设计,四个不同的保护对象(系统块、工具块、消息前缀、标记本身),一条主线。
再看一遍第 1 章那三层防御结构,会发现它是完整的:
text
数据层 —— 哨兵常量把边界写进数据本身
API 层 —— DANGEROUS_ 前缀 + 强制理由参数,让危险操作难写
观测层 —— 727 行的断裂检测,逐维度 hash + 逐工具 hash + 可读 diff
约定、类型、观测,三样都有。 只做其中一样的团队很多,三样都做的很少。
九、动手复核
bash
cd claude-code-deep-dive/extracted-source
# 1. 三种缓存作用域与块拆分
grep -n -A5 'export type CacheScope' src/utils/api.ts
grep -n -A45 'export function splitSysPromptPrefix' src/utils/api.ts
# 2. 「不要再加块了否则 400」
grep -n -B2 -A8 'or you will get a 400' src/services/api/claude.ts
# 3. 那段引用推理服务源码的注释
grep -n -B2 -A16 "Mycro's" src/services/api/claude.ts
# 4. 两处 latch 及其代价
grep -n -B4 -A6 '20K tokens per flip\|mixed\s*$' src/services/api/claude.ts
# 5. 缓存断裂取证系统
sed -n '1,120p' src/services/api/promptCacheBreakDetection.ts
grep -n '77% of tool breaks\|should NOT break cache anymore' src/services/api/promptCacheBreakDetection.ts
# 6. 529 只在前台重试
sed -n '50,95p' src/services/api/withRetry.ts
# 7. 墓碑与孤儿补丁
grep -n -B4 -A10 'tombstone\|yieldMissingToolResultBlocks' src/query.ts | head -60
本机侧:
bash
# 缓存断裂 diff 文件(如果开了检测且真的断过)
ls ~/.claude/tmp/cache-break-*.diff 2>/dev/null
# 关掉缓存看看成本差别(不建议长期开)
DISABLE_PROMPT_CACHING=1 claude -p "hi"
十、总结
- prompt cache 是前缀缓存,全有或全无;写比读贵,所以"反复破坏又重建"比不缓存更糟。 系统提示词 + 工具 schema 几万 token 每轮重发,命中与否决定成本量级
- 三种作用域:global(跨用户)/ org / null。 global 要求那段文本对全世界逐字节一致,这是所有"静态/动态分离"设计的根因
- 哨兵靠内容查找而非下标,找不到会打点告警;块数上限 4 个,超了 400,所以静态/动态各 join 成一块
- 消息级只允许一个 cache_control 标记——理由来自服务端 KV page 的淘汰算法,注释里直接引用了推理服务的源码路径;fork 还要把标记往前挪一格
- 1h TTL 的资格和 allowlist 在会话内被闩死,因为中途翻转一次约烧 20K token——在缓存前缀面前,时效性让位于稳定性
- 727 行的断裂取证系统:逐维度 hash + 逐工具 hash + 可读 diff;数据说 77% 的工具侧断裂是"描述变了但工具没增没减"
- 三条降级路径:流式→非流式(要发墓碑撤回半截消息)、模型→fallback(要剥 thinking signature、补孤儿 tool_result)、429/529 重试(只有前台请求才重试 529,防重试放大)
- 约定(哨兵)、类型(DANGEROUS_ + 强制理由)、观测(断裂检测)三层俱全——这是本教程认为最值得抄的一整套东西
下一章走进那份系统提示词本身:它是怎么被一段一段装配出来的,哪些段落外部用户根本看不到。
- 第3章-Agent-Loop-query这一个循环
- 第5章-系统提示词-一个可编排的装配架构
- 第6章-附件与system-reminder-第二条注入通道 —— 动态内容最终去了哪
- 第12章-Agent调度-fork与fresh两条路 —— fork 的缓存共享细节
- Codex 教程第 5 章 —— 传输层的对照(WebSocket 预热 vs 缓存优化)
- Pi 教程第 4 章 —— 多模型抽象的对照