摘要
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 |
三点值得记住:
- 实测加速比 2.62--2.63 倍,理论上限就是 2.67 倍 (求和 240 / 最大 90)。5 个工具并行并没有变成 5 倍------因为最慢那个 90ms 还在那儿。想把一步压到接近
max(),只能去削最长的工具,不是把并发从 4 调到 8。 - 每一种姿势的实测都比理论高 4--7%,多出来的几毫秒是 aiohttp 连接器、协程调度与 loopback 建连的固定成本。工具越多、单次越短,这块固定成本占比越高。
- 依赖一旦存在,并行收益立刻封顶: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 是因为连接被留在池里,写失败没有在这一轮暴露。
八、避坑指南
- 别拿并发数当性能旋钮。k 从 4 调到 32,只有当后端真有 32 的容量时才有效;后端排队 p95 不掉,说明调错了地方。
- 一步的耗时下限是最慢工具。想降 P95 就削那个长尾工具(缓存、预热、拆步),而不是把其它工具再并行一层。
- 限流并行一定要按耗时长到短提交。本轮这一条值 29ms(18.6%),零成本。
gather是"全都要" 。允许部分结果就用return_exceptions=True,并且明确写清楚降级语义------缺了这个工具的结果,你的回答还能不能说。- 超时时长要有预算层级:单工具 < 单步 < 整轮,逐层递减。只设单工具超时会出现"5 个各自 200ms 的调用把整步拖到 1s"。
- 重试要带抖动和上限,且只对幂等、瞬时失败的调用重试。本轮里对持续性故障重试 2 次,白跑次数从 0 涨到 15--17。
- 工具后端要单独压测,用真实 worker 数,不要在你自己的笔记本 loopback 上外推生产容量。
- 客户端自己也是瓶颈。CPU 型后端加 worker 无效、64 worker 时 90ms 说不清去哪了------这两件事都是"进程内开销"在提醒你:测量结果要留解释余量。
- 模型时间没算进来。本文所有数字都是编排层,模型推理的耗时与它是乘法关系,不是加法。
九、结论:按这张表选
| 场景 | 建议姿势 | 一句话理由 |
|---|---|---|
| 工具数 ≤ 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 输出,未做任何取整美化。