Agent 工具调用怎么编排?本机实测:并行只到 2.63 倍,慢的先发省 30ms,CPU 活加 worker 无效

摘要

Agent 慢,多数时候不是模型慢,是工具调用的编排姿势慢。本文在本机(Windows + Python 3.11.5 + aiohttp 3.14.3)搭了一个可控延迟的工具后端,实测四种编排:5 个工具(标称 20/30/40/60/90ms)串行中位 245.9--246.4ms,全并行 93.6--94.1ms,加速比只有 2.62--2.63 倍(理论上限 2.67);限流 k=2 时,把慢的先发能从 155.4--156.4ms 压到 126.3--129.7ms;一组并行的耗时等于最慢那一个(4 个 25ms 工具 + 1 个 250ms 工具 = 257.1--261.3ms);后端只有 2 个 worker 时,客户端 120 路并发的排队 p95 高达 574.7--576.8ms 而实际执行 p95 只有 10.7--10.8ms;CPU 型后端把 worker 从 8 加到 64,吞吐 308--324 rps 与 310--321 rps 基本重合。结论:先量后端容量,再谈并行。


一、先把实验边界说清楚

所有数字来自本机一个脚本 agent_tool_fanout.py(文末给关键片段,完整代码在 demo 目录),跑了 3 轮,每轮 23.3--23.5 秒。结构是:

  • 服务端 :http.server + ThreadingMixIn,用信号量模拟 N 个后端 worker。请求在信号量上等待的时长会被记下来随响应回传,所以"客户端看到的时间"能拆成后端排队 与实际执行两段。
  • 客户端 :aiohttp,真实发 HTTP 请求,用 TCPConnector(limit=k) 控制在途并发,用 ClientTimeout(total=...) 控制单调用超时。
  • 工具延迟 :由服务端 time.sleep() 注入(20/30/40/60/90/250/900ms 这些是我设定的,不是某个真实 SaaS 的实测延迟)。

所以绝对值不能外推到生产(本机 loopback、单进程 Python、没有 TLS、没有真实网络抖动),能外推的是它们之间的相对关系:并行≈取最大值、限流并行≈列表调度、排队时长往往远大于执行时长、CPU 型后端加线程数无效。

二、四种编排姿势:并行不是 N 倍

计划是 5 个互相独立的工具调用,标称延迟 20/30/40/60/90ms。理论值很好算:串行是求和 240ms,无限并行是取最大 90ms,限流 k 个则是个"装箱"问题。

编排姿势 理论 实测中位(3 轮区间) 7 轮内 min--max
串行逐个 await 240ms 245.9--246.4ms 245.0--247.2
全并行(不限在途) 90ms 93.6--94.1ms 93.3--94.9
限流并行 k=2,小到大提交 150ms 155.4--156.4ms 153.6--160.3
限流并行 k=2,按大到小提交 120ms 126.3--129.7ms 125.4--131.6
限流并行 k=4 110ms 115.2--117.8ms 115.1--122.6
依赖分层 DAG(4 并行 + 150 依赖) 210ms 217.1--221.6ms 215.3--222.5

三点值得记住:

  1. 实测加速比 2.62--2.63 倍,理论上限就是 2.67 倍 (求和 240 / 最大 90)。5 个工具并行并没有变成 5 倍------因为最慢那个 90ms 还在那儿。想把一步压到接近 max(),只能去削最长的工具,不是把并发从 4 调到 8。
  2. 每一种姿势的实测都比理论高 4--7%,多出来的几毫秒是 aiohttp 连接器、协程调度与 loopback 建连的固定成本。工具越多、单次越短,这块固定成本占比越高。
  3. 依赖一旦存在,并行收益立刻封顶:DAG 那一行是前 4 个并行(max=60ms)+ 第 5 个依赖前 4 个结果(150ms),217.1--221.6ms,跟 210ms 理论几乎贴合。编排前先画依赖图:

#mermaid-svg-9npinnL8vhsfiWTj{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-9npinnL8vhsfiWTj .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-9npinnL8vhsfiWTj .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-9npinnL8vhsfiWTj .error-icon{fill:#552222;}#mermaid-svg-9npinnL8vhsfiWTj .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-9npinnL8vhsfiWTj .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-9npinnL8vhsfiWTj .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-9npinnL8vhsfiWTj .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-9npinnL8vhsfiWTj .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-9npinnL8vhsfiWTj .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-9npinnL8vhsfiWTj .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-9npinnL8vhsfiWTj .marker{fill:#333333;stroke:#333333;}#mermaid-svg-9npinnL8vhsfiWTj .marker.cross{stroke:#333333;}#mermaid-svg-9npinnL8vhsfiWTj svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-9npinnL8vhsfiWTj p{margin:0;}#mermaid-svg-9npinnL8vhsfiWTj .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-9npinnL8vhsfiWTj .cluster-label text{fill:#333;}#mermaid-svg-9npinnL8vhsfiWTj .cluster-label span{color:#333;}#mermaid-svg-9npinnL8vhsfiWTj .cluster-label span p{background-color:transparent;}#mermaid-svg-9npinnL8vhsfiWTj .label text,#mermaid-svg-9npinnL8vhsfiWTj span{fill:#333;color:#333;}#mermaid-svg-9npinnL8vhsfiWTj .node rect,#mermaid-svg-9npinnL8vhsfiWTj .node circle,#mermaid-svg-9npinnL8vhsfiWTj .node ellipse,#mermaid-svg-9npinnL8vhsfiWTj .node polygon,#mermaid-svg-9npinnL8vhsfiWTj .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-9npinnL8vhsfiWTj .rough-node .label text,#mermaid-svg-9npinnL8vhsfiWTj .node .label text,#mermaid-svg-9npinnL8vhsfiWTj .image-shape .label,#mermaid-svg-9npinnL8vhsfiWTj .icon-shape .label{text-anchor:middle;}#mermaid-svg-9npinnL8vhsfiWTj .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-9npinnL8vhsfiWTj .rough-node .label,#mermaid-svg-9npinnL8vhsfiWTj .node .label,#mermaid-svg-9npinnL8vhsfiWTj .image-shape .label,#mermaid-svg-9npinnL8vhsfiWTj .icon-shape .label{text-align:center;}#mermaid-svg-9npinnL8vhsfiWTj .node.clickable{cursor:pointer;}#mermaid-svg-9npinnL8vhsfiWTj .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-9npinnL8vhsfiWTj .arrowheadPath{fill:#333333;}#mermaid-svg-9npinnL8vhsfiWTj .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-9npinnL8vhsfiWTj .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-9npinnL8vhsfiWTj .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-9npinnL8vhsfiWTj .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-9npinnL8vhsfiWTj .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-9npinnL8vhsfiWTj .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-9npinnL8vhsfiWTj .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-9npinnL8vhsfiWTj .cluster text{fill:#333;}#mermaid-svg-9npinnL8vhsfiWTj .cluster span{color:#333;}#mermaid-svg-9npinnL8vhsfiWTj div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-9npinnL8vhsfiWTj .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-9npinnL8vhsfiWTj rect.text{fill:none;stroke-width:0;}#mermaid-svg-9npinnL8vhsfiWTj .icon-shape,#mermaid-svg-9npinnL8vhsfiWTj .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-9npinnL8vhsfiWTj .icon-shape p,#mermaid-svg-9npinnL8vhsfiWTj .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-9npinnL8vhsfiWTj .icon-shape .label rect,#mermaid-svg-9npinnL8vhsfiWTj .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-9npinnL8vhsfiWTj .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-9npinnL8vhsfiWTj .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-9npinnL8vhsfiWTj :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 步骤1:可并行
工具A 20ms
汇合
工具B 30ms
工具C 40ms
工具D 60ms
工具E 150ms

依赖A+B结果
汇总给模型

三、限流并行里,提交顺序值 30ms

同样是 k=2,把 5 个调用按 20→30→40→60→90 的顺序提交,实测 155.4--156.4ms;按 90→60→40→30→20 提交,实测 126.3--129.7ms。慢的先发省了约 29ms(18.6%),业务语义完全没变。

原因是限流并行等价于"列表调度":worker 空出来后拿下一个排队的请求。小到大提交时,90ms 那个最后才开始;大到小提交时,它一上来就占住一条道。理论值 150ms 与 120ms 正好对上实测。

工程做法:拿到一个批次要并行的工具调用时,按预估耗时长到短排序后再提交 (预估可以从历史 P50 拿)。这条对 asyncio.Semaphore(k) 和连接池 limit=k 都成立。

四、拖尾:一组并行的耗时等于最慢那一个

4 个 25ms 的快工具 + 1 个 250ms 的慢工具:

姿势 实测中位(3 轮区间)
串行 358.8--361.3ms
并行,等所有人回来 257.1--261.3ms
并行,慢工具设 100ms 超时 102.0--106.7ms

第二行是关键:其它 4 个工具早就在 25ms 时回来了,整组仍然要等到 250ms 。asyncio.gather 的语义是"全都完成",不是"够用了就走"。如果你的 Agent 一步里有 6 个工具、其中 1 个是慢查询,那这一步的下限就是那个慢查询。

第三行确实把整步压到 102.0--106.7ms,代价是慢工具的结果没了 ------7 次重复里每次都稳定超时 1 个(输出里的 每轮超时=[1,1,1,1,1,1,1])。超时是把延迟预算换成结果完整性的交易,不是免费的。真要两头都要,只有两条路:把慢工具拆成独立步骤(先返回能答的,慢的补一轮),或者给它做旁路缓存/预热。

五、后端容量:并发调大为什么不省时间

120 个请求,每个在服务端睡 10ms,客户端一次性全放行。只动后端 worker 数:

后端 worker 总墙钟(3 轮) 理论下限 吞吐 单请求 p95 后端排队 p95 后端执行 p95
2 726.4--746.8ms 600ms 160.7--167.7 rps 627.8--636.7ms 574.7--576.8ms 10.7--10.8ms
8 234.4--244.3ms 150ms 491.2--511.9 rps 181.0--187.3ms 122.1--128.9ms 11.0--11.4ms
32 125.4--152.8ms 38ms 785.4--956.6 rps 104.7--141.1ms 8.3--15.1ms 11.1--12.1ms
64 116.4--121.2ms 19ms 989.8--1031.2 rps 104.7--109.2ms 0.0ms 11.0--11.6ms

读这三列的方式:

  • 排队 p95 从 576.8ms 掉到 0.0ms,执行 p95 一直是 11ms 左右 。也就是说 worker=2 时用户等的那 600ms 里,99% 是在你的后端队列里干等,真正干活只有 10ms。这解释了为什么"下游没变慢但 P99 炸了"------瓶颈在容量不在耗时。
  • 吞吐 roughly 随 worker 线性上升但很快撞墙:32→64 只从 785--956 升到 990--1031 rps,总墙钟从 125--153ms 降到 116--121ms,收益不到 10%。此时瓶颈已经不在后端。
  • worker=64 时总墙钟 116.4--121.2ms,远高于 19ms 的理论下限,而后端排队是 0、执行只有 11ms 。这 90 多毫秒不在这三个测点里,本轮实验没有继续拆(怀疑是 120 路协程 + 120 个服务端线程的进程内开销与 loopback 建连,但这只是推断,未做定位)。这正是要提醒你的一点:单进程 Python 客户端自己也会成为瓶颈,压测数据要留余量解读。

用 Little 法则先估一下:想要 500 rps、单请求后端 10ms,需要的在途并发就是 500×0.01=5 条,加上抖动取 8--16;但同一时刻同时进来的请求数由上游决定 ,不由你决定。所以客户端并发上限应该等于后端的真实容量,而不是"越大越好"。

六、CPU 型后端:加 worker 完全没用

把服务端的 sleep 换成 2ms 忙等(纯 CPU 活),客户端仍然 60 路并发全放行:

后端 worker 总墙钟 吞吐 后端排队 p95
8 185.1--194.8ms 308.0--324.2 rps 0.0ms
64 186.6--193.6ms 310.0--321.6 rps 0.0ms

worker 加 8 倍,吞吐纹丝不动(差值落在两轮噪声里)。这是 GIL 下 Python 多线程跑 CPU 活的必然结果------它代表的是"单进程 Python 后端"的容量,不是所有后端的容量 。同一份实验放到 JVM/Go 后端上,CPU 活会随核数近似线性扩,直到打满核。这里能带走的结论是那句更普适的:并行度只对 IO 等待有效,对 CPU 计算无效;要救 CPU 活只能加进程、加机器或把活挪走(缓存、批处理、异步任务)。

七、超时与重试:省了 780ms,也白跑了 17 次

一次 fan-out 60 个工具调用,其中 5 个(8.3%)的下游卡住 900ms:

策略 整步墙钟中位(3 轮区间) 最终失败 服务端回写失败次数
不超时,硬等到底 944.1--955.7ms 0/60 0
每调用 150ms 超时,不重试 161.4--178.0ms 5/60 0
每调用 150ms 超时,重试 1 次 320.1--344.0ms 5/60 10--13
每调用 150ms 超时,重试 2 次 473.7--483.0ms 5/60 15--17
  • 超时把整步从 950ms 砍到 161.4--178.0ms,5.3--5.9 倍,代价是那 5 个调用直接失败。
  • 重试对"持续性故障"完全无用:最终失败数一直是 5/60,一次都没救回来,但整步耗时按重试次数几乎线性上涨(161→320→473ms)。这是重试最经典的错觉------把偶发抖动当成通用解药。
  • 最后一列是踩坑点:设了超时以后,客户端早走了,服务端还在跑那 900ms,算完往 socket 回写才发现连接没了 (ConnectionAbortedError: WinError 10053)。重试次数越多,这种"白跑"越多(15--17 次/轮)。这说明客户端超时并不会取消上游的工作 :成本你照付,只是没人收货。要做真正的取消,得把取消信号传到服务端(HTTP/2 RST、gRPC deadline 透传,或业务层带 request-id 的主动作废)。这一列只统计"服务端察觉客户端已断开"的次数,不能当成白跑总量的精确值------不超时不重试那一档为 0 是因为连接被留在池里,写失败没有在这一轮暴露。

八、避坑指南

  1. 别拿并发数当性能旋钮。k 从 4 调到 32,只有当后端真有 32 的容量时才有效;后端排队 p95 不掉,说明调错了地方。
  2. 一步的耗时下限是最慢工具。想降 P95 就削那个长尾工具(缓存、预热、拆步),而不是把其它工具再并行一层。
  3. 限流并行一定要按耗时长到短提交。本轮这一条值 29ms(18.6%),零成本。
  4. gather 是"全都要" 。允许部分结果就用 return_exceptions=True,并且明确写清楚降级语义------缺了这个工具的结果,你的回答还能不能说。
  5. 超时时长要有预算层级:单工具 < 单步 < 整轮,逐层递减。只设单工具超时会出现"5 个各自 200ms 的调用把整步拖到 1s"。
  6. 重试要带抖动和上限,且只对幂等、瞬时失败的调用重试。本轮里对持续性故障重试 2 次,白跑次数从 0 涨到 15--17。
  7. 工具后端要单独压测,用真实 worker 数,不要在你自己的笔记本 loopback 上外推生产容量。
  8. 客户端自己也是瓶颈。CPU 型后端加 worker 无效、64 worker 时 90ms 说不清去哪了------这两件事都是"进程内开销"在提醒你:测量结果要留解释余量。
  9. 模型时间没算进来。本文所有数字都是编排层,模型推理的耗时与它是乘法关系,不是加法。

九、结论:按这张表选

场景 建议姿势 一句话理由
工具数 ≤ 3 且耗时接近 全并行 加速比接近理论上限,固定成本占比小
工具数多 / 下游有配额 限流并行 k=后端容量,长任务先提交 排队发生在你的队列里,不是别人的 429 里
有真实依赖(E 要用 A+B 结果) 依赖分层 DAG,逐层 gather 层耗时 = 层内 max + 层间串接
组里有已知的慢工具 拆成独立步骤,或旁路预热 并行不改变 max,只改变等待的分布
下游会持续挂 单调用超时 + 步骤总预算,不盲目重试 本轮重试 1/2 次救回 0 个调用,多花 160--310ms

一句话总结:Agent 的响应时间由"最慢的那条依赖链"决定;并行只把求和变成取最大,容量才决定这条链能不能真的取到最大。 先量后端容量,再决定 k;先削长尾工具,再谈并发。


附:最小可运行片段(本机 Python 3.11.5 + aiohttp 3.14.3 实测通过)

python 复制代码
import asyncio, aiohttp, time

# 关键 1:限流并行 = 连接器 limit + 提交顺序(长任务先提交)
async def run_step(base: str, plan: list[int], k: int) -> float:
    order = sorted(plan, reverse=True)          # 实测比小到大省 18.6%
    conn = aiohttp.TCPConnector(limit=k)        # k 应等于后端真实容量
    async with aiohttp.ClientSession(connector=conn) as s:
        t0 = time.perf_counter()
        await asyncio.gather(*[s.get(f"{base}?ms={m}") for m in order])
        return (time.perf_counter() - t0) * 1000

# 关键 2:给慢工具单独设超时,允许部分结果
async def run_step_with_budget(base: str, fast: list[int], slow: int,
                               budget_s: float) -> dict:
    async with aiohttp.ClientSession() as s:
        res = await asyncio.gather(
            *[s.get(f"{base}?ms={m}") for m in fast],
            s.get(f"{base}?ms={slow}",
                  timeout=aiohttp.ClientTimeout(total=budget_s)),
            return_exceptions=True)             # 没有它,一个超时整组炸掉
    return {"ok": sum(1 for r in res if not isinstance(r, Exception))}

# 关键 3:区分"后端排队"与"实际执行",否则容量优化全靠猜
#   服务端在信号量上等待的时长随响应回传,客户端就能把 p95 拆成两段

完整实验代码(含服务端 worker 信号量、忙等、失败注入与全部四组实验)见 demos/agent_tool_fanout.py,三轮原始输出见 demos/agent_tool_fanout.transcript.txt。文中全部数字均为本机 stdout 输出,未做任何取整美化。

相关推荐
云智慧AIOps社区1 小时前
MSP 的增长瓶颈不在客户,在交付模式:人效与规模如何兼得?
运维·人工智能·自动化运维
Yolo566Q1 小时前
AI-XGBoost 植被与土地利用识别、土壤碳氮空间预测、生物多样性驱动机制、土壤微生物功能预测、生态退化与风险识、论文写作
人工智能
茉莉玫瑰花茶1 小时前
GO [ 标准库 ]
开发语言·后端·golang
用户8356290780511 小时前
使用 Python 在 Excel 中查找、替换数据并高亮显示
后端·python
GPU实战笔记1 小时前
PyCharm 接云 GPU:远端项目连接与远程解释器别混为一谈
python·pycharm·ssh·远程开发·云gpu
自养Agent1 小时前
MCP Server Boundary Audit Report
linux·服务器·人工智能·windows·大模型·llm
范中勤1 小时前
Python 协程(Coroutine)通俗讲解与技术原理:从 await 挂起到 Agent 高并发
python·agent·协程·并发编程·asyncio
小李同学_LHY1 小时前
第一章:从人工智能到大语言模型:概念梳理与关系辨析
人工智能·语言模型·自然语言处理
虎鲸不是鱼1 小时前
【虎鲸进化论 09】活下去:自启、守护、探活
人工智能·自动化·大模型·llm·自进化agent