# Step 39: 多代理编排

一句话导读:在 step38 单个子代理之上再进一步——用一个显式工具 AgentBatch 一次并行派发多个子代理,跑完把结论汇总成带编号的报告。核心洞察是:并行不是新能力,只是新封装。


# 一、这一步做了什么(What)

新增一个工具 AgentBatch(tools/agentBatch.ts,全步只加这一个文件)。模型传入一组子任务,它:

  1. fan-out:用 mapLimit(tasks, 4, ...) 并发上限 4 地并行跑每个子任务(复用 step38 的 runSubAgent);
  2. 汇总:把各子代理结论拼成带编号的报告;
  3. 作为一个 tool_result 返回主代理去综合。

实测:3 个各 500ms 的子任务,总耗时约 503ms(并行;串行会是 1500ms),并汇总成编号报告。


# 二、面试官视角:为什么要做?(Why)

面试题:step18 引擎早就用 Promise.all 并行执行同一轮的多个工具了。既然模型一轮里发多个 Agent 调用本来就会并发跑,为什么还要专门造一个 AgentBatch?

这题的题眼是:AgentBatch 加的不是并行能力,而是并行的「确定性」和「可组合性」。

「靠模型一轮发多个 Agent」有三个不可控:

  1. 不保证并行:模型可能一次只发一个 Agent、拿到结果再发下一个,退化成串行——你无法强制它一次发齐。
  2. 结论散落:三个 Agent 调用产生三个独立的 tool_result,主代理要自己在上下文里把它们拼起来综合,多占上下文也更容易漏。
  3. 不是一等公民:「并行调研」这个意图没有被表达成一个东西,只是碰巧发生的副作用。

AgentBatch 把「fan-out + 汇总」封装成一个模型可确定性触发的工具:模型一个工具调用 = 确定并行 N 个 + 统一汇总成一份报告。这让「并行调研 A/B/C」从「祈祷模型恰好这么做」变成「一次工具调用必然这么做」——更像一个工作流原语而非偶然行为。


# 三、原理:它是怎么工作的(How)

# 并发上限版 map:mapLimit

「并行」的本质就是对 runSubAgent 做一次有上限的 Promise.all。上限靠一个经典的「worker 池」实现:

async function mapLimit<T, R>(items: T[], limit: number,
  fn: (item: T, i: number) => Promise<R>): Promise<R[]> {
  const results: R[] = new Array(items.length);
  let next = 0;
  async function worker() {
    while (next < items.length) {   // 每个 worker 抢下一个待办的 index
      const i = next++;
      results[i] = await fn(items[i], i);
    }
  }
  const n = Math.min(limit, items.length);
  await Promise.all(Array.from({ length: n }, worker));  // 起 n 个 worker
  return results;
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14

起 n = min(limit, tasks) 个 worker 一起跑,每个 worker 循环「抢下一个未处理的 index → 跑完 → 再抢」,跑完一个补一个,同时在跑的永远 ≤ limit。

# 工具主体:fan-out + 汇总

export function createAgentBatchTool(
  runSubAgent: (description: string, prompt: string) => Promise<string>,
): ToolDef {
  return {
    name: "AgentBatch",
    timeoutMs: 0,                    // 并行跑多个子代理,耗时远超 30s,不设外层超时
    async execute(input): Promise<ToolResult> {
      const tasks = (input?.tasks as {description:string;prompt:string}[]) ?? [];
      const CONCURRENCY = 4;
      // ★ fan-out:并发上限 4 地并行跑所有子代理
      const results = await mapLimit(tasks, CONCURRENCY, (t) =>
        runSubAgent(t.description, t.prompt));
      // ★ 汇总:拼成带编号的报告,交主代理综合
      const report = tasks
        .map((t, i) => `### 子任务 ${i + 1}:${t.description}\n${results[i]}`)
        .join("\n\n");
      return { content: [{ type: "text", text: report }] };
    },
  };
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20

注意 AgentBatch 自己不 new 引擎——它只接受注入的 runSubAgent(step38 造子引擎的那个函数),保持工具层对「怎么造子代理」无知。

# 数据流

AgentBatch({ tasks: [ {description,prompt}×3 ] })
   ↓  fan-out:mapLimit(tasks, 4, t => runSubAgent(t.description, t.prompt))
   ↓  3 个子代理并发跑(各自独立引擎、共享 budget)
   ↓  汇总
   ### 子任务1:… <结论>
   ### 子任务2:… <结论>
   ### 子任务3:… <结论>
   ↓  作为一个 tool_result 返回主代理 → 主代理综合
1
2
3
4
5
6
7
8

# 交错日志加标签

多个子代理同时跑,进度输出天然交错。所以 runSubAgent 的每条进度行都加了 [子任务描述] 前缀:

· 派发子代理:调研A
· 派发子代理:调研B
    ↳ [调研A] 调用:Glob
    ↳ [调研B] 调用:Grep      ← 交错也分得清谁是谁
    ↳ [调研A] 工具 Glob 完成
1
2
3
4
5

这不是锦上添花——并发系统里,没有标签的交错日志约等于不可读。可观测性是并行编排的必备项,不是可选项。


# 四、深入追问(面试常见 follow-up)

Q:这里的「并行」是多进程还是多线程?会真的用满多核吗? A:都不是。它是 JS 事件循环上的并发——Promise.all 让多个 runSubAgent 的 await(主要是等 API 响应)交叠进行。子代理大部分时间在等网络 I/O,事件循环期间切去推进别的子代理,所以墙钟时间大幅缩短(3×500ms → ~503ms)。但它们跑在同一个进程、同一个线程,不吃多核。这点和真实 Claude Code 的 AgentTool 子代理一致。

Q:并发为什么要设上限 4?不设、一次性全发出去不是更快? A:不设上限,模型一次给 20 个子任务就会瞬间向 API 打 20 个并发请求——容易触发限流(rate limit)、也可能把 budget 一口气烧穿。mapLimit 的「跑完一个补一个」把瞬时并发压在 4 以内,是任何并发编排的基本功:并发要有背压。真实 swarm 也有类似上限(约 min(16, cpu-2))。

Q:AgentBatch 和真实 Claude Code 的 swarm 是一回事吗? A:不是,别混。真实 CC 有两套多代理系统:① AgentTool 子代理(tools/AgentTool/)——临时、同进程、跑完回结论,就是我们 step38/39 对应的这套;② swarm/coordinator 团队(utils/swarm/ + coordinator/)——持久的团队,队友可以是独立进程(各自一个完整 Claude),有领队、能互发消息(SendMessage)、动态增删成员、跨进程权限同步。我们的 AgentBatch 只对应第一套的并行版,swarm 那套远超当前范围。

Q:子任务之间有依赖(B 要用 A 的结果)能用 AgentBatch 吗? A:不能。AgentBatch 是无依赖的 fan-out——所有子任务同时起跑、互不通信。有先后依赖的活要用单个 Agent 串着做,或让主代理拿到 A 的结论后再派 B。工具的 description 里明确写了这条边界:「子任务之间有先后依赖时不要用它」。


# 五、设计权衡

  • 确定性 fan-out vs 模型自由发挥:AgentBatch 用一个工具锁定「并行 + 汇总」的确定性,代价是模型要能识别「这活能拆成独立子任务」。两条路真实 CC 都保留:既能并行多个 Task,也有 AgentBatch 式的显式批处理。
  • 汇总在工具层 vs 交给主代理:这里选在工具层拼成一份编号报告,主代理只看到一个整齐的 tool_result。好处是主代理上下文干净;代价是汇总格式写死了(简单编号拼接),不如让主代理自由综合灵活。对「先给个结构化概览」的场景,工具层汇总更省心。

# 六、与真实源码的对照

我们的实现 Claude Code 源码
同进程 Promise.all 并行 ≈ 并行版 AgentTool 子代理(tools/AgentTool/)
mapLimit 并发上限 4 真实版有并发池(约 min(16, cpu-2))
汇总成编号报告 单层 fan-out + 汇总
临时、无通信、对等 utils/swarm/ + coordinator/ 是持久团队、领队+worker、队友互发消息
[描述] 标签交错日志 并发编排的可观测性

# 七、一句话总结

AgentBatch = 把「并行 + 汇总」封装成一个确定性的工作流原语:并行能力(Promise.all)step18 就有,这一步加的是「模型一次调用必然 fan-out N 个子代理并统一汇总」的确定性;配 mapLimit 做并发背压、[标签] 做交错日志——并行系统的三件套(复用、背压、可观测)齐了。

# 下一节预告

多代理能力就位后,step40 转向记忆系统:启动时加载 CLAUDE.md(用户级 + 项目级)注入系统提示,让项目约定跨会话持久。