现在不少模型接口都允许模型在一轮回复里同时发起多个工具调用。对写 Agent 的人来说,这是个很诱人的能力:读五个文件不用等五轮,查三个接口可以一起发,整体耗时一下子降下来。
我第一次给自己的 Agent 打开并行执行时,体感确实快了不少。然后就开始出怪事:有一次它在同一批调用里既改了一个文件,又跑了测试,测试跑的却是改之前的代码,结果它看着“通过”的测试,宣布任务完成。还有一次,两个写文件的调用同时改同一个配置文件,后写的把先写的覆盖了,日志里两个调用都显示成功。
这些问题让我明白:模型愿意并行,不代表这些调用适合并行。模型给出的只是“我想做这几件事”,至于能不能同时做,得由执行层来判断。这篇聊聊我现在是怎么划这条线的。
先分清四类调用
我把工具调用按“能不能一起跑”粗分成四类:
第一类:纯读、互不依赖。读文件、搜索代码、查文档、查只读接口。这类调用没有副作用,彼此也不需要对方的结果,是并行的最佳对象。Agent 探索阶段的大部分调用都属于这一类,并行收益也主要来自这里。
第二类:有数据依赖。B 需要 A 的输出才能决定参数。比如先搜索找到文件路径,再读这个文件。严格来说,模型如果真的依赖 A 的结果,它就不该在同一轮里发出 B;但我见过模型“猜”一个参数先发出去,所以执行层也要防。
第三类:写同一个资源。同时改同一个文件、同时操作同一个 git 仓库的索引、同时修改同一条数据库记录。这类调用单独看都没问题,一起跑就可能互相覆盖,或者留下中间状态。
第四类:有外部副作用。发消息、创建工单、调用支付或部署接口。这类调用即使操作的是不同对象,我也倾向于排队,因为一旦出错,回滚成本很高,而且需要在执行前让人确认。

图:哪些工具调用能一起跑:纯读并行,依赖、同资源写、外部副作用、共享状态要排队(原创示意图)
还有一类比较隐蔽:共享隐式状态。比如一个 shell 会话的当前目录和环境变量、一个浏览器的登录会话、一个全局的“当前选中项”。两个调用看起来操作的是不同东西,背后却共用一个状态,一个改了目录,另一个就跑偏了。这类问题最难查,因为日志里看不出冲突。
让工具自己声明“能不能并行”
靠模型判断哪些能并行,不靠谱。我现在的做法是在工具定义里加
是否只读;
会碰哪些资源,最好能从参数里算出一个“资源键”,比如文件路径、仓库路径、记录 ID;
是否幂等,重复执行一次会不会多出副作用;
是否需要人工确认。
调度规则就很简单了:只读调用直接并行;写操作按资源键分组,同一个键的调用严格按模型给出的顺序串行,不同键之间可以并行;有外部副作用的,一律串行,并在执行前过确认。

图:执行层按
用 Java 写,大概是这样一个骨架。真实实现还有超时、取消、并发上限,这里只留核心思路:
Map<String, CompletableFuture<Result>> tails = new HashMap<>();
List<CompletableFuture<Result>> all = new ArrayList<>();
for (ToolCall c : batch) { // 按模型给出的顺序
if (c.tool().readOnly()) {
all.add(CompletableFuture.supplyAsync(() -> run(c), pool));
} else {
String key = c.tool().resourceKey(c.args());
CompletableFuture<Result> prev = tails.getOrDefault(key,
CompletableFuture.completedFuture(null));
CompletableFuture<Result> next = prev.handleAsync((r, e) -> run(c), pool);
tails.put(key, next);
all.add(next);
}
}
这里同一资源键上的写操作被串成一条链,前一个结束(无论成败)才轮到下一个。前一个失败了后一个要不要继续,是个策略问题,我倾向于默认继续执行但在结果里标明“前置写操作失败”,让模型自己决定怎么补救。
读写混在同一批里怎么办
开头那个“改了文件又跑测试”的例子,属于读写混在一起。测试本身是只读的(假设它不写文件),但它读的是刚被写的资源。
我试过几种处理方式,最后选了最保守的一种:同一批调用里只要出现写操作,所有读操作都排在写操作之后执行,或者干脆把这一批拆成两轮,先写后读。会损失一点并行度,但避免了“读到旧数据”这种非常隐蔽的错误。
更精细的做法是判断读操作的资源和写操作有没有交集,没交集就并行。但跑测试这种操作,它会读哪些文件你很难提前知道,交集判断不出来,只能当作“读全部”。所以实际上,测试、构建这类操作在我这里几乎总是排在写操作之后。
部分失败:别让一个错误吞掉整批
并行执行还会带来一个串行时不太明显的问题:部分失败。
五个调用一起发,三个成功、一个超时、一个参数错误。最糟的处理是整批报错,模型只看到“失败”,然后把五个全部重试一遍,成功的那三个也重复执行了。如果那三个里有写操作,就重复写了。
我的原则是:
每个调用独立返回结果,带上调用 ID,成功的给结果,失败的给原因;
结果按调用顺序排列,而不是完成顺序,免得模型把结果和调用对错;
只重试失败的那个,并且只有声明了幂等的工具才允许自动重试。
幂等:并行加重试之后必须考虑的事
说到重试,就绕不开幂等。
串行执行时,一个调用超时了,我们通常还能判断它大概执行到了哪一步。并行时多个调用同时在飞,超时的那个到底执行了没有,往往说不清:可能请求根本没发出去,也可能对方已经处理完、只是响应没回来。
对只读操作,重试无所谓。对写操作,尤其是有外部副作用的,盲目重试就可能产生两条工单、两条消息、两次扣款。我的做法是给这类调用生成一个幂等键,由执行层在第一次调用时生成,重试时带上同一个键,下游据此去重。如果下游不支持幂等键,那这个工具就标成“不可自动重试”,超时后交给人判断。

图:部分失败处理:逐个返回结果,只带同一幂等键重试可重试的调用(原创示意图)
并发上限和速率限制
最后是一个比较“运维”的问题:并行度不是越高越好。
模型可能一口气发出二十个搜索请求,执行层要是全部同时打出去,很容易撞上下游接口的速率限制,或者把本机的进程数、文件句柄撑满。我会给执行层设一个全局并发上限,再给每类工具设单独的上限,比如访问外部接口的工具并发数压得更低。超过上限的调用排队等待,而不是直接拒绝。
这些上限的具体数值,我都是从保守值起步,看运行日志里的排队时长和下游错误率慢慢调,没有什么通用的标准答案。
怎么测这套调度
并发问题有个讨厌的特点:本地跑十次都正常,上线跑一百次才出一次。我测调度逻辑时用了个笨办法:在测试环境里给每个工具的执行随机加一点延迟,让完成顺序每次都被打乱,然后把同一批调用反复跑很多遍,检查最终的文件内容、调用顺序和返回结果是否每次都一致。
如果某个资源键忘了声明,或者某个读操作偷偷依赖了写操作的结果,打乱顺序之后通常很快就会露出马脚。比起靠肉眼审代码找并发问题,这个办法省心得多。
小结
并行调用工具,本质上是把“同时做几件事”的决定从模型手里拿一部分回来,交给一个了解资源和副作用的执行层。我现在守的几条线是:
纯读、互不依赖的,放心并行;
写同一资源的,按资源键排队;
有外部副作用的,一律串行加确认;
读写混在一批里,读排在写后面;
部分失败分别返回,只重试幂等的那个。
规则都不复杂,难的是在工具定义阶段就把只读、资源键、幂等这些信息想清楚。一开始多花点时间写


2026-10-16鱼鱼