我在给自己的 Agent 加一批文件工具的时候,撞上一个特别蠢的问题:让它「把这个目录下 6 个文件都读一遍」,它老老实实读了 6 次,每次 300 毫秒,一共 1.8 秒。我在旁边看着进度条一格一格往前挪,心里想的就一句话——这 6 个文件互相之间一点关系都没有,凭什么排队。

我先把话说前面:并行不是把 Promise.all 一扔就完事。我第一版就是这么干的,然后眼睁睁看着一个日志文件少了一行。这篇记录的就是我怎么从「排队」改到「无脑并行」再改到「建图调度」,以及每一步我实测跑出来的数字。

一、先量一下排队到底有多亏

我写了个小脚本,6 个 read_file,每个固定 300ms 延迟(模拟一次网络往返),真实读本地文件。串行跑一遍,并行跑一遍:

--- 场景1 串行 ---  总耗时 1865 ms
  0  read_file    ok       w0  305ms  line-a-1\nline-b-1\n
  1  read_file    ok       w1  312ms  line-a-2\nline-b-2\n
  2  read_file    ok       w2  312ms  line-a-3\nline-b-3\n
  ...(后三条略)

--- 场景1 并行 ---  总耗时 312 ms / 1 波
  0  read_file    ok       w0  311ms  line-a-1\nline-b-1\n
  1  read_file    ok       w0  311ms  line-a-2\nline-b-2\n
  ...
  => 提速 5.98x

1865ms 对 312ms。这还是只有 6 个工具、每个 300ms 的情况。真实 Agent 里一次十几个工具调用很常见,全排队的话光等就等掉好几秒,而这还是在烧 token 的那段对话里。

所以并行的收益是真的,不用讨论。要讨论的是怎么并行才不会出事。

二、我踩的坑:无脑 Promise.all 让我丢了一行日志

我的工具表里有个 append_line:读文件 → 中间处理 → 写回去。我让它连着往同一个日志文件追加两行,写了这么一段:

await Promise.all(planB.map(s => (async () => {
  await new Promise(r => setTimeout(r, 200));   // 模拟网络往返
  return TOOLS.append_line.run(s.args);
})()));

跑完我去看文件内容:

--- 场景2 无脑并行(不建图,直接 Promise.all) ---
  log.txt 内容: "from-step-1\n"

两行只剩一行。原因很朴素:两个调用同时读到空文件,各自算出「空 + 我这一行」,然后先后写回,后写的把先写的盖了。这就是数据库课本上那个 lost update,我在自己的 Agent 上原样复现了一遍。

这时候我才意识到,模型一次吐 5 个 tool call,不代表这 5 个能一起跑。它不知道 write_file 和 write_file 会打架——它连"文件"这个概念都是从我给它的 schema 描述里猜的。判断依赖这件事,只能我做。

三、我的做法:让每个工具声明它碰了什么

我不想手写一个「哪些工具能并行」的白名单,那玩意儿工具一多就维护不动。我改成让每个工具声明自己读写哪些资源,冲突关系由代码推。

const TOOLS = {
  read_file: {
    ms: 300,
    res: a => ({ reads: [a.path], writes: [] }),      // 声明:读这个路径
    run: async a => fs.readFileSync(a.path, 'utf8'),
  },
  write_file: {
    ms: 200,
    res: a => ({ reads: [], writes: [a.path] }),      // 声明:写这个路径
    run: async a => { fs.writeFileSync(a.path, a.content); return 'wrote'; },
  },
  summarize: {
    ms: 400,
    res: a => ({ reads: a.inputs, writes: [a.out] }), // 读一批、写一个
    run: async a => { /* ... */ },
  },
};

建图就是三种经典冲突:写后读(RAW)、写后写(WAW)、读后写(WAR),再加上我手动指定的 after:

function buildGraph(steps) {
  const adj = Array.from({ length: steps.length }, () => new Set());
  const addEdge = (i, j) => { if (!adj[i].has(j)) adj[i].add(j); };

  steps.forEach((s, j) => (s.after || []).forEach(i => addEdge(i, j)));  // 显式依赖

  const lastWriter = new Map();    // 资源 -> 最近一次写它的 step
  const readersSince = new Map();  // 资源 -> 上次写之后的读 step
  steps.forEach((s, j) => {
    const { reads, writes } = resOf(s);
    for (const r of reads) {
      if (lastWriter.has(r)) addEdge(lastWriter.get(r), j);   // 写后读
      readersSince.set(r, (readersSince.get(r) || []).concat(j));
    }
    for (const w of writes) {
      if (lastWriter.has(w)) addEdge(lastWriter.get(w), j);   // 写后写
      for (const rr of (readersSince.get(w) || [])) addEdge(rr, j); // 读后写
      lastWriter.set(w, j);
      readersSince.set(w, []);
    }
  });
  return adj;
}

有了边,执行就按拓扑分层:每一波里把所有入度为 0 的 step 一起放出去,跑完再放下一波。

同一组追加写再跑一次,这次走调度器:

--- 场景2 调度器 ---  总耗时 714 ms / 2 波
  0  append_line  ok       w0  357ms  ok
  1  append_line  ok       w1  359ms  ok
  log.txt 内容: "from-step-0\nfrom-step-1\n"
  推导出的边: 0->1 = true

两行都在了。代价是从 350ms 变成 714ms——写同一个文件的两次调用本来就不该并行,这个时间是它本来就该花的。

四、失败和超时要往下传

并行之后另一个麻烦:一批里有一个挂了,剩下的怎么办。我的处理是——上游失败或超时,下游一律标 skipped,不跑,也不假装跑过。每个 step 外面套一个 Promise.race 做超时:

const out = await Promise.race([
  p,
  new Promise((_, rej) => { timer = setTimeout(
      () => rej(new Error('TIMEOUT ' + timeout + 'ms')), timeout); }),
]);

我造了个必挂的工具和一个必卡死的工具混进依赖链里实测:

--- 场景3 依赖链 + 失败 + 超时 ---  总耗时 1835 ms / 4 波
  0  list_dir     ok       w0  124ms  f1.txt f2.txt f3.txt ...
  1  read_file    ok       w1  312ms  line-a-1\nline-b-1\n
  2  read_file    ok       w1  312ms  line-a-2\nline-b-2\n
  3  boom         failed   w0   62ms  ENOENT: remote host down
  4  summarize    ok       w2  404ms  total 37 chars
  5  hang         timeout  w0  808ms  TIMEOUT 800ms
  6  read_file    ok       w3  310ms  total 37 chars
  7  grep         skipped  w3    0ms  -
  8  grep         skipped  w3    0ms  -

step 7 依赖挂掉的 3,step 8 依赖超时的 5,两个都被正确跳过了。这里有个我一开始写错的细节:判断「要不要跳过」的时候我在 for...of 里直接 splice 数组,结果迭代器漏掉了一个元素,step 8 照样跑了。改成遍历 ready.slice() 才对。

超时我设的 800ms,那个卡死的工具在第 808ms 被掐掉。超时这一步必须做,不然一个挂住的 shell 调用能把整批拖到天荒地老,而且并行之后拖住的是整波,不是一个。

五、顺手核对了一下官方怎么说(2026-10-01)

我不想凭记忆写 API 名字,所以去翻了两家的文档:

  • OpenAI:参数叫 parallel_tool_calls,默认允许模型在单轮里调多个函数,显式设为 false 才是「零个或一个」。文档还提了一句,内置工具不能进并行批次。
  • Anthropic:并行默认开,关掉要在 tool_choice 对象里设 disable_parallel_tool_use: true,不是顶层参数。文档强调两件事:所有 tool_result 必须放在同一个 user message 里、且排在任何文本之前;没跑的调用也要回一个 is_error: true 的结果。

有意思的是文档里给的建议是「只批处理互相独立的调用(Only batch tool calls that are independent of each other)」——道理谁都知道,但它没说怎么判断独立。我这套 reads/writes 声明 + 建图,就是我把这句话落地的方式。

两处出处(2026-10-01 核对,链接可能随文档改版失效):

我的判断

并行该做,收益是实打实的 6 倍,不做就是白等。但判断能不能并行这件事,责任在我这边,不在模型那边。模型吐出一堆 tool call 时它对资源冲突一无所知,我指望它自己排好序是不现实的。

我现在的做法是三条:只读工具默认全并行;写操作靠声明推导冲突、冲突就串行;失败和超时往下传,下游明确标 skipped 而不是静默跑。整套东西 100 行出头、零依赖,Node 18+ 就能跑,比引入一个编排框架划算得多——这么点逻辑,不值得为它多一层抽象。

要说什么是我不做的:我不会为了并行而并行。两次写同一个文件,调度器排成两波,714ms,比并行的 350ms 慢一倍,但这个慢是对的。为了好看的耗时数字去赌一个丢失更新,这种买卖我做过一次就够了。

(本文所有输出均为本地 Node 22 实测,脚本与结果一一对应;OpenAI / Anthropic 文档内容于 2026-10-01 核对,两家的参数和默认行为都可能随版本调整,用之前请回查官方文档。)

添加新评论

文章目录