Claude Code 的多 Agent 设计并不是简单地“主 Agent 再启动几个子 Agent”。它真正解决的是一组工程问题:
- 子 Agent 能不能访问父 Agent 的所有工具?
- 子 Agent 读取或修改状态时,会不会污染父 Agent?
- 父 Agent 和子 Agent 之间是同步函数调用,还是异步消息通信?
- 子 Agent 的系统提示词很长时,怎么减少 Token 成本和延迟?
- 多个 Worker 并行执行时,谁负责拆任务、收结果、合成最终答案?
围绕这些问题,Claude Code 里可以抽象出三套机制:
| 机制 | 解决的问题 | 系统形态 |
|---|---|---|
| 常规 Subagent | 把局部任务交给独立子 Agent 执行 | 父子结构 |
| Fork Subagent | 复用父 Agent 的 Prompt 缓存,降低成本和延迟 | 缓存友好的父子结构 |
| Coordinator 模式 | 大任务拆给多个 Worker 并行执行 | 协调者 + Worker |
整体关系可以概括成下面这张图:
flowchart TB
A[Claude Code Multi-Agent 机制] --> B[常规 Subagent]
A --> C[Fork Subagent]
A --> D[Coordinator 模式]
B --> B1[工具隔离]
B --> B2[上下文隔离]
B --> B3[异步消息通信]
C --> C1[复用父 Agent Prompt 前缀]
C --> C2[命中 Prompt Cache]
C --> C3[降低输入 Token 成本和首 Token 延迟]
D --> D1[主 Agent 只做协调]
D --> D2[Worker 并行执行]
D --> D3[协调者合成结果]
Multi-Agent 要解决什么问题
一个基础 Agent 通常由三部分组成:
flowchart LR
U[用户任务] --> L[LLM 大语言模型]
L --> T[工具调用]
T --> E[执行环境]
E --> R[观察结果]
R --> L
L --> O[最终回答]
LLM(大语言模型)负责思考,工具负责执行,循环负责持续推进任务。这个模型在小任务里很好用,但在真实软件工程场景里很快会遇到瓶颈。
比如给 Agent 一个任务:
调研 React 18 的新特性,在项目里实现一个 useTransition 示例,并完成代码审查。
这个任务至少包含三类工作:
| 工作 | 需要的上下文 | 合适角色 |
|---|---|---|
| 调研 React 18 | 官方文档、示例、社区资料 | 研究员 |
| 修改项目代码 | 项目结构、组件代码、构建配置 | 工程师 |
| 审查实现质量 | 改动 diff、潜在 bug、边界条件 | Reviewer |
如果所有事情都压给一个 Agent,会出现三个典型问题。
| 问题 | 表现 |
|---|---|
| 上下文膨胀 | 调研资料、项目代码、审查记录全塞进同一个上下文,Token 消耗快速增长 |
| 职责混乱 | 同一个 Agent 一会儿调研、一会儿写代码、一会儿审查,容易在任务阶段之间跳来跳去 |
| 无法并行 | 调研文档时不能同时分析项目代码,写代码时不能同时准备审查清单 |
Multi-Agent 的核心思路是把一个大任务拆成多个职责清晰的小任务,让不同 Agent 在隔离环境里执行,再通过消息或共享状态把结果汇总回来。
常见的 Multi-Agent 形态可以分成三类:
| 形态 | 说明 | 适合场景 |
|---|---|---|
| 父子型 | 主 Agent 遇到子问题时启动 Subagent,拿到结果后继续执行 | 局部调研、代码搜索、一次性分析 |
| 平级协作型 | 多个 Agent 地位相近,通过共享状态或消息协作 | 需要复杂协商的任务,但工程实现难度较高 |
| Coordinator-Worker 型 | 协调者只负责拆任务、派 Worker、收结果、合成答案 | 大规模并行调研、迁移、验证 |
Claude Code 的常规 Subagent 对应父子型;Coordinator 模式对应 Coordinator-Worker 型;Fork Subagent 则是父子型里的缓存优化版本。
常规 Subagent:独立执行单元
在 Claude Code 里,Subagent 可以理解为主 Agent 通过特定工具派出去的独立执行单元。它不是一个普通函数,而是一个拥有自己上下文、工具集合和生命周期的 Agent 实例。
典型流程如下:
flowchart LR
A[主 Agent] -->|调用 Agent/Task 工具| B[创建 Subagent]
B --> C[分配工具集合]
B --> D[创建隔离上下文]
C --> E[Subagent 独立运行]
D --> E
E --> F[生成结果]
F -->|通知| A
Subagent 机制最重要的不是“能启动另一个 Agent”,而是启动之后如何隔离。隔离做不好,子 Agent 会污染父 Agent 的状态,或者越权调用不该用的工具。
Claude Code 主要做了两层隔离:
- 工具隔离:不同类型的 Agent 拿到不同工具集合。
- 上下文隔离:运行时状态按字段决定克隆、共享、屏蔽或新建。
工具隔离:给不同 Agent 分配不同工具箱
主 Agent 通常能使用很多工具:读文件、写文件、执行命令、发起子任务、向用户提问、管理任务列表等。不能把这些工具原封不动交给 Subagent。
原因很直接:
- 如果子 Agent 也能继续派子 Agent,就可能形成无限递归。
- 如果子 Agent 能向用户提问,会抢走主 Agent 的对话权。
- 如果子 Agent 能修改主 Agent 的待办列表,会污染主流程状态。
- 如果后台 Agent 能用交互型工具,执行过程会卡在无人响应的交互上。
Claude Code 的工具过滤可以抽象成三道门:
flowchart TB
A[父 Agent 的完整工具集合] --> B{MCP 工具?}
B -->|是| K[保留]
B -->|否| C{全体 Subagent 黑名单?}
C -->|是| X[移除]
C -->|否| D{自定义 Agent 黑名单?}
D -->|是且非内置 Agent| X
D -->|否| E{异步后台 Agent?}
E -->|否| K
E -->|是| F{异步白名单内?}
F -->|是| K
F -->|否| X
K --> G[Subagent 可用工具集合]
这里的 MCP 指 MCP(Model Context Protocol,模型上下文协议)工具。Claude Code 对 MCP 工具采用了特殊放行策略,其他工具则按黑名单或白名单过滤。
简化后的 TypeScript 逻辑如下:
type Tool = {
name: string
}
function filterToolsForAgent(options: {
tools: Tool[]
isBuiltIn: boolean
isAsync: boolean
}): Tool[] {
const { tools, isBuiltIn, isAsync } = options
return tools.filter(tool => {
// MCP 工具单独放行
if (tool.name.startsWith("mcp__")) {
return true
}
// 所有 Subagent 都不能用的工具
if (ALL_AGENT_DISALLOWED_TOOLS.has(tool.name)) {
return false
}
// 用户自定义 Agent 再加一层限制
if (!isBuiltIn && CUSTOM_AGENT_DISALLOWED_TOOLS.has(tool.name)) {
return false
}
// 异步后台 Agent 只允许使用白名单里的工具
if (isAsync && !ASYNC_AGENT_ALLOWED_TOOLS.has(tool.name)) {
return false
}
return true
})
}
这套设计的关键点是:权限不是按“是不是 Agent”粗暴区分,而是按 Agent 类型、运行模式、工具风险分级处理。
上下文隔离:不能全共享,也不能全新建
工具隔离解决“能做什么”,上下文隔离解决“运行时状态怎么处理”。
父 Agent 运行时会维护很多状态,例如:
- 文件读取缓存:某个文件是否读过、读到第几行。
- 全局 UI 状态:界面展示、任务列表、进度信息。
- 中止信号:用户是否按下 Ctrl+C。
- 权限模式:当前是否允许执行命令或修改文件。
- 任务注册表:后台进程、异步任务的状态。
- Agent 身份信息:Agent ID、嵌套深度、链路追踪 ID。
如果把父上下文完整共享给子 Agent,会发生状态污染。比如父 Agent 读过 file.ts 前 100 行,子 Agent 接着读到 200 行,如果两者共享文件读取缓存,父 Agent 之后可能误以为自己也读过前 200 行。
如果给子 Agent 一个完全空的新上下文,也会出问题。用户中止任务时,子 Agent 可能收不到中止信号;子 Agent 启动的后台进程也可能无法登记到全局任务表,最后变成没人管理的孤儿任务。
Claude Code 的做法是按字段语义分别决策:
| 状态字段 | 处理方式 | 原因 |
|---|---|---|
| 文件读取缓存 | 克隆一份 | 防止子 Agent 改变父 Agent 对文件读取进度的认知 |
| 全局 UI 写入 | 屏蔽为空操作 | 防止异步 Agent 与主线程同时修改 UI 状态 |
| 任务注册通路 | 保留 | 子 Agent 启动的后台任务需要登记和回收 |
| Agent ID | 新建 | 每个 Agent 必须可追踪 |
| 嵌套深度 | 父深度 + 1 | 防止递归嵌套失控 |
| 中止信号、权限信息 | 按需共享 | 子 Agent 必须感知外部控制和权限边界 |
可以用一张流程图理解:
flowchart LR
A[父 Agent 上下文] --> B{字段语义判断}
B --> C[克隆]
B --> D[共享]
B --> E[屏蔽]
B --> F[新建]
C --> C1[文件读取缓存]
D --> D1[中止信号 / 权限状态 / 任务注册通路]
E --> E1[全局 UI 写入]
F --> F1[Agent ID / Trace ID / 深度计数]
C1 --> G[Subagent 上下文]
D1 --> G
E1 --> G
F1 --> G
简化后的上下文创建逻辑如下:
function createSubagentContext(
parentContext: ToolUseContext,
overrides?: Partial<ToolUseContext>
): ToolUseContext {
return {
...parentContext,
// 文件读取状态克隆,避免污染父 Agent
readFileState: cloneFileStateCache(parentContext.readFileState),
// 子 Agent 不能直接修改全局 UI 状态
setAppState: () => {},
// 但任务注册通路要保留,否则后台任务无法被管理
setAppStateForTasks:
parentContext.setAppStateForTasks ?? parentContext.setAppState,
// 每个 Agent 都有独立身份
agentId: overrides?.agentId ?? createAgentId(),
// 嵌套深度递增,方便限制递归
queryTracking: {
chainId: randomUUID(),
depth: (parentContext.queryTracking?.depth ?? -1) + 1,
},
}
}
真正重要的原则是:上下文隔离不是“全部共享”或“全部隔离”的二选一,而是逐字段判断这个状态对子 Agent 是否必要、是否会反向影响父 Agent。
父子 Agent 通信:用消息队列,不用同步函数调用
Subagent 启动之后,父子之间需要通信。最直觉的设计是父 Agent 调一个函数,然后等待子 Agent 返回结果:
const result = await runSubagent(prompt)
这个模型简单,但很快会遇到问题:
- 子 Agent 如果跑 5 分钟,父 Agent 就会被阻塞 5 分钟。
- 父 Agent 想同时派 5 个子 Agent,需要额外管理并发、取消、超时、错误恢复。
- 子 Agent 已经完成后,如果父 Agent 想继续给它补充指令,函数返回模型很难处理。
Claude Code 采用消息驱动模型。每个 Subagent 都有一份任务状态,里面包含 Agent ID、状态、结果、进度和待处理消息队列。
简化类型如下:
type TaskStatus =
| "pending"
| "running"
| "completed"
| "failed"
| "killed"
type LocalAgentTaskState = {
type: "local_agent"
agentId: string
prompt: string
agentType: string
status: TaskStatus
result?: AgentToolResult
progress?: AgentProgress
isBackgrounded: boolean
// 父 Agent 发给子 Agent 的消息会先进入这里
pendingMessages: string[]
// 子 Agent 的对话历史
messages?: Message[]
}
pendingMessages 就是子 Agent 的收件箱。
父 Agent 给子 Agent 发消息
父 Agent 向子 Agent 发送消息时,并不会直接打断子 Agent 的执行,而是把消息追加到目标任务状态的 pendingMessages 数组里。
function queuePendingMessage(
taskId: string,
message: string,
setAppState: SetAppState
): void {
updateTaskState<LocalAgentTaskState>(taskId, setAppState, task => ({
...task,
pendingMessages: [...task.pendingMessages, message],
}))
}
子 Agent 每轮工具调用结束后,会检查自己的收件箱。如果有新消息,就把这些消息注入自己的对话历史,再进入下一轮 LLM 调用。
时序如下:
sequenceDiagram
participant P as 父 Agent
participant S as SendMessage 工具
participant T as 任务状态表
participant C as 子 Agent
P->>S: 给 agent-a1b 发送补充指令
S->>T: pendingMessages 追加消息
S-->>P: 发送成功,立即返回
C->>T: 当前工具调用结束,检查收件箱
T-->>C: 返回 pendingMessages
C->>C: 将消息作为用户输入注入对话历史
C->>C: 进入下一轮 LLM 调用
如果子 Agent 已经完成或被停止,Claude Code 还可以从保存的 transcript 恢复它的对话历史,再把新消息拼进去重新启动。这样子 Agent 不是一次性对象,而是可以被唤醒继续工作的执行单元。
简化逻辑如下:
async function sendMessageToAgent(input: {
agentId: string
message: string
}) {
const task = appState.tasks[input.agentId]
if (isLocalAgentTask(task) && task.status === "running") {
queuePendingMessage(
input.agentId,
input.message,
context.setAppStateForTasks
)
return {
success: true,
message: "message queued",
}
}
// 已停止的 Agent 可以从 transcript 恢复后继续运行
return resumeAgentBackground({
agentId: input.agentId,
prompt: input.message,
})
}
子 Agent 给父 Agent 发通知
子 Agent 完成任务后,需要把结果交给父 Agent。Claude Code 没有把完成事件设计成复杂的内部对象,而是把结果包装成一段 XML 文本,再作为一条消息注入父 Agent 的对话历史。
通知格式大致如下:
<task-notification>
<task-id>agent-a1b</task-id>
<output-file>/tmp/agent-a1b-output.txt</output-file>
<status>completed</status>
<summary>认证模块调研已完成</summary>
<result>
发现 token 校验逻辑在 src/auth/validate.ts:42
没有处理 null session,可能导致异常。
</result>
<usage>
<total_tokens>12345</total_tokens>
<tool_uses>8</tool_uses>
<duration_ms>34567</duration_ms>
</usage>
</task-notification>
这个设计有三个好处:
| 设计点 | 好处 |
|---|---|
| XML 是纯文本 | 可以直接进入对话历史,不需要额外定义复杂事件协议 |
| XML 有清晰结构 | LLM 能稳定识别 task-id、status、result 等字段 |
| 通知被包装成消息 | 父 Agent 可以复用现有 agentic loop,不需要额外状态机 |
生成通知的逻辑本质上是拼接字符串,然后把它放入父 Agent 的待处理消息队列:
function buildTaskNotification(task: LocalAgentTaskState): string {
return `
<task-notification>
<task-id>${task.agentId}</task-id>
<status>${task.status}</status>
<summary>${escapeXml(task.progress?.summary ?? "")}</summary>
<result>${escapeXml(task.result?.content ?? "")}</result>
</task-notification>`
}
enqueuePendingNotification({
value: buildTaskNotification(task),
mode: "task-notification",
})
父子通信整体可以概括为:
flowchart LR
P[父 Agent] -->|SendMessage| Q1[子 Agent pendingMessages]
Q1 -->|循环边界读取| C[子 Agent]
C -->|完成任务| X[task-notification XML]
X --> Q2[父 Agent 待处理消息队列]
Q2 -->|下一轮循环处理| P
Auto-background:长任务自动转后台
常规 Subagent 刚启动时,可以像一次普通工具调用一样在前台运行。问题是有些任务会跑很久,例如大型代码搜索、迁移分析、测试修复等。
Claude Code 的处理方式是设置自动后台化阈值:
- 如果 Subagent 很快完成,父 Agent 直接拿结果继续执行。
- 如果 Subagent 超过阈值仍未完成,就把它转成后台任务。
- 后台任务完成后,通过
task-notification通知父 Agent。
阈值逻辑可以抽象成:
function getAutoBackgroundMs(): number {
if (
isEnvTruthy(process.env.CLAUDE_AUTO_BACKGROUND_TASKS) ||
getFeatureValue("tengu_auto_background_agents", false)
) {
return 120_000 // 2 分钟
}
return 0
}
这个机制把“同步等待”自动降级为“异步通知”,让父 Agent 不必一直被长任务占住。
flowchart TB
A[启动 Subagent] --> B{是否在阈值内完成?}
B -->|是| C[前台返回结果]
B -->|否| D[转后台执行]
D --> E[父 Agent 继续处理其他任务]
D --> F[Subagent 完成]
F --> G[发送 task-notification]
G --> H[父 Agent 处理结果]
Fork Subagent:为 Prompt Cache 设计的分身
常规 Subagent 能解决任务拆分问题,但还有一个隐藏成本:系统提示词很长。
Claude Code 的系统提示词可能包含大量内容:
- 工具说明。
- 行为规范。
- 当前项目上下文。
- 用户配置。
- 环境信息。
- 历史对话前缀。
每次启动一个拥有独立系统提示词的 Subagent,API 都需要处理一大段输入 Token。Subagent 调用越频繁,成本和延迟越明显。
Anthropic 的 Prompt Cache 可以缓解这个问题。它的核心规则是:如果请求前缀和之前请求的前缀完全一致,就可以命中缓存,减少输入 Token 成本并降低首 Token 延迟。
关键在于“完全一致”。这里不是语义相同,也不是字符串大致相似,而是字节级一致。一个空格、工具顺序、动态字段差异,都可能导致缓存失效。
Fork Subagent 就是为这个规则设计的。
Fork 的核心思路
Fork Subagent 不是创建一个拥有独立提示词的专业 Agent,而是创建一个“继承父 Agent 请求前缀的分身”。它要尽量保证 API 请求前缀和父 Agent 已经缓存过的前缀一致。
需要对齐的内容包括:
| 内容 | 为什么影响缓存 |
|---|---|
| system prompt | 请求前缀的主体 |
| user context | 会拼进消息前缀 |
| system context | 会拼进系统上下文 |
| 工具池定义和顺序 | 工具 schema 会被序列化进请求 |
| 对话历史前缀 | Fork 从哪一轮消息分叉,会影响请求字节 |
Claude Code 用一个“缓存安全参数”对象把这些字段打包:
type CacheSafeParams = {
// 必须直接复用父 Agent 已渲染好的系统提示词
systemPrompt: SystemPrompt
// 拼在消息前的用户上下文
userContext: Record<string, string>
// 系统环境上下文
systemContext: Record<string, string>
// 工具集合、模型、权限等运行时信息
toolUseContext: ToolUseContext
// 父 Agent 的消息前缀
forkContextMessages: Message[]
}
流程如下:
flowchart LR
A[父 Agent 请求前缀] --> B[Prompt Cache]
A --> C[Fork Subagent]
C --> D[复用相同 system prompt]
C --> E[复用相同工具定义]
C --> F[复用相同对话前缀]
D --> G[字节级一致请求前缀]
E --> G
F --> G
G --> B
B --> H[缓存命中]
Fork 的 system prompt 为什么返回空字符串
Fork Subagent 的定义里有一个容易误解的点:它自己的 getSystemPrompt 可以返回空字符串。
简化定义如下:
const FORK_AGENT = {
agentType: "fork",
tools: ["*"],
maxTurns: 200,
model: "inherit",
permissionMode: "bubble",
source: "built-in",
// Fork 不靠这个函数生成系统提示词
getSystemPrompt: () => "",
}
这不是说 Fork Subagent 没有系统提示词,而是它不重新生成系统提示词。它直接复用父 Agent 已经渲染好的那份字节。
如果重新调用生成函数,哪怕逻辑相同,也可能因为动态配置、功能开关、上下文顺序等原因产生细微差异。一旦字节不同,缓存就无法命中。复用父 Agent 已渲染结果是更稳的做法。
Fork 适合什么任务
Fork 适合“需要父 Agent 完整上下文,但又不希望污染主循环”的任务。
例如:
- 基于当前完整对话生成 PR 描述。
- 基于刚完成的一轮操作做总结。
- 尝试某个分支思路,但不想影响父 Agent 的主对话状态。
- 利用当前上下文做轻量分析。
不适合 Fork 的场景也很明确:
| 场景 | 更适合的机制 | 原因 |
|---|---|---|
| 专门做代码搜索 | 常规 Subagent | 只需要只读工具和搜索提示词,不需要继承父 Agent 全量上下文 |
| 专门做规划 | 常规 Subagent | 需要定制角色和系统提示词 |
| 大规模并行迁移 | Coordinator 模式 | 需要协调者统一拆分和合成 |
| Worker 之间要被统一调度 | Coordinator 模式 | Fork 不是团队调度机制 |
Fork 与 Coordinator 通常互斥。Coordinator 模式下,主 Agent 已经是协调者,Worker 默认异步执行,不需要再用 Fork 做“轻量分身”。
function isForkSubagentEnabled(): boolean {
if (!feature("FORK_SUBAGENT")) {
return false
}
if (isCoordinatorMode()) {
return false
}
if (getIsNonInteractiveSession()) {
return false
}
return true
}
Coordinator 模式:主 Agent 退化成协调者
常规 Subagent 适合“主 Agent 干活,子 Agent 帮忙”。如果任务天然可以拆成很多独立部分,比如并行调研十个模块、批量迁移多个包、同时验证多个测试场景,父子型就不够了。
Coordinator 模式把主 Agent 的职责改成:
- 拆任务。
- 派 Worker。
- 收集 Worker 结果。
- 理解结果并做决策。
- 合成最终输出。
主 Agent 不再亲自读代码、改代码、跑测试,而是通过 Worker 完成这些工作。
flowchart TB
U[用户目标] --> C[Coordinator Agent]
C --> W1[Worker 1: 调研 auth 模块]
C --> W2[Worker 2: 调研 session 模块]
C --> W3[Worker 3: 调研 token 模块]
W1 --> R1[调研结果 1]
W2 --> R2[调研结果 2]
W3 --> R3[调研结果 3]
R1 --> C
R2 --> C
R3 --> C
C --> S[合成实现规格]
S --> W4[Worker 4: 实现修改]
S --> W5[Worker 5: 独立验证]
W4 --> C
W5 --> C
C --> O[最终答复]
Coordinator 模式不是默认开启,通常需要功能开关和环境变量同时满足:
function isCoordinatorMode(): boolean {
if (feature("COORDINATOR_MODE")) {
return isEnvTruthy(process.env.CLAUDE_CODE_COORDINATOR_MODE)
}
return false
}
Coordinator 的内部工具
协调者需要一组专门管理 Worker 的工具。典型能力包括:
| 工具能力 | 作用 |
|---|---|
| 创建 Worker 或团队 | 批量启动执行单元 |
| 删除团队 | 清理不再需要的 Worker 组 |
| 发送消息 | 给已有 Worker 补充指令 |
| 合成输出 | 把最终结果交给用户 |
| 停止 Worker | 发现方向错误时及时止损 |
简化定义如下:
const INTERNAL_WORKER_TOOLS = new Set([
"team_create",
"team_delete",
"send_message",
"synthetic_output",
])
这些工具只给协调者使用,Worker 不应该继续使用团队管理工具。否则 Worker 还能再派 Worker,系统会变成递归树,难以控制成本和执行边界。
flowchart TB
C[Coordinator] -->|允许| T1[创建 Worker]
C -->|允许| T2[给 Worker 发消息]
C -->|允许| T3[合成最终输出]
C -->|允许| T4[停止 Worker]
W[Worker] -->|禁止| T1
W -->|禁止| T3
W -->|允许| A[读文件 / 搜索 / 编辑 / 执行命令]
Coordinator 的并行执行模型
Coordinator 模式真正有价值的地方在并行。
如果一个任务能拆成三个互不依赖的模块调研,串行执行会是:
sequenceDiagram
participant C as Coordinator
participant W1 as Worker 1
participant W2 as Worker 2
participant W3 as Worker 3
C->>W1: 调研 auth
W1-->>C: 返回结果
C->>W2: 调研 session
W2-->>C: 返回结果
C->>W3: 调研 token
W3-->>C: 返回结果
并行执行则是:
sequenceDiagram
participant C as Coordinator
participant W1 as Worker 1
participant W2 as Worker 2
participant W3 as Worker 3
C->>W1: 调研 auth
C->>W2: 调研 session
C->>W3: 调研 token
W2-->>C: 返回 session 结果
W1-->>C: 返回 auth 结果
W3-->>C: 返回 token 结果
C->>C: 汇总、判断、制定后续方案
Coordinator 要避免把可以并行的工作串行化。一次 LLM 回复里可以生成多个工具调用,底层并发启动多个 Worker。这样总耗时接近最慢的那个 Worker,而不是所有 Worker 时间之和。
Coordinator 的任务流水线
一个软件工程任务在 Coordinator 模式下通常会被拆成四个阶段:
| 阶段 | 执行者 | 目标 |
|---|---|---|
| 调研 | 多个 Worker 并行 | 找相关文件、理解代码路径、定位风险点 |
| 合成 | Coordinator | 阅读所有结果,形成统一判断和实现规格 |
| 实现 | Worker | 按协调者给出的明确规格修改代码 |
| 验证 | 新 Worker | 用相对独立的视角检查改动和运行测试 |
中间的“合成”必须由 Coordinator 亲自完成。协调者如果只是把 Worker A 的发现转发给 Worker B,就会退化成消息中转站。合格的 Coordinator 要读懂多个 Worker 的输出,识别冲突、过滤无关信息,再把实现要求整理成明确规格。
正确方式:
flowchart LR
A[Worker 调研结果] --> C[Coordinator 理解和合成]
B[另一个 Worker 调研结果] --> C
C --> D[明确实现规格]
D --> E[Worker 按规格实现]
错误方式:
flowchart LR
A[Worker 调研结果] --> C[Coordinator]
C -->|原样转发| E[Worker 实现]
这一区别很关键。Multi-Agent 系统不是“多个 Agent 聊天”,而是“拆分、执行、理解、合成”的工程流水线。
Continue 还是 Spawn:复用旧 Worker,还是新建 Worker
Coordinator 会频繁遇到一个决策:新任务应该交给已有 Worker 继续做,还是启动一个新 Worker?
可以按下面的规则判断:
| 情况 | 选择 | 原因 |
|---|---|---|
| 新任务和旧 Worker 上下文强相关 | Continue | 旧 Worker 已经读过相关文件,继续执行成本更低 |
| 新任务与旧上下文无关 | Spawn | 避免无关上下文干扰判断 |
| 旧 Worker 方向明显走偏 | Spawn | 继续复用会放大错误 |
| 需要独立验证 | Spawn | 写代码的人不适合完全验证自己的改动 |
| 只是补充一个小问题 | Continue | 发送消息比重新启动更省成本 |
这个规则和人类团队协作类似:熟悉上下文的人适合继续推进同一块工作,但验证和审查最好换一双新眼睛。
三套机制的对比
常规 Subagent、Fork Subagent、Coordinator 模式各自解决的问题不同,不能混用成一个万能方案。
| 维度 | 常规 Subagent | Fork Subagent | Coordinator 模式 |
|---|---|---|---|
| 主 Agent 角色 | 主流程执行者 | 主流程执行者 | 纯协调者 |
| 子 Agent 定位 | 专门任务执行者 | 父 Agent 的缓存友好分身 | Worker |
| 系统提示词 | 通常独立定制 | 复用父 Agent 已渲染前缀 | Worker 可按任务配置 |
| 成本优化 | 常规 API 调用成本 | 尽量命中 Prompt Cache | 通过并行缩短总时长 |
| 通信方式 | 消息队列 + XML 通知 | 类似父子通信 | 协调者统一收发消息 |
| 适合场景 | 局部调研、搜索、规划 | 总结、PR 描述、分支尝试 | 大规模并行调研、实现、验证 |
| 主要风险 | 上下文隔离和工具权限 | 缓存前缀稍变就失效 | 协调者如果不合成会变成转发器 |
Multi-Agent 系统的工程原则
Claude Code 的设计可以沉淀成几条通用原则,适合迁移到自建 Agent 系统里。
1. 上下文隔离要按字段粒度做
不要简单地把上下文全共享,也不要给子 Agent 一个完全空上下文。每个状态字段都要单独判断:
- 子 Agent 是否需要读它?
- 子 Agent 修改它会不会影响父 Agent?
- 它是否必须共享才能响应中止、权限、任务回收?
- 它是否应该新建以便追踪链路?
字段级隔离比整体隔离麻烦,但能避免很多隐蔽 bug。
2. 通信优先走消息模型
同步函数调用适合短任务,不适合长时间运行、可恢复、可并发的 Agent 系统。
更稳的模型是:
- 父到子:写入子 Agent 的消息队列。
- 子到父:把完成结果包装成结构化文本消息。
- 状态变化:落在任务状态表里。
- 长任务:后台运行,完成后通知。
消息模型天然支持并发、恢复、持久化和异步调度。
3. 工具权限要分级控制
不同 Agent 不应该拿到同一套工具。至少要区分:
| Agent 类型 | 工具策略 |
|---|---|
| 主 Agent | 可使用完整工具集合 |
| 内置 Subagent | 去掉高风险工具 |
| 自定义 Agent | 比内置 Agent 更严格 |
| 后台异步 Agent | 只允许白名单工具 |
| Worker | 禁止团队管理类工具 |
权限控制不仅是安全问题,也是防递归、防状态污染、防成本失控的基础。
4. 缓存友好也是架构能力
Agent 系统的成本和延迟会直接影响可用性。Prompt Cache 命中条件越严格,架构层越要保证稳定前缀。
Fork Subagent 的思路可以概括为:
- 不重新生成能复用的系统提示词。
- 保持工具定义和顺序稳定。
- 明确哪些字段会影响缓存。
- 对需要父上下文的轻量任务,优先复用缓存前缀。
能便宜、快速地启动更多执行单元,系统能力边界也会随之扩大。
5. 协调者必须合成,而不是转发
Coordinator 的价值不在于“把消息从 A 传给 B”,而在于理解多个 Worker 的结果,并做出统一判断。
好的协调者会:
- 发现 Worker 结果之间的冲突。
- 过滤无关信息。
- 把模糊发现整理成明确实现规格。
- 决定哪些任务继续复用旧 Worker,哪些任务新建 Worker。
- 安排独立验证,而不是让实现者自证正确。
Multi-Agent 系统真正的难点不是“启动多个 Agent”,而是“让多个 Agent 的输出形成一个可控、可验证、可收敛的工程流程”。
总结
Claude Code 的多 Agent 机制可以拆成五个核心设计点:
- Subagent 不是普通函数,而是拥有独立上下文、工具集合和生命周期的执行单元。
- 工具隔离通过黑名单、白名单和 Agent 类型控制权限,避免递归派生和越权操作。
- 上下文隔离按字段粒度处理,既防止状态污染,又保留中止、权限、任务注册等必要通路。
- 父子通信采用异步消息模型,父到子写消息队列,子到父发送 XML 通知。
- Fork Subagent 通过复用父 Agent 的字节级请求前缀命中 Prompt Cache,Coordinator 模式通过协调者和 Worker 实现大任务并行。
这套设计的价值在于把 Multi-Agent 从“概念上能跑”推进到“工程上可控”。隔离、权限、通信、缓存和调度都处理好,多 Agent 才能真正承担复杂软件工程任务。