上个月 A 同学把一份对比报告拍在我桌上,说 vLLM 在并发 8 的时候吞吐量是 SGLang 的 1.8 倍,结论基本可以写了。我看了一眼他的测试脚本,劝他把并发数调到 32 再跑一轮。结果反转了,SGLang 反而领先 15%。同一个模型、同一张卡、同一份负载,两个引擎的胜负居然由并发数决定。这听起来很反直觉,但恰恰是 LLM 推理服务基准测试里最常见的陷阱——数字本身是真的,实验设计是错的,于是结论既不可复现,也不可解释。
这篇文章想把"怎么做一次合格的 vLLM / SGLang 并发实验"这件事讲透。它适合两类人:一类是刚接触推理服务选型,想拿一组靠谱数字给团队交差的;另一类是已经在用其中一个引擎,却总觉得网上 benchmark 和自己的环境对不上的。我会从数字失真来源讲起,把控制变量、指标口径、两个引擎的实现差异、最终可复现的实验流程一条条列出来。所有命令和脚本都是我在本地实际跑过的,带注释,可以直接照抄。
1. 先拆数字失真源:采样、预热与"并发"的定义都要先对齐
1.1 采样随机性:默认配置下根本不该直接取平均值
LLM 推理服务默认走随机采样,同一条 prompt 每次生成的 token 数量都不一样。token 数量直接决定 decode 迭代次数,接着决定端到端延迟。所以同一批请求跑两轮,吞吐量差 5% 到 10% 是家常便饭。我实际测过一组数据:100 条请求、每轮重跑,默认 temperature 下吞吐波动在 ±8% 左右;把 temperature 固定为 0(greedy 解码)之后,波动压到了 ±2% 左右。想比较引擎,先固定解码方式,否则你的误差范围比两个引擎的真实差异还大,结论自然不可复现。
另一个容易被忽略的点是 max_tokens 上限。很多压测脚本只传 prompt 不传 max_tokens,模型会一直生成到结束为止,生成长度完全由模型临时决定。这等于每轮之间的请求负载都在变,比较的基准是飘的。正确做法是固定 max_tokens(比如 256),让每条请求的生成长度上限一致。这不是"限制模型能力",而是构造可对比的控制条件——负载里只有输入差异,没有生成长度的随机差异。
1.2 预热不足:冷启动阶段的数字属于另一个世界
引擎首次启动有很多一次性成本:权重加载、CUDA graph 捕获、KV cache 预分配、算子 autotune。这些工作发生在正式请求之前,但它们的影响不会立刻消失。CUDA graph 捕获后的第一次调用往往明显偏慢,显存分配和 page 初始化也需要几轮请求才会达到稳态。
我踩过的一个典型坑是:脚本启动服务后立刻开跑,前 20 条请求的延迟比后面高 30% 以上;如果总请求数只有 50 条,冷启动部分在平均值里占比过大,两个引擎的差异会被严重稀释。后来我在流程里强制加了预热轮:正式计数的轮次之前,先用同一份负载跑 2 轮并丢弃。判断预热是否完成,观察连续两轮的平均延迟是否收敛——收敛了再开始记录。这两轮不算实验数据,只算服务端热身。
1.3 "并发"在两个引擎里指的不是同一个东西
压测脚本里的"并发数"是客户端同时在途请求数,服务端实际怎么执行是另一回事。vLLM 的--max-num-seqs控制服务端最多同时处理的序列数,SGLang 对应的是--max-running-requests。请求进入服务端后还要经过调度器排队,并不是客户端发多少服务端就立刻处理多少。
更关键的是,连续批处理(continuous batching)机制下,prefill 和 decode 不是严格分批完成的:新请求可以在已有请求 decode 的空隙插入。所以并发数一旦超过服务端上限,多出来的请求只是在客户端排队,压测结果体现的是排队延迟而不是引擎能力。这一点直接决定了并发阶梯怎么设计——不能瞎选档位,要能覆盖服务端 batch 上限以内和以外两个区域。
2. 控制变量清单:锁频率、锁参数、锁负载
2.1 硬件层固定项:先把 GPU 频率和功耗钉死
服务器上其他任务、GPU 频率调度、功耗墙,都会让实验产生小时级别的漂移。我每次实验开头的固定动作是三条命令:
# 锁定 GPU 频率,具体数值按你这张卡实测的稳定频率填 nvidia-smi -lgc <最低频率>,<最高频率> # 限制整卡功耗,避免 boost 带来的波动 nvidia-smi -pl <功耗值> # 实验结束后记得解除锁定 nvidia-smi -rgc锁频的作用是消除自动 boost 带来的随机性。GPU 频率差 200MHz,吞吐量能差 10% 以上,这个幅度在两个引擎的实际性能差异面前,属于不可忽略的噪声级别。不锁频的话,你很难说清一次结果波动到底是引擎问题还是频率波动。
实验全程要盯显存温度和功耗。显存温度超过阈值后降频是常见的环境干扰。可以这样采样:nvidia-smi --query-gpu=temp_mem,clocks.sm,power.draw --format=csv -l 1,定时输出到文件,跑完和基准结果放一起归档。我后面在 5.3 节会给一个明确的通过标准。
另外,压测期间机器上不要跑别的推理服务、不要开多线程编译、不要挂视频转码任务。实验前用nvidia-smi确认没有其他进程占用显存。这条看起来是废话,但我确实见过同事在实验期间忘了关另一个推理服务,最后两个引擎都"变慢",数据全废。
2.2 引擎层参数对照表:参数并不是一一对应的
两个引擎的目标一致,但参数命名和粒度很不一样。我整理了一份常用对照:
| 作用 | vLLM | SGLang |
|---|---|---|
| 服务端最大序列数 | --max-num-seqs | --max-running-requests |
| 显存分配比例 | --gpu-memory-utilization | --mem-fraction-static |
| 最大序列长度 | --max-model-len | --max-total-tokens |
| prefill 分块 | --enable-chunked-prefill | --chunked-prefill-size |
| CUDA graph 批大小 | --cuda-graph-max-num-seqs | --cuda-graph-max-batch-size |
| 请求日志 | --disable-log-requests | --log-level warn |
表格里的"对应"只是功能上近似,并不是等价。vLLM 的 max-num-seqs 是调度器层面的硬上限,SGLang 的 max-running-requests 类似,但两者对排队行为、KV cache 预留策略的处理方式有差异。这也是为什么对比实验里,我会保留两个引擎各自的默认参数,而不是强行调成"看起来一样"——强行对齐参数有时反而会掩盖引擎在自己默认配置下的真实表现。
不过有一组参数必须手动统一:max-model-len 和显存比例。如果 vLLM 用 8192、SGLang 用 4096,后者能塞进更多 batch,吞吐量自然占便宜。统一切到 8192,显存比例都设 0.90,这是对比的底线,不统一这组参数,后面所有结论都站不住。
2.3 负载设计:合成请求的可控性与真实流量的说服力
请求负载是另一个主要变量。我常用的方案分三层:
- 固定长度负载:所有 prompt 截断到相同长度(比如 512 token),生成长度固定 256 token。变量最少,用来快速定位引擎自身的能力上限。
- 混合长度负载:按真实服务的统计分布生成,输入在 128 到 2048 token 之间、输出在 64 到 512 token 之间。用来模拟生产环境。
- 前缀复用负载:一批请求共享同一段长系统提示词或多轮对话前缀,用来观察前缀缓存的效果。
压测时三种负载分开跑,不要混在一个结果里。特别是前缀复用负载,它专门放大前缀缓存机制的差异,混进普通负载会让结论失真。前缀缓存对 SGLang 的 RadixAttention 和 vLLM 的自动前缀缓存影响都很大,属于"加分项"负载,不适合作为综合对比的唯一依据。
请求文件要提前生成好、固定顺序,跑完连同结果一起归档。不要用脚本现场随机生成,那样每轮之间的负载都不一样,连可复现的基础都没有。
3. 指标拆解:只比吞吐量基本等于没比
3.1 三个关键指标的定义与采集
并发实验至少要同时记录三个指标。任何只给一个吞吐数字的报告,我都持保留态度:
- 吞吐量(tokens/s 或 requests/s):单位时间完成的 token 数或请求数。
- TTFT(Time To First Token):从请求发出到收到第一个 token 的耗时。流式输出下,客户端记录发请求时刻和首个数据块到达时刻的间隔。
- TPOT(Time Per Output Token):相邻两个输出 token 的平均间隔。流式场景下用(总耗时 - TTFT)/ 生成 token 数近似,更精细的做法是在流式解码过程中记录每个 token 到达的时间戳,算真实相邻间隔。
这三个指标必须从客户端视角采集,而不是从服务端日志推断。服务端日志里的时间戳经常不包含网络排队时间,而恰恰是并发上升时排队时间增长最快。客户端采集的好处是能真实反映用户体验,缺点是会把网络抖动也带进来;走本地回环地址测试,可以把这部分影响压到很小。
3.2 统计口径:中位数、P95 和样本数一起给
并发实验里延迟分布是长尾的,平均数会被个别掉队的请求带偏。我出报告时的固定口径是:每个并发档位至少跑 3 轮,每轮至少 100 条请求;延迟类指标给中位数和 P95,吞吐类指标给中位数;样本数不足 30 的档位直接不写结论。
这里有个采集细节:吞吐量在不同轮次之间差别不大时,取平均或取中位数都行;但延迟的 P95 对"线程池调度抖动"非常敏感。为了让 P95 可信,客户端脚本要把请求均匀地铺到各个线程上,不要在循环里先发完一个线程的任务再发下一个线程的任务。压测工具本身的质量会影响 P95 的可信度,这个问题经常被人忽略。
3.3 并发阶梯怎么选点
并发档位要覆盖服务端 batch 上限以内和上限以外。一个我常用的阶梯:
| 并发档位 | 观察重点 |
|---|---|
| 1 | 单请求延迟基线,TTFT / TPOT 的理论最优值 |
| 4 | 轻负载,调度器尚未构成瓶颈 |
| 8、16 | 常见生产负载区,观察吞吐是否随并发线性增长 |
| 32 | 接近服务端 max-num-seqs 上限,观察拐点 |
| 64 | 超过上限,观察排队延迟和错误率 |
档位之间按约 2 倍递增,原因是:要画出完整的吞吐-延迟曲线,档位太少找不到拐点;档位太多耗时成倍增加。我的经验是 6 个档位、每档 3 轮,总实验时长控制在 1 到 2 小时,信息量和耗时比较平衡。
4. 引擎差异如何塑造并发曲线
4.1 连续批处理:共同基底,但是两种调度风格
两个引擎都实现了连续批处理,这是它们性能优于传统静态批处理的关键。区别在于调度器何时把 prefill 请求插入 decode 阶段。vLLM 的调度器倾向于一有空闲 slot 就把排队请求的 prefill 做掉;SGLang 在类似机制上还叠加了对 prefill 块大小的控制。当并发低于资源上限时,两者行为接近;当并发逼近上限时,调度时机就决定了排队延迟和吞吐曲线的形状。
用生活化的类比:两家餐厅都在"来一桌、安排一桌",但一家是只要有空桌就立刻上菜,另一家会等到能凑几桌再一起上。低客流时无差别,高客流时排队节奏和出餐节奏完全不同。这也是为什么"并发 8 时 vLLM 赢、并发 32 时 SGLang 赢"这种反转并不奇怪——两个引擎的调度平滑区不一样。
4.2 前缀缓存:为什么共享前缀会改变结论
SGLang 的 RadixAttention 会对 KV cache 做前缀复用,相同系统提示词只算一次 prefill;vLLM 也有自动前缀缓存,但两者的命中粒度、淘汰策略不一样。我实验里遇到过一个典型场景:请求都带一段很长的系统提示词,SGLang 的 TTFT 明显领先;去掉共享前缀后,两者的 TTFT 差距大幅缩小。
这说明一个关键点:如果你拿"带长公共前缀"的负载跑出某个引擎全面领先,结论只对该负载成立。报告里必须写明负载中的前缀占比、长度分布,否则换一个应用场景,结论就可能翻车。前缀缓存是真实优势,但它属于场景相关优势,不是普适优势。
4.3 曲线上拐点的两种读法
并发曲线的拐点可能来自两个完全不同的原因:
- 资源达到上限:显存或计算单元被用尽,吞吐不再增长,甚至开始下降。
- 调度排队开始显著:服务端 batch 已满,新请求在队列里等待,TTFT 快速上升。
分辨方法很简单:同步看 TTFT 曲线。吞吐还在涨但 TTFT 开始陡增,说明瓶颈是排队;吞吐和 TTFT 同时恶化,说明是资源上限。任何一组实验报告,都应该把这两条曲线放在一起解释,而不是只甩一条吞吐曲线。单独看吞吐量,你会把"排队导致的 TTFT 恶化"误判成"引擎能力不足"。
5. 一套可复现的压测流程:启动、压测、自检
5.1 服务端启动参数清单
以统一 max-model-len=8192、显存比例 0.90 为例,两个引擎的启动命令:
# vLLM 服务端 python -m vllm.entrypoints.openai.api_server \ --model /data/models/7b-chat \ --max-model-len 8192 \ --gpu-memory-utilization 0.90 \ --max-num-seqs 32 \ --enable-chunked-prefill \ --disable-log-requests# SGLang 服务端 python -m sglang.launch_server \ --model-path /data/models/7b-chat \ --max-total-tokens 8192 \ --mem-fraction-static 0.90 \ --max-running-requests 32 \ --chunked-prefill-size 2048 \ --log-level warn注意我把 max-num-seqs 和 max-running-requests 统一设成 32,这样并发阶梯里 32 档正好是服务端上限的分界点。CUDA graph 相关参数我建议保持默认,除非你明确知道要调什么,否则动它只是增加变量。
5.2 客户端压测脚本:从第一个 token 到总耗时的采集
脚本核心逻辑是用 ThreadPoolExecutor 控制并发,对每条请求开流式响应,分别记录 TTFT、总耗时和生成 token 数。
import json import time from concurrent.futures import ThreadPoolExecutor import requests URL = "http://127.0.0.1:8000/v1/completions" def build_payload(prompt): return { "prompt": prompt, "max_tokens": 256, "temperature": 0, # greedy,消除采样随机性 "stream": True, } def measure_one(prompt): t_start = time.perf_counter() ttft = None tokens = 0 with requests.post(URL, json=build_payload(prompt), stream=True) as resp: for line in resp.iter_lines(decode_unicode=True): if not line: continue if line.startswith("data: "): line = line[6:] if line == "[DONE]": break if ttft is None: ttft = time.perf_counter() - t_start tokens += 1 total = time.perf_counter() - t_start return {"ttft": ttft, "total": total, "tokens": tokens} def run_concurrency(prompts, workers, rounds): results = [] for _ in range(rounds): with ThreadPoolExecutor(max_workers=workers) as pool: results.extend(pool.map(measure_one, prompts)) return resultsrun_concurrency外面再套一层文件输出即可,每个档位一个独立结果文件,文件名带上并发档位、轮次、时间戳。批量跑的时候,每档之间暂停 30 秒,让服务端队列清空,避免上一档的尾巴污染下一档的起始状态。
5.3 跑完之后的数字自检清单
拿到数据别急着画图,先过一遍自检。任何一个检查项不过,这组数据就不能用来下结论:
| 检查项 | 通过标准 |
|---|---|
| 预热轮 | 正式计数前至少丢弃 2 轮 |
| GPU 频率 | 全程不低于锁频值,无降频记录 |
| 显存温度 | 实验前后温差小于 5°C |
| 采样设置 | temperature=0,max_tokens 固定 |
| 请求负载 | 同一份 prompts 文件,未重新生成 |
| 版本记录 | 引擎版本、CUDA、驱动、权重 hash 已归档 |
| 样本量 | 每档每轮不少于 100 条请求 |
自检不过的数据直接重跑,不要带着已知噪声做结论。这个清单看起来繁琐,但排查"数字对不上"的问题时,它的价值比任何调优技巧都大。
6. 把结论框在可信边界里:噪声识别与版本锁定
6.1 多轮运行与置信区间
以 6 个档位、每档 3 轮、每轮 100 条请求计算,每组有 300 个样本。算均值和标准差,做一个 95% 置信区间。如果 vLLM 和 SGLang 的区间有重叠,那无论点估计差多少,都只能写"在该并发下无显著差异"。
实际跑下来最常见的局面是:低并发下两者 TTFT 差异明显、置信区间不重叠;高并发下吞吐差异很小、区间重叠。这种结果比"谁全面领先"更有价值——它说明差异集中在某个工作区间,选型决策也应该基于目标负载所在的区间来做,而不是基于单一榜单。
6.2 环境漂移:频率、温度与后台任务
我经历过一次很典型的环境漂移:同一组实验,上午跑出来 vLLM 领先,下午跑变成 SGLang 领先。查日志发现下午 GPU 频率整体低了 180MHz,原因是机柜散热变差,显存温度比上午高 6°C。所以环境监控日志不是可有可无的附件,而是解释数字反转的第一嫌疑人。把 GPU 监控采样固定为 1 秒一次,和请求日志放同一个目录,这是我实验档案的标配。
6.3 版本锁定与实验档案
性能实验最容易被忽略的是版本漂移。两个引擎迭代都很频繁,两周前的 commit 和现在的 commit 可能差出 20% 的性能。我的做法是给每个实验建一个独立的档案目录:
exp-20250601-7b-concurrency/ ├── env.txt # 引擎版本、commit、CUDA、驱动、模型 hash ├── config-vllm.yaml # 服务端完整启动参数 ├── config-sglang.yaml ├── prompts.jsonl # 请求负载(固定) ├── gpu-monitor/ # 每秒采样日志 └── results/ # 每档原始结果和统计脚本env.txt 里记录的不只是pip show vllm的版本号,还要记 commit。版本号在小版本迭代里经常不变,但代码已经变了。模型权重也要记录目录的文件 hash,防止换过权重文件却还在用旧文件名。档案攒多了之后,你就能更快判断一次新报告的差异到底是引擎进化、环境变化,还是实验设计变了——这也是"可解释"的最终形态。
最后分享一个小习惯:每次实验前后,我都会在终端跑一遍显卡状态查询把基线存档,实验结束再跑一遍对比。两分钟的事,但在解释数字时能省下好几个小时的排错时间。性能对比实验最大的敌人不是引擎本身,而是我们没有把环境变量关进笼子里。把频率钉住、把负载固定、把缓存预热、把统计口径写清楚,剩下的差异,才真正值得讨论。