从设计者的角度理解源码–父子 Agent 的三条通信路径
引言
本篇是《从设计者的角度理解源码》系列里聚焦通信机制的一篇。
读 Claude Code 的多 Agent 子系统,最让我感慨的不是它用了什么高深的并发原语,而是作者面对"通信"这件事时的克制:没有上来就搞一条"万能消息总线",也没有为了架构好看引入事件框架,而是老老实实地把"通信"这个含糊的词拆开——消息往哪个方向流?走什么介质?发送方和接收方是什么时序关系?——三个问题答完,该有几条通道,自然就浮出来了。
这篇文章,我试着代入设计者的视角,把父子 Agent 之间那三条通信路径的推演过程重走一遍。
为什么要设计 Agent 间通信
哎,一个 Agent 好好的,什么活不能干,为什么非要搞出"父子 Agent"?
因为上下文窗口是有限的。假设我让 Agent 去 120 个仓库里找"优惠计算逻辑在哪个文件",它如果在一个对话里挨个仓库 grep、读文件,检索结果会把主对话撑爆——等它找到答案,前面聊的需求早就被挤出上下文了。
有了:派活。父 Agent 负责思考和分派,子 Agent 负责去单个仓库里翻找,翻完只把"答案摘要"带回来。这样海量的中间结果都消耗在子 Agent 的上下文里,主对话始终干净。
可问题马上来了——子 Agent 干完活,怎么把结果还给父 Agent?
哎,"通信"不就是 A 把结果告诉 B 吗?搞一条通道不就完了,为什么源码里是三条?
这正是我当初看源码时最别扭的地方。别急,我们顺着设计者的思路,一条一条推演。
第一条路径:同步返回值(tool_result)
哎,父 Agent 调子 Agent 干活,最自然的模型是什么?不就是函数调用吗?
父调子,父等在调用点上;子跑完,return 一个结果,父拿了结果继续往下走。零延迟、零序列化、语义清晰。
有了:第一条路径——tool_result。子 Agent 在当前进程里阻塞执行,跑完后把最终结果归纳成一个标准的 tool_result 块,直接进父 Agent 的下一轮上下文。它的本质就是函数返回值。
源码里,同步分支的注释把这件事说得很直白:
// src/tools/AgentTool/AgentTool.tsx
// ────────────────────────────────────────────────────────────────
// 区块 14:Agent 启动 — 分支 B:同步 Agent(阻塞执行)
// ────────────────────────────────────────────────────────────────
// 同步 Agent 在当前进程中阻塞执行,父 Agent 等待子 Agent 完成后
// 才能继续。通过 tool_result 直接返回结果。
//
// 同步 Agent 的子→父通信走路径一(tool_result),而不是路径二
// (异步通知)。意味着父 Agent 被阻塞期间无法处理任何其他消息。
结果怎么组装成 tool_result?由 mapToolResultToToolResultBlockParam 根据状态字段构造不同内容:
// src/tools/AgentTool/AgentTool.tsx
mapToolResultToToolResultBlockParam(data, toolUseID) {
const internalData = data as InternalOutput;
// ...
if (data.status === 'completed') {
// 子 Agent 正常完成:把归纳后的内容作为 tool_result 返回
const contentOrMarker = data.content.length > 0 ? data.content : [{
type: 'text' as const,
// 若子 Agent 没有任何文本输出,放一个明确标记——
// 否则空的纯元数据块会被模型解读为"没东西要处理"而直接结束 turn
}];
// ...
}
}
注意这里一个细节:连"子 Agent 跑完但啥也没说"这种边角都考虑了——空结果会让模型误以为任务结束,所以宁可塞一个明确标记。这是被真实使用打磨过的痕迹。
哎,但是等等。父要是干等,子 Agent 跑个几分钟,父不就卡死几分钟吗?我想让子在后台跑、父同时干别的,怎么办?
函数返回值模型在这里彻底失效:父一旦不阻塞、扭头干别的去了,等子完成时,调用点早就没人接返回值了。阻塞模型撑不起异步场景。
第二条路径:异步通知(task-notification)
哎,后台子 Agent 跑完的那一刻,父 Agent 根本不在调用点等着,返回值没人接,这可怎么办?
有了:别再指望"返回值"了。子完成后,主动往父 Agent 的消息队列里塞一条通知,父 Agent 下一轮对话组装消息时,会"自动看到"这条通知。这就是第二条路径——task-notification,本质是异步推送的事件。
它比 tool_result 多带了一层结构化元数据:状态(completed / failed / killed)、摘要、token 用量,甚至 worktree 分支信息。通知长这样:
// src/tasks/LocalAgentTask/LocalAgentTask.tsx
export function enqueueAgentNotification({
taskId, description, status, error, setAppState,
finalMessage, usage, toolUseId, worktreePath, worktreeBranch
}: {
// ...
status: 'completed' | 'failed' | 'killed';
usage?: { totalTokens: number; toolUses: number; durationMs: number };
// ...
}): void {
// P1:原子地检查并置位 notified,防止重复通知(TaskStopTool 可能已通知过)
let shouldEnqueue = false;
updateTaskState<LocalAgentTaskState>(taskId, setAppState, task => {
if (task.notified) return task;
shouldEnqueue = true;
return { ...task, notified: true };
});
if (!shouldEnqueue) return;
// P2:组装 <task-notification> 结构化内容
const summary = status === 'completed'
? `Agent "${description}" completed`
: status === 'failed'
? `Agent "${description}" failed: ${error || 'Unknown error'}`
: `Agent "${description}" was stopped`;
const usageSection = usage
? `\n<usage><total_tokens>${usage.totalTokens}</total_tokens>` +
`<tool_uses>${usage.toolUses}</tool_uses>` +
`<duration_ms>${usage.durationMs}</duration_ms></usage>`
: '';
const message = `<${TASK_NOTIFICATION_TAG}>
<${TASK_ID_TAG}>${taskId}</${TASK_ID_TAG}>
<${OUTPUT_FILE_TAG}>${getTaskOutputPath(taskId)}</${OUTPUT_FILE_TAG}>
<${STATUS_TAG}>${status}</${STATUS_TAG}>
<${SUMMARY_TAG}>${summary}</${SUMMARY_TAG}>${usageSection}
</${TASK_NOTIFICATION_TAG}>`;
// P3:推入父 Agent 的待处理通知队列
enqueuePendingNotification({ value: message, mode: 'task-notification' });
}
而后台子 Agent 启动时,父拿到的根本不是结果,而是一个"已发射"的回执:
// src/tools/AgentTool/AgentTool.tsx
if (data.status === 'async_launched') {
const prefix = `Async agent launched successfully.\nagentId: ${data.agentId} ...`;
const instructions = `The agent is working in the background. ` +
`You will be notified automatically when it completes.`;
// 立刻返回,父 Agent 不阻塞
}
哎,可这条路径有两个硬伤。
其一,通知只在子 Agent 生命终点触发(完成/失败/被杀的那一刻)。那运行中途呢?父想给子追加一句"顺便把测试也看了",子想向父同步一个"我发现这个仓库权限不够"的风险点——这种运行中的双向对话,终点通知根本撑不起来。
其二,通知是高度结构化的"状态报告",它塞不下一句自由文本的对话。你没法用 <status>completed</status> 这种格式去聊"我觉得这个方案有问题"。
第三条路径:文件信箱(SendMessage)
哎,我需要的是:运行中任意时刻,任意两个 Agent 之间能互发一句话;而且发的时候,对方可能根本没在运行。这怎么办?
"对方没运行也能收到"——这句话直接排除了内存和队列(进程一退就没了)。能跨进程、能持久化、发完就走不等待的介质,最朴素的就是文件。
有了:文件系统信箱。每个 Agent 在磁盘上拥有一个独立的信箱文件,发信就是往对方信箱里追加一条消息(fire-and-forget,不阻塞);收信方下次轮询时自己来读。哪怕收件人此刻不存在,消息也安安静静躺在文件里等它。这就是第三条路径——SendMessage,本质是持久化的异步信箱。
信箱路径和消息结构:
~/.claude/teams/{team_name}/inboxes/{agent_name}.json
// src/utils/teammateMailbox.ts
export type TeammateMessage = {
from: string // 发送方 Agent 名称
text: string // 消息内容(纯文本或 JSON 结构化消息)
timestamp: string // ISO 时间戳
read: boolean // 是否已读
color?: string // 发送方颜色(UI 展示用)
summary?: string // 5-10 词的 UI 预览摘要
}
Agent 发消息时调的是 SendMessageTool,它的处理逻辑薄得很——真正的活全在信箱层:
// src/tools/SendMessageTool/SendMessageTool.ts
async function handleMessage(
recipientName: string,
content: string,
summary: string | undefined,
context: ToolUseContext,
): Promise<{ data: MessageOutput }> {
const appState = context.getAppState()
const teamName = getTeamName(appState.teamContext)
const senderName = getAgentName() || (isTeammate() ? 'teammate' : TEAM_LEAD_NAME)
// 写对方信箱,发完即返回,不关心对方在不在
await writeToMailbox(
recipientName,
{
from: senderName,
text: content,
summary,
timestamp: new Date().toISOString(),
color: getTeammateColor(),
},
teamName,
)
return { data: { success: true, message: `Message sent to ${recipientName}'s inbox` } }
}
深挖:多个 Agent 同时写一个信箱,怎么不冲突?
文件信箱听起来简陋,但 swarm 模式下可能有多个 Claude 进程同时往同一个信箱文件里写。这是整套设计里最考验工程功底的地方,值得展开。
哎,多个进程同时"读出来 → 追加一条 → 写回去",会不会互相覆盖、丢消息?读方会不会读到写了一半的损坏 JSON?
有了,两板斧:原子写 + 文件锁加退避重试。
先看锁的配置。作者特意留了注释,解释为什么异步锁需要显式重试:
// src/utils/teammateMailbox.ts
// Lock options: retry with backoff so concurrent callers (multiple Claudes
// in a swarm) wait for the lock instead of failing immediately. The sync
// lockSync API blocked the event loop; the async API needs explicit retries
// to achieve the same serialization semantics.
const LOCK_OPTIONS = {
retries: {
retries: 10, // 最多重试 10 次
minTimeout: 5, // 最小退避 5ms
maxTimeout: 100, // 最大退避 100ms
},
}
为什么是"等待重试"而不是"抢不到就失败"?因为信箱写入是个一定要成功的操作——两个 Agent 同时写信,失败者不该报错,而该等一下再试。
再看完整的写入流程:
// src/utils/teammateMailbox.ts
export async function writeToMailbox(
recipientName: string,
message: Omit<TeammateMessage, 'read'>,
teamName?: string,
): Promise<void> {
await ensureInboxDir(teamName)
const inboxPath = getInboxPath(recipientName, teamName)
const lockFilePath = `${inboxPath}.lock`
// P1:信箱文件不存在则先建一个空数组(proper-lockfile 要求文件必须存在)
try {
await writeFile(inboxPath, '[]', { encoding: 'utf-8', flag: 'wx' })
} catch (error) {
if (getErrnoCode(error) !== 'EEXIST') { logError(error); return }
}
let release: (() => Promise<void>) | undefined
try {
// P2:加文件锁,抢不到就按 LOCK_OPTIONS 退避重试
release = await lockfile.lock(inboxPath, {
lockfilePath: lockFilePath,
...LOCK_OPTIONS,
})
// P3:拿到锁后【重新读】最新状态——别的进程可能刚写过
const messages = await readMailbox(recipientName, teamName)
// P4:追加新消息
messages.push({ ...message, read: false })
// P5:整体写回(写临时文件再原子替换,读方永远读不到中间态)
await writeFile(inboxPath, jsonStringify(messages, null, 2), 'utf-8')
} catch (error) {
logError(error)
} finally {
// P6:无论如何释放锁
if (release) await release()
}
}
这里有两个关键设计,值得单独说:
第一,P3 的"锁内重读"。 加锁之前读的内容是不算数的——从你读到到你加锁之间,别的进程可能已经写过了。所以必须在拿到锁之后重新读一次最新状态,再 append。这就是经典 read-modify-write 竞态的标准解法。
第二,读方的容错。 信箱文件可能压根还没创建:
// src/utils/teammateMailbox.ts
export async function readMailbox(
agentName: string,
teamName?: string,
): Promise<TeammateMessage[]> {
const inboxPath = getInboxPath(agentName, teamName)
try {
const content = await readFile(inboxPath, 'utf-8')
return jsonParse(content) as TeammateMessage[]
} catch (error) {
if (getErrnoCode(error) === 'ENOENT') {
return [] // 文件不存在 = 信箱是空的,而不是报错
}
logError(error)
return []
}
}
文件不存在不叫异常,叫"空信箱",直接返回 []。这个语义取舍很妙——它让"第一个来信的人"不需要任何特殊初始化逻辑。
知识补习:
- 文件锁 proper-lockfile 的工作原理(lockfile + 目录/文件存在性探测)
- 为什么"写临时文件 + rename 原子替换"能保证读者永远读不到中间态(rename 在同文件系统上是原子操作)
- read-modify-write 竞态条件与乐观/悲观锁
信箱里还藏着"控制信令"
哎,权限请求、关闭协商、计划审批这类东西,也走自由文本聊天吗?让模型去解析"对方好像是在请求权限"?
那太危险了。模型可能把一句普通对话误读成审批,也可能把真正的权限请求当成闲聊忽略。控制信令和对话内容必须分开。
有了:信箱里除了纯文本,还承载结构化协议消息——用 JSON 的 type 字段区分类型,由专门的处理器路由,根本不进 LLM 上下文:
// src/utils/teammateMailbox.ts
/**
* 判断一条消息是否是结构化协议消息——这类消息由 useInboxPoller 路由,
* 而不是作为原始 LLM 上下文消费。若被当成普通文本打包进附件,
* 它们就永远到不了对应的处理器(权限队列、沙箱队列等)。
*/
export function isStructuredProtocolMessage(messageText: string): boolean {
try {
const parsed = jsonParse(messageText)
if (!parsed || typeof parsed !== 'object' || !('type' in parsed)) {
return false
}
const type = (parsed as { type: unknown }).type
return (
type === 'permission_request' ||
type === 'permission_response' ||
type === 'sandbox_permission_request' ||
type === 'sandbox_permission_response' ||
type === 'shutdown_request' ||
type === 'shutdown_approved' ||
type === 'team_permission_update' ||
type === 'mode_set_request' ||
type === 'plan_approval_request' ||
type === 'plan_approval_response'
)
} catch {
return false
}
}
这背后其实是个很通用的设计原则:人机/机机交互里,控制面和数据面要分离。聊业务用自然文本,走协议用结构化消息——后者机器精确路由,前者交给模型理解,井水不犯河水。顺带一提,这也解释了为什么权限模型要足够细:Agent 间通信本质是一次"写对方信箱",但它和"写业务文件"风险等级完全不同,应当被视为低风险、免交互——权限粒度够细,才能把这类本可自动化的交互真正交给模型。
为什么不是 EventEmitter
哎,一说"通信",程序员的第一反应不就是发布-订阅吗?谁关心进度就 agent.on('progress', handler),多省事,为什么不用?
我们来杠一下这个方案。多对多订阅看起来灵活,但它把控制权反转给了订阅者,带来三个麻烦:
// EventEmitter 模式(反例)
agent.on('progress', ui.update) // UI 直接订阅
agent.on('progress', sdk.notify) // SDK 直接订阅
agent.on('progress', analytics.track) // 分析直接订阅
// 麻烦 1:ui.update 抛异常,sdk.notify 还跑不跑?——订阅者异常污染发布者
// 麻烦 2:谁先注册谁先跑,顺序跟业务优先级无关
// 麻烦 3:发布者一股脑推,根本不管消费者吃不吃得下——没有背压
有了:作者反其道而行,用主干驱动 + 消费者解复用——流式产物只有一个消费者(for await 循环),消费者在每次迭代里主动把消息分发给多个下游:
// src/tools/AgentTool/agentToolUtils.ts
// 单生产者 makeStream() → 单消费者 for await
for await (const message of makeStream(onCacheSafeParams)) {
agentMessages.push(message) // 下游 1:积累完整消息历史
rootSetAppState(prev => { // 下游 2:UI 实时可见(retain 时)
const t = prev.tasks[taskId]
if (!isLocalAgentTask(t) || !t.retain) return prev
return { ...prev, tasks: { ...prev.tasks,
[taskId]: { ...t, messages: [...(t.messages ?? []), message] } } }
})
updateProgressFromMessage(tracker, message, resolveActivity,
toolUseContext.options.tools) // 下游 3:更新进度追踪
const lastToolName = getLastToolUseName(message)
if (lastToolName) {
emitTaskProgress(tracker, taskId, toolUseContext.toolUseId,
description, metadata.startTime, lastToolName) // 下游 4:SDK 事件
}
}
这不是发布-订阅,而是"主干遍历 → 在遍历点联动多个外部系统"的串行模型。EventEmitter 的三个坑,恰好对应它的三个优势:
- 背压天然可控——
for await的消费速度决定全链路节奏,消费者不拉下一条,生产者就停; - 异常隔离——每个下游处理器各自包在自己的作用域里,一个炸了不影响其他;
- 顺序确定——每次分发是确定性的函数调用,不依赖谁先注册。
知识补习:
- AsyncGenerator /
for await的拉取模型与背压(backpressure) - 发布-订阅模式的控制反转问题
三条路径的正交本质
推演到这儿,回头看"为什么是三条",答案就清楚了。"通信"从来不是一个动作,而是三个正交维度的组合:
- 方向:消息单向流,还是双向?
- 传输层:走内存、队列,还是文件?
- 时序:同步阻塞、异步推送,还是异步轮询?
三条路径,恰好是三个真实场景各自需要的那个组合:
| 场景 | 方向 | 传输层 | 时序 | 对应路径 |
|---|---|---|---|---|
| A:同步子 Agent 跑完,父立刻要结果 | 单向(子→父) | 内存对象 | 同步阻塞 | tool_result |
| B:后台子 Agent 跑完,父已在干别的 | 单向(子→父) | 消息队列 | 异步推送 | task-notification |
| C:运行中任意时刻想跟对方说句话 | 双向 | 文件系统 | 异步轮询 | SendMessage |
反证一下"只留一条行不行"——每条路径在自己擅长的维度上最优,换两个维度就崩:
- 只留 tool_result? 后台 Agent 直接废掉。阻塞模型下父必须干等,"后台"毫无意义。
- 只留 task-notification? 运行中的双向对话没了。它只在生命终点触发,撑不起中途交流。
- 只留 SendMessage? 结构化状态丢了。纯文本信箱表达不了"completed 还是 failed 还是 killed",父还得自己猜。
所以三条不是冗余,是正交分解后的必然结果,一条都删不掉。
代码结构图
后台子 Agent 从发射到通知,经历一个严格的 8 步生命周期,三条路径在其中各就各位:
runAsyncAgentLifecycle(后台子 Agent 生命周期)
│
├─ ① createProgressTracker() 创建进度追踪器
├─ ② for await (message of makeStream) 主循环:逐条消费输出流
│ ├─ agentMessages.push() ──→ 消息积累
│ ├─ rootSetAppState() ──→ UI 同步(主干驱动·下游2)
│ ├─ updateProgressFromMessage() ──→ 进度追踪(下游3)
│ └─ emitTaskProgress() ──→ SDK 事件(下游4)
│ 〔运行中:SendMessage 走文件信箱,双向自由通信〕──── 路径③
├─ ③ stopSummarization() 停止摘要线程
├─ ④ finalizeAgentTool() 归纳最终结果
├─ ⑤ completeAsyncAgent() ★先标记任务完成,解除 TaskOutput 阻塞
├─ ⑥ classifyHandoffIfNeeded() 安全审查(auto 模式,可能调 API)
├─ ⑦ getWorktreeResult() 清理 worktree(可能执行 git)
└─ ⑧ enqueueAgentNotification() 注入 <task-notification> ──── 路径②
catch (AbortError) → killed 通知
catch (other) → failed 通知
〔同步子 Agent 则在调用点直接返回 tool_result〕──────────────── 路径①
注意 ⑤ 和 ⑥ 的顺序——这是个有意的反直觉排序:
// src/tools/AgentTool/agentToolUtils.ts
// ── 5. 先标记任务完成(completeAsyncAgent),使 TaskOutput(block=true)
// 立即解除阻塞。后续的 classifyHandoffIfNeeded(API 调用)和
// getWorktreeResult(git 执行)可能 hang——它们不能阻塞状态转换。
// 参考:gh-20236 ──
completeAsyncAgent(agentResult, rootSetAppState)
// ── 6. 可选安全审查:在 auto 模式下审查子 Agent 输出 ──
if (feature('TRANSCRIPT_CLASSIFIER')) {
const handoffWarning = await classifyHandoffIfNeeded({ /* ... */ })
}
为什么"标记完成"必须排在"安全审查"前面?因为 ⑥ 的分类器要调网络 API(可能超时、可能宕机),⑦ 的 git worktree remove 可能因锁冲突 hang。如果把它们排前面,等待子 Agent 结果的 TaskOutput(block=true) 就永远解不了阻塞,父 Agent 被死锁。先做保证性操作(状态转换,解除阻塞),再做尽力而为操作(安全审查、清理)——增强项永远不能成为可用性的阻塞点。
可迁移的设计原则
把三条路径、正交维度、信箱并发、主干驱动收拢起来,最后能提炼出一条通信决策的代价阶梯:
能用同栈 mutate 就不序列化,能用回调就不推队列,能用队列就不走文件。
同栈直接改内存 → 零序列化、最快,但只限同进程
回调 → 灵活,但引入控制反转
消息队列 → 解耦时序,但进程内
文件系统 → 跨进程持久化,但代价最高(序列化 + I/O + 并发控制)
越往下代价越高。所以设计通信时,从最便宜的那档开始,够用即止——不要为了"将来可能要跨进程"就提前上文件信箱,也不要为了"将来可能有多个订阅者"就提前上事件总线。真到需求出现那天,再往下走一档。
下次你在自己的系统里设计模块间通信,别急着选技术方案,先问三个问题:
- 消息往哪个方向流?(单向 / 双向)
- 走什么介质?(内存 / 队列 / 文件)
- 发送方和接收方是什么时序关系?(阻塞 / 推送 / 轮询)
答清楚这三个问题,该有哪几条路径,自然就浮出来了。
本文的机制分析基于 Claude Code 源码,关键代码位于 src/tools/AgentTool/、src/tasks/LocalAgentTask/、src/utils/teammateMailbox.ts、src/tools/SendMessageTool/。