单轮并行工具调用:一次返回多个工具请求怎么扇出与汇总
讲解单轮并行工具调用的原理与工程实现:模型在一轮回复中返回多个 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 需要负责:
- 解析参数:
json.loads(call.function.arguments),解析失败要捕获并作为错误结果返回,而不是抛出去中断整批。 - 路由到具体实现:按
call.function.name查表分发。未知工具名应返回明确的错误文本。 - 独立超时:给每次调用设置单独的 timeout。一个慢工具不应拖垮整批,也不应无限阻塞。
- 结果规格化:无论成功失败,都返回统一结构,附带原始
toolcallid。
并发度不要无脑放开。如果一次返回几十个调用,直接全部并发可能打爆下游服务或触发限流。实践中常用一个有界并发池(semaphore)或分批执行,兼顾吞吐与稳定。
汇总:把结果拼回一次追问调用
并发执行结束后,需要把 N 个结果变成 N 条 tool 角色消息,追加到对话历史,再发起下一次请求。
每条消息的形态:
role=tooltoolcallid= 对应调用的 idcontent= 该工具的结果(通常是字符串,结构化数据需序列化)
三条硬性约束:
- id 必须严格对应。模型靠
toolcallid把结果和它当初的请求配对。id 错位会导致答非所问,甚至接口报错。 - 数量必须齐全。多数 API 要求"每个 tool_call 都要有对应的 tool 消息",缺一个可能被拒绝。如果某个工具确实执行失败,就回填一条说明失败原因的 content,而不是省略。
- 顺序保持一致。虽然配对靠 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,很容易顶到上限。必要时分批:先并行一半,汇总后再并行另一半。
安全与工具设计边界
并行放大了风险面,几个原则值得坚持:
- 默认只并行只读操作。多个写操作并发可能产生竞态或不可预期的组合效果。
- 同时只读也可能有副作用。某些"查询"接口会计费、会写日志、会消耗配额,需要按实际情况判断。
- 把模型请求当作不可信输入。工具名、参数都要校验:白名单工具、参数类型与范围检查、路径与命令注入防护。
- 工具描述要写清楚。说明它做什么、参数含义、何时该用。模型是否正确地并行调用,很大程度取决于描述质量。
- 给并发设上限,并在工具内部做好限流与超时,防止被一次异常请求拖垮。
一次完整轮次的检查清单
- 收到 assistant 消息,检查是否存在
tool_calls。 - 有则解析每个调用的 id、name、arguments;解析失败记为该调用的错误结果。
- 并发执行(有界并发 + 单项超时),收集统一结构的结果。
- 为每个调用生成一条
tool消息,id 严格对应,顺序与请求一致,失败也要回填。 - 把 assistant(tool_calls) 与全部 tool 消息追加到历史。
- 发起下一次请求,让模型汇总。
- 若模型再次返回
tool_calls,重复以上流程,并设置最大轮次上限防止死循环。
把这套流程封装成通用函数后,底层换用哪家模型、通过哪种聚合方式接入,业务代码基本不用改动——差异都被收敛在"发请求"和"解析返回"这两处适配层里。