Claude Code Fork子Agent的Prompt Cache共享机制

Claude Code在执行复杂任务时会并行派生多个子agent, 每个子agent都需要完整的父对话上下文才能有效工作. 这带来一个现实的成本问题: 假设父对话积累了100K token的上下文, 同时派生3个子agent, 朴素实现需要为每个子agent支付100K token的输入费用, 合计300K. Anthropic API的Prompt Cache机制对缓存命中的前缀部分提供90%的价格折扣, 但前提是多个请求之间的前缀字节完全一致. 从行为上观察, Claude Code的fork子agent在构造API请求时做了精心设计, 使得所有并行子agent之间99%以上的字节完全相同, 从而将3个子agent的实际输入成本压缩到约120K token等价价格(100K全价 + 2 * 100K * 10%).

Prompt Cache的工作原理

先简单回顾一下Anthropic API的prompt cache机制. 当你发送一个API请求时, 可以在消息内容块上标记cache_control: {type: "ephemeral"}, 告诉服务端把到这个位置为止的所有内容缓存起来. 后续请求如果前缀字节完全一致, 就可以命中缓存, 被缓存覆盖的token按原价10%计费. 缓存的key由多个维度共同决定: system prompt的完整内容, tools定义的JSON序列化, model名称, messages数组的前缀, 以及thinking config. 任何一个维度的字节级差异都会导致缓存未命中.

这里"字节级一致"是重点. 不是语义一致, 不是逻辑等价, 而是wire上传输的JSON字节序列完全相同. 一个多余的空格, 一个字段顺序的变化, 一个布尔值从feature flag读取时机不同导致的差异, 都会让缓存失效. 这个约束条件决定了整个fork缓存共享设计的走向.

两种子Agent路径

从行为上可以观察到Claude Code存在两类不同的子agent派生路径. 第一类是命名子agent, 包括Explore和Plan等, 它们有独立的system prompt, 使用更小或更便宜的模型(Explore对外部用户使用Haiku), 工具集经过裁剪(只读工具, 不含文件编辑), 并且会省略CLAUDE.md中的项目规范以节省token. 第二类是fork子agent, 它们继承父agent的完整对话上下文、完整工具集、相同模型, 本质上是父agent的克隆, 只是各自执行不同的指令.

这两类路径的缓存策略截然不同. 命名子agent拥有自己独立的缓存链, 它们的system prompt、工具定义、模型都和父agent不同, 不可能与父agent共享缓存. Fork子agent则走了另一条路: 它们被刻意构造成与父agent以及彼此之间共享同一段缓存前缀. 这不是自然发生的, 而是需要在API请求构造的每一个环节刻意对齐才能实现.

字节一致的前缀构造

这是整个设计中最核心的部分. 当父agent在一次回复中发出3个并行的工具调用(tool_use), Claude Code为每个fork子agent构造的消息序列如下. 注意观察三个fork之间的异同:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
Fork 1:
[...parent_history,
assistant { tool_use_1, tool_use_2, tool_use_3 },
user { tool_result_1="Fork started...",
tool_result_2="Fork started...",
tool_result_3="Fork started...",
<fork-directive> directive_1 </fork-directive> }]

Fork 2:
[...parent_history,
assistant { tool_use_1, tool_use_2, tool_use_3 },
user { tool_result_1="Fork started...",
tool_result_2="Fork started...",
tool_result_3="Fork started...",
<fork-directive> directive_2 </fork-directive> }]

Fork 3:
[...parent_history,
assistant { tool_use_1, tool_use_2, tool_use_3 },
user { tool_result_1="Fork started...",
tool_result_2="Fork started...",
tool_result_3="Fork started...",
<fork-directive> directive_3 </fork-directive> }]

除了最后的directive文本块不同以外, 三个fork的整个消息序列字节完全一致. 用伪代码表示:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
FORK_PLACEHOLDER_RESULT = "Fork started - processing in background"

def build_forked_messages(directive, assistant_message):
# Clone the full assistant message (all tool_use blocks, thinking, text)
full_assistant = clone(assistant_message)

# Collect ALL tool_use blocks from the assistant message
tool_use_blocks = [b for b in assistant_message.content
if b.type == "tool_use"]

# Build IDENTICAL placeholder results for every tool_use
tool_results = [
{"type": "tool_result",
"tool_use_id": block.id,
"content": [{"type": "text",
"text": FORK_PLACEHOLDER_RESULT}]}
for block in tool_use_blocks
]

# Single user message: all placeholder results + per-child directive
user_message = create_user_message(
content=[*tool_results,
{"type": "text",
"text": build_child_directive(directive)}]
)

return [full_assistant, user_message]

这段逻辑中有几个值得注意的设计决策.

首先是占位符文本FORK_PLACEHOLDER_RESULT是一个常量字符串, 所有fork的所有tool_result都用完全相同的文本. 你可能会想, 为什么不把每个fork对应的tool_use的真实结果放进去? 因为fork是并行启动的, 在Fork 1开始执行时, Fork 2和Fork 3的结果还不存在. 而且即使存在, 放入不同的结果也会破坏前缀一致性. 所以干脆用一个常量占位符, 所有fork全部一样.

其次, 父agent的assistant消息被完整保留, 包含所有tool_use块, 而不是只取当前fork对应的那个. 这一点初看有些反直觉: Fork 1在逻辑上只需要执行directive_1, 为什么要看到tool_use_2和tool_use_3? 答案还是缓存. 如果Fork 1只看到tool_use_1而Fork 2只看到tool_use_2, 三个fork从assistant消息开始就产生分歧, 前缀一致性在这里就断了. 保留全部tool_use是缓存共享的必要条件.

第三, 所有fork子agent共享相同的tool_result数量和顺序. 不管一个fork实际上只关心3个tool_use中的哪一个, 它的user消息里都包含全部3个tool_result. 这保证了从tool_result序列到directive之间的每一个字节都相同.

还有一个细节: fork的directive文本被包裹在一个XML标签<fork-boilerplate>中, 里面包含一段固定的指令模板, 告诉子agent它是一个forked worker, 不应该再派生子agent, 不应该闲聊, 应该直接用工具执行任务然后汇报. 这段模板在所有fork之间是一样的. 唯一不同的是模板末尾附加的具体任务指令. 这种设计进一步压缩了差异区域: 即使directive中有几百token的模板文本, 也是所有fork共享的, 真正不同的只是最后几十个token的具体任务描述.

五个维度的缓存对齐

Prompt cache key由五个维度组成: system prompt、tools定义、model、messages前缀、thinking config. 消息前缀的一致性已经通过上面的构造解决了, 剩下的四个维度也需要逐一对齐. 任何一个维度出现哪怕一个字节的差异, 缓存就完全失效, 没有部分命中这一说.

System prompt: fork子agent不使用自己的system prompt, 而是直接使用父agent已经渲染好的system prompt字节. 从行为推断, 这里有一个微妙的考量: system prompt的渲染可能依赖feature flag的状态(比如GrowthBook), 而flag状态可能在父agent回合开始和fork子agent创建之间发生变化. 如果fork子agent重新渲染system prompt, 即使逻辑相同, flag状态变化也可能导致输出的字节不同. 所以正确的做法是直接传递父agent渲染完成的字节, 而不是重新渲染. 代码中有一个fallback路径, 当渲染好的字节不可用时重新计算, 但会标注这可能导致缓存失效.

Tools定义: fork子agent通过useExactTools=true标志接收父agent的完整工具池, 不做任何过滤或重排. 常规子agent会根据自己的permissionMode重新组装工具集, 工具定义的序列化可能在第一个差异处就打破缓存. Fork path跳过这一步, 直接使用父agent的tools数组引用.

ThinkingConfig: fork子agent继承父agent的thinking配置. 这个配置是缓存key的一部分. 从行为推断, 如果fork子agent设置了不同的maxOutputTokens, 可能会间接改变budget_tokens(通过下游的clamping逻辑), 导致thinking config的字节表示变化, 进而缓存失效. 所以fork path不设置maxOutputTokens, 而是完整继承.

ContentReplacementState: 这个维度稍微隐晦一些. Claude Code有一套工具结果预算管理机制, 会对过长的tool result进行截断或替换. 这些替换决策会影响线上发送的消息内容——同一个tool_use_id的result, 在全新状态下和在克隆状态下可能做出不同的替换决策. Fork子agent克隆父agent的替换状态(而非创建全新状态), 这样在处理继承的父消息时, 对相同的tool_use_id会做出相同的替换决策, 保持wire prefix一致. 用代码注释中的说法: "A fresh state would see them as unseen and make divergent replacement decisions, a clone makes identical decisions."

CacheSafeParams模式

并行fork并不是唯一需要共享缓存的场景. 在主循环每个回合结束后, Claude Code还会启动一些旁路fork——比如session memory提取、prompt suggestion生成、/btw侧问等. 这些任务也需要尽量搭上主循环的缓存. 从行为推断, 系统在每次主循环回合结束后会保存一份缓存安全参数快照:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
class CacheSafeParams:
system_prompt: SystemPrompt
user_context: dict
system_context: dict
tool_use_context: ToolUseContext
fork_context_messages: list[Message]

# Global slot, written after each main-loop turn
last_cache_safe_params: CacheSafeParams | None = None

def save_cache_safe_params(params):
global last_cache_safe_params
last_cache_safe_params = params

def get_last_cache_safe_params():
return last_cache_safe_params

这个全局slot在每个主循环回合的stop hooks中被更新. 后续的旁路fork读取这个快照来构造自己的API请求, 而不是每个调用方自己重新收集参数. 这避免了参数收集时机不同导致的微小差异, 比如system context在两次收集之间可能有变化. 只有来自主线程(repl_main_thread)或SDK的查询才会保存这个快照, 子agent自己的回合不会覆盖它.

一个值得注意的设计点是, CacheSafeParams中包含的forkContextMessages是主循环的消息数组引用. 旁路fork在使用时会通过createSubagentContext克隆必要的可变状态, 但消息前缀本身保持引用共享, 确保字节一致.

这个模式解决的一个实际问题是时间窗口差异. 假设主循环在T0时刻完成一个回合, session memory提取在T0+100ms启动, prompt suggestion在T0+200ms启动. 如果两者各自独立收集system prompt和context, T0到T0+200ms之间feature flag可能发生变化, GrowthBook配置可能更新, 甚至system prompt的模板可能被热更新. 用同一份快照就消除了这个时间窗口带来的差异风险.

递归Fork防护

Fork子agent的工具池中保留了Agent工具. 这不是为了让fork子agent真的能再次fork, 而是为了保持工具定义的字节一致性. 如果从fork子agent的工具池中移除Agent工具, 工具schema就会和父agent不同, 缓存前缀在tools维度上就对不齐了.

防止递归fork的检测分两层. 第一层检查querySource是否为fork agent的标识符, 这个检查能抗住autocompact(自动压缩会重写消息但不会改context.options, 因为querySource是在spawn时设置在options上的). 第二层扫描消息历史中是否存在fork boilerplate标签, 作为querySource没有被正确传递时的兜底:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
def is_in_fork_child(messages):
"""Check if we're already inside a fork child."""
for msg in messages:
if msg.type != "user":
continue
for block in msg.content:
if block.type == "text" and "<fork-boilerplate>" in block.text:
return True
return False

# In AgentTool.call():
def handle_agent_call(input, context):
effective_type = input.subagent_type or (
None if is_fork_enabled() else "general-purpose"
)
is_fork_path = effective_type is None

if is_fork_path:
# Primary guard: querySource (survives autocompact)
if context.options.query_source == "agent:builtin:fork":
raise Error("Fork not available inside a forked worker")
# Fallback guard: message scan
if is_in_fork_child(context.messages):
raise Error("Fork not available inside a forked worker")

两层防护的存在说明autocompact是一个需要特别关注的场景. 当对话变长触发自动压缩时, 消息会被重写, fork boilerplate标签可能被压缩掉. 如果只依赖消息扫描, 一个被autocompact过的fork子agent就可能绕过递归检测. 所以querySource作为options上的属性(不受消息重写影响)成为了主检测手段, 消息扫描降级为fallback. 这是一个防御性编程的好例子: 两层检测各有盲区, 组合起来才能覆盖所有路径.

命名子Agent的成本优化

命名子agent走一条完全不同的优化路径. 它们不追求与父agent共享缓存, 而是通过削减上下文来降低绝对成本. 思路很朴素: 如果一个子agent不需要完整上下文, 那就不给它完整上下文.

Explore agent对外部用户使用Haiku模型, 只保留只读工具(Glob、Grep、Read、Bash), 不加载CLAUDE.md项目规范. 从行为推断, 剥离CLAUDE.md在全网规模上节省了相当可观的token量, 大概是因为CLAUDE.md通常包含几千到几万token的项目规范, 而Explore作为只读搜索agent根本不需要知道代码风格指南和CI流程. 这个优化有一个kill switch, 可以通过feature flag回滚, 说明这是经过AB测试验证过效果的.

类似地, Explore和Plan都会省略system context中的gitStatus. 这个信息在session开始时采集, 可能有几万token, 到子agent运行时可能已经过时了. 如果子agent需要git信息, 它们可以自己执行git status获取实时数据. 省掉一个陈旧的大文本块, 既节省了token又避免了过时信息误导子agent的决策.

这两种优化策略(fork的缓存共享 vs 命名agent的上下文裁剪)分别适用于不同的场景. Fork适合需要完整上下文的并行任务, 比如同时修改多个相关文件, 子agent需要理解整个对话的来龙去脉才能做出正确的修改决策. 命名agent适合职责明确的单一任务, 比如搜索代码库或制定计划, 它们不需要父对话的完整历史, 用更小的模型和更少的上下文就能完成工作. 从成本角度看, 命名agent在单次调用上更便宜(更小的模型 + 更短的上下文), fork在多次并行调用上通过缓存共享摊薄了边际成本.

缓存共享的完整链路

把以上几个机制串联起来, 一次典型的fork并行执行的缓存共享链路是这样的:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
Parent turn N:
API Request:
system_prompt [cached from turn N-1]
tools [cached from turn N-1]
messages[0..K] [cached from turn N-1]
messages[K+1] [new user message, cache_control: ephemeral]
Response:
assistant { thinking, text, tool_use_1, tool_use_2, tool_use_3 }

Fork 1 (first request):
API Request:
system_prompt [CACHE HIT - same bytes as parent]
tools [CACHE HIT - same tool pool, useExactTools]
messages[0..K] [CACHE HIT - same parent history]
messages[K+1] [CACHE HIT - same user message]
messages[K+2] [assistant with all 3 tool_uses]
messages[K+3] [placeholder results + directive_1]
-> Pays: full price for messages[K+2..K+3], 90% off on [0..K+1]

Fork 2 (second request, moments later):
API Request:
system_prompt [CACHE HIT]
tools [CACHE HIT]
messages[0..K+2] [CACHE HIT, including the assistant message]
messages[K+3] [placeholder results + directive_2]
-> Pays: full price only for the directive_2 tail,
90% discount on everything including messages[K+2]

Fork 3 (third request):
-> Same pattern as Fork 2, nearly everything cached

第一个fork创建了缓存条目, 后续的fork全部命中. 关键在于Fork 2和Fork 3甚至能缓存命中Fork 1新增的assistant消息和placeholder results, 因为这些内容在所有fork之间是字节一致的, 只有最后几十个token的directive不同. 这个增量缓存的效果使得fork数量的增加几乎不增加边际成本.

来算一笔账. 假设parent_history有100K token, 3个tool_use的assistant消息有500 token, placeholder results加起来200 token, 每个directive约100 token. Fork 1需要为assistant消息+placeholder+directive付全价(约800 token), 前面100K命中缓存按10%计费. Fork 2和Fork 3连assistant消息和placeholder都命中缓存了, 只有directive的100 token付全价. 三个fork的总输入成本大约是: 100K * 10% * 3(缓存命中部分) + 800(Fork 1新内容) + 100 * 2(Fork 2/3的directive) = 31K token等价. 对比不做缓存共享的300K+, 节省了近90%.

这个收益随着对话上下文的增长而放大. 当parent_history从100K增长到200K时, 缓存共享节省的绝对token量也翻倍. 考虑到Claude Code的典型使用场景中对话上下文经常达到几十K甚至上百K, fork缓存共享在成本控制上的效果是相当显著的. 反过来说, 如果没有这个机制, 频繁的并行fork会让API调用成本线性增长, 在长对话中很快变得不可接受.

隔离模型: 共享前缀, 隔离运行时

共享缓存不代表共享状态. 每个fork子agent在执行时会获得一个完全隔离的上下文: 文件状态缓存是克隆的(不是共享引用), abort controller是新建的(但链接到父agent, 父abort会传播), setAppState等mutation回调默认是no-op. 多个fork不会互相干扰, 一个fork修改文件不会污染另一个fork的readFileState缓存.

在fork完成后, 隔离资源被显式释放: readFileState.clear()清空克隆的文件缓存, initialMessages.length = 0释放克隆的上下文消息数组. 这对长session来说很重要, 否则每次fork都会留下一份100K+ token消息副本的内存占用. fork子agent注册的shell后台任务、Perfetto tracing条目、todo state等也会在finally块中被清理, 防止长session中的资源泄漏.

fork还有一种worktree隔离模式, 可以为子agent创建独立的git worktree. 这种情况下, 子agent收到的消息中会追加一条提示, 告知它正在worktree中工作, 需要将继承的路径转换为worktree根目录下的路径, 并且在编辑前重新读取文件(因为父agent可能已经修改了它们). 这条提示被追加在fork directive之后, 所以不影响所有fork之间的公共前缀.

隔离还有一个细节值得提一下. Fork子agent的abort controller虽然是新建的, 但它链接到父agent的controller, 父agent被中断时信号会向下传播. 这意味着用户在父agent层面按下中断, 所有并行fork都会收到信号. 但反过来不成立: 一个fork自己遇到问题中止不会影响其他fork或父agent. 这种单向传播的设计在并行执行场景中是合理的, 用户需要能一键停掉所有子任务, 但单个子任务的失败不应该连带其他任务.

从行为推断, fork子agent执行完毕后的结果会以notification的形式呈现给父agent. fork的启用还会改变Agent工具的整体行为模式: 所有agent spawn都变成异步的, 采用统一的task-notification交互模型. 这意味着父agent发出fork指令后不会阻塞等待, 而是继续处理其他工作或等待所有fork完成. 这个设计选择使得fork天然适合并行场景, 但也意味着fork子agent的结果不会像同步子agent那样直接插入到父对话流中.

一个设计观察

整个fork缓存共享机制本质上是在解一个约束满足问题: API的缓存key要求字节级一致, 而fork子agent天然需要差异化的指令. 解法是把所有差异推到消息序列的最末尾——一个比prefix小两到三个数量级的区域. 这很像数据库的clustered index设计: 把高基数列放在最后, 让公共前缀尽可能长.

如果把这个设计思路抽象一下, 它其实是一种通用的multi-tenant缓存共享模式: 当N个请求共享一段长前缀、只在末尾有少量差异时, 把差异推到最后, 让缓存覆盖所有公共部分. 这个模式在CDN的vary header设计、数据库查询计划缓存、甚至CPU指令缓存的prefetch策略中都能找到类似的影子. Claude Code的实现特别之处在于它需要跨越五个独立的维度(system prompt、tools、model、messages、thinking config)同时保持一致, 任何一个维度的偏差都会让整个缓存失效. 这使得它更像是在一个五维空间中走钢丝.

对于自己构建LLM agent系统的开发者来说, 这里有一个可以直接借鉴的设计原则: 如果你的系统需要并行发起多个LLM调用, 并且这些调用共享大量公共上下文, 那么值得花时间确保这些调用的API请求前缀字节一致. 具体来说就是: 常量化所有占位内容, 避免重新渲染可能产生差异的动态部分, 保留而非裁剪公共schema, 克隆而非重建有状态的处理器. Prompt cache的折扣幅度(90%)足够大, 使得即使是相对复杂的对齐工作也有很好的ROI.

为了保持工具定义的字节一致, fork子agent保留了Agent工具但禁止调用它. 一个工具的存在纯粹是为了它在schema序列化时产生的字节, 和它的功能完全无关. 在这个系统里, 一个工具可以没有被调用的可能性, 但不能没有被序列化的必要性. 大概这就是byte-level compatibility对工程美学的终极要求了.