一次并发 20 个工具调用:跑最快的那条路丢了 8 个结果
版权与内容来源声明本文为原创整理。文中涉及官方文档、开源仓库、论文与公开报道的内容均在附表 A 中标注来源引用官方原文保持原样不作改写。文中命令、版本号与界面截图以本文成文时的实测/核验结果为准标注「待验证」的部分请以你本地环境实际输出为判断依据。本文不推荐任何不合规的软件获取方式也不对任何收益结果作承诺。转载请注明出处。摘要2026 年 10 月主流平台的函数调用文档都写明了「模型可能在一轮里调用多个工具」也提供了parallel_tool_calls这类开关。本文不讨论该不该开只算一笔并发账同样是 20 个工具调用一把全发与贴着上限分批发谁的耗时更短、谁的返回结果是残缺的。一、先把结论摆出来并发全开看着最快交回来的却可能是残缺结果把并发开到最大往往不是最快的做法——它只是最先返回代价是把一部分调用直接打成了失败等着重试去补。我按「平台允许 4 路并发」建模跑了 20 个互不依赖的工具调用发起方式耗时限流次数丢失调用拿到结果一把全发重试 3 次1.26s38812 / 20贴着上限分批宽度 42.09s0020 / 20逐个串行6.32s0020 / 20单看耗时一把全发最快比守上限快近一倍。但同一行里还藏着两个数38 次限流、8 个调用彻底丢失。「并发越多越快」这句话的漏洞在于它把「快」定义成了「先返回」而不是「把活干完」。1.26 秒处理完 12 件事并不比 2.09 秒处理完 20 件事更划算——如果那 8 件恰好是你真正需要的结果快就是个假象。本文给三条能核对的结论并发宽度应当贴着平台上限设并发返回的顺序不等于发起顺序结果必须按标记回填以及在什么规模下老老实实串行其实够用。二、测试条件先钉死一个 4 路并发的网关两种发起方式先说清测的是什么因为这决定了结论能用在哪儿。被测对象是一个按「并发上限 4」建模的模拟网关在途请求达到上限时它直接返回限流错误不替你排队。这一点和真实平台一致——官方文档把超限的返回写得很明确429 | rate_limit_error | slow_down | Your request rate increased too quickly. | FollowRetry-Afterwhen it’s present, reduce your request rate, and then increase it gradually.任务是一次发起 20 个互不依赖的工具调用单次处理耗时在 0.10~0.50 秒之间浮动。真实的工具本来就耗时不齐把它设成固定值反而看不出问题。发起方式说明是否尊重并发上限逐个串行一个调用完成再发下一个是峰值并发 1一把全发20 个同时发起被限流就等一会儿再试重试上限 3 次否贴着上限分批一批只发 4 个整批回来再发下一批是口径「耗时」是墙上时间wall time「限流次数」是网关返回限流错误的次数「丢失」指重试用尽仍未成功的调用数。本文的网关是按并发上限建模的模拟对象不是接入真实平台它用来比较三种发起方式的相对关系绝对耗时会随真实延迟变化。2.1 为什么用「拒绝」而不是「排队」建模如果网关会自动排队那把并发开到多大都无所谓——反正它会替你限速。真实平台不是这样超限就直接回错误排队这件事得你自己做。分批发起本质上就是你在客户端自己实现一个队列而一把全发等于把这个队列取消了把压力全甩给平台平台能做的只有拒绝。这才是三种策略会产生差异的根源。2.2 一个只影响结论、不影响事实的前提本文用的是模拟网关不是真实接口。这样选的目的是把「并发控制」这一个变量单独拎出来看——真实环境里网络抖动、工具自身超时、平台限额变化会混在一起很难判断到底是哪个环节出的问题。模拟测的是机制不是性能指标数值本身没有意义三种方式之间的倍数关系才是结论。三、8 个调用看不出差别20 个调用时才露馅 实测环境Python 3.13.12 / macOS / 仅标准库asyncio、random、timeimportasyncioimportrandomimporttime LIMIT4# 平台允许的并发上限rngrandom.Random(11)classGateway:按并发上限建模的网关在途请求达到上限时直接返回限流错误。def__init__(self,limit):self.limitlimit self.inflight0self.peak0self.rejected0self.done[]# 完成顺序asyncdefcall(self,i,lat):ifself.inflightself.limit:self.rejected1return(i,429)self.inflight1self.peakmax(self.peak,self.inflight)awaitasyncio.sleep(lat)self.inflight-1self.done.append(i)return(i,200)deflatencies(n):return[round(rng.uniform(0.10,0.50),2)for_inrange(n)]asyncdefserial(gw,lats):逐个串行。return[awaitgw.call(i,l)fori,linenumerate(lats)]asyncdefnaive(gw,lats,retries3):一把全发所有调用同时发起被限流就等一会儿重试。asyncdefone(i,l):for_inrange(retries):rawaitgw.call(i,l)ifr[1]200:return(i,200)awaitasyncio.sleep(l)return(i,None)# 重试用尽这次调用丢了returnawaitasyncio.gather(*(one(i,l)fori,linenumerate(lats)))asyncdefsized(gw,lats,width):贴着上限分批发一批全部回来再发下一批。idxlist(enumerate(lats))out[]forsinrange(0,len(idx),width):outawaitasyncio.gather(*(gw.call(i,l)fori,linidx[s:swidth]))returnout运行结果原样贴出【本轮 8 个工具调用】 策略 N 耗时 峰值 被拒 丢失 -------------------------------------------------------------- 串行 8 2.47s 1 0 0 一把全发(重试3) 8 0.84s 4 5 0 守上限 宽度4 8 0.80s 4 0 0 守上限 宽度2 8 1.29s 2 0 0 【本轮 20 个工具调用】 策略 N 耗时 峰值 被拒 丢失 -------------------------------------------------------------- 串行 20 6.32s 1 0 0 一把全发(重试3) 20 1.26s 4 38 8 守上限 宽度4 20 2.09s 4 0 0 守上限 宽度2 20 3.35s 2 0 0三行读法8 个调用时一把全发和守上限几乎一样快0.84 秒 对 0.80 秒差别只是多了 5 次限流。规模小的时候这个坑不容易被发现——这也正是它常被留到线上才炸的原因。20 个调用时一把全发仍然最快但丢了 8 个结果。它用 1.26 秒交了 12 份活另一条路用 2.09 秒交了 20 份。串行并不是最差的兜底宽度 2 用 3.35 秒串行要 6.32 秒。真正拖慢整体的是串行不是「并发低一点」。这里有个反直觉的地方值得点出丢调用的那一行恰恰是耗时最短的那一行。如果监控只看「平均耗时」你看到的是一条漂亮的曲线只有把「丢失数」也画上去才知道代价付在了哪里。本节的资料把函数调用、并发与重试的官方文档整理成了一份核对清单另外也放了 LangChain LangGraph 的实战视频和一份大模型学习路线图。扫码即可获取四、第二层坑更隐蔽并行返回的顺序不是发起顺序并发发起时结果按「谁先完成」回来而不是按你发起的顺序。 实测环境同上只把单次调用耗时的随机分布固定下来 返回顺序并行时按「谁先完成」回来不是按发起顺序 各调用耗时 [0.4, 0.48, 0.18, 0.48, 0.45, 0.34, 0.27, 0.14] 完成顺序 [2, 0, 1, 3, 7, 6, 5, 4] 发起顺序 [0, 1, 2, 3, 4, 5, 6, 7] 是否一致 False8 个调用的耗时各不相同0.14~0.48 秒完成顺序是[2, 0, 1, 3, 7, 6, 5, 4]和发起顺序完全对不上。这为什么危险因为调用方很容易按位置取结果。比如你同时发了「查天气」和「查汇率」第一个回来的其实是查汇率如果你按下标 0 取就会把汇率当成天气填进提示词。这类错误不会抛异常只会让模型拿到错的输入再给出一段看起来正常的答案。排查的时候模型那边是干净的错在你这里。正确的做法只有一条每个调用带一个唯一标记——一般用「工具名 参数哈希」或一个自增序号——结果回来后按标记回填位置只用来展示不用来取数。这条规则在并发数只有 2 的时候看不出价值一旦并发上到两位数它就是唯一能保证结果不错配的办法。还有一条官方提醒直接决定了「一把全发 立即重试」为什么是负和unsuccessful requests contribute to your per-minute limit, so continuously resending a request won’t work.翻成人话失败的请求照样占配额。所以「全发 → 被限流 → 立刻重试」这条路是在用自己的配额反复抽自己。官方给的替代做法是遵循Retry-After没有这个字段时退避并加一点随机抖动Treat this value as a minimum: wait at least that long and add a small random delay so multiple clients don’t retry at the same time.本篇涉及的官方文档与示例把并发、限流、重试这几份材料与代码示例整理进了资料包配合视频课看更顺。扫码即可获取五、并发宽度怎么定贴着上限设并且给每个结果打标记把上面两段实测收敛成一张能直接对号入座的表你的情况做法理由调用 3~5 个且都很快逐个串行就够并发收益会被协调成本吃掉实测 8 个以内三种方式差异很小调用数明显超过并发上限贴着上限分批宽度 平台上限实测宽度 4 用 2.09 秒拿全结果宽度 2 要 3.35 秒一次要发几十个调用先分批再对失败项单独退避重试一把全发实测丢失 8 个调用靠加大重试次数补不回来结果要拼进同一段上下文必须按唯一标记回填完成顺序与发起顺序不一致按下标取会错配什么时候该停手先看「丢失」这一列。只要它不是 0就说明你的并发宽度已经超过平台上限此时再多试几次也不会变好——应当降低宽度而不是加大重试次数。判断标准很具体当宽度调到平台上限、丢失数归零之后继续往上加只会增加限流次数而不会减少耗时就停在这个宽度。最后一条容易被忽略的经验并发宽度别写死让它跟着上限走。平台上限会变——不同模型、不同账户等级都不一样——把宽度硬编码成 8 或 16换一个环境就会重新踩一遍坑。把上限做成配置项宽度从它推导出来才是能跨环境复用的做法。附表 A本文引用事实与出处对照表事实出处本文位置「The model may choose to call multiple functions in a single turn.」《Function calling》OpenAIhttps://developers.openai.com/api/docs/guides/function-calling摘要 / 第 1 章parallel_tool_calls设为false可确保「exactly zero or one tool is called」同上第 2 章「Keep the number of initially available functions small for higher accuracy…Aim for fewer than 20 functions available at the start of a turn」同上第 5 章超限返回429 / rate_limit_error / slow_down需遵循Retry-After并逐步降低请求速率《Rate limits》OpenAIhttps://developers.openai.com/api/docs/guides/rate-limits第 2 章「unsuccessful requests contribute to your per-minute limit, so continuously resending a request won’t work.」同上第 4 章「Treat this value as a minimum: wait at least that long and add a small random delay」同上第 4 章20 个调用下三种发起方式的耗时 / 限流次数 / 丢失数完成顺序与发起顺序不一致本文实测脚本见第 3 章第 3、4 章表下口径本文实测的耗时是在按并发上限 4 建模的模拟网关上得到的不是接入真实平台的账单它用来比较三种发起方式的相对关系绝对耗时取决于真实调用延迟与账户限额。写在最后这篇用到的资料写这篇文章时把并发控制、限流与重试的官方材料又翻了一遍顺手也整理了几份配套的东西大模型学习路线图从零基础到能自己动手做 Agent按阶段说明每一步该学什么、哪些可以先跳过《LangChain LangGraph MCP 智能体开发实战》视频课7 个模块从私有化部署、EmbeddingRAG 到 MCPAgent 全流程AI 大模型知识库在线可查Agent Skills 从入门到落地、Claude Skills 完全指南等专题按目录浏览即可640 套 AI 大模型行业报告 经典 PDF 书籍看行业落地案例和别人怎么做的时候用得上大模型零基础到精通教学视频跟着敲一遍比只读文档快得多资料是我自己整理的放在下面这个码上扫码即可获取添加时备注「AI」优先通过。资料按「先路线、再动手、最后查漏」的顺序整理好了建议先看学习路线那一份照着它挑一条适合自己当前基础的路径再往下看。