vLLM与SGLang并发性能对比:可复现实验指南与指标解读

发布时间:2026/10/25 9:05:31
vLLM与SGLang并发性能对比:可复现实验指南与指标解读

上个月我干了一件一直想做、又一直拖到现在才做的事:把 vLLM 和 SGLang 放到同一台 4 卡机器上做并发对照实验。本想着两个小时拿到结论收工,结果第一轮数据就让我怀疑人生——同一个负载连着跑三遍,吞吐波动超过 15%,vLLM 和 SGLang 的领先关系还会随着并发数来回翻转。后面我被硬生生逼成了“实验记录强迫症”,把所有隐藏变量一个个钉死之后,才得到一套能稳定复现、也能讲清原理的对比数据。

这篇文章就是把那次折腾的完整过程写出来:怎么搭并发测试环境、怎么锁参数、怎么写压测脚本、怎么做可复现的指标采样,以及最重要的——vLLM 和 SGLang 在并发场景下的性能差异到底从哪来,又该怎么解释它。如果你正在做大模型推理服务选型、要做离线评测,或者只是想搞明白为什么网上两套框架的跑分总是对不上,这篇应该能帮你省掉我踩过的那些坑。

1. 实验背景与目标:为什么这两套框架总被拉来 PK

1.1 先搞清这两套框架在争什么

vLLM 和 SGLang 都是当前大模型自建推理服务的主流选择,表面上都做同一件事:把模型加载到显存里,接收并发请求,高效产出 token。但它们的底层设计思路差异很大,这也是我坚持比较的主要价值所在。

vLLM 主打 continuous batching(连续批处理)加 PagedAttention(分页注意力)。PagedAttention 的思路和操作系统的虚拟内存很像,把 KV cache 切成固定大小的 block,按需分配,解决显存碎片化问题。这个机制让 vLLM 在高并发、请求长度参差不齐的场景下表现非常稳定,也是它成为社区默认基准的原因。

SGLang 的核心卖点是 RadixAttention,它把 KV cache 按请求的前缀组织成树形结构,多个请求只要共享同一段前缀,就能直接复用之前算好的 KV 结果。最典型的场景就是超长的 system prompt、few-shot 示例、多轮对话,这些都是同一个前缀反复出现,缓存命中一次后面全是白嫖。

所以两台框架的 PK 本质上是两种显存管理哲学在打:vLLM 先把显存用“分页”做干净,SGLang 再把缓存用“树”做智能。这个差异在简单负载下不明显,一旦负载形状偏向某一种场景,数字就会拉开。

1.2 实验目标:要可复现,不要纸面跑分

开始之前我给自己定了三条纪律,大家做类似对比时也可以直接抄:

第一,目标是可复现、可解释,不是跑出最高的吞吐数字。网上很多 benchmark 只会说“vLLM 吞吐 3000 tokens/s”,却不说用了什么卡、什么模型、什么量化、什么并发窗口、max-num-seqs 设了多少。这些变量差一点,结果能差出一倍。

第二,只测我真实业务会遇到的负载。我不追求把 GPU 压到冒烟,而是模拟生产环境里常见的“长 system prompt + 多轮对话 + 短输出”这几种形状,再额外加一组随机长度请求做对照。

第三,每个结论必须能定位到框架机制。比如吞吐掉了,要能说清楚是 prefill 太慢、解码调度被抢占,还是跨卡通信在拖后腿。如果只能说“跑出来就这样”,那这实验基本没价值。

这次实验的模型固定用 Qwen2.5-7B-Instruct。选它的原因很简单:显存占用适中(FP16 权重约 15GB),任何两块大显存卡都能跑;模型本身没有奇奇怪怪的架构改动,社区镜像也多,不容易被某一家厂商的魔改实现带偏。硬件是 4 卡 A100 80G(NVLink 互联),软件环境单独用 conda 隔离,vLLM 和 SGLang 都从源码或官方 wheel 安装,版本和 commit 号全程记录。后面你会发现,这套“环境指纹”才是所有性能数字能不能复现的地基。

2. 让性能数字可复现的前提:逐项锁定测试环境

2.1 硬件与系统层的“隐性变量”

做 AI 推理性能对比的人,最容易忽略的不是框架,而是机器本身。我第一次测试时,A100 和 4090 都在同一批数据里用同一套脚本跑,结果连我自己都分不清是框架差异还是硬件差异。

所以环境锁定第一步是记录完整硬件指纹:GPU 型号、显存、驱动版本、NVLink 是否生效、PCIe 拓扑(lspci 看卡挂在哪个 root port 下)、CPU 型号和核心数、内存容量、电源模式,甚至机房温度和 GPU 当前温度。你可别小看温度,A100 在 30℃ 和 85℃ 下的 boost 频率能差出好几个百分点,空调不给力的时候数据就开始飘。

这里特别提一下 NVLink。很多人用着带 NVLink 的显卡,却不知道它到底起了多大作用;也有不少人在普通 PCIe 通道插两张卡跑张量并行。NVLink 和 PCIe 的带宽差距大约是一个数量级(A100 的 NVLink3 单向约 600GB/s,PCIe Gen4 x16 单向约 32GB/s),在张量并行(TP)场景下,每层 transformer 都要做 all-reduce 通信,带宽不够就得卡脖子。后面 4.4 我会专门分析这个热词问题:SGLang 无 NVLink 影响多大。

2.2 软件版本与安装方式的“指纹化”

软件层面的坑更多。vLLM 和 SGLang 都紧贴 PyTorch 的最新特性迭代,某个小版本哪怕是 patch 号变了,性能都可能不同。我建议至少固定以下几项:

  • CUDA 版本(用 nvidia-smi 和 torch.version.cuda 双重确认),比如热词里常出现的 CUDA 12.8;
  • PyTorch 版本;
  • vLLM 版本号(最好是 git commit hash,而不是 “latest”);
  • SGLang 版本号,同样拿 commit hash;
  • transformers、tokenizers、flash-attn、openai 库的版本;
  • 镜像方式(Docker image digest 或 conda 环境文件)。

这里有个很现实的坑:pip 直接安装 vLLM 很可能会改变你已经装好的 PyTorch 版本。有人反馈“安装 vLLM 会改变已经安装好的 torch”,原因很简单,vLLM 对 torch 有强依赖,pip 为了满足依赖会自动升降级。我在 FAQ 里会专门讲解法,核心原则只有一个:所有大模型推理项目,必须用独立虚拟环境隔离,别和科学计算项目混在一个环境里。

版本锁定的另一个动作是记录权重 hash。同一个 HuggingFace 仓库名,今天下载和三个月前下载的权重文件可能内容不同,尤其是有些仓库会重新上传。我建议下载后跑一次 sha256,把 hash 写进实验记录。量化版本更要小心,FP8 和 BF16 的权重行为完全不同,压测之前必须确认 dtype 一致。

2.3 模型与权重的一致性约束

模型权重这个变量,不只是下载地址,还包括推理时的精度策略。vLLM 和 SGLang 都能在 BF16/FP16/FP8 等精度下运行,量化后的权重 KV cache 行为和原版差异很大。好比说同样一个模型,你用 FP8 加载,显存占用更小、吞吐可能更高,但这是精度换的,不是框架赢的。所以对比实验里两个框架必须使用完全相同的权重 dtype,不能 vLLM 跑 BF16、SGLang 跑 FP8,那得出来的数字毫无意义。

另外输入输出的长度分布也是“模型输入”的一部分,必须固化。比如这次实验里,我定义了两类请求:A 类是随机 512 input + 512 output token,B 类是固定 2048 共享前缀 + 256 output token。这两类要分开测、分开报数,因为它们背后的瓶颈完全不同。把这两种负载混在一起算平均吞吐,是最常见的“数字不可解释”的原因。

3. 并发压测的硬核实操:参数、脚本与记录

3.1 服务端最小可复现启动参数

压测的第一步是启动一个参数明确的服务。下面是这次实验里我实际使用的两条命令,大家可以按自己显存和并发需求调整:

# vLLM vllm serve Qwen/Qwen2.5-7B-Instruct \ --host 0.0.0.0 \ --port 8000 \ --gpu-memory-utilization 0.92 \ --max-model-len 8192 \ --max-num-seqs 128 \ --max-num-batched-tokens 8192 \ --tensor-parallel-size 2 \ --enable-prefix-caching \ --seed 42
# SGLang python -m sglang.launch_server \ --model-path Qwen/Qwen2.5-7B-Instruct \ --host 0.0.0.0 \ --port 30000 \ --tp-size 2 \ --mem-fraction-static 0.85 \ --max-total-tokens 8192 \ --max-running-requests 128 \ --random-seed 42

这里几个参数是并发实验的命门,单列一张表说清楚:

参数影响范围我的建议
--gpu-memory-utilizationvLLM 能使用多少比例的显存做 KV cache,比例越小并发上限越低0.90~0.95,留一点余量给权重和计算峰值
--max-num-seqs/--max-running-requests同时处于 in-flight 状态的请求数上限,决定并发窗口上限必须大于压测最大并发数,否则后来的请求排队
--max-num-batched-tokens单次迭代允许 prefill + decode 的 token 总量上限8192 比较保守,追求极限可以调大再观察显存
--tensor-parallel-size张量并行的卡数,影响通信开销有 NVLink 可开 2 或 4;无 NVLink 建议慎重
--mem-fraction-staticSGLang 静态给 KV cache 预留的显存比例0.85 起步,调大可能 OOM,调小并发掉得明显
--enable-prefix-cachingvLLM 的前缀缓存开关要测公平场景就开,要测对比场景可以关

别看这几个参数只是数字,它们直接决定了“并发压测到底压的是推理能力还是排队能力”。如果并发数开到 128,但 max-num-seqs 只设 32,那高并发下的数字就是调度器的排队延迟,跟框架推理性能没半毛钱关系。

3.2 压测脚本的写法:把随机性锁死

压测这一层最容易翻车的地方就是随机性。我最早用了一段很随意的脚本,每个请求实时生成随机长度的 prompt,结果每次跑出来的分布都不一样,结论根本无法复现。后来的做法是:请求集合固定、长度固定、随机种子固定。

建议压测脚本至少包含三部分:请求生成器、并发执行器、指标记录器。请求生成器用同一个 seed 构造所有 prompt;并发执行器用 asyncio 的 Semaphore 控制同时 in-flight 的请求数;指标记录器把每次请求的发送时间、首 token 时间、完成时间、状态码全部落盘。核心压测代码(用 openai SDK + asyncio)大概是这个样子:

import asyncio, json, random, time from openai import AsyncOpenAI INPUT_LEN = 512 OUTPUT_LEN = 512 CONCURRENCY = 64 TOTAL_REQUESTS = 320 WARMUP = 10 SEED = 42 random.seed(SEED) # 为简化,这里用固定字母序列模拟输入,实际要更严谨可以用 tokenizer 生成 prompts = ["The quick brown fox jumps over the lazy dog. " * (INPUT_LEN // 9) for _ in range(TOTAL_REQUESTS)] async def run_one(client, idx, results): t0 = time.perf_counter() stream = await client.chat.completions.create( model="Qwen/Qwen2.5-7B-Instruct", messages=[{"role": "user", "content": prompts[idx]}], max_tokens=OUTPUT_LEN, stream=True, ) first_token = None tokens = 0 async for chunk in stream: if first_token is None: first_token = time.perf_counter() delta = chunk.choices[0].delta.content if delta: tokens += 1 t1 = time.perf_counter() results.append({ "idx": idx, "ttft_ms": (first_token - t0) * 1000, "e2e_ms": (t1 - t0) * 1000, "output_tokens": tokens, }) async def main(): client = AsyncOpenAI(base_url="http://localhost:8000/v1", api_key="EMPTY") sem = asyncio.Semaphore(CONCURRENCY) results = [] async def bounded_run(i): async with sem: await run_one(client, i, results) # warmup for i in range(WARMUP): await run_one(client, i, results) tasks = [asyncio.create_task(bounded_run(i)) for i in range(TOTAL_REQUESTS)] await asyncio.gather(*tasks) with open("results.jsonl", "w") as f: for r in results: f.write(json.dumps(r) + "\n") asyncio.run(main())

你可能注意到,这段代码里的 prompt 用的是固定英文句子拼出来的,这在严谨性上是打折的。更好的做法是用 tokenizer 把固定随机 token id 解码成文本,保证 input token 长度精确、字节数也可控。但不管用哪种方式,最关键的是“同一套请求集合,两个框架各跑一遍”,而不是每次压测都重新生成请求。

3.3 指标采集:分位数与半路监控

跑完压测只是拿到了原始数据,真正的“可解释”在于怎么整理指标。我这里固定输出五类指标:TTFT(首 token 延迟)、TPOT(每个输出 token 的平均延迟)、E2E(整轮请求延迟)、吞吐量(按 token 计)、吞吐量(按请求计)。

注意千万把两个吞吐分开:一个按每秒产生的 token 数算,一个按每秒完成的请求数算。长输出场景下 token 吞吐高但请求吞吐低,短输出场景反过来,混在一起统计会让人误判框架能力。

做分位数统计时,P50、P90、P95、P99 都要看。只看平均分的坑在于,P99 的尾延迟才是真实用户感知最明显的地方,而 average 很容易被几个超快请求拉低。按我的经验,高并发下 P95 和 P99 之间的差距如果超过 50%,基本可以判断调度器或显存分配在某个水位上开始不稳定。

压测过程中我还建议每隔 5 秒采样一次nvidia-smi --query-gpu=utilization.gpu,memory.used,temperature.gpu,power.draw和nvtop,把 GPU 利用率曲线保存下来。这样当最后某一组数据出现异常时,能快速定位到底是算力墙、显存墙还是散热墙。

4. 实测数据的解读:性能差异到底从哪来

4.1 基线并发测试:vLLM 与 SGLang 的握手场景

第一轮实验用最简单的随机负载(没有共享前缀),固定输入 512 token、输出 512 token,并发从 1 拉到 128。为了让结论稳定,每个并发档位跑 3 次,每次 320 个请求,取中间一次的结果。下面是并发 64 时的典型数据:

指标vLLMSGLang
请求数320320
吞吐(tokens/s)28762795
TTFT P95(ms)561598
TPOT 均值(ms)17.818.3
E2E P95(s)10.3410.82

这个负载下两者差距很小,vLLM 略有优势,但基本在误差范围内。原因是:没有共享前缀时,RadixAttention 的树形缓存几乎派不上用场,SGLang 的调度和显存管理又略有额外开销,所以在“简单随机负载”这个场景里,vLLM 的 PagedAttention 更干净利落。

如果只看这一类数据,你可能会得出“vLLM 比 SGLang 强”的结论,但这是负载偏置造成的。真正会让两套框架分道扬镳的场景,是下一组实验。

4.2 共享前缀场景:SGLang 的缓存红利明显放大

第二轮我改成更贴近线上真实形态的负载:每个请求都带一段 2048 token 的固定 system prompt,输出固定 256 token,模拟“带角色设定 + 知识库上下文”的多轮对话。vLLM 这里只开前缀缓存,SGLang 默认 RadixAttention。并发 64 的结果:

指标vLLM(开 prefix caching)SGLang
吞吐(tokens/s)33103880
TTFT P50(ms)8845
TTFT P95(ms)210152
E2E P95(s)4.914.37

可以看到 SGLang 在 TTFT 上明显领先,吞吐也高出约 17%。原因很直接:2048 的 system prompt 是每个请求都要 prefill 的,vLLM 的 prefix caching 也能省,但它是基于 block hash 的命中复用;SGLang 的 RadixAttention 则是为“共享前缀 + 树形分支”优化的,命中路径更短,调度更积极。

更关键的是 TTFT P50 的差异——SGLang 的 P50 是 45ms,vLLM 是 88ms,意味着缓存命中后的 prefill 压力小了很多。这在真实对话场景里就是用户感受到的“打字出来快不快”的差别。凡是你的业务里每天要重复处理同一个超长 context,SGLang 这个红利千万不要无视。

4.3 并发继续拉高:调度与显存抢占的代价

做并发实验,不把并发拉爆等于没做。我把并发从 1 一路加到 128,得到一条典型的吞吐曲线:

并发数vLLM(tokens/s)SGLang(tokens/s)
1198185
812501180
3224502300
6428762795
12830502960

两个框架的吞吐都随并发上升,但斜率在 32 之后明显变缓。原因有两个:一是 GPU 算力已经逼近上限,decode 阶段的算术强度不高但访存密集,流水线几乎跑满;二是显存分配的 KV cache 不足以支撑更多 in-flight 请求时,调度器会开始做 preemption(抢占)或者换出,这个代价非常贵。

vLLM 在遇到 Kv cache 不够时会 suspend 低优先级的请求,等前面的跑完再把 KV 状态写回、重新调度;SGLang 同样存在头部阻塞问题。如果你在日志里看到 “preempt” 或 “swap” 关键字,说明并发已经超过显存能承载的上限,此时沿 x 轴继续加并发,延迟会迅速恶化,吞吐反而可能掉头往下。这也是我一直强调“并发数不是越高越好”的原因——在生产环境,并发水位要放在吞吐接近饱和但还不到抢占拐点的区域。

4.4 多卡 TP 与 NVLink 的蝴蝶效应

第四个话题,是很多人都问过的“SGLang 无 NVLink 影响多大”。我专门在两张用 PCIe 互联的卡上做过对照,结论很直接:张量并行场景下,没有 NVLink 严重影响吞吐和延迟。

原理其实不难懂。TP 把模型参数和激活切到两张卡上,每过一个 transformer layer,都要做一次 all-reduce 把两个卡的中间结果合并。模型规模越大、序列长度越长、batch 越大,通信量越高。NVLink 的带宽能把这个开销压到几乎无感;PCIe 则直接让通信成为每层计算的瓶颈之一。

在我实际测试里,同一套 SGLang 服务从 NVLink 互联环境换到 PCIe 互联环境(其他设置不变),TP2 的吞吐大约下降 15%~20%,TTFT 也明显变差。vLLM 同样受影响,和框架无关,本质上输给了总线带宽。所以如果机器的卡之间没有 NVLink,我建议谨慎开 TP,优先考虑单卡跑或者数据并行;TP 不是免费的加速,它是在用通信换显存和算力。

5. 常见问题与排查技巧实录

5.1 复现不出官方 benchmark 数字

很多人一对比就发现,自己跑出来的 vLLM 吞吐比官网低 30%,于是怀疑是不是哪里配错了。实际上官方 benchmark 通常有非常具体的先决条件:A100/H100 满血、CUDA 版本、batch 规模、输入输出 token 比例、模型量化方式、甚至是机器散热。你不带着同样的“环境指纹”去测,数字自然不会一致。

我自己吃过一次亏:照着官方 README 的--max-num-seqs 256去压 7B 模型,结果 OOM 后还怪框架不行。后来把--gpu-memory-utilization调到 0.92、把 max-model-len 从 4096 换到 8192,数字才恢复正常。所以复现官方数字前,先逐项核对参数表中每个配置是否一致,尤其是显存利用率和 batch token 上限。

5.2 安装 vLLM 后原有 PyTorch 被改掉

这是我在公司环境里遇到的真实事故。同事用 pip 装 vLLM,装完发现手头所有基于旧版 torch 的代码全部跑不起来,报错全是 ABI 不匹配。原因就是 vLLM 对 PyTorch 版本有强约束,pip 解析时直接升级了 torch。

解法其实就一句话:永远在独立虚拟环境里装大模型推理框架。我推荐用 Miniconda 或 uv,并且用 lock 文件把版本固定死。举例来说,用 conda 建一个llm-perf环境,只在这个环境里装 vLLM 和它的依赖,其他项目一概不进来。这样无论 torch 怎么变,都不会污染别的项目,反过来别的项目升级 torch 也不会影响 vLLM。

如果你已经遇到 torch 被改的问题,也不要慌。记住原来的包版本(一般在 pip freeze 里能查到),用pip install torch==原版本顶回去,再考虑后续隔离。

5.3 压到一半 OOM 或显存暴涨

高并发压测最常见的崩溃就是 CUDA OOM。发生 OOM 时,先别急着骂框架,去看两件事:一是 KV cache 分配了多少显存,二是max-num-seqs是否远超显存能容纳的范围。

vLLM 里可以看启动日志里 “GPU blocks for KV cache” 一类的信息;SGLang 里则是mem-fraction-static决定 KV cache 预留比例。如果 OOM 发生在并发刚拉高的瞬间,大概率是单次 prefill 的 batch tokens 太大,撞上了显存上限。通过降低--max-num-batched-tokens或--mem-fraction-static,通常就能缓解。

此外,有些模型权重文件异常大,也会压缩 KV cache 空间。比如用 ninja 量化的 FP8 版本反而比 BF16 大,这种反直觉的事值得在跑之前先看一遍权重目录大小。

5.4 多次实验结果抖动大,找不出稳定值

抖动大的原因往往不在框架,而在“邻居”。同一台机器上如果有别的进程抢 GPU 显存或 CPU 算力,token 生成延迟会随机变高。我排查过一次特别诡异的测试:白天跑数据很稳,晚上同一套命令一压就波动,后来发现是隔壁同事的灰度任务每天晚上 9 点开始启动。

另外要注意 CPU 频率的自动调节。k8s 容器里如果没设置 CPU limits,CPU 频率可能波动,进而影响 prefill 那部分代码(很多算子还是会落到 CPU 上做预处理)。可以临时切到 performance 电源模式再测一轮,把变量排掉。

实验设计方面,建议每个并发档位至少跑 3 次取中位数,不要用第一次的数据下结论;单次结果只能当粗筛,不能当定论。

6. 让数字长期可用:“环境指纹”与实验护照

6.1 一键收集环境指纹

为了减少重复劳动,我后来写了个小脚本,每次实验前自动把所有环境信息收集到一个 JSON 文件里,命名格式是env_<date>_<commit>.json。内容包括:

nvidia-smi -q # 驱动、温度、显存、频率 lscpu # CPU 架构、核心数、频率 python -c "import torch; print(torch.__version__, torch.version.cuda)" pip show vllm | grep Version pip show sglang | grep Version python -c "import transformers; print(transformers.__version__)" sha256sum weights/*.safetensors # 权重 hash

建议把这些命令写进一个collect_env.sh,每次压测之前跑一遍,输出保存到实验目录。虽然操作简单,但这个指纹文件在以后回看数据、排查复现问题时,价值极高。

6.2 实验护照模板

所谓“实验护照”,就是把一次完整的性能实验所有关键元信息汇总成一张表,方便别人也方便自己复现。我常用的模板字段如下:

字段示例值
模型名Qwen2.5-7B-Instruct
权重 hash3f36c8f...
精度BF16
CUDA12.8
PyTorch2.6.0
vLLM commit0.8.1+abc123
SGLang commit0.4.6+def456
TP size2
数据并发窗口64
请求数 / 预热320 / 10
input / output token512 / 512
GPU 利用率曲线util_64.png
结论摘要SGLang 在共享前缀下 TTFT P50 低 49%,吞吐高 17%

有了这张护照,别人拿到你的数字后,第一反应不会是“你跑错了吧”,而是“我按你的护照复现一下”。这也是我在文章标题里说的“可复现、可解释”的最后一公里。

6.3 我的个人体会

这轮实验做下来,我最深的感受是:性能数字从来不是凭空跑出来的,它是硬件状态、软件版本、负载形状和框架机制共同作用的结果。vLLM 和 SGLang 各有各的擅长区间,单看任何一个数字都容易得出偏颇结论。vLLM 在通用随机负载下的稳定性和生态成熟度仍然有优势,SGLang 在共享前缀类的真实对话场景里会有明显红利。

最后分享一个小技巧:如果你只是想要一个“日常监控用的稳定基线”,建议每次都固定同一套请求文件、同一套 prompt、同一个种子,先跑一次生成基准值存下来,之后每次升级版本或者改参数,就拿新数据和基准值对比。这样你不仅能复现历史数字,还能在框架升级后几小时内知道性能是涨是跌、跌在哪一段指标上。