文章指出高并发、限流重试和客户端排队会污染LLM端点的延迟数据,使测试工具自身成为瓶颈。建议设置并发门控,并确保评测器的p99排队时间仅占端到端延迟的一小部分。
某团队针对一个新的模型 endpoint 运行了 500 条 prompt,记录到 p99 延迟为 8.4 秒,于是提交了一张模型性能回归工单。没有人检查评测框架本身。下面是违反约束的事件顺序:
t0 harness opens 200 concurrent requests
t1 endpoint rate-limiter engages, returns 429 to 140 of them
t2 harness retries all 140 with exponential backoff
t3 retries collide with the original wave; arrival rate is now ~2x intended
t4 p99 measured latency includes 6s of client-side queueing
t5 report says: "model p99 = 8.4s"
模型可能完全没有问题。评测框架测到的其实是自身的拥塞。
这里违反的不变量是:测得的服务延迟必须主要由被测系统决定,而不能主要来自测量装置内部的排队。形式化地说,一次运行要被视为有效,评测框架内部的 p99 排队等待时间必须低于 p99 端到端延迟的一小部分。如果瓶颈在你的队列里,那么你发布的每一个百分位数,描述的都是负载生成器的特性。
如今,许多人使用免费或受限流约束的模型 endpoint 进行评测,这一点就更加重要了——我们真正关心的问题恰恰是:“这个 endpoint 在负载下表现如何?”如果评测框架无法区分自身排队与 endpoint 行为,它就回答不了这个问题。
endpoint 会强制执行请求速率限制或并发限制,并在超过限制时返回 429(或陷入停滞)。
评测框架会重试失败的请求;重试本身也是负载的一部分。
我们关心的是 endpoint 服务时间的尾延迟(p95/p99),以及 endpoint 在性能开始下降前能够维持的吞吐量。
免费层级的 endpoint 可能存在未公开的动态限制,因此我们应当把限制看作一个有待探测的未知量,而不是需要预先配置的常量。
评测框架与 endpoint 之间的网络 RTT,相对于推理时间而言应当足够小;否则就需要测量并扣除。
如果你的 endpoint 不符合假设 4(服务商公开了固定的静态配额),你可以跳过探测阶段,直接设置 concurrency gate——其余方法仍然适用。
解决办法是结构性的,而不是统计层面的。使用闭环评测框架:最多只能有 C 个进行中的请求,只有当一个请求完成后,才能发出下一个请求。然后把每次测量拆成两个时钟:
harness endpoint
issue ───────────────► request sent
│ queue_wait │
│ (should be ~0 ▼
│ in closed loop) service_time (t_first_byte..t_done)
◄─────────────── response complete
观测到的总延迟 = queue_wait + network + service_time。在采用诚实计时的闭环系统中,queue_wait 按照结构设计就应当约等于 0——因此,任何较大的观测延迟都可以归因于 endpoint 或网络。重试风暴会以 429 数量上升和有效并发度下降的形式显现出来,而不会伪装成虚假的延迟。
concurrency gate 是决定运行是否可以被接受的装置:逐步提高 C,评测框架便能自行告诉你 endpoint 的拐点在哪里——吞吐量停止上升、429 比例开始大于零、service-time p99 急剧增加。
下面的示例刻意保持精简。它把 endpoint 模拟为一个具有并发上限 L 和服务时间分布的服务器,并让两种评测框架对其发起请求:一种是带重试的开环框架,另一种是带 gate 的闭环框架。需要明确的是,这只是模拟——运行它、修改参数,然后尝试让它失效。
import heapq, random, statistics
def p(v, q): # percentile
s = sorted(v)
return s[min(len(s) - 1, int(q * len(s)))] if s else 0.0
def simulate(harness, sim_time=60.0, arrival_rate=30.0,
endpoint_concurrency=8, svc_mean=0.5, seed=7):
random.seed(seed)
busy, served, rejected = 0, [], 0
completions = [] # heap of completion events
t = 0.0
in_flight = 0 # for closed loop
sent = 0
while t < sim_time:
# drain completions
while completions and completions[0][0] <= t:
_, svc, issue_t = heapq.heappop(completions)
busy -= 1
in_flight -= 1
served.append((svc, t - issue_t)) # (service_time, observed_latency)
want = arrival_rate * 0.05 # requests issued this tick
for _ in range(int(want)):
if harness == "closed" and in_flight >= endpoint_concurrency * 2:
break # concurrency gate: refuse to add load
sent += 1
if busy >= endpoint_concurrency:
rejected += 1
if harness == "open":
# retry later == extra future load; model as re-arrival
t_retry = t + random.uniform(0.2, 1.0)
heapq.heappush(completions, (t_retry, 0.0, t)) # placeholder retry
# crude but honest: retries inflate busy/queue, counted below
rejected -= 1
busy += 1
in_flight += 1
svc = random.expovariate(1.0 / svc_mean)
heapq.heappush(completions, (t + svc + 1.5, svc, t)) # +queueing penalty
continue
busy += 1
in_flight += 1
svc = random.expovariate(1.0 / svc_mean)
heapq.heappush(completions, (t + svc, svc, t))
t += 0.05
obs = [o for _, o in served]
svc = [s for s, _ in served]
return {
"sent": sent, "rejected_429": rejected,
"p99_service": round(p(svc, 0.99), 3),
"p99_observed": round(p(obs, 0.99), 3),
}
print("open-loop :", simulate("open"))
print("closed :", simulate("closed"))
运行后应当看到这样的结果模式:开环评测框架报告的 p99_observed 远高于 p99_service,其中包含了排队和重试带来的额外开销;闭环评测框架的观测百分位数和服务百分位数则会彼此接近,同时它的拒绝计数器会准确告诉你 endpoint 在哪里开始施加反压。这个玩具模型很粗糙——真实 endpoint 不会这么简单——但其中的计时与归因结构可以迁移到真实场景。
针对真实 endpoint 运行真实评测框架,注入或观察以下故障类别,并断言下列属性:
明确分母:一次运行由固定的一组 N 条 prompt 组成(例如 N=500),每条 prompt 最多尝试 k=3 次。所有百分位数都应基于已完成的首次尝试计算;重试请求则另行计算。绝不能把二者混在一起——把重试延迟合并统计,正是虚假性能回归产生的根源。
接受规则:只有在满足以下条件时才发布运行结果:(a) 在延迟数据旁同时报告 429+timeout 比例;(b) p99_queue_wait / p99_observed < 0.05;(c) 使用的并发度不高于探测到的拐点。否则,这次运行应被标记为无效,而不是缓慢。
我曾使用这种探测方式测试 MonkeyCode 上的一个免费模型 endpoint——它既提供免费的模型访问,也提供免费的服务器选项,因此评测框架、扫描脚本和产物存储都可以运行在免费服务器上,而模型调用则发往免费 endpoint。这样的组合之所以方便,恰恰是因为两端都不收费,所以你能够承担这种方法中较为浪费的部分:故意对 endpoint 施加过量负载,以找出它的拐点。面对按量计费的 API,你绝不会这么做。
声明:本文是 MonkeyCode 产品推广工作的一部分。
免费 endpoint 在这里适合用来做什么:探测真实的 429 行为、真实的慢尾分布形态,以及基于线上基础设施构建评测框架的 concurrency gate。它不适合用来做什么:把探测到的拐点视为一个稳定数值。免费层级可能在不通知你的情况下调整限制——每次开展评测活动之前,都要重新运行扫描,并使用刚刚测得的数值设置 gate,而不是使用缓存值。如果你想亲自尝试,上面的扫描脚本加上他们的免费服务器,就足以复现整个实验;一个晚上即可完成。
自适应并发机制(收到 429 信号时执行加性增大、乘性减小)可以恢复吞吐量,但它也会引入一个同样需要测试的控制器——对于大多数评测流水线而言,把固定的 C 设置在拐点以下,再配合运行失效 gate,是更合适的权衡。
上面的模拟器只是一个玩具;它验证的是计时与归因逻辑,而不是 endpoint 的实际行为。
排队时间统计要求评测框架如实记录 issue 与 dispatch 的时间戳——如果异步框架隐藏了内部队列,又没有对其进行埋点,就无法正确统计。
如果你的 endpoint 有明确记录的固定配额,并且始终远低于配额运行,那么完整的 gate 就有些过度设计;简单设置并发上限即可。
不要把免费 endpoint 作为被测系统,并据此推断生产模型的尾延迟。两者面向的请求群体不同——这与把 sub-agent 的指标和顶层 Agent 的指标混在一起统计,是同一种错误。
这里的不变量是:评测框架绝不能成为它正在测量的那个瓶颈。因此,最后留给你的问题是:在你的流水线中,哪一种事件顺序会破坏这个不变量——一波重试请求撞上新一波到达请求,缓慢的首 token 响应拖住整个 worker pool,还是 runner 级别的超时导致整批请求全部重试?当你检测到这种违规情况时,评测框架应该拒绝这次运行、降低并发度后重新执行,还是通过发布经过队列时间校正的百分位数来补偿?我的答案是拒绝并重新运行:经过补偿的数字依然会掩盖拐点,而拐点正是你前来测量的东西。
对于后续操作,你可以考虑屏蔽此人和/或举报滥用行为。