Skip to content

Q41 · 如何实现工具的并行调用?什么时候该串行? ​

用户对客服智能体说:“订单 A123 的耳机能退吗?顺便查一下我的积分。”系统需要做三次读取:查 A123 的订单、查当前用户的积分、查耳机类别的退货政策。若把三次请求排成一队,一个结束才开始下一个,用户会白等。但如果把它们不分条件地同时发出,政策工具在订单还没返回时可能连商品类别都不知道。

正确做法是先画出数据依赖:查订单与查积分互不需要对方的结果,可以同时开始;查政策要用订单返回的类别,必须等查订单完成;答复要看哪些结果成功、哪些失败,再合并。所谓并行工具调用,在这类远程查询里通常指异步并发:等待一个接口返回的同时,程序可以等待或处理另一个接口,而非一定让两个 CPU 核心同时计算。

官方文档也区分了“模型提出多个调用”与“应用怎样执行”。OpenAI 说明模型可能在一轮产生多个函数调用,应用可以用 parallel_tool_calls: false 限制每轮最多一个;Anthropic 明确指出多个工具调用可以由应用并发、串行或混合执行,选择取决于依赖和副作用。OpenAI:Parallel function calling · Anthropic:Parallel tool use

先把术语、数据和代码名说清楚 ​

术语或符号含义本例对应物
Agent / 智能体模型与应用程序配合,决定查什么、接收结果并答复用户客服问答程序
工具调用模型或编排程序请求应用执行一项操作;实际数据库/API 请求由应用执行查订单、查积分、查政策
串行后一步开始前,必须等前一步结果拿到订单 category 才查政策
并发多项工作在时间上重叠推进,适合等待网络或数据库的任务查订单和查积分同时等待各自服务
并行日常讨论常把独立调用的并发执行也叫“并行”;严格说,物理同时运行与异步交错不是一回事两个远程读取同时处于执行中
依赖一个操作的输入或正确性依赖另一个操作的输出政策查询需要订单里的商品类别
汇合 / fan-in等所需分支有结果或明确失败,再组合输出退货结论与积分信息组成答复
副作用操作改变外部状态提交退款、扣积分;本例三次查询均只读
超时为一次调用设置等待上限;达到上限后给出失败结果积分服务过慢时报告暂不可查
order_id用户给出的订单号A123
user_id服务端从登录会话取的用户标识,不能让模型猜演示用户 U9
category订单系统返回的标准商品类别earphone
call_id / tool_use_id不同接口里用来对应请求与结果的调用标识两个同名工具调用也能各自对上结果

文中的业务数据都是假设:当前日期为 2026-09-26;A123 在 2026-09-23 签收、显示未拆封、类别为 earphone;用户 U9 的积分为 240;演示政策是“签收次日起 7 个自然日内且未拆封,可申请退货,仍需审核例外条件”。这些不是某平台的真实规则。日期以签收日为起点,不能偷换成购买日。

先画依赖,再算能省多少等待 ​

查订单与查积分并行开始,查政策等待订单类别返回,再合并答复

图上“查订单”和“查积分”从同一个用户请求分叉:它们都是只读,而且查积分不需要订单结果。查政策只能沿订单这条路继续,因为它的输入 category=earphone 必须从订单结果取得。最右边的答复收集两条分支;若积分超时,可以只对退货问题给有证据的答复,并明确积分暂不可查。

把三次服务用时记为 T订单、T积分、T政策。完全串行时约等于 T订单 + T积分 + T政策;按图执行时约等于 max(T订单 + T政策, T积分),其中 max 表示取两条路径中较慢的一条。比如订单 120 毫秒、积分 160 毫秒、政策 80 毫秒,串行约 360 毫秒,按依赖并发约 200 毫秒。这只是工具服务阶段的理想化估算,实际还要加模型调用、排队和网络开销,也不保证每次都快 160 毫秒。

决定能否并发时,逐对问三个问题:

  1. 输入是否已知? 查积分只要服务端 user_id,可以与查订单同时启动;查政策的 category 要等订单返回。
  2. 会不会互相改变状态? 两次纯读取通常容易并发;扣积分与提交退款若共享余额或订单状态,就要考虑顺序、事务和并发冲突。
  3. 业务上能否接受各自失败? 积分只是用户“顺便”问的附加信息,积分服务失败不必阻断退货问题;若政策不可用,就不能给确定的退货资格结论。

即使模型在同一轮提出“查订单”和“查政策”,也不能因为它们都出现在一轮就直接并发执行。若政策参数来自尚未查询的订单,应用应拒绝猜出的参数,等订单结果后再发起政策查询。某些接口会在模型输出中给每个调用单独的 ID;返回结果时要按 ID 配对,不能假设“第一个返回”就是“第一个发出”。OpenAI 的函数调用示例用 call_id 对应输出;Anthropic 的并行调用文档要求每个 tool_use_id 都有对应结果,并说明跳过的调用也要返回错误结果。OpenAI:工具结果与 call_id · Anthropic:执行语义

用 Python 跑出“先并发、后依赖、再汇合” ​

下面给一段可用 Python 3.9+ 直接运行的模拟程序,不需要安装第三方包或接真实模型。它展示的是应用的执行编排,不是某家模型 API 的完整接线代码。真实系统应把三个模拟函数换成带鉴权的数据库或 HTTP 客户端,并按提供商接口把每次调用结果连同调用 ID 交还模型。

先认识代码会用到的名字:

代码名作用
get_order模拟用订单号查订单;输出类别、签收日和未拆封状态
get_points模拟从登录用户 ID 查积分;slow=True 用来制造慢服务
get_policy模拟按订单返回的类别查政策
guarded给单次工具调用加超时,并把成功或失败统一变成带 ok 的结果
answer负责按依赖启动三个操作,返回订单、政策和积分三份结果
order_task、points_taskasyncio.create_task 创建的两个已启动任务,分别代表并发中的订单和积分读取
seconds单次调用最多愿意等待的秒数;模拟值只为演示
slow_points是否让积分服务故意变慢,演示部分失败

async def 定义一个可以在等待 I/O 时让出执行权的异步函数;await 是等待某个异步结果;asyncio.create_task 会把异步函数安排为可并发推进的任务。asyncio.wait_for 到时会尝试取消被等待的任务并抛出超时错误;代码用 asyncio.TimeoutError 兼容 Python 3.9 与较新的版本。asyncio.gather 在代码末尾只负责等待子任务清理。Python 文档提醒,取消需要被调用方处理,因此实际总耗时可能超过设定的超时秒数;远程服务是否已经产生副作用,更不能只凭本地取消判断。Python:Coroutines and Tasks

python
import asyncio


async def get_order(order_id, user_id):
    await asyncio.sleep(0.12)  # 模拟网络等待
    if user_id != "U9":
        raise PermissionError("FORBIDDEN")
    if order_id != "A123":
        raise LookupError("ORDER_NOT_FOUND")
    return {
        "order_id": "A123",
        "category": "earphone",
        "signed_at": "2026-09-23",
        "unopened": True,
    }


async def get_points(user_id, slow=False):
    await asyncio.sleep(0.45 if slow else 0.16)
    if user_id != "U9":
        raise PermissionError("FORBIDDEN")
    return {"balance": 240}


async def get_policy(category):
    await asyncio.sleep(0.08)
    if category != "earphone":
        raise LookupError("UNKNOWN_CATEGORY")
    return {"version": "demo-2026-09", "days": 7,
            "starts_from": "签收次日", "requires_unopened": True}


async def guarded(operation, seconds):
    try:
        data = await asyncio.wait_for(operation, timeout=seconds)
        return {"ok": True, "data": data}
    except asyncio.TimeoutError:
        return {"ok": False, "error": "TIMEOUT"}
    except PermissionError:
        return {"ok": False, "error": "FORBIDDEN"}
    except LookupError as error:
        return {"ok": False, "error": str(error)}
    except Exception:
        return {"ok": False, "error": "UPSTREAM_ERROR"}


async def answer(order_id, user_id, slow_points=False):
    order_task = asyncio.create_task(
        guarded(get_order(order_id, user_id), seconds=0.30)
    )
    points_task = asyncio.create_task(
        guarded(get_points(user_id, slow=slow_points), seconds=0.25)
    )
    try:
        order = await order_task
        policy = {"ok": False, "error": "SKIPPED_NO_ORDER"}
        if order["ok"]:
            category = order["data"]["category"]
            policy = await guarded(get_policy(category), seconds=0.30)
        points = await points_task
        return {"order": order, "policy": policy, "points": points}
    finally:
        for task in (order_task, points_task):
            if not task.done():
                task.cancel()
        await asyncio.gather(order_task, points_task, return_exceptions=True)


async def main():
    print("正常:", await answer("A123", "U9"))
    print("积分超时:", await answer("A123", "U9", slow_points=True))


asyncio.run(main())

从 main 的第一行跟着执行:answer("A123", "U9") 一进入,就创建订单和积分两项任务;订单大约 0.12 秒后返回 category="earphone",于是程序开始查政策。此时积分仍在进行,不会因为程序正在等政策而停住。政策大约再过 0.08 秒返回;积分约 0.16 秒返回。因此三份结果可以汇合,输出里 order.ok、policy.ok、points.ok 都是 True。答复可以据此说明:按演示规则,9 月 23 日签收的 A123 到 9 月 26 日仍在申请期内,订单显示未拆封,初步可申请;积分余额为 240。例外条款与最终审核仍要核实,程序没有提交退款。

第二行把 slow_points=True 传给积分模拟函数,令其耗时约 0.45 秒;积分分支只等 0.25 秒,于是 points 变成 {"ok": False, "error": "TIMEOUT"}。订单和政策分支照常成功。合并答复时只陈述有证据的退货信息,再说“积分服务暂时超时,余额未查到”,绝不能把积分当成 0。guarded 把每一项成功、超时、无权限和查询失败包装成同一外形,让上层逐项判断。finally 则保证外层任务被取消时,不留下无人管理的订单或积分子任务;asyncio.gather(..., return_exceptions=True) 在此处只用于等待清理,不把异常默默当成业务成功。

还要看另一种失败:若订单号是 A999,get_order 返回 ORDER_NOT_FOUND,程序把政策标成 SKIPPED_NO_ORDER,不会猜类别继续查。积分仍可独立返回 240;答复应请用户核对订单号,并可报告积分。若政策返回 UNKNOWN_CATEGORY 或服务超时,订单存在也不足以断言能退。各分支的错误要保留自己的来源,不要把“政策没查到”写成“订单不符合退货条件”。

示例里每项服务是用 asyncio.sleep 模拟的可等待操作。若真实客户端是阻塞式函数,直接放进 async def 并不会自动并发,需要使用真正的异步客户端或受控线程池。真实系统还要加总请求截止时间、连接池和最大并发数,并把 user_id 从已验证的服务端会话取出;本地超时或取消不能当作远端写操作已撤销。

接上真实模型接口时,应用先读取模型提出的每个工具名、参数和调用 ID,逐个核对工具是否在允许列表、参数是否有效,再按依赖分组。对同一批独立调用,可用与上例相同的异步任务并发执行,并给每个结果保留原调用 ID;有依赖的调用则在前一结果回来后再执行,必要时让模型进入下一轮。最后按接口要求把每个已提出的调用对应的成功值或错误交还模型,而不是只把最快的一项结果发回去。OpenAI 使用与 call_id 对应的 function_call_output;Anthropic 使用与 tool_use_id 对应的 tool_result,且其并行文档要求同一轮的结果一起返回。两种接口的消息包装不同,不能把上面的 Python 模拟结果直接当作任一接口的请求体。OpenAI:函数结果示例 · Anthropic:并行结果格式

什么时候必须串行,什么时候可并发 ​

情况处理方式原因
查订单、查积分,各自只需现成输入并发两次读取独立,等待可重叠
查政策需要订单返回的 category串行接在查订单后输入依赖明确,提前调用只能猜
先修改订单状态,再读修改后的状态串行,并验证读到的版本后一步必须观察前一步结果
两个操作都要扣同一积分余额不随意并发;由后端事务、锁或原子条件更新保护并发可能造成超扣或丢失更新
提交退款等对外写操作先鉴权、确认、校验状态,再执行;用幂等键防重复“同时发出”可能产生不可逆的重复动作
多个独立只读调用,但下游限流严格限定并发数,必要时排队过高并发会造成超时和服务拥塞

串行不等于一定慢:有依赖时串行是正确性要求。并发不等于一次发尽所有工具:即使两次读取相互独立,也要考虑下游容量和用户是否真正需要。工具调用中的“并行”更不能当成放宽权限检查的理由。对于对外写操作,先完成必要读取和用户确认,再以服务端校验执行;若写入失败或超时,用业务幂等键查询最终状态,不能盲目重试导致重复退款。

“失败汇合”也要有规则。退货结论依赖订单与政策两个必需结果,任一缺失就降低结论强度或停止判断;积分是独立的可选分支,超时不应被写成余额为零。若多个必需工具都失败,返回能让用户继续处理的错误,而非编一个完整答案。日志里记录每个工具的开始、结束、耗时、调用 ID、错误码和依赖关系,方便定位是模型选错工具、服务慢,还是编排顺序错。

面试中可以这样回答 ​

“我先把工具调用画成依赖图。输入已知、互不改状态的读取可以同时启动;后一步要用前一步输出,就必须串行。比如用户问 A123 能否退货并问积分,查订单和查积分并发,订单返回商品类别后再查政策,最后按结果汇合。应用侧用异步任务执行,每项设超时和错误码,结果按调用 ID 对应;积分超时可以给退货部分答案,但订单或政策失败就不能断言能退。写操作、共享状态、先写后读以及需要用户确认的步骤要有明确顺序、权限与幂等保护。模型一轮提出多个调用,只表示有多个请求,能否真的并发仍由应用根据依赖和副作用判断。”

若追问“并发数越大越好吗”,可以回答:不是。延迟主要受最长依赖路径限制,继续增加与任务无关的调用只会占用连接、触发限流或扩大失败面。先统计每项服务的耗时和依赖,再设置并发上限、超时与优先级;为必需分支保留时间预算,附加分支失败时给清楚的部分结果。

最后更新2026-09-26
难度P0
频率very-high
阅读20 min
主题agent / tool-calling / parallel-tools
觉得有帮助?把这个链接转给正在求职的朋友 · 用 Ctrl + K 全站搜索其它题