一个 Agent 接入多个工具后,模型一次回复可能不只返回一个 tool_call。
例如,用户要求同时分析两个文件,模型可能返回:
text
read_file(a.js)
read_file(b.js)
这两个文件如果每个读取 2 秒,顺序执行大约需要 4 秒,并行执行则可能只需要 2 秒左右。
看起来,直接把所有工具都放进 Promise.all() 就能提高性能。
但把任务换成下面这样:
text
创建项目
安装依赖
启动开发服务器
如果三步同时执行,安装依赖时项目可能还没有创建,启动服务时依赖也可能尚未完成。
因此,多 Tool Call 的关键问题不是"会不会使用 Promise.all()",而是:
text
这些调用之间是否存在依赖关系?
本文对比项目里的两种真实 Agent Loop:
tool.mjs使用Promise.all()并行执行mini-cursor.mjs和多 MCP Demo 使用for...of + await顺序执行
看清它们分别适合什么任务,以及工具结果怎样准确对应回原来的 tool_call_id。
模型返回的是 Tool Call 数组
模型决定使用工具时,会在 AIMessage 中返回 tool_calls:
js
response.tool_calls
其中每一项通常包含:
js
{
id: 'call_xxx',
name: 'read_file',
args: {
filePath: './src/app.js'
}
}
当一次回复需要多个工具时,tool_calls 就是一个数组:
js
[
{
id: 'call_1',
name: 'read_file',
args: { filePath: './src/app.js' }
},
{
id: 'call_2',
name: 'read_file',
args: { filePath: './src/config.js' }
}
]
模型只负责生成调用意图和参数,真正决定怎样调度这些任务的是 Runtime。
Runtime 可以选择:
text
顺序执行:一个完成后再执行下一个
并行执行:同时启动,等待全部完成
方案一:使用 Promise.all() 并行执行
tool.mjs 中使用 Promise.all() 处理一轮里的全部 Tool Call:
js
const toolResults = await Promise.all(
response.tool_calls.map(async (toolCall) => {
const tool = tools.find(
item => item.name === toolCall.name
);
if (!tool) {
return `错误:找不到工具 ${toolCall.name}`;
}
try {
return await tool.invoke(toolCall.args);
} catch (err) {
return `错误:${err.message}`;
}
})
);
map() 会把每个 Tool Call 转换成一个 Promise,Promise.all() 同时等待这些异步任务。
执行过程可以理解为:
text
tool_call 1 ──开始────────完成
tool_call 2 ──开始────完成
tool_call 3 ──开始──────────完成
↓
所有任务完成后继续
它最适合彼此独立的 I/O 任务,例如:
- 同时读取多个互不相关的文件
- 查询多个城市的天气
- 请求多个独立数据接口
- 对多份内容执行互不影响的检查
这些任务不需要等待前一个结果,也不会修改同一份共享状态。
Promise.all() 的结果顺序会乱吗?
不会。
即使第二个工具最先执行完成,Promise.all() 返回的结果数组仍然按照传入 Promise 的顺序排列。
text
输入顺序:call_1、call_2、call_3
完成顺序:call_2、call_1、call_3
结果数组:result_1、result_2、result_3
因此,项目里可以通过相同的 index 把结果对应回原来的调用:
js
response.tool_calls.forEach((toolCall, index) => {
messages.push(
new ToolMessage({
content: toolResults[index],
tool_call_id: toolCall.id,
})
);
});
这里有两个对应关系:
text
toolResults[index] ↔ response.tool_calls[index]
tool_call_id ↔ toolCall.id
数组下标帮助 Runtime 取到正确结果,tool_call_id 帮助模型确认结果属于哪一次调用。
并行执行不代表可以省略 tool_call_id。
Promise.all() 为什么经常"一错全错"?
标准 Promise.all() 有一个重要特点:只要其中一个 Promise 进入 rejected 状态,外层等待就会立即 reject。
js
await Promise.all([
taskA(),
taskB(), // rejected
taskC(),
]);
需要注意,外层提前失败并不会自动取消已经启动的其他异步任务。taskA 和 taskC 仍可能继续执行,只是当前代码不再等待它们的完整结果。
项目里的处理方式是在每个工具内部捕获错误:
js
try {
return await tool.invoke(toolCall.args);
} catch (err) {
return `错误:${err.message}`;
}
工具失败后返回的是错误字符串,而不是继续抛出异常。
这样 Promise.all() 仍然可以拿到完整结果数组:
text
[
'文件 A 的内容',
'错误:文件不存在',
'文件 C 的内容'
]
模型在下一轮既能看到成功结果,也能看到失败原因,再决定是否重试或换一种方式处理。
方案二:使用 for...of 顺序执行
mini-cursor.mjs 中采用了另一种方式:
js
for (const toolCall of response.tool_calls) {
const foundTool = tools.find(
tool => tool.name === toolCall.name
);
if (foundTool) {
const toolResult = await foundTool.invoke(
toolCall.args
);
messages.push(
new ToolMessage({
content: toolResult,
tool_call_id: toolCall.id,
})
);
}
}
await 位于循环内部,所以当前工具完成后,程序才会执行下一个工具。
text
tool_call 1:开始 → 完成
tool_call 2: 开始 → 完成
tool_call 3: 开始 → 完成
顺序执行耗时可能更长,但更适合会产生副作用或共享状态的任务:
- 写入同一个文件
- 创建项目后再安装依赖
- 安装依赖后再启动服务
- 打开网页后再获取页面快照
- 获取快照后再根据页面内容点击元素
- 点击进入详情页后再截图
这些任务不能只看"有几个 Tool Call",还要看先后关系。
顺序执行就能解决所有依赖吗?
不能。
同一个 AIMessage 里的多个 Tool Call,是模型在看到工具结果之前一次性规划出来的。
即使 Runtime 按顺序执行,后一个调用的参数也已经提前确定:
text
模型同时生成 call_1 和 call_2
→ Runtime 执行 call_1
→ Runtime 执行 call_2
执行 call_1 后,模型没有机会根据真实结果重新生成 call_2 的参数。
例如浏览器任务:
text
打开搜索页
→ 获取页面快照
→ 根据快照找到酒店按钮
→ 点击按钮
模型必须先看到页面快照,才能知道应该点击哪个元素。
这种真正依赖上一步结果的任务,更适合拆成多个 Agent Loop:
text
第 1 轮:模型调用打开网页
→ ToolMessage 返回页面状态
第 2 轮:模型根据状态调用页面快照
→ ToolMessage 返回快照内容
第 3 轮:模型根据快照决定点击哪个元素
所以需要区分两种"顺序":
text
同一轮内按顺序执行:只保证副作用发生的先后顺序
跨模型轮次执行:后一步可以真正依据前一步结果重新规划
哪些工具不能轻易并行?
1. 修改同一个资源
两个工具同时写入同一个文件,最终结果可能取决于谁最后完成:
text
write_file(app.js, 内容 A)
write_file(app.js, 内容 B)
这会产生竞态条件,不能简单放进 Promise.all()。
2. 浏览器操作
点击、切换标签页、输入和截图依赖当前页面状态。
如果同时点击两个元素或在页面加载前截图,结果会变得不可预测。
3. 命令行任务
下面三步有明确依赖:
text
pnpm install
pnpm build
pnpm preview
构建依赖安装结果,预览又依赖构建产物。
4. 共享数据库状态
同时执行扣库存、创建订单和更新支付状态,可能产生数据一致性问题。
是否允许并发,应由业务事务和幂等设计决定,不能只由模型判断。
哪些工具适合并行?
通常满足下面条件的任务更适合并行:
- 彼此不依赖返回结果
- 不修改同一资源
- 失败不会破坏其他任务
- 调用顺序不影响最终结果
- 服务端允许相应并发量
例如:
text
读取 a.js、b.js、c.js
查询北京、上海、广州天气
检查三份独立配置文件
获取多个互不关联的公开接口数据
并行前仍要考虑接口限流、连接数量和机器资源。
如果模型一次返回几十个调用,直接全部启动也可能触发 API 限流或占满文件句柄。实际项目通常还要增加并发上限,而不是无限制使用 Promise.all()。
Runtime 应该怎样决定?
不能把"并行还是顺序"完全交给 LLM 的自然语言推理。
更稳妥的做法是让 Runtime 根据工具属性和资源冲突做判断。
可以从四个问题开始:
- 后一个调用是否需要前一个调用的返回值?
- 多个调用是否会修改同一个文件、页面或数据库记录?
- 工具失败后是否可以安全重试?
- 外部服务是否有并发与频率限制?
简单决策可以整理成:
| 场景 | 建议方式 |
|---|---|
| 多个独立只读查询 | 并行执行 |
| 修改同一份状态 | 顺序执行 |
| 后一步需要前一步结果 | 跨 Agent 轮次执行 |
| 浏览器点击、等待、截图 | 顺序并分轮执行 |
| 大量独立调用 | 限制并发数量 |
ToolMessage 的顺序也要保持稳定
无论工具怎样调度,最终都要把每个结果放回 messages:
text
AIMessage(tool_calls)
→ Runtime 执行工具
→ ToolMessage(tool_call_id)
→ 再次调用模型
并行执行时,不能按照"哪个工具先完成就先随意塞进去"的方式丢失对应关系。
项目使用 Promise.all() 的结果顺序和原始 tool_calls 下标完成映射,再为每条消息保留 tool_call_id,这是并行工具执行能够稳定工作的关键。
总结
Agent 一次返回多个 Tool Call,并不意味着所有工具都应该并行。
text
并行解决的是等待时间
顺序保证的是状态和依赖
跨轮次解决的是根据真实结果重新规划
可以把选择原则记成:
text
独立、只读、无共享状态 → Promise.all()
存在副作用或资源冲突 → for...of + await
后一步依赖前一步结果 → 返回 ToolMessage,让模型进入下一轮
模型负责提出调用计划,Runtime 才应该负责并发控制、错误隔离和执行顺序。
这也是 Agent 从"能调用工具"走向"稳定完成复杂任务"时必须补上的工程能力。