《AI Agent 工程实战》14.3 吞吐与并发压测:找容量上限与拐点

用纯标准库 asyncio 写一个极简压测器(固定并发、固定请求数,统计 QPS/P50/P95/错误率),对第 8 章的并发受限服务跑 4 档并发。真跑显示 QPS 在并发 8 附近见顶(110→176 后不再涨),延迟却从 45ms 涨到 224ms,拐点清晰;再加请求超时后 QPS 会虚高而错误率飙到 87%,并给出容量规划余量。

14.3 吞吐与并发压测:找容量上限与拐点

14.1 量单次 run 有多慢,14.2 量单次 run 有多贵,但这两个指标都是单个请求的视角。真实服务要回答的是另一个问题:同一时刻来 100 个用户,服务还扛得住吗? 单请求 200ms 的服务,在 100 并发下可能变成每个请求 5 秒、错误率过半——因为服务内部有并发上限(连接池、信号量、模型供应商的速率限制),超过它的请求只能排队。排队不会凭空增加容量,只会把延迟堆起来。 本节要做的,就是找到「排队开始压垮服务」的那个点。

本节把 ResearchCopilot 推进到「知道自己的容量上限」:用纯标准库 asyncio 写一个极简压测器,对第 8 章的并发受限服务跑 4 档并发,真跑打出每档的 QPS / P50 / P95 / 错误率,明确指认拐点,并给出容量规划的余量结论。

14.3.1 单请求快 ≠ 能扛并发

一个常见的误判是:单请求 40ms,那 QPS 不就是 25 吗,并发 10 个不就是 250?错。服务的吞吐由内部并发上限决定,而不是由单请求耗时除以并发数决定。第 8 章给 ResearchCopilot 装了信号量限并发——最多同时处理 N 个请求,其余的在门外排队。这个 N 就是容量上限:

  • 并发请求数 ≤ N:请求立刻被处理,QPS = N / 单请求耗时,延迟稳定。
  • 并发请求数 > N:只有 N 个在跑,其余排队,QPS 封顶在 N / 单请求耗时,延迟随并发线性上升。

所以「服务能扛多少」不是一个固定数字,而是一条曲线:并发低时 QPS 随并发上升,到某个点后封顶,再往上加并发只增加延迟、不增加吞吐。这条曲线的转折点就是拐点(knee),也就是服务真正的容量上限。

14.3.2 拐点:QPS 涨不动、延迟开始飙

把上面两段合成一张图(这里用表代替),形状是这样的:

并发区间QPS延迟状态
未达上限随并发线性上升稳定有富余,加并发有效
接近上限上升变缓开始抬升临界,余量告急
超过上限封顶不再涨线性飙升过载,加并发只堆延迟

拐点的判据是**「QPS 不再涨」**:当并发从 c 加到 2c,QPS 却几乎不变,说明已经撞到天花板,多出来的并发全变成了排队。此时再提升延迟容忍度、加机器,都救不了——瓶颈是内部并发上限,不是机器。

拐点之所以重要,是因为它是容量规划的分水岭:拐点以下,服务健康;拐点以上,延迟和错误率一起失控。生产系统必须把正常负载压在拐点以下,并留出应对突发流量的余量。

还有一点要提前说清:拐点是「当前配置」的拐点,不是「这台机器」的拐点。把第 8 章的信号量上限从 8 调到 16,拐点就跟着右移——它测的是「服务在当前并发配置下能扛多少」,而不是某个物理极限。所以拐点值必须和配置绑定记录,配置一变就要重测。

14.3.3 排队论直觉:利用率趋近 100% 时延迟趋于无穷

为什么过了拐点延迟会「飙」而不是「缓慢涨」?这背后是排队论最基本的直觉:当系统利用率 ρ 趋近 100% 时,平均等待时间趋于无穷。

  • ρ = 50%:队列基本不积压,延迟约等于服务时间。
  • ρ = 80%:队列偶发积压,P95 明显高于均值。
  • ρ = 95%:队列持续积压,轻微波动就雪崩。
  • ρ → 100%:任何一点抖动都会让队列无限增长,延迟发散。

这条规律解释了一个反直觉的现象:服务在 80% 负载时看起来还好,到 90% 就突然崩——不是慢慢变差,而是过了某个临界点后非线性恶化。所以容量规划从不把服务跑到满负荷,而是把正常峰值压在 60%~70% 利用率,给抖动和突发留缓冲。这条「不要跑满」的纪律,正是拐点测量的直接用途。

14.3.4 极简压测器:纯标准库 asyncio

压测器只需要四个能力:控制并发、发固定数量的请求、计时、统计分位数。纯 asyncio 就能写,不需要任何第三方库:

import asyncio, time

async def load_test(svc, concurrency: int, total: int):
    client_sem = asyncio.Semaphore(concurrency)   # 客户端并发闸门
    lat: list[float] = []
    errors = 0
    async def one():
        nonlocal errors
        async with client_sem:
            t0 = time.perf_counter()
            try:
                await svc.handle()
            except Exception:
                errors += 1
            lat.append(time.perf_counter() - t0)
    t0 = time.perf_counter()
    await asyncio.gather(*[one() for _ in range(total)])
    wall = time.perf_counter() - t0
    lat.sort()
    p50 = lat[len(lat) // 2] * 1000
    p95 = lat[min(len(lat) - 1, int(len(lat) * 0.95))] * 1000
    return total / wall, p50, p95, errors / total

四个设计点:

  • 客户端并发闸门(client_sem):用信号量把「同时在途」的请求数卡在 concurrency。这是压测器最核心的一行——没有它,gather 会一次性把所有请求全发出去,测出来的不是「并发 c 的表现」而是「无限并发的表现」。
  • 墙钟吞吐:total / wall 就是 QPS。用总请求数除以总耗时,而不是「单请求耗时倒数」——后者在多并发下会被排队放大。
  • 分位数而非均值:lat.sort() 后取 P50/P95。均值会掩盖慢尾,而慢尾正是过载最先暴露的地方。
  • 错误率:errors / total。过载时下游超时、连接被拒,都会让错误率跳起来——这是拐点最响的信号。

用纯标准库而不是 locust / wrk 这类专业压测器,是因为这一节要教的不是工具用法,而是「压测在测什么」。四个能力都在上面这几行里,读者能看清每个数字怎么来的;理解了原理,换专业工具只是换个壳。还有两个工程细节值得补:① 预热——真实服务有连接池、JIT、缓存冷启动,正式计时前先打一小段流量预热;② 固定请求数而非固定时长——固定请求数让每档的样本量一致,分位数才可比,固定时长则会让快的那档样本多、慢的那档样本少。

被测服务用第 8 章的并发受限形状:一个 Semaphore(8) 限制同时处理数,每个请求 40ms:

WORK = 0.040
LIMIT = 8

class Service:
    def __init__(self):
        self.sem = asyncio.Semaphore(LIMIT)
    async def handle(self):
        async with self.sem:                       # 服务端并发上限
            await asyncio.sleep(WORK + random.uniform(0, 0.008))
            return "ok"

LIMIT = 8 就是这台「机器」的容量上限。压测的目的,就是在不知道这个 8 的前提下,从外部把它测出来。

14.3.5 真跑:4 档并发找拐点

跑 5 / 10 / 20 / 40 四档并发,每档 400 个请求。真跑输出(Python 3.14.6;连跑 3 次,QPS 波动 < 2%、P95 波动 < 8%,取一次代表值):

服务端并发上限 8,单请求 ~40ms,每档 400 个请求
    并发      QPS   P50(ms)   P95(ms)      错误率
     5    110.3      45.2      48.7     0.0%
    10    175.7      51.5      78.9     0.0%
    20    176.8     111.3     129.1     0.0%
    40    175.6     223.6     233.1     0.0%

这张表把拐点指认得清清楚楚:

  • 并发 5 → 10:QPS 从 110 涨到 176(+60%),延迟只从 45ms 涨到 52ms。这一段并发是有效的,服务还有富余。
  • 并发 10 → 20:QPS 几乎不动(175.7 → 176.8),延迟却从 52ms 翻到 111ms。QPS 停止增长的那一刻,就是拐点。
  • 并发 20 → 40:QPS 还是 176,延迟继续翻到 224ms。多出来的 20 个并发,全部变成了排队,一点吞吐都没换来。

拐点落在 8 附近(介于 5 和 10 之间)——正好等于服务内部的 LIMIT = 8,被压测器从外部精确测了出来。这就是压测的价值:你不需要读服务的源码,也能从延迟曲线的转折把它算出来。

再看理论值核对:容量上限 8、单请求 40ms,理论 QPS = 8 / 0.04 = 200。实测封顶在 176,是 200 的 88%——差的那 12% 来自事件循环的调度开销与 random.uniform 的抖动。实测略低于理论值是正常的,如果你的实测远超理论值,反而说明压测器有问题(比如并发闸门没生效)。

压测数据必须先排除噪声,再下结论。把整轮扫描独立复跑三次:

三次独立复跑(QPS / P95ms)
    并发              #1              #2              #3
     5  110.7/   48.6  110.1/   48.9  111.1/   48.6
    10  175.7/   77.0  175.9/   80.8  176.3/   79.9
    20  176.9/  129.0  175.9/  131.4  175.9/  130.2
    40  176.9/  234.3  176.6/  232.2  176.7/  231.4

三次的 QPS 波动都在 1% 以内,P95 波动在 5% 以内——噪声小到足以支撑结论。所以前面那张表可以放心用单次值。反过来,如果复跑三次 QPS 差了 30%,那就不能说「拐点在 8」,只能加大样本量或固定负载时长再测。测量有噪声时,先证明噪声可控,再谈结论——这是所有性能数据的前提。

有了稳定的数据,拐点检测本身可以写成规则:QPS 增量低于某个阈值(比如 10%)的那一档,就是拐点。

拐点检测(QPS 增量 < 10% 即视为封顶)
  并发  5 → 10: QPS  110.7 →  175.7  (+ 58.7%)
  并发 10 → 20: QPS  175.7 →  176.9  (+  0.7%)  ← 拐点
  并发 20 → 40: QPS  176.9 →  176.9  (+ -0.0%)  ← 拐点

规则把「用眼睛看曲线」变成了可自动化的判断:从并发 10 往上,QPS 增量掉到 1% 以下,拐点被钉在 10 这一档之前(即容量上限 8)。生产里这套检测可以接进 13.2 的看板——当线上压测的 QPS 增量连续低于阈值,就说明该扩容了,不必等人盯图。

14.3.6 超时的陷阱:QPS 会虚高

过载时最容易踩的坑是加超时后的 QPS 假象。给每个请求加 150ms 超时,同样的四档再跑一遍:

async with asyncio.timeout(0.150):     # 超过 150ms 就放弃
    await svc.handle()

真跑输出:

服务端上限 8,单请求 ~40ms,请求超时 150ms
    并发      QPS   P50(ms)   P95(ms)      错误率
     5    110.4      44.9      48.9     0.0%
    10    176.0      52.9      77.9     0.0%
    20    176.8     111.1     130.3     0.0%
    40    262.7     151.2     152.6    86.8%

看并发 40 那一行:QPS 反而涨到了 262.7,比不加超时时还高。这是假象——超时的请求在 150ms 就被放弃、提前释放了客户端槽位,于是客户端能发出更多请求。表面吞吐上升,实际成功吞吐暴跌:有效 QPS = 262.7 × (1 − 0.868) ≈ 34.7,不到拐点吞吐(176)的五分之一。

这条教训非常硬:别只看 QPS,要同时看错误率。QPS 单独一个数字可以被超时「刷」得很漂亮,但只要错误率一起看,过载立刻现形。正确做法是同时报成功 QPS(total × (1 − 错误率) / wall)和错误率,两个指标合起来才说明问题。这也解释了为什么过载时「加超时」不是优化而是止损——它把「所有请求都慢」变成「一部分请求快速失败」,让健康的那部分能继续服务。

把「成功 QPS」也算出来,两种口径并排看:

attempt_qps, p50, p95, err = await load_test(Service(), 40, 400, 0.150)
success_qps = attempt_qps * (1 - err)     # 只有成功的请求才算有效吞吐
print(f"尝试 QPS {attempt_qps:.1f}  成功 QPS {success_qps:.1f}  错误率 {err*100:.1f}%")

真跑输出:

尝试 QPS 262.7  成功 QPS 34.7  错误率 86.8%

两个数字放一起,真相立刻清楚:尝试吞吐 262.7 看着比拐点还高,有效吞吐却只有 34.7——比拐点吞吐(176)还低 80%。只看第一个数字会得出「加并发还能提升吞吐」的错误结论,直接导致线上继续加压、雪崩更快。报吞吐必须报成功吞吐,这是压测报告最基本的诚实。

14.3.7 容量规划结论:留多少余量

拿到拐点,容量规划就是一道算术题。假设拐点吞吐是 QPS_knee(本例 176),规划步骤:

步骤做法本例结果
1. 定峰值统计业务的日峰值 QPS假设 100
2. 定余量峰值不超过拐点的 60%~70%100 / 176 = 57% ✓
3. 定扩容线峰值超拐点 70% 就加实例触发线 123
4. 定告警延迟 P95 或错误率逼近阈值P95 > 150ms 告警

结论是:单实例安全承载 ≈ 拐点 × 0.7。本例单实例能稳稳扛 ~120 QPS;业务峰值 100,所以一个实例够用但余量偏紧,建议上两个实例做冗余(同时也防单点故障)。为什么是 0.7 而不是 0.9?回到 14.3.3 的排队论:利用率到 90% 时,一点抖动就会雪崩,而真实流量的突发性远高于压测的均匀分布。

余量留多少还有一层考虑:拐点本身会随上游变化。模型供应商限流、数据库连接池被别的服务占用、缓存命中率下降——任何一个都会把拐点压低。所以 0.7 这个系数是对「拐点会缩水」的预判,不是保守。容量规划的目标不是把机器用满,而是让服务在流量翻倍时还有反应时间。

扩容的两种方式要分清:垂直扩容(把 LIMIT 从 8 调到 16)受限于单机的 CPU / 内存 / 下游连接数,迟早撞墙;水平扩容(多起几个实例 + 负载均衡)理论上线性叠加,但有共享瓶颈——如果所有实例共用一个 Postgres 连接池,那么池子打满时加实例也没用。所以扩容前必须先确认瓶颈是不是可水平分摊的:无状态的模型调用可以分摊,有状态的数据库连接池不行。这条判断决定了该加实例还是该优化下游,而它的依据,正是压测时逐下游定位到的那个先撞墙的组件。

14.3.8 验收与真实环境

本节把「服务能扛多少」从拍脑袋变成了可测的数字:

  • 极简压测器:纯 asyncio,客户端并发闸门 + 固定请求数 + QPS/P50/P95/错误率。
  • 拐点:QPS 在并发 8 附近封顶(110→176 后不再涨),延迟从 45ms 涨到 224ms。
  • 超时陷阱:加超时后过载档 QPS 虚高到 262,但错误率 86.8%,有效 QPS 只剩 34.7。
  • 容量结论:单实例安全承载 ≈ 拐点 × 0.7,留 30% 余量应对突发与拐点缩水。

这三块拼图之间是互相印证的:14.1 的占比表告诉你瓶颈在哪个阶段,14.2 的成本表告诉你钱花在哪个环节,14.3 的拐点告诉你这个服务在过载前能接住多少——同一套标识(run_id / 阶段 / 实例)把它们串起来,一次容量问题才能从「哪个实例的 QPS 封顶」一路下钻到「哪个阶段的延迟拖的」。孤立地看任何一张表,都只能看到问题的一个侧面。

验收清单:

  • 压测器有客户端并发闸门(否则测的是无限并发)
  • 报告同时包含 QPS、P50、P95、错误率,且 QPS 区分为「尝试」与「成功」
  • 至少跑了 4 档并发,能看出 QPS 封顶的那一档
  • 有明确的容量结论:单实例承载上限 + 扩容触发线 + 告警阈值
  • 压测数据复跑过至少两次,噪声在可接受范围内

一个常见的验收误区是「压到拐点就收工」。压到拐点只回答了「容量是多少」,没回答「过载会怎样」。真正完整的一次压测要一路压到失败:看错误率是怎么起来的、延迟是缓慢恶化还是断崖式崩塌、服务能不能自动恢复。知道崩点在哪,和知道容量在哪,同样重要——前者决定你的告警阈值,后者决定你的扩容线。

真实环境 / 真实模型下会怎样:本节的「服务」是 asyncio.sleep 假装的,负载是均匀的、服务时间是固定的。真实世界有三个差异。第一,真实请求的耗时方差极大——模型调用 1s 还是 5s 取决于生成长度,而方差大的服务拐点更低、过载更突然,所以真实压测必须用真实流量回放而不是均匀假负载。第二,真实瓶颈往往不在你的代码里——模型供应商的速率限制、Postgres 连接池、Redis 带宽,任何一个都可能是先撞到的上限,压测要逐个下游定位,而不是只看端到端。第三,真实压测要压到失败——只测到「还好」没用,要一路加压到错误率跳起来,才能知道真正的崩溃点在哪。此外,真实容量是持续变化的:供应商会调限流、你的流量结构会变、依赖会升级,所以拐点不是测一次就完事,而是随每次大版本发布重测——这正是 17.2 灰度发布里「新版本先扛小流量」的依据。一句话收束本章:14.1 找到了慢在哪,14.2 找到了贵在哪,14.3 找到了扛不住在哪——性能与成本这三块拼图凑齐,一个 Agent 服务才谈得上「可控」。

阅读导航:上一节:14.2 成本优化:缓存、路由、小模型降级 + 评测兜底 · 下一节:15.1 输入侧:提示注入与越权工具拦截 。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「agent」更多文章

  1. 《AI Agent 高级编程》目录
  2. 《AI Agent 高级编程》11.3 全景回顾:从零到框架的完整路径与展望
  3. 《AI Agent 高级编程》11.2 真实 API 冒烟:假模型 vs 真模型量化差距