Fork subagent 机制

目标

这页回答:

Claude Code 里的 fork subagent 到底是什么?它和普通 subagent 有什么本质区别?

关键文件:

  • src/tools/AgentTool/forkSubagent.ts
  • src/tools/AgentTool/AgentTool.tsx
  • src/tools/AgentTool/runAgent.ts
  • src/utils/forkedAgent.ts

一句话结论

fork subagent 不是“选了另一种 agent 类型”的普通子 agent, 而是:

从父 agent 当前上下文里劈出一个分叉 worker,尽可能复用父请求的 cache-critical 前缀,让多个子任务在共享上下文的同时还能吃到 prompt cache。

也就是说,fork 的重点不是“再起一个 agent”,而是:

  • 继承父上下文
  • 保持 API 前缀尽量字节级一致
  • 只在最后一小段 directive 上分叉

1. 什么时候会走 fork?

AgentTool.call() 里:

const effectiveType = subagent_type ?? (isForkSubagentEnabled() ? undefined : GENERAL_PURPOSE_AGENT.agentType)
const isForkPath = effectiveType === undefined

含义

如果:

  • fork feature gate 开启
  • subagent_type 没填

那系统不会默认走 general-purpose,而是进入 fork path

gate 条件

isForkSubagentEnabled() 里还能看到:

  • coordinator mode 下禁用
  • non-interactive session 下禁用

所以 fork 不是全局永远开的能力,而是特定交互模式下的实验特性。


2. fork 用的不是普通 agent definition

forkSubagent.ts 定义了一个 synthetic agent:

export const FORK_AGENT = {
  agentType: 'fork',
  tools: ['*'],
  maxTurns: 200,
  model: 'inherit',
  permissionMode: 'bubble',
  ...
}

这几个字段都很关键

tools: ['*']

表示 fork child 原则上拿全工具面。

但真正关键不是这个,而是后面配合:

useExactTools: true

让它直接继承父 agent 的 exact tool pool。

model: 'inherit'

尽量保持和父 agent 一样的模型 / 上下文特性。

permissionMode: 'bubble'

权限提示冒泡到父终端,而不是让 fork child 自己弹 UI。


3. 为什么 fork 要继承父 system prompt?

AgentTool.tsx 里写得很白:

  • fork path 不使用 FORK_AGENT.getSystemPrompt()
  • 而是优先使用 toolUseContext.renderedSystemPrompt
  • 如果没有,才 fallback 重新计算

原因

因为 fork 的目标之一是:

保持与父请求的 prompt prefix 尽量 byte-identical

如果重新渲染 system prompt,哪怕语义一样, 只要因为 feature flag / GrowthBook 状态变化多了一个字节,cache 就可能 miss。

所以这里直接传父级已经渲染好的 system prompt bytes。


4. buildForkedMessages() 是 fork 的灵魂

这个函数非常关键。

它做的不是简单地给子 agent 一句新 prompt, 而是构造一段特殊消息前缀:

步骤

  1. 克隆父 assistant message
  2. 保留其中的全部 content:
    • thinking
    • text
    • 每个 tool_use
  3. 为每个 tool_use 生成一个占位 tool_result
  4. 所有占位结果都使用同样的 placeholder 文本:
    • Fork started — processing in background
  5. 最后再加一段 per-child directive

结果形态

大致像:

[父 assistant 完整消息]
[一个 user message,其中包含:
  tool_result(tool1=placeholder)
  tool_result(tool2=placeholder)
  ...
  text(你这次 fork 的具体指令)
]

这为什么重要?

因为这样做后:

  • 不同 fork child 之间,大部分请求前缀完全一样
  • 只有最后 directive 那块不同

这正是 prompt cache 最喜欢的结构。


5. 为什么要给每个 tool_use 补 placeholder tool_result?

这是协议层需要。

Claude 的消息协议要求:

  • assistant 发了 tool_use
  • 后面必须有对应 tool_result

父 assistant message 里保留了全部 tool_use, 那 fork child 这边就必须把这些 tool_result 也补出来。

但这些结果不是要真的重跑父工具, 所以用统一 placeholder 顶上。

这是一举两得

  • 协议合法
  • 前缀稳定

6. fork child 会收到一段非常强硬的 boilerplate 指令

buildChildMessage() 里写得很凶,核心意思是:

  • 你不是主 agent
  • 你不要继续 fork
  • 你不要聊天,不要解释,不要发散
  • 直接用工具做事
  • 最后按固定格式简洁汇报

这段指令的意义

fork child 继承了父上下文,甚至可能继承父的“默认倾向继续 fork”。

所以这里必须加一段非常强的 guard rail,明确告诉它:

你就是 worker,不是 orchestrator。

这不只是行为约束,也是防止 fork 递归膨胀的手段。


7. fork 还专门有递归保护

isInForkChild(messages) 会检查消息里有没有 fork boilerplate tag。

AgentTool.call() 里也会判断:

  • toolUseContext.options.querySource === agent:builtin:fork
  • 或者消息历史里已经出现 fork boilerplate

一旦命中,就拒绝再 fork。

原因

fork child 依然保留 AgentTool,是为了维持工具定义和 prompt prefix 的一致性。

但保留 AgentTool 不代表允许无限递归 fork。 所以要在 call-time 再做守门。


8. fork path 为什么强制 async?

AgentTool.tsx 里有这一句:

const forceAsync = isForkSubagentEnabled();

也就是 fork 开启后,agent spawn 会被推向统一的 async / task-notification 模型。

为什么

因为 fork 更像分叉 worker,不适合像同步 subagent 一样卡住主线程等待。

统一 async 的好处是:

  • 主 agent 可以继续协调其它工作
  • worker 完成后用 task notification 回来
  • 交互模型更一致

9. fork path 在 runAgent() 里会启用 useExactTools

AgentTool.tsx 传给 runAgent()

availableTools: isForkPath ? toolUseContext.options.tools : workerTools,
forkContextMessages: isForkPath ? toolUseContext.messages : undefined,
useExactTools: true

这三个点要一起看

availableTools = parent exact tools

不是重新 assemble 一套 worker tools,而是直接用父的 exact tool array。

forkContextMessages = parent messages

子 agent 带着父上下文消息一起进来。

useExactTools = true

告诉 runAgent()

  • 不要再过滤 / 重建工具池
  • thinkingConfig 也尽量继承父级
  • isNonInteractiveSession 也沿用父级

核心目的

还是那个词: cache-identical prefix


10. fork 与 worktree 也能组合

如果 fork child 还启用了 worktree,系统会额外注入一段 notice:

  • 父上下文里的路径是父 cwd
  • 你现在在隔离 worktree 里
  • 要把路径翻译到自己的 worktree root
  • 编辑前最好重新读文件

这说明什么

作者已经意识到 fork child 继承父上下文时, “上下文里的文件路径” 与 “当前实际工作目录” 可能不一致。

所以这里再补一层工作目录认知修正。


11. fork 本质上解决什么问题?

普通 subagent 的特点

  • 有自己的 agent type
  • 有自己的 system prompt
  • 有自己的工具筛选
  • 更像独立角色

fork subagent 的特点

  • 直接从父 agent 当前上下文分叉
  • 尽量共享请求前缀
  • 尽量复用 prompt cache
  • 更像临时 worker

所以你可以粗暴地记成:

普通 subagent 像“叫来一个专家角色”

fork subagent 像“把当前自己劈成几个并行干活的分身”


12. 这套设计最妙的地方

最妙的不是“能 fork”,而是它把以下几件事绑在了一起:

  • 消息协议合法性
  • prompt cache 命中率
  • 子 worker 行为约束
  • 递归保护
  • async task 生命周期

也就是说,fork 不是一个孤零零的小 feature, 而是 deeply integrated 到:

  • AgentTool
  • runAgent
  • createSubagentContext
  • query / tool protocol
  • cache / transcript / task 系统

当前结论

fork subagent 机制的核心不是“省几行 prompt”,而是:

在不破坏消息协议的前提下,把父 agent 当前轨迹尽可能完整地共享给多个 worker,并把分叉成本压到最小、把 prompt cache 命中率拉到最高。

这很像一个为高频并发委派专门优化过的子 agent 模式。

不是花架子,是真有工程味。