Claude Code 的多 Agent 机制
这一章要解决的问题很直接:Claude Code 为什么不是简单地“再开几个聊天线程”,而是做了一套 Agent 派生、后台生命周期、团队任务图、消息邮箱和远程计划系统?
源码里的答案可以压成一句话:多 Agent 的核心不是让模型互相聊天,而是把任务、身份、权限、通信和生命周期外化成运行时状态。模型负责理解和执行,运行时负责隔离、调度、审批和收尾。
核心直觉
Claude Code 的多 Agent 不是一个功能点,而是一组递进能力:AgentTool 负责派生,runWithAgentContext() 负责身份隔离,Fork 负责复用父上下文,Coordinator 负责分阶段编排,Teams 负责平面协作,Ultraplan 负责把计划阶段远程化。
先看源码入口
这一章要追的入口比较多,但可以按“层级派生”和“平面协作”分成两组。
| 源码定位 | 关键符号 | 负责什么 |
|---|---|---|
tools/AgentTool/AgentTool.tsx | AgentTool、baseInputSchema、shouldRunAsync | 所有 Agent 派生的统一入口,动态暴露 subagent_type、model、run_in_background、isolation、team_name 等字段 |
utils/agentContext.ts | AsyncLocalStorage@@INLINE_0@@、runWithAgentContext() | 在同进程并发 Agent 之间隔离 agentId、agentType、invokingRequestId |
tools/AgentTool/forkSubagent.ts | isForkSubagentEnabled()、FORK_AGENT、buildForkedMessages() | Fork 模式,继承父会话上下文和系统提示词,最大化 Prompt Cache 共享 |
coordinator/coordinatorMode.ts | isCoordinatorMode()、getCoordinatorUserContext() | 协调者模式,主 Agent 只负责规划、综合和派发 Worker |
tools/TeamCreateTool/TeamCreateTool.ts | team_name、agent_type | 创建 Team,写入 TeamFile,并绑定对应 TaskList |
utils/swarm/teamHelpers.ts | TeamFile、members[] | 团队配置文件,记录队友身份、模型、权限模式、后端类型和活动状态 |
utils/swarm/spawnMultiAgent.ts | spawnTeammate() | 根据 tmux、iTerm2、In-Process 后端生成队友,并把 CLI 参数和初始指令传过去 |
tools/SendMessageTool/SendMessageTool.ts | SendMessageTool、to: "*" / uds: / bridge: | 队友间消息路由,支持名称、广播、UDS 和 Remote Control bridge |
utils/tasks.ts、useTaskListWatcher.ts | Task、findAvailableTask()、claimTask() | Teams 的共享任务图和最小调度器 |
utils/swarm/permissionSync.ts | pending/、resolved/ | 队友危险工具调用交给 Leader 代理审批 |
commands/ultraplan.tsx、utils/ultraplan/ccrSession.ts | launchUltraplan()、pollForApprovedExitPlanMode() | 把计划阶段上传到 CCR 远程容器运行,并轮询 ExitPlanMode 状态 |
这些入口共同说明:Claude Code 的多 Agent 不是单独靠 prompt 约束出来的,它在运行时有明确的数据结构和状态转移。
总流程图
这张图里最容易被忽略的是 Teams 这条线。标准子 Agent 和 Fork 仍然是“父派生子”的层级结构;Teams 则变成平面团队,靠 TaskList、Mailbox 和权限同步协作。
第一层:AgentTool 是统一派生入口
所有派生都先经过 AgentTool。它不是一个静态工具定义,而是会根据 Feature Flag 和运行时状态动态组合 schema。
const baseInputSchema = lazySchema(() => z.object({
description: z.string(),
prompt: z.string(),
subagent_type: z.string().optional(),
model: z.enum(['sonnet', 'opus', 'haiku']).optional(),
run_in_background: z.boolean().optional()
}))
这段代码证明:标准的 Agent 派生至少包含任务描述、完整 prompt、可选 Agent 类型、模型和后台运行开关。原文还说明,当 Swarm 特性启用时,schema 会继续合并 name、team_name、mode;当 Fork 模式启用时,run_in_background 会被移除,因为 Fork 默认后台化。
这个动态 schema 很关键。模型不是看到一份“全功能说明书”,而是只看到当前运行条件下允许使用的参数。能力暴露本身就是控制面。
同进程隔离靠 AsyncLocalStorage
后台 Agent 可能和主 Agent 在同一进程里并发运行。如果身份信息放在全局 AppState,Agent A 的事件可能被 Agent B 的上下文覆盖。源码用 AsyncLocalStorage 隔离每条异步执行链。
const agentContextStorage = new AsyncLocalStorage<AgentContext>()
export function runWithAgentContext<T>(
context: AgentContext,
fn: () => T,
): T {
return agentContextStorage.run(context, fn)
}
这段代码证明:Claude Code 把 Agent 身份当成异步上下文,而不是普通全局变量。AgentContext 用 agentType 区分 subagent 和 teammate,并带有 agentId、subagentName、teamName、isTeamLead、invokingRequestId 等字段。
invokingRequestId 还有一个细节:它只在 spawn 或 resume 后的第一个 API 事件消费一次。这是一条“稀疏边”,用于把某次后台任务和触发它的请求连起来,又避免每个后续事件重复携带同一个来源。
第二层:标准子 Agent、Fork 与 Coordinator
AgentTool 后面至少有三条分支,它们的差异不在“会不会另起一个模型调用”,而在上下文继承、工具池、生命周期和缓存策略。
| 模式 | 上下文 | 系统提示词 | 执行方式 | 适合场景 |
|---|---|---|---|---|
| 标准子 Agent | 全新对话,只看到父 Agent 传入的 prompt | 来自 Agent 定义 | 前台或后台 | 独立搜索、验证、分析 |
| Fork Agent | 继承父会话消息 | 继承父级已渲染提示词 | 强制后台 | 需要完整上下文的并行探索 |
| Coordinator Worker | Worker 独立,Coordinator 负责综合 | Worker 工具上下文由协调者生成 | 强制后台 | 大任务分阶段研究、实现、验证 |
标准子 Agent:隔离优先
标准子 Agent 的默认类型来自 general-purpose。当调用没有显式传 subagent_type,且 Fork 模式关闭时,源码会回退到通用 Agent。
const effectiveType = subagent_type
?? (isForkSubagentEnabled() ? undefined : GENERAL_PURPOSE_AGENT.agentType)
这段代码证明:标准子 Agent 默认是一个隔离的执行单元。它不继承父对话历史,只拿到父 Agent 明确写入 prompt 的任务。这带来一个好处:子 Agent 不会被主对话里的噪音拖累;也带来一个代价:父 Agent 必须把任务边界说清楚。
分叉 Agent:用同一段前缀换并行
Fork 模式的关键不是“后台跑”,而是继承父上下文并保持 API 请求前缀稳定。
export const FORK_AGENT = {
agentType: FORK_SUBAGENT_TYPE,
tools: ['*'],
maxTurns: 200,
model: 'inherit',
permissionMode: 'bubble',
source: 'built-in',
baseDir: 'built-in',
getSystemPrompt: () => '',
}
这段代码证明三点:
model: 'inherit'表示 Fork 子进程沿用父模型,避免上下文窗口和行为策略漂移。getSystemPrompt: () => ''不是没有提示词,而是使用父级已经渲染好的系统提示词。permissionMode: 'bubble'表示权限结果会向父层冒泡,而不是让 Fork 子进程自己随意放行。
buildForkedMessages() 的设计更能说明 Fork 的工程目标。它会保留父消息里的 assistant 内容,为所有正在启动的 tool_use 构造固定的占位 tool_result,再只在最后追加每个子 Agent 不同的指令。
父历史消息
assistant: 多个 tool_use
user: 固定占位 tool_result
user: 当前 Fork 的专属任务指令
这证明 Fork 在追求两件事:完整上下文和Prompt Cache 命中。如果每个 Fork 从头构造不同前缀,成本和延迟都会膨胀;保持稳定前缀则可以把差异压到最后的 per-child 指令。
源码还禁止 Fork 内继续 Fork:
if (
toolUseContext.options.querySource === `agent:builtin:${FORK_AGENT.agentType}`
|| isInForkChild(toolUseContext.messages)
) {
throw new Error('Fork is not available inside a forked worker.')
}
这段代码证明:Fork 是一层并行展开,不是无限递归树。检查同时依赖 querySource 和消息中的 @@INLINE_0@@ 标签,是为了在压缩或消息变形后仍然保留递归防护。
协调者模式:永远不要委托理解
Coordinator Mode 通过 CLAUDE_CODE_COORDINATOR_MODE 环境变量和 COORDINATOR_MODE 构建特性开启。
export function isCoordinatorMode(): boolean {
if (feature('COORDINATOR_MODE')) {
return isEnvTruthy(process.env.CLAUDE_CODE_COORDINATOR_MODE)
}
return false
}
协调者模式把主 Agent 变成一个不直接编码的编排者。源码提示词把流程拆成四段:
| 阶段 | 执行者 | 关键要求 |
|---|---|---|
| Research | Worker | 并行调查代码库和风险点 |
| Synthesis | Coordinator | 主 Agent 必须亲自阅读、理解和归纳 |
| Implementation | Worker | 按协调者规格修改代码 |
| Verification | Worker | 独立验证实现正确性 |
原文特别强调一句规则:不要写 “based on your findings”。这不是文字洁癖,而是在防止协调者把理解责任继续下放。Worker 可以收集材料,最终设计判断必须回到 Coordinator。
Worker 完成后,结果不是作为普通 assistant 消息注入,而是用 @@INLINE_0@@ 作为用户角色消息传回协调者。
<task-notification>
<task-id>{agentId}</task-id>
<status>completed|failed|killed</status>
<summary>{summary}</summary>
<result>{result}</result>
<usage>
<total_tokens>N</total_tokens>
<tool_uses>N</tool_uses>
<duration_ms>N</duration_ms>
</usage>
</task-notification>
这段结构证明:Claude Code 需要让协调者明确区分“用户说的话”和“Worker 返回的运行时事件”。否则主 Agent 可能把 Worker 结果当成新的用户指令,导致角色混乱。
第三层:Teams 是平面结构,不是递归树
Teams 解决的问题和 AgentTool 子 Agent 不同。它不是父亲不断派孩子,而是创建一个平面团队,让成员围绕同一个任务图协作。
const inputSchema = lazySchema(() =>
z.strictObject({
team_name: z.string().describe('Name for the new team to create.'),
description: z.string().optional(),
agent_type: z.string().optional(),
}),
)
这段 TeamCreateTool schema 证明:团队创建的最小输入是团队名,外加可选描述和 Leader 类型。Team 创建后会持久化 TeamFile,并初始化对应的 TaskList。
源码还明确禁止队友再生成队友:
if (isTeammate() && teamName && name) {
throw new Error(
'Teammates cannot spawn other teammates - the team roster is flat.',
)
}
这段代码是 Teams 的架构边界。队友名册是平面数组,协调权集中在 Leader;如果允许队友继续嵌套派生队友,任务图、权限审批和终端后端都会变得难以追踪。
团队配置文件是运行时配置
原文给出的 TeamFile 结构大致如下:
{
name: string,
description?: string,
createdAt: number,
leadAgentId: string,
members: [{
agentId: string,
name: string,
agentType?: string,
model?: string,
prompt: string,
color: string,
planModeRequired: boolean,
tmuxPaneId?: string,
sessionId?: string,
backendType: BackendType,
isActive: boolean,
mode: PermissionMode,
}]
}
这段结构证明:Team 不是一个内存里的临时数组。它有持久配置、成员身份、终端颜色、后端类型、权限模式和活跃状态。存储路径是 ~/.claude/teams/{teamName}/config.json。
队友 ID 采用 {name}@{teamName} 格式。这不是 UI 小细节,而是日志、Mailbox、权限请求和事件追踪都能直接看出成员归属。
团队调度内核:任务表不是待办清单
如果只看到 SendMessageTool,很容易误解成“Agent 之间互发消息就叫协作”。源码里真正驱动 Teams 的是 TaskList。原文明确写到:Team = TaskList。团队和任务表是同一个运行时对象的两个视图。
任务结构不是普通 Todo,而是 DAG 节点。
{
id: string,
owner?: string,
status: 'pending' | 'in_progress' | 'completed',
blocks: string[],
blockedBy: string[],
}
这段代码证明:blocks 和 blockedBy 把任务列表升级成依赖图。Leader 可以一次性声明“auth 和 payment 完成后再跑集成测试”,而不是在自然语言里反复提醒每个队友不要抢错任务。
useTaskListWatcher.ts 里的 findAvailableTask() 才是 Swarm 的最小调度器。它筛选的条件是:
status === 'pending'owner为空blockedBy中的任务都已完成
找到候选任务后,运行时先 claimTask() 写入 owner,再把任务格式化成 prompt 交给 Agent。提交失败时释放 claim。
这个流程证明:Teams 的并行来自共享状态和原子 claim,而不是来自“大家互相商量”。模型不用自己维护全局锁,运行时把冲突编码进任务状态。
文件邮箱:文件系统消息队列
队友间通信使用 SendMessageTool。to 字段根据 Feature Flag 支持多种寻址。
to: z.string().describe(
feature('UDS_INBOX')
? 'Recipient: teammate name, "*" for broadcast, "uds:<socket-path>" for a local peer, or "bridge:<session-id>" for a Remote Control peer'
: 'Recipient: teammate name, or "*" for broadcast to all teammates',
)
这段代码证明:消息路由有三层能力。
| 寻址方式 | 语义 | 用途 |
|---|---|---|
tester | 指定队友名 | 点对点通知 |
* | 广播给所有队友,排除发送者 | Leader 发布全局状态 |
uds:@@INLINE_0@@ | Unix Domain Socket 本地 peer | 低延迟本机通信 |
bridge:@@INLINE_0@@ | Remote Control peer | 跨会话控制 |
普通 Teams 通信落到文件系统邮箱:
~/.claude/teams/{teamName}/inboxes/{agentName}.json
消息结构大致如下:
type TeammateMessage = {
from: string,
text: string,
timestamp: string,
read: boolean,
color?: string,
summary?: string,
}
这段结构证明 Mailbox 是 durable queue。它不是内存事件,一旦队友进程崩溃或终端断开,消息仍然可检查。原文还提到写入时使用 async lockfile 和指数退避,说明文件邮箱不是“随手写 JSON”,而是考虑了并发写入。
控制消息被嵌在 text 字段里,包括 idle、shutdown_request、shutdown_response、plan_approval_response。其中 idle 通知尤其重要:
type IdleNotificationMessage = {
type: 'idle',
teamName: string,
agentName: string,
agentId: string,
idleReason: 'available' | 'error' | 'shutdown' | 'completed',
summary?: string,
peerDmSummary?: string,
errorDetails?: string,
}
这段结构证明:队友空闲不是“没有输出”,而是一个显式运行时事件。Leader 可以通过 idle reason 判断队友是可继续领任务、执行出错、准备关闭,还是已经完成。
回合结束事件:TaskCompleted 与 TeammateIdle
Teams 不是纯 pull,也不是纯 push。队友会主动从 TaskList 领取工作,同时回合结束后触发事件。
原文定位在 query/stopHooks.ts:当当前执行者是 teammate,普通 Stop hooks 之后还会运行两类专用事件:
| 事件 | 触发时机 | 影响 |
|---|---|---|
TaskCompleted | 当前队友拥有的 in_progress 任务完成 | 更新任务状态,可能解除其他任务的 blocker |
TeammateIdle | 队友进入空闲 | 通知 Leader,并回到 TaskList 查找新任务 |
这说明 Teams 的调度闭环是:
这也是为什么 Teams 更像一个小型分布式调度器,而不是多窗口聊天。
权限同步:危险操作集中到 Leader
队友运行在独立进程时,不能让每个队友自己弹权限确认。源码把权限请求落成共享目录里的 pending/resolved 文件。
~/.claude/teams/{teamName}/permissions/
pending/
resolved/
权限流程是:
这段流程证明:Teams 的自治没有越过权限系统。真正危险的工具调用仍然在 Leader 终端集中审批,这样用户不用在多个 tmux pane 里追权限弹窗,也避免后台队友悄悄放权。
三种后端:tmux、iTerm2 与 In-Process
Teams 的物理执行后端统一在 PaneBackend 和 TeammateExecutor 接口之后。原文列出三种后端:
| 后端 | 进程模型 | 通信机制 | 场景 |
|---|---|---|---|
| Tmux | 独立 CLI 进程,tmux 分屏 | 文件 Mailbox | Linux/macOS 默认路径 |
| iTerm2 | 独立 CLI 进程,iTerm2 分屏 | 文件 Mailbox | macOS 原生终端体验 |
| In-Process | 同进程执行,用 AsyncLocalStorage 隔离 | AppState 内存队列 | 没有 tmux/iTerm2 时回退 |
In-Process 队友仍然有自己的上下文:
type TeammateContext = {
agentId: string,
agentName: string,
teamName: string,
parentSessionId: string,
isInProcess: true,
abortController: AbortController,
}
runWithTeammateContext<T>(context, fn: () => T): T
这段代码证明:In-Process 只是物理运行位置变了,不等于共享一坨全局内存。身份、取消控制、消息队列和 UI 缓冲仍然被隔离。
团队记忆:共享但不能混进个人记忆
Teams 还有一个容易被忽略的状态面:Team Memory。原文定位在:
~/.claude/projects/{project}/memory/team/MEMORY.md
它和个人记忆 ~/.claude/projects/{project}/memory/ 独立。团队成员共享 team memory,但不能把个人记忆自动提升成团队记忆。这个边界在后续记忆系统里更重要:共享知识需要用户明确选择,不能由后台整理任务擅自升级。
路径安全也很重。原文提到 memdir/teamMemPaths.ts 的补丁覆盖了 null byte、URL 编码遍历、Unicode 正规化攻击、反斜杠遍历、符号链接循环和 realpath 逃逸验证。团队记忆是共享写入面,一旦路径验证放松,就会从“知识共享”变成“跨目录写文件”。
远程计划:把计划阶段传送到远程
本地多 Agent 仍然占用用户终端。Ultraplan 解决的是另一个问题:把计划阶段上传到 CCR 远程容器运行,让用户终端继续可用。
源码入口集中在:
| 文件 | 关键符号 | 行为 |
|---|---|---|
utils/ultraplan/keyword.ts | findUltraplanTriggerPositions()、replaceUltraplanKeyword() | 检测自然语言里的 ultraplan,排除代码、路径、引用等上下文 |
commands/ultraplan.tsx | launchUltraplan() | 检查资格、构造提示词、调用 teleportToRemote()、注册远程任务 |
utils/ultraplan/ccrSession.ts | pollForApprovedExitPlanMode()、ExitPlanModeScanner | 每 3 秒轮询远程会话,扫描计划审批状态 |
state/AppStateStore.ts | ultraplanLaunching、ultraplanSessionUrl、ultraplanPendingChoice | 本地 UI 状态机 |
Ultraplan 的本地状态大致是:
ultraplanLaunching?: boolean
ultraplanSessionUrl?: string
ultraplanPendingChoice?: {
plan: string
sessionId: string
taskId: string
}
ultraplanLaunchPending?: {
blurb: string
}
isUltraplanMode?: boolean
这段结构证明:本地端不执行计划,只管理远程会话 URL、启动防重、计划回传和用户选择。
远程轮询有固定参数:
const POLL_INTERVAL_MS = 3000
const MAX_CONSECUTIVE_FAILURES = 5
const ULTRAPLAN_TIMEOUT_MS = 30 * 60 * 1000
这证明 Ultraplan 是一个有超时和失败阈值的后台任务,而不是把本地 Loop 无限挂到远程。
ExitPlanModeScanner 的扫描结果也很清晰:
type ScanResult =
| { kind: 'approved'; plan: string }
| { kind: 'teleport'; plan: string }
| { kind: 'rejected'; id: string }
| { kind: 'pending' }
| { kind: 'terminated'; subtype: string }
| { kind: 'unchanged' }
这段类型证明:Ultraplan 不是只有“完成/失败”。它区分已批准远程执行、传送回本地、被拒绝后继续修改、等待审批、终止和无变化。
状态和数据结构
| 状态或结构 | 关键字段 | 影响 |
|---|---|---|
AgentContext | agentType、agentId、invokingRequestId | 决定事件归属、后台任务来源和同进程隔离 |
FORK_AGENT | model: 'inherit'、tools: ['*']、permissionMode: 'bubble' | 保持父上下文能力,同时把权限向父层冒泡 |
TeamFile | leadAgentId、members[]、backendType、mode | 决定队友身份、后端、权限模式和生命周期 |
Task | status、owner、blocks、blockedBy | 把 Todo 变成可 claim 的 DAG 节点 |
TeammateMessage | from、text、read、summary | 文件系统 Mailbox 的最小消息单元 |
IdleNotificationMessage | idleReason、summary、errorDetails | Leader 判断队友是否可继续调度 |
SwarmPermissionRequest | toolName、input、suggestions | 把队友权限请求转交 Leader 审批 |
UltraplanPendingChoice | plan、sessionId、taskId | 远程计划批准后,本地决定继续远程还是传回终端 |
设计取舍
第一,隔离优先于共享。 标准子 Agent 从新上下文开始,Fork 虽然继承上下文但禁止递归,In-Process teammate 虽在同进程运行仍用 AsyncLocalStorage 隔离身份。这说明 Claude Code 不把“共享一切”当成协作捷径。
第二,调度状态外化。 Teams 没有让模型用自然语言维护谁该做什么,而是把任务写入 ~/.claude/tasks/{team-name}/,用 owner、blockedBy、status 做调度。这让并行协作可以被文件系统观察、恢复和调试。
第三,通信选择 durable mailbox。 文件系统 Mailbox 比 IPC 慢,但崩溃后可检查、跨进程简单、适合终端工具调试。对 Agent 协作而言,秒级延迟通常可接受,消息可恢复更重要。
第四,权限集中到 Leader。 队友可以并行工作,但危险工具调用不能分散审批。pending/ 和 resolved/ 把审批变成一个可轮询、可恢复的状态机。
第五,远程计划不等于远程放权。 Ultraplan 把探索和计划上传到 CCR,但 ExitPlanMode 仍然要经过审批;计划还可以传回本地执行。这把“更强规划能力”和“最终执行控制”分开。
读源码抓手
读源码抓手
读多 Agent 不要从“有哪些 Agent 类型”开始,而要沿着状态怎么流动追。
建议路线:
- 先看
tools/AgentTool/AgentTool.tsx的 schema 组合和shouldRunAsync分支,理解能力怎么暴露给模型。 - 再看
utils/agentContext.ts,确认 Agent 身份如何进入事件、后台任务和同进程执行链。 - 接着读
tools/AgentTool/forkSubagent.ts,重点看FORK_AGENT和buildForkedMessages()如何保持 cache-safe 前缀。 - 然后跳到
tools/TeamCreateTool/TeamCreateTool.ts和utils/swarm/teamHelpers.ts,看 TeamFile 如何落盘。 - 再追
utils/tasks.ts、useTaskListWatcher.ts的findAvailableTask()和claimTask(),这是 Teams 的调度核心。 - 最后读
tools/SendMessageTool/SendMessageTool.ts、utils/swarm/permissionSync.ts和utils/ultraplan/ccrSession.ts,分别看通信、审批和远程计划。
小结
小结
- 多 Agent 的关键不是“多开几个模型”,而是运行时能否隔离身份、控制权限、持久化任务和回收结果。 - 标准子 Agent 用新上下文换干净边界,Fork 用父上下文换缓存和并行,Coordinator 用 Worker 换阶段化执行。 - Teams 的核心是 TaskList + claim + Mailbox + stop hooks,消息只是协作补充,不是主调度面。 - 文件系统在这里不是落后方案,而是 durable、可调试、跨进程友好的协作基底。 - Ultraplan 把计划远程化,但仍保留审批、超时、失败阈值和传回本地的控制面。