模型在一轮回复里同时给出多个 tool call,只说明它认为这些调用可以一起做,并不构成运行时保证。直接
Promise.all会把只读查询和有副作用的写入绑成一次全成或全败,既容易互相覆盖,也容易在局部失败后整组盲目重试。下面用分级、依赖图和并发预算,把「能用的结果」和「必须对账再动」分开。
并行调用不是系统保证
在 Agent 循环里,模型经常在同一轮输出多个 tool call。这很诱人:把它们丢进 Promise.all,觉得端到端延迟会被最慢的那一次调用盖住。问题在于,模型给出的「可并行」是它对工具语义的猜测,不是你的账户、订单、库存所承诺的隔离级别。调度权在宿主进程,不在模型。
Promise.all 还有一个更隐蔽的行为:任意一个拒绝,整个 Promise 就拒绝,但已经发出去的写入不会被撤回。调用方如果据此做「整组重试」,只读请求通常只是多打一遍,有副作用的请求却可能被再提交一次。Promise.allSettled 能避免「一个失败就看不清其余结果」,但它仍然不处理依赖,也不处理同一资源上的互斥。把模型的并行暗示直接翻译成 Promise.all,等于把猜测当成了系统保证。
典型事故并不需要复杂链路。一次余额查询加一笔转账,再加一条审计日志:日志超时导致整组失败,转账却可能已经成功;下一轮原样重放,就会重复扣款。或者两个写入打到同一购物车,后写覆盖先写,模型只看到最后一次的返回。如果你在工具层还做了超时重试,问题会被放大——并行发出的写入里只要有一次响应丢失,客户端重试加上整组重跑,重复提交的窗口会叠在一起。这些都不是模型「不会并行」的问题,而是运行时没有分级。
工具先分级,再进调度器
我把工具分成三档,只根据副作用和互斥来标,不根据模型是否把它们放在同一轮。分级是调度器的输入,不是注释。漏标成只读,后果是重复提交;错标成串行,后果只是变慢。看不清时默认按串行写处理。
实践里,分类可以很机械。没有外部写入、没有邮件、没有队列、返回值只来自查询,就标只读。会插入或更新一行、会扣减余额、会发出不可撤回的通知,就标串行或有上限并发:同一主键或同一账户走串行,天然可分区且覆盖可接受则走有上限并发。不要用「这个接口很快」来分级,速度和副作用无关。
三档标签
无依赖只读。 例如查询余额、读用户资料。调用可重试,成功结果在本轮内可复用,并发主要受下游 QPS 和连接数限制。它们之间没有必须的先后顺序,也不该被一次写入失败拖死。
需串行。 同一资源上的写入,或者「先读后写」且写依赖读的结论。转账依赖余额查询;同一账户上的两笔转账不能交错。串行的粒度是资源,不是全局单线程。全局排队会把无关账户绑在一起,那是在用正确性换错误的慢。
可并发但有上限。 审计日志、通知、互相不覆盖的写入可以并行,但必须有并发预算。没有上限的「能并行就全开」,只是把限流从自己的进程推给下游,失败模式会变成超时和重试风暴。
依赖关系要写在工具元数据里:deps 表示必须拿到哪些任务的成功结果才能启动。模型漏标依赖时,宿主应按资源冲突补边,而不是信任模型的并行列表。落地时也不要让模型自由点名内部函数。给每个可暴露工具一份静态清单:名字、档位、如何从参数抽出 resource(例如 account:${accountId})、依赖是固定边还是从参数推断。调度器只读这份清单。模型多叫了一个只读工具,无妨;模型把两个互斥写入标成可并行,清单上的资源键仍会把它们串起来。
可运行的 Node.js 调度器
下面这份调度器用 Node.js 内置的 Promise、Map 和队列实现信号量,无第三方依赖。它做三件事:按 deps 等待并检测环;只读与写入使用不同并发上限,写入还可按 resource 互斥;单个任务失败时无关分支继续,依赖它的任务记为 skipped 而不是跟着变成一次含糊的整组错误。
把文件存成 tool-scheduler.js,用当前 LTS 的 Node.js 执行 node tool-scheduler.js 即可。
'use strict';
class Semaphore {
constructor(max) {
this.max = max;
this.current = 0;
this.queue = [];
}
acquire() {
return new Promise((resolve) => {
const tryAcquire = () => {
if (this.current < this.max) {
this.current += 1;
resolve();
} else {
this.queue.push(tryAcquire);
}
};
tryAcquire();
});
}
release() {
this.current -= 1;
const next = this.queue.shift();
if (next) next();
}
}
async function scheduleTools(tasks, options = {}) {
const { readLimit = 4, writeLimit = 2 } = options;
const byId = new Map(tasks.map((task) => [task.id, task]));
const state = new Map();
const readSem = new Semaphore(readLimit);
const writeSem = new Semaphore(writeLimit);
const resourceLocks = new Map();
function lockFor(resource) {
if (!resource) return null;
if (!resourceLocks.has(resource)) {
resourceLocks.set(resource, new Semaphore(1));
}
return resourceLocks.get(resource);
}
const visiting = new Set();
const visited = new Set();
function visit(id, stack) {
if (visited.has(id)) return;
if (visiting.has(id)) {
throw new Error(`dependency cycle: ${[...stack, id].join(' -> ')}`);
}
const task = byId.get(id);
if (!task) throw new Error(`unknown task: ${id}`);
visiting.add(id);
for (const dep of task.deps || []) visit(dep, [...stack, id]);
visiting.delete(id);
visited.add(id);
}
for (const task of tasks) visit(task.id, []);
async function runOne(task) {
for (const depId of task.deps || []) {
const depState = state.get(depId);
if (!depState || depState.status !== 'fulfilled') {
state.set(task.id, {
status: 'skipped',
reason: `dependency ${depId} not fulfilled`,
});
return;
}
}
const sem = task.kind === 'readonly' ? readSem : writeSem;
const resLock = task.kind === 'readonly' ? null : lockFor(task.resource);
await sem.acquire();
if (resLock) await resLock.acquire();
try {
const depResults = {};
for (const depId of task.deps || []) {
depResults[depId] = state.get(depId).value;
}
const value = await task.run(depResults);
state.set(task.id, { status: 'fulfilled', value });
} catch (err) {
state.set(task.id, {
status: 'rejected',
reason: err instanceof Error ? err.message : String(err),
});
} finally {
if (resLock) resLock.release();
sem.release();
}
}
const running = new Map();
function ensure(id) {
if (running.has(id)) return running.get(id);
const promise = (async () => {
const task = byId.get(id);
for (const depId of task.deps || []) {
await ensure(depId);
}
await runOne(task);
})();
running.set(id, promise);
return promise;
}
await Promise.allSettled(tasks.map((task) => ensure(task.id)));
const fulfilled = {};
const rejected = {};
const skipped = {};
for (const [id, item] of state) {
if (item.status === 'fulfilled') fulfilled[id] = item.value;
else if (item.status === 'rejected') rejected[id] = item.reason;
else skipped[id] = item.reason;
}
return { fulfilled, rejected, skipped };
}
function delay(ms, value) {
return new Promise((resolve) => setTimeout(() => resolve(value), ms));
}
async function main() {
let transferCalls = 0;
const tasks = [
{
id: 'get_balance',
kind: 'readonly',
deps: [],
run: async () => delay(30, { account: 'A', available: 100 }),
},
{
id: 'get_profile',
kind: 'readonly',
deps: [],
run: async () => delay(20, { name: 'Ada', kyc: 'ok' }),
},
{
id: 'transfer',
kind: 'serial',
resource: 'account:A',
deps: ['get_balance'],
run: async ({ get_balance }) => {
transferCalls += 1;
if (get_balance.available < 80) {
throw new Error('insufficient funds');
}
if (transferCalls === 1) {
throw new Error('upstream 503');
}
return {
txId: 'tx_1',
amount: 80,
idempotencyKey: 'transfer:A:80:order_9',
};
},
},
{
id: 'audit_log',
kind: 'bounded',
resource: 'audit',
deps: [],
run: async () => {
throw new Error('audit sink timeout');
},
},
];
const round1 = await scheduleTools(tasks, { readLimit: 4, writeLimit: 2 });
console.log('round1', JSON.stringify(round1, null, 2));
console.log('transferCalls after round1', transferCalls);
const retry = [];
if (round1.fulfilled.get_balance) {
retry.push({
id: 'get_balance',
kind: 'readonly',
deps: [],
run: async () => round1.fulfilled.get_balance,
});
}
if (round1.rejected.transfer) {
retry.push({
id: 'check_transfer',
kind: 'readonly',
deps: [],
run: async () => ({ exists: false, txId: null }),
});
retry.push({
id: 'transfer',
kind: 'serial',
resource: 'account:A',
deps: ['get_balance', 'check_transfer'],
run: async ({ get_balance, check_transfer }) => {
if (check_transfer.exists) {
return { txId: check_transfer.txId, replayed: false };
}
transferCalls += 1;
if (get_balance.available < 80) {
throw new Error('insufficient funds');
}
return {
txId: 'tx_1',
amount: 80,
idempotencyKey: 'transfer:A:80:order_9',
};
},
});
}
const round2 = await scheduleTools(retry, { readLimit: 4, writeLimit: 1 });
console.log('round2', JSON.stringify(round2, null, 2));
console.log('transferCalls after round2', transferCalls);
}
main().catch((err) => {
console.error(err);
process.exit(1);
});
第一次运行里,get_profile 与 get_balance 并发,audit_log 独立失败,transfer 因模拟的 503 进入 rejected,但资料查询的结果仍在 fulfilled 里。transferCalls after round1 会是 1,说明写入没有因为审计失败而被多调一次。这就是和 Promise.all 的差别:失败停在任务边界上,不把整轮结果扔掉。
部分失败之后:复用、隔离、对账
调度器返回 fulfilled / rejected / skipped 三类,对应三条处理规则。这些规则应该写成测试,而不是写在值班手册里口头执行。给模型的观察结果也要过滤:skipped 和被拒绝的写入不应伪装成成功;可以返回结构化错误,让模型决定改参数还是停手。把失败原因吞掉,再让模型基于过期只读结果继续推理,比全程串行还危险。
| 状态 | 能否直接回填模型 | 下一步 |
|---|---|---|
| 只读且 fulfilled | 能 | 本轮缓存,避免重复查询 |
| 写入且 fulfilled | 能 | 记下幂等键或业务单号,后续禁止无对账重放 |
| 只读且 rejected | 否 | 按原参数限次重试 |
| 写入且 rejected | 否 | 先对账,未落地才带同一幂等键提交 |
| skipped | 否 | 依赖变为 fulfilled 后再跑,不与 rejected 混着重试 |
写入遇到 503 时尤其不能直接重放:上游可能没落地,也可能已经落地但响应丢了。正确顺序是用只读对账接口查状态——未创建则带同一把幂等键提交,已创建则把原单据当作成功交给模型。示例第二轮把余额查询的成功结果复用掉,先跑 check_transfer,再决定是否真正调用转账;idempotencyKey 标明这是同一笔业务,而不是新的一笔。
把对账做成只读任务,而不是写在 catch 里的临时逻辑。catch 里直接重放,测试很难断言「有没有第二次副作用」。对账任务进入同一套调度器,失败就停在 rejected,成功才给写入开闸。这样重试循环和第一轮是同一套代码路径。
审计日志这类 bounded 失败,不应触发转账重试。隔离的意思是:重试集合等于「被拒绝且重试安全」的任务,加上「因依赖未满足被跳过、且依赖现已可用」的任务,绝不是「模型这一轮点过的全部工具」。可测的调度边界至少覆盖三种夹具:审计失败但转账成功,只把审计放入重试集;余额查询失败,转账必须是 skipped 且 run 一次都没执行;转账 503 后对账发现已成功,禁止第二次产生副作用的 run。边界稳定之后,「模型说可以并行」就只是调度器的参考输入,而不是运行时保证。
总结
并行工具调用能缩短一轮里只读查询的等待,但它不是免费加速,更不是正确性策略。先给工具标上只读、串行、有上限并发,再用依赖图和并发预算执行;失败时拆成可复用、须对账、须等待依赖三类结果。宿主进程把这套边界守住,模型才可以继续大胆地一次抛出多个 tool call——大胆在推理侧,保守在副作用侧。
评论