作者通过 100 次 LLM API 请求说明,平均延迟会掩盖长尾、排队及限流等行为,并介绍测量工具 llm-bench。文章主张使用 p50、p95、p99 等分位数描述响应分布;摘录实验采用免费接口、预热后顺序请求。
最初发表于 saksham.digital/blog。
我对一个 LLM API 做了 100 次基准测试。平均值显示耗时 1.7 秒,中位数却是 2.2 秒。如果你觉得这个大小关系不可能出现,那很好。这种直觉,正是这篇文章想讨论的重点。
搜索任何 LLM 延迟对比,你都会看到同一种图表:各家服务商的平均响应时间。OpenAI 一根柱子,Anthropic 一根,Groq 一根。清晰、方便比较,却没什么用。
原因在这里:延迟不是一个数字,而是一种分布。LLM API 的延迟分布往往很难看:长尾、排队造成的延迟陡增、冷启动、限流壁垒。平均值把这些结构全部压成一个数,而这个数经常无法描述任何一次实际发生过的请求。
在交易系统领域,你很早就会被反复灌输这个教训。任何一家像样的机构,都不会报告平均延迟。你要报告的是 p50、p95、p99、p99.9。p99 对应的是体验最差的用户所感受到的延迟,而在生产环境中,体验最差的用户往往就是提交工单的那个人。
LLM API 也应该受到同样的对待。所以,我做了一个工具来实现这件事:llm-bench。
工具完成后,我做的第一次正式基准测试,是向 Groq 免费套餐上的 llama-3.3-70b-versatile 发出 100 次请求。先预热,再串行执行:
llm-bench openai/llama-3.3-70b-versatile
(n=100)
+--------+-------+-------+-------+-------+
| Metric | p50 | p95 | p99 | mean |
+--------+-------+-------+-------+-------+
| TTFT | 2.20s | 2.32s | 2.33s | 1.71s |
| Total | 2.28s | 2.39s | 2.41s | 1.79s |
| ITL | 0ms | 10ms | 13ms | 2ms |
+--------+-------+-------+-------+-------+
Throughput: 536.1 tok/s | Cost/call: $0.000058 | Errors: 0/100
看 TTFT 这一行。平均值是 1.71 秒,中位数是 2.20 秒。平均值比中位数低了半秒。
对于右偏的延迟分布,也就是通常的情况,长尾会拉高平均值,使它高于中位数。这次却反了过来,意味着这个分布不是偏态分布,而是双峰分布:
最初大约 30 次请求,响应时间都在 200ms 左右。走的是快速路径,没有排队。
随后,免费套餐的限流器开始发挥作用。之后的每一次请求,都要等待大约 2.2 秒。
一次测试里,出现了两种完全不同的延迟状态。平均值把它们揉成了 1.7 秒,而这个数字对哪种状态都描述不准。没有任何一次请求实际经历了 1.7 秒的延迟。中位数落在占多数的受限流请求中,反而如实反映了大多数请求的实际体验。
如果基准测试只报告平均值,它就会打印一个“1.7 秒”,然后结束。你据此制定延迟预算并上线,依据的却是一个实际并不存在的数字。
工具里的每一项测量决策,都记录在 README 中。因为一个无法追问测量依据的基准测试,不过是给主观感觉配了一张表。简要来说:
TTFT 与总延迟分开测量。 首个 token 到达的时间,决定了用户什么时候感觉“应用开始响应了”。它刻意包含连接建立、排队和 prefill,因为用户必须等待这些过程全部完成。总延迟则在最后一个内容 chunk 到达时结束,而不是等到后续的 usage 帧传完,或连接拆除时才结束。
使用真实的百分位数,按 nearest-rank 方法计算。 不插值,不平滑。当 n=100 时,p99 就是倒数第二慢的请求,是一次真实观测,而不是凭空造出来、夹在两次观测之间的数值。当 n<100 时,工具会发出警告,因为 5 个样本的 p99,不过是披着百分位数外衣的最大值。
把原始间隔汇总起来,计算 token 间延迟。 记录每两个相邻 chunk 之间的时间间隔,将所有请求的间隔汇总,再直接基于这些原始间隔计算百分位数。一个很诱人的捷径,是先计算每次请求的平均间隔。但这条捷径恰好会抹掉你最想看到的信号:一段原本流畅的输出流中,如果发生一次 500ms 的停顿,它就会被淹没在该请求的平均值里。汇总原始间隔,才能让它在 p99 中显现出来。
吞吐量按解码速率计算。 每秒 token 数的计算公式是 (completion_tokens - 1) / (total - TTFT)。如果把 TTFT 算进分母,就会把排队和 prefill 混进一个号称代表生成速度的数字里。上面 Groq 的 536 tok/s 是解码速率,也就是 token 开始输出后,实际流式传输的速度。
错误请求绝不参与百分位数计算。 超时请求没有有效的延迟值。它会被计数并报告,例如 errors: 0/100,但绝不会纳入平均值,因为一个 30 秒超时的“延迟”,会污染 p90 以上的所有统计结果。
预热是一个严格独立的阶段。 即使在并发情况下,也必须等所有预热请求完整结束后,才开始正式测量,确保连接建立和服务端缓存效应不会混入测得的分布。
这些做法都不新鲜。它们是系统性能工作中的标准实践,只是被应用到了一个不知为何还没有把它们当作标准的领域。
发布前检查工具时,我发现自己的第一版实现,也犯了好几个它本来要揪出来的错误。ITL 的“p99”是根据每次请求的平均值计算的。吞吐量计算包含了 TTFT。timeout 参数虽然被接受,却被悄悄忽略。在高并发情况下,预热请求会与正式测量的请求同时运行。
这些问题都会产生看起来很合理的数字。这也是测量 bug 比崩溃更糟糕的原因:崩溃会明确告诉你出了问题,而一个悄悄算错的百分位数,却可能被引用到博客文章里。
修复这些问题,靠的是同一套原则:先写清楚指标应该表达什么,再检查代码是否准确计算了这个含义,没有掺入其他东西。现在,测试套件中加入了一个用例:在 98 个很短的间隔中,藏入 2 个突增的间隔,并断言汇总原始间隔后计算的 p99 能显示出 500ms;如果先按请求取平均值,报告出来的却会是 12ms。
# no API key needed, mock provider runs the full pipeline
uvx --from git+https://github.com/saksham10arora-dotcom/llm-bench llm-bench \
--provider mock --model demo --prompt "hi" -n 20
# real benchmark against any OpenAI-compatible endpoint
llm-bench --provider openai --model llama-3.3-70b-versatile \
--base-url https://api.groq.com/openai/v1 \
--prompt "Explain recursion" -n 100
# compare two providers side by side
llm-bench --provider anthropic --model claude-sonnet-4-6 \
--prompt "Explain recursion" -n 100 --compare openai:gpt-4o
代码仓库:github.com/saksham10arora-dotcom/llm-bench
如果你不同意任何一项测量决策,可以从 README 的 Methodology 部分开始讨论。它存在的目的,就是让大家能讨论这些决策。
更新(v0.2.0):llm-bench 现已上架 PyPI,可通过 pip install llm-latency-bench 安装。此外,还新增了 --task 参数,支持 text、code、pdf、image、chat。这来自 Meta 一位资深数据科学家的反馈:在讨论任何延迟数字之前,都应该先明确任务类型。夜间批量处理 PDF 所关注的指标组合,与交互式代码生成完全不同。这个参数会为每次基准测试的输出补上任务上下文。
如果还想采取进一步行动,你可以考虑屏蔽此人,或者举报滥用行为。