我在给自己的 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.98x1865ms 对 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 核对,链接可能随文档改版失效):
- Anthropic 并行工具使用:https://platform.claude.com/docs/en/agents-and-tools/tool-use/parallel-tool-use
- OpenAI Function Calling 指南:https://platform.openai.com/docs/guides/function-calling
我的判断
并行该做,收益是实打实的 6 倍,不做就是白等。但判断能不能并行这件事,责任在我这边,不在模型那边。模型吐出一堆 tool call 时它对资源冲突一无所知,我指望它自己排好序是不现实的。
我现在的做法是三条:只读工具默认全并行;写操作靠声明推导冲突、冲突就串行;失败和超时往下传,下游明确标 skipped 而不是静默跑。整套东西 100 行出头、零依赖,Node 18+ 就能跑,比引入一个编排框架划算得多——这么点逻辑,不值得为它多一层抽象。
要说什么是我不做的:我不会为了并行而并行。两次写同一个文件,调度器排成两波,714ms,比并行的 350ms 慢一倍,但这个慢是对的。为了好看的耗时数字去赌一个丢失更新,这种买卖我做过一次就够了。
(本文所有输出均为本地 Node 22 实测,脚本与结果一一对应;OpenAI / Anthropic 文档内容于 2026-10-01 核对,两家的参数和默认行为都可能随版本调整,用之前请回查官方文档。)