均值 3 秒的 Agent 回归全绿,线上却一直有人在等超时:延时只看均值等于没看
现象:回归测试全绿,用户却在等超时
先看一个很常见的断言写法:
# 前提:pytest + 任意 Agent SDK;此处只关心计时口径
def test_agent_latency(agent_client):
elapsed = []
for case in CASES:
t0 = time.perf_counter()
agent_client.run(case.input)
elapsed.append(time.perf_counter() - t0)
avg = sum(elapsed) / len(elapsed)
assert avg < 3.0 # 单位:秒,示例阈值
这段代码在你的机器上跑得很稳,CI 上也稳。然后线上监控里出现零星的超时告警,用户侧反馈「有时候特别慢」。你去翻测试报告:均值一直贴着阈值下方,没有任何一次失败。
问题不在 Agent 本身,在于这个断言测的东西和用户感受到的东西不是一回事。
拆解:均值把长尾稀释掉了,而且 Agent 的耗时分布本来就右偏
均值是一个求和量:一条很慢的轨迹和一条很快的轨迹,在求和里按各自的绝对数值贡献。分位数看的是排序位置:它只关心最慢的那一撮落在哪里。一条长尾变长,均值会被大量快样本摊薄,分位数会直接跟着动。
这就是「回归不敏感」的机制来源:
- 均值对少数极端样本不敏感。只要快样本足够多,单条变慢几乎不改变均值。
- 均值对分布形状不敏感。同样是均值落在阈值内,一种是「大家都差不多」,另一种是「大多数很快、少数极慢」,用户体验完全不同,但均值一样。
- Agent 任务的耗时分布天然右偏。多步工具调用、重试、模型侧排队、上下文变长,都可能让个别轨迹的耗时远远甩开中位数。右偏分布下,均值本来就不是一个有代表性的位置统计量。
对体验负责的指标是「有多少比例的用户等了多久」,不是「平均每个请求等了多久」。用户不会「平均地等」,他要不是这次很快,要么是这次很慢。
所以:断言均值,等于允许长尾在绿灯下持续恶化。
怎么做:p95 做门禁,p50 做对照,样本量不够就补
1. 断言口径换成 p95 上限 + p50 对照
主断言用 p95(或 p90,取决于你的体验容忍度),同时把 p50 记录下来做对照。p50 用来回答「典型用户快不快」,p95 用来回答「最慢那一撮有多慢」。两个数一起看,才能区分「整体变慢」和「只有长尾变慢」——这两种情况的处理方式完全不同。
# 前提:Python 3.9+;不依赖第三方分位数库,避免版本差异
import math
import time
def percentile(samples, p):
"""线性插值分位数,p 取 0~100。samples 不能为空。"""
if not samples:
raise ValueError("empty samples")
xs = sorted(samples)
if len(xs) == 1:
return xs[0]
rank = (len(xs) - 1) * (p / 100.0)
lo = math.floor(rank)
hi = math.ceil(rank)
if lo == hi:
return xs[int(rank)]
return xs[lo] + (xs[hi] - xs[lo]) * (rank - lo)
断言部分:
# 前提:阈值取自本机/CI 机器的分档配置,不要硬编码同一个数
P95_LIMIT = float(os.environ["AGENT_P95_LIMIT_SEC"])
P50_LIMIT = float(os.environ["AGENT_P50_LIMIT_SEC"])
p95 = percentile(samples, 95)
p50 = percentile(samples, 50)
assert p95 <= P95_LIMIT, f"p95={p95:.2f}s 超过上限 {P95_LIMIT}s"
assert p50 <= P50_LIMIT, f"p50={p50:.2f}s 超过上限 {P50_LIMIT}s"
阈值走环境变量、按机器分档,是为了后面「边界」那一节说的误报问题。
2. 样本量太小时,分位数会抖
分位数是排序位置统计量,样本少的时候它对个别样本极其敏感:加一条慢样本,p95 可能直接跳一个数量级。在样本量不足以稳定估计分位数时,有两种处理方式:
- 用固定随机种子的合成轨迹补足样本量。合成轨迹不追求语义真实,只追求耗时分布形状接近(比如按线上观测到的分布抽样出步数、工具调用次数),目的是让分位数估计稳定、让回归可重复。
- 或者明确声明「本用例样本量不足以断言分位数」,只做 p50 对照,把 p95 交给样本量足够的聚合任务。
固定种子这一点很关键:分位数门禁如果每次跑出来的样本集合都不一样,失败就不可复现,团队很快就会开始忽略它。
# 前提:合成轨迹只用于耗时分布,不参与语义断言
import random
def synthetic_durations(n, seed=20240101, base=0.5, tail_scale=6.0):
"""按右偏形状生成耗时样本;具体形状参数以你线上观测到的分布为准调整。"""
rng = random.Random(seed)
out = []
for _ in range(n):
# 对数正态:天然右偏,适合模拟多步 Agent 的耗时
out.append(rng.lognormvariate(mu=0.0, sigma=1.0) * tail_scale + base)
return out
3. 计时口径统一在夹具里,冷启动单独记
耗时统计最容易出的问题不是分位数,是口径不统一:有的用例把客户端构造算进去,有的不算;有的第一次调用包含连接建立、模型侧预热、缓存冷启动,有的没有。口径不统一时,分位数再准也没意义。
做法是把计时收进一个统一的夹具或上下文管理器:
# 前提:pytest;冷启动样本单独记录,不混进 p95 样本集
import time
import contextlib
@contextlib.contextmanager
def timed(bucket):
t0 = time.perf_counter()
try:
yield
finally:
bucket.append(time.perf_counter() - t0)
# 用法:第一条单独进 cold 桶,其余进 warm 桶
cold, warm = [], []
with timed(cold):
agent_client.run(CASES[0].input)
for case in CASES[1:]:
with timed(warm):
agent_client.run(case.input)
为什么冷启动要剔除但又要单独记录:不剔除,它会把长尾拉长,让 p95 门禁天天误报;不记录,预热成本就被藏起来了,某天冷启动变成主要瓶颈你也不会知道。冷启动值应该有自己的阈值和趋势,而不是混进稳态分位数里。
落地到流水线
- 本地开发:跑 p50 对照即可,快速反馈;p95 允许告警不阻塞。
- CI 门禁:p95 阻塞合并,阈值按 CI 机器规格单独配置,不和本地共用。
- 聚合任务:用足够样本量重算 p95,作为趋势看板,和单次 CI 结果分开解读。
- 冷启动:单独一条趋势线,单独阈值。
- 报告产物:每次跑都把 p50 / p95 / 样本量 / 冷启动值一起落盘,缺样本量这个字段的分位数不可信。
一个容易忽略的落地细节:分位数门禁失败时,报告里要能直接看到最慢的那几条轨迹是哪些用例。否则工程师只知道「p95 超了」,不知道去优化谁,几次之后就会把门禁调松。
边界与不适用场景
| 场景 | 适用 p95 门禁 | 不适用 / 需要换口径 | 代价 |
|---|---|---|---|
| 单步、耗时分布接近对称的调用 | 可用,但收益有限 | — | 多一次排序,可忽略 |
| 多步 Agent、分布明显右偏 | 强烈建议 | — | 需要足够样本量,跑得更久 |
| 样本量很小的用例集 | 不建议单独断言 | 只做 p50 对照或聚合后断言 | 门禁覆盖变弱 |
| 冷启动占主导的场景 | 不适用 | 冷启动单独建阈值 | 需要额外维护一条指标 |
| 本地机器与 CI 机器混用同一阈值 | 不适用 | 按机器规格分档 | 不分档会持续误报 |
| 需要区分「整体变慢」与「长尾变慢」 | 需配合 p50 | 单看 p95 会误判 | 多维护一个指标 |
几个明确的边界:
- 分位数阈值必须按机器规格分档。 CI 机器和本地机器性能不同,用同一个数要么本地一直误报,要么 CI 形同虚设。分档方式可以是按 CPU 核数/机型标签映射到一组阈值配置,具体数值以你在各档机器上的基线为准。
- 样本量不足时不要硬断言分位数。 这是分位数门禁最常见的翻车方式:样本少、抖动大、失败不可复现,最后团队把门禁关掉。
- 分位数只描述延时分布,不描述正确性。 它和功能断言是两条正交的门禁,不能互相替代。
- 方向确定,幅度取决于你的基线。 换成 p95 门禁能不能抓到你现在漏掉的回归,取决于你现有用例的样本量和分布形状;机制上它比均值敏感,但具体敏感多少要拿你自己的数据看。
回到开头那个场景:均值断言全绿而线上有人等超时,不是偶然,是均值这个统计量本身对长尾不敏感导致的必然结果。把门禁换成 p95、把 p50 留作对照、把冷启动单独拎出来、把阈值按机器分档——这四件事做完,你的回归测试才开始测用户真正感受到的东西。
想要一份免费质量体检:把你的测试集或评测脚本发我,我按上面的指标跑一遍,告诉你哪几项其实是假通过。私信我。
更多推荐


所有评论(0)