从设计者的角度理解源码–CludeCode的通讯机制

王大爷 2026年09月07日 13次浏览

从设计者的角度理解源码–父子 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 + 并发控制)

越往下代价越高。所以设计通信时,从最便宜的那档开始,够用即止——不要为了"将来可能要跨进程"就提前上文件信箱,也不要为了"将来可能有多个订阅者"就提前上事件总线。真到需求出现那天,再往下走一档。

下次你在自己的系统里设计模块间通信,别急着选技术方案,先问三个问题:

  1. 消息往哪个方向流?(单向 / 双向)
  2. 走什么介质?(内存 / 队列 / 文件)
  3. 发送方和接收方是什么时序关系?(阻塞 / 推送 / 轮询)

答清楚这三个问题,该有哪几条路径,自然就浮出来了。


本文的机制分析基于 Claude Code 源码,关键代码位于 src/tools/AgentTool/src/tasks/LocalAgentTask/src/utils/teammateMailbox.tssrc/tools/SendMessageTool/