现象:回归测试全绿,用户却在等超时

先看一个很常见的断言写法:

# 前提: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 留作对照、把冷启动单独拎出来、把阈值按机器分档——这四件事做完,你的回归测试才开始测用户真正感受到的东西。


想要一份免费质量体检:把你的测试集或评测脚本发我,我按上面的指标跑一遍,告诉你哪几项其实是假通过。私信我。

Logo

更多推荐