性能测试这件事,最怕的不是结果不好看,而是结果说不出为什么,换个环境就崩。上个月我把 vLLM 和 SGLang 两个推理引擎拉到一起做并发实验,前前后后折腾了两周,最后发现坑全不在引擎本身,而在实验设计和指标口径。这篇东西想把我用的那套方法完整交代一遍:服务端参数怎么锁、压测客户端怎么写、指标怎么算、结果怎么归因,让性能数字既能复现又能解释。如果你是做 LLM 推理服务选型、要写性能报告,或者遇到“这次测得比上次快”却始终找不到原因,可以照着这套思路重新跑一轮。
1. 实验目标拆解:性能对比到底在对比什么
1.1 动手前先回答三个问题
在做并发实验之前,先别急着装环境、拉版本。我这次最大的教训是:目标不清晰,实验设计一定会跑偏。你至少要回答清楚三个问题:这次对比是为了选型、回归验证,还是定位瓶颈?结论要服务谁?能接受多长的实验周期?
这三个目标对应的做法完全不同。选型,看重的是容量边界,要测出在可接受的延迟分位下,系统能扛住多大并发和吞吐。回归,则是固定配置、固定请求集,比较新版本有没有变慢,任何变量都要尽量锁死。定位瓶颈,则需要更细的监控,比如把 prefill、decode、排队时间分开看,而不是只看一个总延迟。
我这次的目标很明确:并发压力下的稳态行为。也就是并发从 1 逐步增加到 64 的过程中,两个引擎的吞吐和延迟怎么变化,以及这些变化能不能落到具体机制上,比如连续批处理、KV 缓存复用、PD 分拆这些调度策略。先把目标定了,后面所有参数和指标才有的放矢。
1.2 并发、QPS、连接数是三个不同的东西
很多同学做压测时会把并发数理解成 QPS,或者直接用压测工具里的连接数来代表并发。这是个很常见的误区。并发数指的是同一时刻服务端正在处理的请求数量,QPS 是每秒完成的请求数,连接数则是空闲和活动连接的总和,三个根本不是一个量纲。
在 LLM 推理场景里,我们真正要控制的是 in-flight requests,也就是已经发出去、还没收到最终响应的请求数量。如果只是开一堆 HTTP 连接但请求是串行发的,并发数依然只有 1。我用的方法是:固定 in-flight 数量等于目标并发,每完成一个请求立即补发一个,形成一个“掉队补位”的稳态模型。这比一次性发 N 个请求更接近线上网关的真实负载模式,也方便观察排队行为。
另外,客户端和服务端一定要放在两台机器上。我之前图省事用同一台机器压测,结果 CPU 抢占、网络栈竞争、日志落盘全混在一起,数据完全没法看。把压测机独立出来,是保证可复现性的第一道门槛。
1.3 可复现性的三层含义
我理解的可复现性有三层:时间上,同一台机器、同一套参数,隔一小时重跑,核心指标差异小于 5%;环境上,换一台配置差不多的机器,趋势相对一致;归因上,每一个数字都能指向某个参数或者某个调度机制,而不是“这次运气好”。
后一层才是可解释性的关键。比如两个引擎在 32 并发下吞吐差 10%,如果你只知道“SGLang 快一点”,这个结论没法迁移。但如果能说清楚“差别主要来自前缀缓存命中率,关闭缓存后差距缩到 2%”,这个结论就立住了。所以整篇实验设计都要围绕这三层可复现性展开,环境、参数、指标、归因,缺一不可。
2. 实验配置:从服务端到客户端的约束清单
2.1 模型、权重、请求语料三者锁死
实验的第一件事是把输入物料固定住。模型权重选同一个开源权重,加载路径一致,下载后记录文件哈希。两个引擎加载的是同一份权重,不能一个用量化版一个用原始版,那样比的是量化策略而不是引擎。
请求语料是很多人忽略的点。不要用随机自然语言做并发测试,因为 prompt 的 token 数浮动非常大,prefill 阶段的开销和 token 数强相关,一旦输入长度不一致,吞吐数字就失去意义。我的做法是固定一个长模板,比如同一段技术问答指令加同一段代码片段,重复发给服务端。实验前用 tokenizer 分别打印 input token 数量,确认在同一个值附近。
输出长度也要控制。给每个请求设固定的 max_tokens,比如 256,同时尽量用会生成满长度响应的固定模板。这样每个请求的输出 token 数都接近上限,吞吐指标才稳定。如果你用随机短句,模型可能在 20 个 token 就输出了 EOS,平均输出长度只有几十 token,那算出来的“每秒 token 数”就会虚高,而且没法定量归因。
2.2 服务端参数对齐清单
两个引擎的默认参数不一样,直接启动就是不公平对比。我在实验前专门列了一张对齐清单,逐项核对:
| 对齐项 | vLLM 常见参数 | SGLang 常见参数 | 为什么必须对齐 |
|---|---|---|---|
| 上下文窗口长度 | --max-model-len | --context-length | 长度影响 KV cache 分配 |
| 最大批处理请求数 | --max-num-seqs | --max-running-requests | 决定并发上限 |
| 前缀缓存 | --enable-prefix-caching | --enable-radix-cache | 缓存命中直接影响 TTFT |
| chunked prefill | --disable-chunked-prefill / 默认开启 | --chunked-prefill-size | prefill 与 decode 交错策略 |
| 张量并行 | --tensor-parallel-size | --tensor-parallel-size | 影响显存分配和通信开销 |
| 显存利用率 | --gpu-memory-utilization | --mem-fraction-static | 影响 KV cache 余量 |
| 采样参数 | temperature=0, do_sample=false | 同左 | 避免生成随机性 |
参数名在不同版本里可能略有出入,动手之前先看两个--help输出,以实际版本为准。但我建议不要只看默认值,而是做一个“开关正交”实验:比如先两边都关闭前缀缓存测一轮,再同时开启测一轮。这样你才能把差异拆开,知道到底是引擎调度更快,还是缓存命中带来的数字红利。
2.3 客户端并发模型和连接池
压测客户端我推荐用 Python asyncio,配合兼容 API 的流式接口。核心是控制 in-flight 数量,而不是盲目开协程。具体做法是初始化一个asyncio.Semaphore,每个请求发出前先获取信号量,请求完成再释放,信号量大小就是目标并发。
连接池也要单独设置。连接池大小最好等于目标并发数,同时确认 HTTP keep-alive 行为一致。如果连接池太小,请求会在客户端排队,你测出来的不是服务端性能,而是客户端线程调度性能。如果连接池太大,又会引入 TIME_WAIT 和连接建立开销,同样污染数据。
正式采集数据前一定要预热。我先发几十个请求让模型权重加载、CUDA kernel 初始化、图捕获全部完成,观察 TTFT 稳定后再清空状态,等队列排空,才开始正式计时。不预热就跑的话,第一个请求会因为权重加载多出好几秒,完全会把 P95 拉到失真。
3. 指标口径与统计方法:数字本身会说谎
3.1 四个核心指标的口径
LLM 性能报告里,最常出现的四个指标是端到端延迟、TTFT、TPOT 和吞吐量。口径不同,数字天差地别。
端到端延迟是从客户端发出请求到收到最后一个 token 的完整时间,包含网络传输、排队、prefill、decode。TTFT 是请求发出到第一个 token 到达的时间,它最能反应排队和首次 prefill 的代价。TPOT 是排除 TTFT 后,相邻 token 之间的平均间隔,相当于“稳定出字速度”。吞吐量我习惯同时记录两个口径:一个是每秒完成请求数 RPS,另一个是每秒输出 token 数。
一个很容易犯的错误是用端到端延迟除以输出 token 数来估算 TPOT。这在低并发下勉强可用,高并发下完全不对,因为端到端延迟里包含了排队等待,会把 TPOT 拉高好几倍。要测准 TPOT,必须在流式响应里逐个记录 token 到达时间,然后取中段稳定区间的间隔,而不是用总时长做除法。
3.2 为什么分位数比平均值重要
给管理层汇报性能,平均值是最没有说服力的一个数字。想象一个场景:100 个请求,其中 99 个延迟 50ms,剩下 1 个因为踩到慢调度路径花了 20 秒。平均值是 250ms,看起来非常健康,但 P99 是 20 秒,线上真实体验大概率是有人在骂。
在大并发下,TTFT 的分位分布尤其关键。排队理论告诉我们,当系统利用率升高,尾延迟会指数级恶化,P99 翻几倍是常态。所以我的报告里固定给 P50、P95、P99 三组数,缺一不可。如果只写平均延迟,等于把最严重的短板藏掉了。
另外,异常点不要随手删除。你可以标注哪些请求属于冷启动、哪些出现了超时,但必须在正文里保留原始计数。删掉异常点之后再下结论,那就不是实验报告,是粉饰数据。
3.3 吞吐量的绝对值和归一
说到吞吐量,有一个很隐蔽的坑:如果请求的输出长度不固定,纯吞吐不可比。假设 A 引擎平均输出 200 token,B 引擎平均输出 180 token,单位时间 token 数差了 10%,但请求数一致。你很难说 B 慢,因为它就是提前停了。
所以我在每次运行后都会统计实际平均输出长度,并写进报告。比如“max_tokens 固定 256,实测平均输出 token 252,方差 3”,这样吞吐量才有基准。报告里同时给 RPS 和输出 token 吞吐两个数字,一个反映请求处理能力,一个反映生成吞吐,互相印证,避免被单一指标误导。
还有一个细节:如果是多卡并行,TPOT 会被通信开销影响。记录吞吐时最好同时标注张量并行数,否则你测出来的“每秒钟 token 数”到底是几卡合力完成的,读者完全看不出来。
3.4 一个反例
举个我实际见过的错误报告写法:某次实验里直接报了“系统吞吐 500 tokens/s,优于另一引擎 12%”。但细看请求集,用的是一批长度从 50 到 2000 token 不等的随机 prompt,平均输出长度也没有记录。这个 500 tokens/s 根本没法定量,可能是 prompt 短了,也可能是 EOS 提前命中。正确的做法是固定输入输出长度,记录平均输出 token,打开流式逐 token 采时间戳。指标口径不统一,结论就没有讨论价值。
4. 实操推进:从启动服务到拿到稳定数据
4.1 服务端启动命令参考
下面是我在实验里实际使用的启动命令,按当前常见版本参数写,具体字段以你本地版本的--help为准。
vLLM 启动侧:
vllm serve /path/to/model \ --max-model-len 8192 \ --max-num-seqs 64 \ --tensor-parallel-size 8 \ --gpu-memory-utilization 0.9 \ --enable-prefix-caching \ --disable-chunked-prefill \ --port 8000SGLang 启动侧:
python -m sglang.launch_server \ --model-path /path/to/model \ --max-running-requests 64 \ --tensor-parallel-size 8 \ --mem-fraction-static 0.9 \ --enable-radix-cache \ --chunked-prefill-size 4096 \ --port 30000注意两个命令里的显存预留、并行数、批大小都是对齐过的。除此之外,我建议把--port、--host显式固定,防止服务漂到别的端口。日志级别调到 warning,压测期间不要开 debug 或每秒一条的访问日志。
4.2 压测脚本骨架
客户端脚本我用 Python asyncio 写,固定并发、预热、阶段计时三步。核心骨架大致是:
import asyncio import time import json from compat_client import AsyncClient # 用兼容接口的 SDK SEM = None SINK = [] async def bench_one(client, payload): async with SEM: start = time.perf_counter() first_token_time = None async for chunk in client.stream(payload): if first_token_time is None: first_token_time = time.perf_counter() end = time.perf_counter() SINK.append({ "e2e": end - start, "ttft": first_token_time - start, "tpot_avg": (end - first_token_time) / max(output_tokens - 1, 1), "output_tokens": output_tokens, }) async def main(): global SEM SEM = asyncio.Semaphore(CONCURRENCY) tasks = [] for _ in range(TOTAL_REQUESTS): tasks.append(loop.create_task(bench_one(client, payload))) await asyncio.gather(*tasks)这段代码里最关键的是流式逐块记录首 token 时间,而不是等整个响应结束才拿数据。输出 token 数要从响应末尾的 usage 字段取,很多兼容接口都支持流式末尾返回用量信息,取不到就退而求其次用客户端自己统计的 delta 文本长度去估算。
预热阶段我会额外跑 20 个请求,确认 TTFT 不再显著下降后,再等队列清空,开始正式并发。正式轮次里我会固定一个时间窗口,比如 120 秒,而不是跑到某个请求数为止,这样天然能算出稳态 RPS。
4.3 统计脚本
拿到一批 JSON Lines 记录后,统计脚本要输出 RPS、平均输出 token、延迟分位数、TTFT 分位数、TPOT 平均值。核心逻辑如下:
import json import numpy as np records = [json.loads(line) for line in open("run.jsonl")] window = end_ts - start_ts rps = len(records) / window output_tokens = np.mean([r["output_tokens"] for r in records]) ttft = [r["ttft"] for r in records] tpot = [r["tpot_avg"] for r in records] print("RPS:", round(rps, 2)) print("avg output tokens:", round(output_tokens, 1)) print("TTFT P50/P95/P99:", np.percentile(ttft, [50, 95, 99])) print("TPOT P50/P95/P99:", np.percentile(tpot, [50, 95, 99]))为了快速发现异常,我还会多打印一个“吞吐时间线”:把窗口切成 10 秒一段,分别统计每个小窗口的 RPS。如果某个窗口掉到平均值的 70% 以下,说明中间发生了显存驱逐、日志刷盘或者其他环境干扰。发现问题就去查日志修环境,而不是手滑把这段数据删了。
4.4 审计清单和 metadata
可复现性的最后一环是审计。我每次跑完都会生成一个 metadata 文件,包含:Git commit 或代码版本、依赖包版本列表、GPU 状态查看工具的输出快照、启动命令全文、请求模板、tokenizer 打印的 prompt token 数、日期时间和并发档次。
第一次做实验时我没记驱动版本,结果换了显卡驱动之后,同一套程序的吞吐漂了 10%,我花了整整一天才定位到漂移来源。从那以后,metadata.txt就成了必需品。别人来问“这个数字怎么来的”,直接把整个实验目录打包扔过去,比任何口头解释都有说服力。
5. 结果解读与机制归因:为什么并发上去之后差这么多
5.1 队列、prefill、decode 三阶段模型
解读并发实验数据,脑子里要先有一个三阶段模型:请求进入服务端后,先排队,再 prefill,最后 decode。prefill 阶段并行处理 prompt 生成长度为 1 的 KV 状态,是重型整矩阵运算;decode 阶段逐 token 生成,是访存密集型的小矩阵运算。
可以拿餐厅出餐类比:prefill 是备菜,decode 是出餐窗口,并发数就是堂食人数。刚开始并发翻倍,出餐总量会跟着涨;涨到一定程度,厨房人手和灶台(GPU 算力和显存)都满了,再增加人数就只增加排队时间,出餐速度不再提升,这就是典型的饱和曲线。
TTFT 主要受排队时长和首次 prefill 影响,TPOT 则体现稳定出餐速度。高并发下 P99 TTFT 指数级上升,大部分时间不是 GPU 算不过来,而是请求在队列里等待调度。理解了这一点,你才能在报告里准确描述“为什么并发 64 时端到端延迟从 1 秒涨到 6 秒”,而不是笼统地说“系统不行”。
5.2 同并发下两个引擎的差异来源
vLLM 和 SGLang 都是优秀的推理引擎,但调度机制不同。vLLM 的核心是连续批处理和 PD 分拆,调度器按序列状态动态决定哪些请求进入 prefill,哪些进入 decode。SGLang 则在前缀缓存上做得更激进,RadixAttention 把 KV 缓存的复用粒度做得更细,细到能复用任意前缀子树。
这带来一个明显影响:如果压测请求有共享前缀,SGLang 的缓存命中率可能更高,第一个 token 的生成代价大幅下降,TTFT 会显著变好。这不代表它推理比 vLLM 快,只代表它在“前缀复用”这个维度上做得更好。反过来,如果请求前缀完全不重合,缓存优势就变小,双方差距会拉近。
所以我在实验里专门做了一个缓存开关正交项:关掉缓存测一轮、开启缓存再测一轮。这样得到的性能差距,才能被清晰地归因到“调度效率”还是“缓存命中率”,而不是混成一锅粥。
5.3 一组用于解释趋势的样例数据
下面这组数字是我在固定环境下得到的一批样本,不代表任何官方基准,不同模型、不同 GPU、不同显存配置下都会变。但趋势非常典型,适合用来理解怎么解读:
| 并发数 | vLLM RPS | SGLang RPS | vLLM P99 TTFT (s) | SGLang P99 TTFT (s) |
|---|---|---|---|---|
| 1 | 12 | 12 | 0.2 | 0.2 |
| 8 | 88 | 92 | 0.8 | 0.7 |
| 16 | 140 | 158 | 1.5 | 1.2 |
| 32 | 190 | 210 | 3.2 | 2.6 |
| 64 | 205 | 218 | 6.8 | 5.5 |
低并发下两个引擎几乎没有差异,因为硬件资源远远富余,瓶颈在模型本身。并发到了 16 以上,调度器开始成为瓶颈,差距开始显现。再看 P99 TTFT,并发翻倍之后尾延迟接近翻倍,这是排队堆积的典型特征。RPS 的增速在 32 到 64 之间大幅放缓,说明系统已经靠近饱和拐点。我的建议是,这种实验只报 32 并发以下的数据作为选型依据,64 并发以上的长尾延迟已经超过大多数线上 SLA。
5.4 如何用正交实验定位差异
如果要在两份数据之间做归因,正交实验是不可缺少的。我会跑一张 2x2 的矩阵:缓存开/关、chunked prefill 开/关,再把两组引擎在同一矩阵下各跑一遍。每个格子固定请求集,重复三次取中位数。
在关闭缓存的前提下,如果 SGLang 依然在 32 并发快 10%,那可以判断是调度器或者内核实现层面的差异。如果差距完全来自缓存命中,那结论就得写“在带前缀复用的工作负载下,SGLang 的缓存机制带来 X% 收益”。这种报告才是可解释的。很多时候你不需要知道“谁快”,你只需要知道“在什么工作负载下,什么参数让谁快”。
6. 常见问题排查与避坑清单
6.1 两遍压测结果偏差超过 5% 先查什么
如果同一套命令隔一小时跑,核心指标偏差超过 5%,从高到低排查:GPU 温度与频率是否漂移,有没有开启节能模式;压测机是不是有人在登录操作;服务端有没有别的进程占 GPU;连接池是不是被 TIME_WAIT 堵住;以及日志级别是不是被动过。我见过最多的还是 GPU 频率漂移,解决方案是锁定运行时钟频率,能接受的话直接在启动命令里固定频率档位。
6.2 显存爆了导致吞吐像过山车
max-num-seqs设得过大时,一旦 KV cache 占满,引擎会驱逐缓存或者拒绝新请求,表现为吞吐周期性跌到接近 0,然后恢复,再跌。GPU 利用率看起来很高,但 RPS 完全不稳定。解决方式是把并发上限调小一点,同时观察显存里的 KV cache 剩余量。别把max-num-seqs当成越大越好,它是容量边界,不是性能旋钮。
6.3 随机 prompt 是最大的不可控变量
随机自然语言 prompt 的 token 长度方差极大,同一段话里可能因为符号、空格、换行导致长度差出 30%。这种请求做小规模冒烟可以,做并发对比完全不合格。我会提前把 prompt 模板 tokenize,确认长度一致,再固定 max_tokens,最后在 metadata 里记下实测 token 数。多花十分钟控制输入,后面省下的解释成本是十倍。
6.4 日志污染比你想的严重
把服务端日志级别从 warning 调到 debug,响应耗时能涨 10% 到 30%。访问日志每请求写一行,在高 RPS 下也会显著占 CPU。压测期间我习惯把访问日志全部关掉,只保留启动信息和错误信息。如果实在要记录,也在压测机侧记录客户端视角,而不是服务端逐请求打印。
6.5 计量窗口不要太短也不要太长
太短的窗口受冷启动和批处理首波影响,太长又会混入温度升高、内核内存碎片等因素。我习惯取 120 秒,前 10 秒丢弃,后 10 秒也丢弃,只统计中间稳定区段。窗口再长的话,我会在最后把时间线图放出来,辅助说明系统是否保持了稳态。
拿这批样本跑完几轮之后,我个人体会最深的是:性能数字如果不是用来决策的,那它只是数字;如果是用来决策的,就必须能被另一个人复现。我现在做任何一次压测,都坚持把实验脚本化、参数记录化、归因正交化,不做“跑出来了但不知道怎么回事”的实验。如果你正准备做推理引擎选型,建议先别急着比谁快,把并发压力下的延迟分布画出来,再把缓存开关做一轮正交实验,等你拿到那张能解释的趋势图,结论自然就出来了。