parallel-tool-calls-single-turn · ZH · 2026-10-08

单轮并行工具调用:一次返回多个工具请求怎么扇出与汇总

讲解单轮并行工具调用的原理与工程实现:模型在一轮回复中返回多个 tool_calls 时,如何并发扇出执行、按 tool_call_id 汇总结果消息,再拼成一次追问调用;并给出幂等、超时、错误回填、结果裁剪与安全边界等实践要点。

单轮并行工具调用的价值

在工具调用(tool calling / function calling)范式里,模型可以在一轮回复中只请求一个工具,也可以同时请求多个。后者通常被称为单轮并行工具调用(parallel tool calls)。

它解决的问题很直接:当若干子任务之间没有依赖关系时,串行执行会白白多花几轮往返(round trip)。每多一轮,就多一次网络延迟、多一次上下文重复传输、多一次模型推理开销。并行调用把"问—答—再问—再答"压缩成"一次问、并发答、一次汇总"。

典型场景:

  • 用户问"北京和上海明天天气怎么样",两个城市互相独立,可以并发查询。
  • 用户问"帮我比较这两款手机的当前售价",两次商品查询可以并发。
  • 一个 Agent 需要同时检索文档、查数据库、读日历,三者无依赖。

判断标准只有一条:这些工具调用之间是否存在数据依赖。有依赖就必须串行,没有依赖才适合扇出。

模型返回长什么样

在 Chat Completions 风格的接口里,助手消息可能带有 toolcalls 数组,而不是单个 toolcall。其结构大致是:

  • 顶层消息的 role 为 assistant,content 可能为空。
  • tool_calls 是一个数组,每个元素包含 id、type 和 function。
  • function 里有 name(工具名)和 arguments(通常是 JSON 字符串)。

关键是记两点:每个调用都有唯一 id,arguments 是字符串不是对象(需要自己解析)。

不同厂商的字段命名可能略有差异(例如有的用 toolcalls,有的用 functioncall 单数形式,有的在流式返回中分片下发)。工程上应当抽象出一层适配,把各家返回统一成内部结构,而不是让业务代码到处判断厂商。

流式场景特别注意:并行调用的参数往往是分片累积的。你会先收到某个调用的 id 和 name,然后是若干段 arguments 增量。必须按 index 或 id 归并,拼完再解析 JSON,否则会拿到半截字符串。

扇出:并发执行多个调用

拿到 tool_calls 后,扇出的核心是把"逐条 for 循环"换成"并发调度"。

伪代码示意:

assistant_msg = resp.choices[0].message
calls = assistant_msg.tool_calls or []

results = await asyncio.gather(*[
    run_one(c) for c in calls
], return_exceptions=True)

run_one 需要负责:

  1. 解析参数:json.loads(call.function.arguments),解析失败要捕获并作为错误结果返回,而不是抛出去中断整批。
  2. 路由到具体实现:按 call.function.name 查表分发。未知工具名应返回明确的错误文本。
  3. 独立超时:给每次调用设置单独的 timeout。一个慢工具不应拖垮整批,也不应无限阻塞。
  4. 结果规格化:无论成功失败,都返回统一结构,附带原始 toolcallid。

并发度不要无脑放开。如果一次返回几十个调用,直接全部并发可能打爆下游服务或触发限流。实践中常用一个有界并发池(semaphore)或分批执行,兼顾吞吐与稳定。

汇总:把结果拼回一次追问调用

并发执行结束后,需要把 N 个结果变成 N 条 tool 角色消息,追加到对话历史,再发起下一次请求。

每条消息的形态:

  • role = tool
  • toolcallid = 对应调用的 id
  • content = 该工具的结果(通常是字符串,结构化数据需序列化)

三条硬性约束:

  1. id 必须严格对应。模型靠 toolcallid 把结果和它当初的请求配对。id 错位会导致答非所问,甚至接口报错。
  2. 数量必须齐全。多数 API 要求"每个 tool_call 都要有对应的 tool 消息",缺一个可能被拒绝。如果某个工具确实执行失败,就回填一条说明失败原因的 content,而不是省略。
  3. 顺序保持一致。虽然配对靠 id,但按原顺序追加更稳妥,也便于排查。

拼装完成后,历史大致是:

system
user
assistant (tool_calls: [A, B, C])
tool (id=A)
tool (id=B)
tool (id=C)

然后把这些一起发给模型,模型基于全部结果生成最终自然语言回答。注意:不要把原始 toolcalls 丢掉,追问请求里必须同时包含那条带 toolcalls 的 assistant 消息,否则 tool 消息会成为孤儿。

幂等、重试与错误回填

并行扇出后,单点失败的概率被放大了:三个工具各 99% 成功,整批全成的概率就降到约 97%。所以错误处理不是可选项。

  • 区分重试性错误。网络抖动、超时、限流通常可重试;参数非法、权限不足、资源不存在重试也没用。
  • 只读工具才敢自动重试。写操作(下单、发消息、改数据)一定要幂等键或干脆不自动重试,避免重复副作用。
  • 错误要结构化回填。给模型的 content 最好包含"工具名 + 失败原因 + 可选的建议",让它能向用户解释或改用别的路径,而不是抛出一坨堆栈。
  • 部分成功也是成功。一个检索失败不该让整轮对话崩掉;把成功的结果和失败的说明一起交给模型,由它组织回答。
  • 区分"工具报错"和"调用非法"。前者应以 tool 消息回填,后者(参数压根不是合法 JSON)同样应以 tool 消息回填错误文本,而不是让流程中断。

结果裁剪与上下文预算

并行调用会一次性把多份结果塞进上下文,token 消耗增长很快。几条常见做法:

  • 对每个工具结果设长度上限,超长就截断或摘要,并在 content 里标注"已截断"。
  • 只回填必要字段,不要把整个 API 响应原样塞进去。
  • 对检索类工具做去重与排序,合并多个来源的重复条目后再交给模型。
  • 注意上下文窗口。如果并行 10 个工具、每个结果几千 token,很容易顶到上限。必要时分批:先并行一半,汇总后再并行另一半。

安全与工具设计边界

并行放大了风险面,几个原则值得坚持:

  • 默认只并行只读操作。多个写操作并发可能产生竞态或不可预期的组合效果。
  • 同时只读也可能有副作用。某些"查询"接口会计费、会写日志、会消耗配额,需要按实际情况判断。
  • 把模型请求当作不可信输入。工具名、参数都要校验:白名单工具、参数类型与范围检查、路径与命令注入防护。
  • 工具描述要写清楚。说明它做什么、参数含义、何时该用。模型是否正确地并行调用,很大程度取决于描述质量。
  • 给并发设上限,并在工具内部做好限流与超时,防止被一次异常请求拖垮。

一次完整轮次的检查清单

  1. 收到 assistant 消息,检查是否存在 tool_calls。
  2. 有则解析每个调用的 id、name、arguments;解析失败记为该调用的错误结果。
  3. 并发执行(有界并发 + 单项超时),收集统一结构的结果。
  4. 为每个调用生成一条 tool 消息,id 严格对应,顺序与请求一致,失败也要回填。
  5. 把 assistant(tool_calls) 与全部 tool 消息追加到历史。
  6. 发起下一次请求,让模型汇总。
  7. 若模型再次返回 tool_calls,重复以上流程,并设置最大轮次上限防止死循环。

把这套流程封装成通用函数后,底层换用哪家模型、通过哪种聚合方式接入,业务代码基本不用改动——差异都被收敛在"发请求"和"解析返回"这两处适配层里。