模型在一轮回复里同时给出多个 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——大胆在推理侧,保守在副作用侧。