2317 字
12 分钟
拆解 Claude Code 的 Agent 缓存经济学:每个子 Agent 都在烧钱

每次在 Claude Code 里敲一个需要研究的问题,它就会 spawn 出一堆 subagent 并行工作。看着终端里五六个 agent 同时转,感觉很酷。

但有个问题一直困扰我:这些 agent 的 prompt cache 是怎么处理的?每个 agent 是独立冷启动,还是能复用主对话的缓存?答案藏在 Claude Code 的源码里。

背景:Prompt Cache 是什么#

Anthropic 的 API 支持 prompt caching。简单说,如果两个 API 请求的前缀相同(system prompt + tools + 消息历史的前面部分),后续请求可以命中缓存,只需支付 10% 的 input token 费用。

缓存的 key 是精确前缀匹配——tools → system prompt → messages,按这个顺序拼接,取 hash。哪怕一个字符不同,就是 cache miss。

这对 Claude Code 来说特别关键。它的 system prompt 很长——工具定义、CLAUDE.md 规则文件、环境信息、Git 状态,加起来大约 10-15K tokens。主对话每轮都能命中缓存(前缀不变),但 subagent 呢?

两种 Agent Spawn 模式#

src/tools/AgentTool/AgentTool.tsx,Claude Code 有两条截然不同的 agent spawn 路径:

Normal 模式:完全隔离#

这是当前公开版本使用的模式。spawn agent 时指定 subagent_type(比如 Explore、Plan、general-purpose),每个 agent 获得:

  • 独立的 system prompt——由 agentDefinition.getSystemPrompt() 生成,和主对话的 prompt 完全不同
  • 独立的 tool pool——通过 resolveAgentTools() 按 agent 类型过滤
  • 空白的消息历史——只有一条 user message(你给它的 prompt)
  • 独立的缓存生命周期——无法复用主对话的任何缓存
src/tools/AgentTool/AgentTool.tsx:318-322
const effectiveType = subagent_type
?? (isForkSubagentEnabled() ? undefined : GENERAL_PURPOSE_AGENT.agentType)
const isForkPath = effectiveType === undefined

isForkSubagentEnabled() 返回 false 时,省略 subagent_type 只是 fallback 到 general-purpose,不会走 fork 路径。

Fork 模式:缓存共享(实验性)#

这条路径被 feature('FORK_SUBAGENT') 编译时开关控制。当开启时,省略 subagent_type 会触发一个完全不同的行为:子 agent 继承父级的完整上下文。

核心在 buildForkedMessages()src/tools/AgentTool/forkSubagent.ts:107-168):

src/tools/AgentTool/forkSubagent.ts
// 所有 fork children 共享相同的消息前缀:
// [...parent_history, assistant(所有 tool_use 块), user(placeholder_results..., directive)]
// 只有最后一个 text block 不同 → 最大化 cache hit
const FORK_PLACEHOLDER_RESULT = 'Fork started — processing in background'

关键技巧:对父 agent 的所有 tool_use 调用,子 agent 统一用一个 placeholder 填充。这样多个子 agent 的消息前缀字节完全一致,只有最后的指令文本不同。Anthropic 的 prompt cache 按前缀匹配,所以 fork 出来的子 agent 几乎不额外消耗缓存配额。

为了确保字节级一致,代码做了几件精心的事:

  1. 继承父级已渲染的 system prompt 字节——不重新调用 getSystemPrompt() 重建(GrowthBook 配置可能在冷热切换间变化导致字节差异)
  2. 使用父级完整的 tool pool——useExactTools: true
  3. 克隆 contentReplacementState——确保对父级消息中 tool_use_id 的替换决策完全一致
src/utils/forkedAgent.ts:389-403
// 克隆而非新建:
// cache-sharing fork 处理父级消息时会遇到父级的 tool_use_id,
// 新建的 state 会做出不同的替换决策 → wire prefix 不同 → cache miss
// 克隆的 state 做出相同决策 → cache hit

甚至还有一个全局槽位机制,让后续的 fork 不需要显式传递缓存参数:

src/utils/forkedAgent.ts:70-81
// 每轮结束后保存到全局变量,供 session_memory、speculation、/btw 等 post-turn fork 使用
let lastCacheSafeParams: CacheSafeParams | null = null
export function saveCacheSafeParams(params: CacheSafeParams | null): void {
lastCacheSafeParams = params
}

feature() 是构建时决定的#

feature('FORK_SUBAGENT') 来自 Bun 的编译时内置模块 bun:bundle。在 Bun 构建时,它会被替换为 truefalse 字面量,然后整个 if 分支被 tree-shaking:

scripts/build.mjs:87-88
// 开源构建脚本:所有 feature() 直接替换为 false
src = src.replace(/\bfeature\s*\(\s*['"][A-Z_]+['"]\s*\)/g, 'false')

所以 fork 模式在开源构建中永远关闭。官方发布版是否开启,取决于 Anthropic 的内部构建配置。

isForkSubagentEnabled() 还有运行时检查——即使构建时开启了 fork,在 coordinator 模式或非交互模式下也会动态禁用:

src/tools/AgentTool/forkSubagent.ts:32-38
export function isForkSubagentEnabled(): boolean {
if (feature('FORK_SUBAGENT')) { // 构建时决定
if (isCoordinatorMode()) return false // 运行时检查
if (getIsNonInteractiveSession()) return false
return true
}
return false
}

CTF 实验:当前版本走哪条路径?#

理论分析完了,做个实验验证。

我在主对话中藏了一个 CTF flag:FLAG{prompt_cache_inheritance_test_7f3a9b},然后同时 spawn 两个 agent:

  1. 不指定 subagent_type——测试是否走 fork 路径
  2. 指定 subagent_type: Explore——Normal 路径对照组

两个 agent 的任务一样:搜索你的整个上下文,报告是否看到 FLAG{...} 字符串。

结果:

Agent指定 subagent_type?看到 FLAG?看到父级对话?
no-type-agent
explore-agent是(Explore)

两个 agent 都报告只有一条 user message,看不到父级的任何对话历史。

结论:当前官方版本(v2.1.80)的 FORK_SUBAGENT 是关闭的。 不指定 subagent_type 只是 fallback 到 general-purpose,不会继承父级上下文。每个 subagent 都是完全冷启动。

成本影响:每个 Agent 都在重复支付#

Normal 模式下,每 spawn 一个 subagent 的固定开销:

Agent system prompt ~1-3K tokens(agent 定义)
Tool definitions ~3-5K tokens(8+ 工具 schema)
CLAUDE.md 规则文件 ~2-5K tokens
环境信息 + Git status ~0.5-1K tokens
──────────────────────────────────────
合计 ~10-15K tokens / agent
NOTE

Explore 和 Plan 这类只读 agent 会省略 CLAUDE.md 和 Git status(omitClaudeMd: true),降到约 5-8K tokens。源码注释说这个优化在 fleet 级别每周节省 5-15 Gtok。

而实际的 user prompt 通常只有 100-200 tokens。99% 的 input tokens 是重复的系统模板。

对比主对话每轮的成本——如果对话已经 100K tokens:

场景等价 tokens说明
主对话一轮~13.5Ksystem prompt cache hit + 历史 cache hit + 新增内容
Normal agent~15K全部 full price(无 cache hit)
Fork agent~11.6K共享前缀 cache hit + 指令增量

Fork 模式每个 agent 节省约 3.5K 等价 tokens。听起来不多?但 Anthropic 的规模是每周 34M+ 次 Explore spawn。

不过换个角度看——当主对话已经很长(>100K tokens)时,cache hit 价格只有 10%,agent 的 15K system prompt 全价也不过是对话总量的 15%。Fork 的节省在长对话场景下趋于无关紧要。 Fork 真正值钱的是短命高频 agent:对话才 5K tokens 时,Normal 模式的 15K system prompt 是 full price 的大头,而 Fork 的 5K prefix cache hit 只需 0.5K 等价——节省 96%。

Subagent 的隔离策略#

除了缓存,createSubagentContext()src/utils/forkedAgent.ts:345-462)实现了精细的状态隔离:

资源默认行为可共享?
readFileState(文件读取缓存)克隆覆盖
abortController新建子控制器(父级 abort 传播)可选共享
setAppStateno-op同步 agent 可共享
setAppStateForTasks始终共享
contentReplacementState克隆(缓存安全)覆盖
UI 回调全部 undefined不可共享

setAppStateForTasks 始终共享是个有意思的设计——注释说如果不这么做,异步 agent 的后台 bash 任务会变成 zombie 进程(PPID=1),永远不会被 kill。

Agent vs Teammate#

顺便提一下,Claude Code 还有另一套完全不同的多 agent 机制——Teammate/Swarm。

Teammate 不是一次性任务,而是持续运行的独立对话循环。InProcess Teammate 跑在同一个 Node.js 进程里,通过 AsyncLocalStorage 隔离上下文,通过文件 mailbox 通信。它有 idle 检测、shutdown 请求、独立的 permission mode——更像一个长期驻守的同事,而不是一个跑完就消失的工具。

Teammate 的 abort controller 故意不链接到父级——leader 被中断时,teammate 继续工作。这和 subagent 的设计完全相反(subagent 的 abort controller 是父级的子控制器,父级 abort 时子级跟着 abort)。

三层缓存架构#

把整个缓存体系画出来:

┌──────────────────────────────────────────────────────┐
│ Layer 1: Anthropic API Prompt Cache │
│ - cache_control: { type: 'ephemeral', ttl: '5m'/'1h'}│
│ - TTL 在 bootstrap 时锁定,防止中途切换导致缓存失效 │
│ - Fork children 共享父级的 cache prefix │
│ - Cache break 检测:16+ 参数追踪,区分客户端/TTL/服务端│
├──────────────────────────────────────────────────────┤
│ Layer 2: System Prompt Section Cache │
│ - systemPromptSection() 会话内 memoize │
│ - /clear 或 /compact 时重置 │
│ - DANGEROUS_uncachedSection 每轮重算(破坏缓存) │
├──────────────────────────────────────────────────────┤
│ Layer 3: Context Memoization │
│ - getMemoryFiles() lodash memoize(CLAUDE.md 文件 IO)│
│ - getUserContext() / getSystemContext() 会话内单次计算 │
└──────────────────────────────────────────────────────┘

TTL 锁定是个精妙的设计(src/bootstrap/state.ts)。Anthropic 的 1h cache TTL 需要特定的订阅等级,如果用户在会话中途从 overage 状态切回普通状态,TTL 从 1h 降到 5m,会导致已缓存的 ~20K tokens 突然失效。所以在 bootstrap 时就锁定 TTL 资格,整个会话期间不变。

还有个 skipCacheWrite 优化:fire-and-forget 的 fork(比如 session_memory 提取)不会在最后一条消息上写入缓存,因为没有后续请求会读取这个前缀。

结论#

Claude Code 的 agent 缓存策略本质上是两个哲学的碰撞:

  • Normal 模式追求隔离安全——独立 prompt、独立 tools、默认 no-op 回调。代价是每个 agent 冷启动 10-15K tokens
  • Fork 模式追求缓存效率——字节级一致的 API 请求前缀。代价是更复杂的状态管理和 contentReplacementState 克隆

当前公开版本只用 Normal 模式。你在 Claude Code 里 spawn 6 个并行 agent,就是 6 份完整的 system prompt,各自独立缓存。这在长对话中不算什么(cache hit 已经把大头覆盖了),但在短对话 + 高频 spawn 的场景下,fork 模式的 96% 节省是实打实的。

Anthropic 用 feature() 编译时开关控制这个实验,说明 fork 可能还没完全稳定,或者在做 A/B 测试评估效果。但从代码的完成度来看——CacheSafeParams 类型、全局槽位机制、promptCacheBreakDetection.ts 的 728 行检测逻辑——这不是一个原型,而是一个即将上线的生产功能。

拆解 Claude Code 的 Agent 缓存经济学:每个子 Agent 都在烧钱
https://blog.lishuyu.top/posts/claude-code-agent-cache/
作者
猫猫魔女
发布于
2026-03-31
许可协议
CC BY-NC-SA 4.0