Claude Code 的多 Agent 机制:从 subagent 到 Agent Teams 的源码解读
如果只从产品体验看,多 Agent 像是“让 Claude 多叫几个帮手”。但从 Claude Code 2.1.88 的还原源码看,它不是简单地在一个上下文里扮演多个角色,而是一套真正的运行时系统:它能创建独立 Agent、把 Agent 放到后台运行、让它们通过 mailbox 通信、同步权限审批、记录 transcript,甚至把队友放进 tmux/iTerm pane 或同一个 Node.js 进程里长时间运行。
本文基于 /Users/felix/ai/其他/claude-code-sourcemap-main 2/restored-src 的 sourcemap 还原源码分析。因为源码来自还原结果,不是官方手写源码,所以本文关注架构和机制,不把它当作公开 API 文档。
先给结论
Claude Code 里的多 Agent 可以分成两条主线:
第一条是 subagent。它的核心入口是 tools/AgentTool。你可以把它理解成“为当前主 Agent 委派一个任务”。这个子 Agent 可以同步跑,也可以后台跑,还可以 fork 父会话上下文。
第二条是 Agent Teams。源码里经常叫 swarm 或 teammate。它不是一次性任务,而是“创建一组可持续协作的队友”。队友可以收发消息、等待任务、申请权限、进入 idle 状态、被 shutdown,甚至在 tmux/iTerm 里作为独立 Claude Code 进程运行。
两者共用底层能力,但语义不同:
| 维度 | subagent | Agent Teams |
|---|---|---|
| 目标 | 委派一个任务 | 创建可持续协作的成员 |
| 生命周期 | 通常一次性 | 可长期运行、idle、继续接任务 |
| 通信 | 完成后返回 task notification | mailbox / SendMessage 双向通信 |
| 上下文 | 子上下文隔离 | teammate 自己维护多轮历史 |
| 权限 | 后台默认避免弹窗,必要时 deny/bubble | leader UI 或 mailbox 统一审批 |
| 运行位置 | 本进程后台为主 | in-process、tmux、iTerm、window |
一句话概括:subagent 是“派一个人做事”,Agent Teams 是“组一个团队协作”。
一切仍然回到 query()
这个项目的核心推理循环在 src/query.ts。主会话、subagent、fork agent、in-process teammate,最终都会回到同一套逻辑:
Claude API stream
-> assistant message
-> tool_use
-> tool orchestration
-> tool_result
-> 下一轮 query
所以多 Agent 并没有复制一套新引擎。它复用主引擎,只是在进入 query() 之前,把上下文、权限、任务状态、模型、工具列表、transcript 等包装成一个新的运行单元。
这也是整个架构最重要的设计点:多 Agent 是同一个推理引擎的多实例化,而不是多个完全不同的执行系统。
subagent:从 AgentTool 开始
subagent 的主入口是:
src/tools/AgentTool/AgentTool.tsx
它大致做几件事:
- 判断是否是 Team teammate 创建请求。
- 判断是否走 fork 子 Agent。
- 加载 agent definition。
- 解析模型、工具、MCP、skills、hooks、权限模式。
- 判断同步执行还是后台执行。
- 调用
runAgent()。
简化调用链是:
AgentTool.call()
-> resolve agent definition
-> resolve model/tools/permissions
-> if async/background/fork/coordinator:
register LocalAgentTask
runAgent() in background
else:
runAgent() in foreground
runAgent() 是真正执行 Agent 的地方。它会构造子 Agent 的 system prompt、工具列表、MCP、hook、skill 环境,然后进入 query()。
这意味着自定义 Agent 本质上不是一套新执行器,而是一份配置:
agentTypewhenToUsetoolsdisallowedToolsskillsmcpServersmodeleffortpermissionModemaxTurnsinitialPromptmemoryhooks
你配置不同的 Agent,本质是给同一个 runAgent() 换一套身份、工具和约束。
子 Agent 为什么不能直接复用父上下文
关键函数在:
src/utils/forkedAgent.ts
createSubagentContext()
这个函数非常重要。它回答了一个问题:子 Agent 能不能直接拿父 Agent 的 ToolUseContext 用?
答案是:不能。
父上下文里有很多状态是危险的,比如 UI 更新、当前工具进度、文件读取缓存、权限提示、AbortController、任务注册器。如果子 Agent 原样使用父上下文,就可能出现这些问题:
- 后台 Agent 改了父 UI。
- 子 Agent 弹权限框,但主会话不知道怎么处理。
- 子 Agent 的文件读取状态污染父会话。
- 父会话 abort 时误杀不该杀的任务,或者反过来。
- 后台 Bash 任务注册不到根任务表,留下僵尸进程。
所以 createSubagentContext() 做的是“选择性隔离”。
它默认隔离:
readFileState- memory trigger
- skill discovery state
- denial tracking
- UI callbacks
- in-progress tool IDs
- response length mutation
但它也会保留一些必须共享的能力:
setAppStateForTasksupdateAttributionStatefileReadingLimitsoptionsmessages
最值得注意的是权限提示策略。如果子 Agent 不共享父 abortController,它会把 toolPermissionContext.shouldAvoidPermissionPrompts 设置成 true。也就是说,后台 Agent 默认不要尝试弹交互式权限框。
这不是小细节,而是后台 Agent 能稳定运行的前提。
权限系统:多 Agent 安全性的中心
权限主逻辑在:
src/utils/permissions/permissions.ts
src/utils/permissions/PermissionMode.ts
权限模式包括:
defaultplanacceptEditsbypassPermissionsdontAskautobubble
其中 bubble 是理解多 Agent 的关键词。它表达的是:子 Agent 遇到权限问题时,不自己处理,而是把审批冒泡给父会话或 leader。
权限判定不是一个简单开关,而是一条有很多短路点的管线:
hasPermissionsToUseTool()
-> deny rule
-> ask rule
-> tool.checkPermissions()
-> tool deny
-> user interaction requirement
-> content-specific ask
-> safety check
-> bypassPermissions
-> always allow rule
-> passthrough -> ask
-> dontAsk / auto / headless transformation
这里最容易误解的是 bypassPermissions。
它不是“绝对无条件放行”。显式 deny、内容级 ask、安全检查、需要用户交互的工具,仍然可能在 bypass 前被截住。比如跨机器 bridge message 被标成 bypass-immune safety check,就必须用户明确同意。
对于后台 Agent,权限更严格。如果最终结果是 ask,但当前上下文 shouldAvoidPermissionPrompts = true:
- 先跑
PermissionRequesthooks。 - hook 给出 allow/deny 就采用。
- 没有 hook 决策就 deny。
这避免了后台 Agent 卡在一个用户看不到的权限弹窗上。
Task framework:后台 Agent 的运行时内核
如果说 AgentTool 是入口,query() 是推理引擎,那么 Task framework 就是后台 Agent 的运行时内核。
核心文件:
src/Task.ts
src/utils/task/framework.ts
src/tasks/LocalAgentTask/LocalAgentTask.tsx
LocalAgentTask 表示一个本地后台 Agent。它的状态里有:
agentIdpromptselectedAgentagentTypemodelabortControllerprogressmessagespendingMessagesisBackgroundedretaindiskLoadedevictAfter
一个后台 subagent 的典型生命周期是:
AgentTool
-> registerAsyncAgent()
-> registerTask()
-> runAgent()
-> completeAgentTask() / failAgentTask()
-> enqueueAgentNotification()
完成后,它不是简单把结果塞回父上下文,而是发送结构化通知:
<task-notification>
<task-id>...</task-id>
<output-file>...</output-file>
<status>completed</status>
<summary>...</summary>
<result>...</result>
<usage>...</usage>
</task-notification>
这个设计很好地控制了上下文膨胀。主 Agent 不需要把子 Agent 的每个工具输出都吸进来,只需要知道结果和 output file 路径。
Task framework 还负责:
- 轮询任务输出增量。
- 更新 output offset。
- 回收已通知的终态任务。
- 避免异步 await 后用旧 snapshot 覆盖新状态。
- 支持停止和后台化。
- 支持
SendMessage给运行中的 Agent queue message。
也就是说,后台 Agent 在系统里更像“可管理的进程”,而不是普通函数调用。
fork subagent:复制父上下文,但尽量不浪费 token
fork 相关文件:
src/tools/AgentTool/forkSubagent.ts
src/utils/forkedAgent.ts
fork subagent 是一个更高级的路径。它不是“开一个空白子 Agent”,而是让 child 继承父会话完整上下文。
启用条件包括:
FORK_SUBAGENTfeature 开启。- 不是 coordinator mode。
- 不是 non-interactive session。
fork 用一个 synthetic agent:
agentType = "fork"tools = ["*"]model = "inherit"permissionMode = "bubble"maxTurns = 200
fork 最有意思的是它对 prompt cache 的优化。
普通做法可能是给每个 child 都塞一份不同 prompt。但这样多个 child 的请求前缀会很早分叉,cache 效果差。源码里的做法是:
- 克隆父 assistant message,保留所有
tool_use、thinking、text。 - 给每个
tool_use都补一个相同的占位tool_result。 - 最后才追加每个 child 不同的 directive。
结构像这样:
...parent history
assistant(all tool_use blocks)
user(same placeholder tool_results..., child-specific directive)
这样多个 fork child 的前缀几乎完全一致,只有最后的任务指令不同,所以 prompt cache 命中最大化。
fork child 还会被明确禁止继续 fork。代码层会扫描 <fork-boilerplate> 标签,prompt 层也要求 child 不要 spawn sub-agents,而是直接使用工具。
Agent Teams:从一次性委派到持续协作
Agent Teams 的核心入口是:
src/tools/TeamCreateTool/TeamCreateTool.ts
src/tools/SendMessageTool/SendMessageTool.ts
src/tools/shared/spawnMultiAgent.ts
但源码里经常不叫 Agent Teams,而叫:
swarmteammateteamin_process_teammate
这也是为什么读源码时只搜 “Team” 会漏掉很多实现。
创建团队的调用链是:
TeamCreateTool.call()
-> 检查当前 leader 是否已有 team
-> 创建 TeamFile
-> writeTeamFileAsync()
-> resetTaskList()
-> setLeaderTeamName()
-> setAppState(teamContext)
TeamCreateTool 只创建团队上下文,不直接启动队友。队友通常是后续通过 AgentTool 带上 team_name + name 来 spawn:
AgentTool.call(input)
if input.team_name && input.name:
spawnTeammate(...)
这说明 Teams 不是完全独立于 subagent 的另一套系统。它复用了 AgentTool 的 agent definition、model、tool resolution,只是执行后端换成 teammate runtime。
teammate 可以跑在哪里
spawnMultiAgent.ts 会选择 teammate 后端:
spawnTeammate()
-> handleSpawn()
-> in-process
-> split-pane
-> separate-window
三种模式分别是:
- in-process:同一个 Node.js 进程内运行,用 AsyncLocalStorage 隔离身份。
- split-pane:tmux 或 iTerm2 分屏里启动新的 Claude Code。
- separate-window:tmux 独立 window,偏 legacy。
pane-based teammate 会真正启动一个 Claude Code 进程,并传入身份参数:
--agent-id
--agent-name
--team-name
--agent-color
--parent-session-id
--plan-mode-required
--agent-type
它不会把初始 prompt 直接放命令行,而是写入 teammate 的 mailbox。新进程启动后通过 inbox poller 读到第一条任务。
in-process teammate 则不同。它不启动新进程,而是:
handleSpawnInProcess()
-> spawnInProcessTeammate()
-> register InProcessTeammateTask
-> startInProcessTeammate()
-> runInProcessTeammate()
它的初始 prompt 直接传给 runner,不走 mailbox,避免重复消息。
in-process teammate:一个常驻 Agent 循环
in-process teammate 的核心在:
src/utils/swarm/inProcessRunner.ts
它不是跑完一次 runAgent() 就结束,而是一个长循环:
runInProcessTeammate()
while not aborted:
runAgent(currentPrompt)
update progress/messages
mark idle
send idle notification
wait for:
- pending user message
- mailbox message
- task list claim
- shutdown request
它有自己的 allMessages,所以可以跨多次任务保留队友自己的上下文。上下文过大时,它会调用 compact,并重置相关状态,避免长期运行导致上下文无限膨胀。
这里有一个很重要的产品语义:teammate 完成一次工作后,不会自动把所有输出汇报给 leader。 源码注释明确说,teammate 应该自己用 SendMessage 与 leader 通信。
这和 subagent 完全不同。subagent 完成后系统自动发 task notification;teammate 则像真实团队成员,需要主动汇报。
mailbox:Agent Teams 的消息总线
Teams 的通信核心是 mailbox:
~/.claude/teams/{team_name}/inboxes/{agent_name}.json
消息结构很简单:
{
from: string
text: string
timestamp: string
read: boolean
color?: string
summary?: string
}
写入时会用 lockfile 做并发保护:
writeToMailbox()
-> ensure inbox dir
-> create inbox file if missing
-> acquire lock
-> read current messages
-> append read=false message
-> write JSON
-> release lock
这个设计朴素但有效。它让不同进程、不同 pane、leader 和 teammate 都能通过文件系统通信。
SendMessage:不只是给队友发消息
SendMessageTool 很关键,因为它有三种路由。
第一种是 Remote Control / UDS:
to = bridge:<session-id> 或 uds:<socket-path>
跨机器 bridge message 会触发 bypass-immune safety check,必须用户明确同意。
第二种是本地 subagent:
input.to 命中 appState.agentNameRegistry
-> 如果 LocalAgentTask running:
queuePendingMessage()
如果 stopped:
resumeAgentBackground()
如果 task 被回收:
从 transcript 尝试 resume
所以 SendMessage 不只服务 Teams,也能给本地后台 subagent 发消息或恢复它。
第三种才是 Team mailbox:
to = "*"
-> broadcast 给 teamFile.members 里除自己外的成员
to = teammate name
-> writeToMailbox(recipient)
结构化消息还支持:
shutdown_requestshutdown_responseplan_approval_response
这让 Teams 有了基本协作协议,而不是只有字符串聊天。
权限如何在 Teams 中同步
Teams 里的权限审批不能让每个 worker 自己弹 UI。否则多个 pane、多个进程、多个后台任务会非常混乱。
相关文件:
src/utils/swarm/permissionSync.ts
src/hooks/useInboxPoller.ts
src/utils/swarm/inProcessRunner.ts
pane-based teammate 的权限链路大致是:
worker tool permission ask
-> createPermissionRequest()
-> sendPermissionRequestViaMailbox()
-> writeToMailbox(team-lead)
-> leader useInboxPoller
-> ToolUseConfirm UI
-> sendPermissionResponseViaMailbox(worker)
-> worker callback
-> allow / reject
in-process teammate 更直接。如果 leader UI queue 可用:
createInProcessCanUseTool()
-> hasPermissionsToUseTool()
-> result ask
-> getLeaderToolUseConfirmQueue()
-> 直接塞进 leader 的 ToolUseConfirm UI
-> resolve PermissionDecision
只有 leader UI queue 不可用时,它才 fallback 到 mailbox。
这套机制的核心是:权限最终收敛到 leader,而不是分散到每个 worker。
plan mode 和 shutdown:Teams 的协作协议
如果 teammate 创建时带 plan_mode_required,它初始会处于 plan mode。并且 spawn 时不会继承 leader 的 bypass permissions。这避免了一个危险情况:leader 处于高权限模式时,要求先规划的 teammate 却直接跳过审批开始改文件。
plan approval 的安全点是:teammate 侧只接受来自 team-lead 的 approval response,避免其他队友伪造批准。
shutdown 也不是简单强杀。它是一套请求/响应协议:
leader -> shutdown_request
teammate -> 模型决定 approve/reject
teammate -> shutdown_response
leader -> kill pane / abort controller / remove member
也就是说,系统默认把 teammate 当作有状态的协作者,而不是随时可无条件销毁的临时函数。
task list:除了聊天,还能抢任务
Agent Teams 还绑定了一套任务列表。
创建 team 时会:
resetTaskList(teamName)
ensureTasksDir(teamName)
setLeaderTeamName(teamName)
in-process teammate idle 时会尝试:
listTasks()
findAvailableTask()
claimTask()
updateTask(status="in_progress")
formatTaskAsPrompt()
所以 leader 可以通过两种方式派活:
- 用
SendMessage给某个 teammate 发明确消息。 - 创建 task list,idle teammate 自动 claim 可执行任务。
这让 Agent Teams 更像一个小型协作系统,而不是聊天群。
这套设计的亮点
第一,复用统一推理引擎。无论主 Agent、subagent、fork child、teammate,最终都回到 query()。这降低了系统复杂度。
第二,用 ToolUseContext 做边界。子 Agent 的隔离不是靠“相信 prompt”,而是靠上下文对象限制它能改什么、能不能弹权限、能不能写父 UI。
第三,用 Task framework 管后台生命周期。后台 Agent 是可停止、可通知、可回收、可恢复的任务,不是失控 Promise。
第四,Teams 用 mailbox 做跨进程通信。它不优雅,但可靠、可调试、跨 tmux/iTerm/process 都能工作。
第五,fork 为 prompt cache 做了非常具体的优化。它不是抽象地“继承上下文”,而是维护字节级前缀一致性。
风险和复杂度
这套系统的复杂度也很高。
权限路径很多:规则、工具检查、mode、auto classifier、hook、headless、bubble、leader UI、mailbox 都可能参与。新增工具如果没有正确实现 checkPermissions(),在多 Agent 场景下容易出现误拒绝或误放行。
任务状态也容易出错。源码里多次避免异步 await 后写回旧 snapshot,这说明并发状态竞争是真问题。
fork 对缓存一致性非常敏感。改 system prompt、工具池、placeholder 文案、message filtering,都可能破坏 cache prefix。
Teams 依赖文件协议。mailbox 和 team file 简单直接,但要处理锁、重复消息、已读状态、进程退出、pane 被杀、task unassign 等边界。
如果要二次开发,从哪里读
建议顺序:
src/tools/AgentTool/AgentTool.tsxsrc/tools/AgentTool/runAgent.tssrc/utils/forkedAgent.tssrc/tasks/LocalAgentTask/LocalAgentTask.tsxsrc/utils/task/framework.tssrc/utils/permissions/permissions.tssrc/tools/AgentTool/forkSubagent.tssrc/tools/TeamCreateTool/TeamCreateTool.tssrc/tools/shared/spawnMultiAgent.tssrc/utils/swarm/inProcessRunner.tssrc/tools/SendMessageTool/SendMessageTool.tssrc/hooks/useInboxPoller.tssrc/utils/swarm/permissionSync.tssrc/utils/teammateMailbox.ts
读完这条线,基本就能理解 Claude Code 多 Agent 的主干。
最后的架构判断
Claude Code 的多 Agent 不是一个“多角色 prompt 模板”,而是一套围绕 query() 构建的 Agent 运行时。
subagent 负责把任务委派出去,Task framework 负责把它变成可管理后台任务,permission system 负责把安全边界收住,fork 负责高效继承父上下文,Agent Teams 则在 mailbox、task list、leader approval 和 teammate runner 之上,把一次性委派扩展成持续协作。
真正值得借鉴的是这个分层:
同一个推理引擎
+ 隔离的 ToolUseContext
+ 可管理的 TaskState
+ 可审计的 PermissionDecision
+ 可持久化的 Transcript/Mailbox
+ 可选择的运行后端
这就是它能同时支持 subagent、fork 和 Agent Teams 的原因。