☰
vLLM与SGLang并发压测全流程:从环境固定到指标口径的可复现方法
2026/10/12 3:29:00 网站建设 项目流程

你有没有过这种经历:明明同一个模型、同一张卡、同样的并发数,昨天跑出来的吞吐量还是 900 tokens/s,今天换个环境跑只剩下 600;或者 vLLM 和 SGLang 各跑一遍,数字差得离谱,但谁都不敢说这个差距是框架本身的差距,还是测试方法造成的假象。如果你也卡在这种“数字对不上”的困境里,这篇文章就是写给你的。

我以 vLLM 与 SGLang 的并发压测实验为例子,完整走一遍从环境固定、请求集设计、并发脚本、指标口径到机制解释的流程。不是给你看“谁跑得快”,而是把“为什么这个数字是可信的”“差距到底出在哪”讲清楚。适合正在做推理服务选型、性能调优、容量规划的同学参考。

1. 为什么你的压测数字总在变:影响可复现性的四个隐蔽因素

大多数性能对比做不严谨,不是工具不行,也不是框架不稳定,而是测试条件里藏着太多没被发现的变量。我先列四个最容易被忽视、却能显著改写结论的因素,建议你在做任何对比前都自查一遍。

1.1 输入多样性让 prefill 与 decode 混在一起,得出“不可比”的吞吐

语言模型推理的时间构成和普通服务完全不同。一段请求里,处理输入 token 的阶段叫 prefill,逐 token 生成输出的阶段叫 decode。这两个阶段的单位成本差一个数量级以上:prefill 是矩阵乘为主的并行计算,decode 是访存受限的串行生成。

如果你的压测脚本是从某个真实日志里随机抽了一堆长短不一的 prompt,跑出来的吞吐数字其实是“混合吞吐”。这本身没问题,问题在于你要对比两个框架时,如果两边拿到的 prompt 分布不一样,或者同一批请求的调度顺序不一样,prefill 和 decode 的占比就会漂移,最终数字差异根本没法归因到框架上。

想复现,就要把请求集固化。我习惯的做法是固定一份 prompt 文件,分成不同长度档位,比如短 prompt(512 token)、中等(2048 token)、长(4096 token),控制输出长度也统一。这样 prefill 总量和 decode 总量在两次实验之间是严格一致的。不要用随机生成器现场造数据,随机种子一变,数字就跟着变。

1.2 热身不足,你测的是冷启动状态的框架

vLLM 和 SGLang 这类框架里有大量懒加载逻辑:显存池要等第一个请求进来才真正分配、CUDA kernel 要跑几轮才完成 autotuning、block manager 的页表结构要等负载上来才进入稳态。这是很多人在低并发下测出“越跑越快”现象的直接原因。

有个非常典型的情况:只发 20 个请求,每并发 4,前 5 个请求的平均耗时明显偏高,后面的请求越来越快。如果直接把这 20 个请求的结果取平均,你的“框架性能”实际被前几个冷启动请求严重拖低。

我建议的流程是:正式记录数据之前,先跑 5 到 10 分钟的低并发热身,让显存分配、kernel 选择都稳定下来,然后再开始按梯度压测。这一步成本很低,但对复现性的帮助几乎是最大的。

1.3 “并发数”设了,但请求未必真的同时在飞

很多人用并发压测工具时,只关注“并发数”这个参数,却忽略了请求是否真正重叠。举例来说,如果只用线程池发请求,每个请求是同步阻塞的 HTTP 调用,那么并发数是真实的。但如果你拿一个异步脚本,每个协程里先做一堆本地预处理再发请求,或者服务端把请求排队了,客户端看到的“并发”和服务端实际的“inflight 请求数”是两回事。

我见过最离谱的情况是:脚本里每个请求之间加了随机 sleep,美其名曰模拟真实用户,结果并发 32 的测试服务端实际同时只处理 2 个请求,测出来的延迟曲线当然平得漂亮。做并发实验,你要有能力确认服务端真实接收到的并发负载。做法很朴素:服务端开 debug 日志,或者用nvidia-smi看 GPU 利用率是否随着并发数上升而同步变化,再看显存占用是否在并发升高后增加。如果并发从 8 提到 32,GPU 利用率纹丝不动,说明你的“并发”根本没压到服务上。

1.4 后台进程、CPU 频率与日志输出,属于默认没人管的隐藏抖动源

推理服务是 CPU 和 GPU 混合负载,GPU 负责张量计算,CPU 负责调度、tokenization、HTTP 解析。一旦宿主机上有其他进程抢 CPU,或者 CPU 进入了降频状态,调度开销就会变大,延迟曲线会出现周期性的尖刺。

还有一点很反直觉:日志输出级别会影响性能。默认 info 级别下,每个请求都会打印一条日志,高并发时每秒几百条日志,磁盘 IO 和 CPU 都会被拖慢。对比实验里,两个框架的默认日志行为还不一样,它们之间的性能差异里就混入了“日志开销差异”。我强烈建议压测时统一把日志级别调到 error,或者重定向到内存盘,把这类无关噪声抹平。

2. 实验设计:先把“公平”定义清楚再动手

可复现性的根基是“固定条件”,这一节我把一套可以直接照抄的条件模板拆开说明。你不需要完全照搬,但每一项都值得追问一句“我这里固定了吗”。

2.1 硬件与部署环境固定方法

说句得罪人的话,很多网上流传的“vLLM 和 SGLang 对比”连硬件型号都没写清楚,更不用说驱动版本、容器镜像、CUDA 版本这些直接影响性能的底层变量。推理框架对运行环境极其敏感,驱动版本差一个 minor 版本,某些 kernel 的算子选择就会不一样。

我的做法是先用nvidia-smi和python -c "import torch; print(torch.__version__, torch.version.cuda)"把环境信息导出成一份快照文件,连同框架的 commit hash 一起记录下来。容器镜像是更好的方案,把镜像名和 tag 写进实验目录的 README 里,半年后回来看数据还能还原环境。

固定环境清单里必须包含这几项:加速卡型号与驱动版本、CUDA 版本、PyTorch 版本、框架版本(要精确到 commit hash)、GCC 版本、容器镜像 ID。缺任何一项,数字都有不可解释的空间。

2.2 模型、请求集与采样参数统一

对比实验最忌讳“一个框架用贪心,一个框架开启随机采样”,采样器在计算上的开销差异很大。统一使用 greedy 解码,关闭 temperature 采样,可以让输出路径更确定,减少采样器随机性带来的耗时波动。

请求集方面,这里有一个更隐蔽的坑:输出长度如果不固定,每次请求实际生成的 token 数不一样,吞吐计算就失去了分母。同样是“输出 200 token”的配置,如果使用max_tokens=200,有的请求可能提前遇到结束符提前退出,有的则完整生成 200 个。为了对齐,我在测试数据集里用硬编码的 prompt 模板,让模型必须生成固定长度的内容,并在结果校验时过滤掉输出长度异常的样本。

另外,如果模型本身支持前缀缓存特性,你在对比两个框架时要特别小心。SGLang 的前缀缓存命中逻辑和 vLLM 的自动前缀缓存策略不同,同样的重复 prompt 会让其中一个框架占很大便宜。做对比时,要么刻意使用互不重复的 prompt,要么把“缓存命中率”作为一个显式的变量控制住,否则你测出来的差异反映的是缓存策略差异,而不是解码性能差异。

2.3 指标口径定义:吞吐、TTFT、TPOT、端到端时延

性能对比之前,先把话说清楚:你到底在比什么。我用的指标是四个,每个都有明确的定义。

吞吐量(throughput)用 tokens/s 表示,也可以细分为请求吞吐 req/s。TTFT(time to first token)是用户发出请求到收到第一个输出 token 的耗时,它反映系统的排队和 prefill 效率。TPOT(time per output token)是每个输出 token 的平均耗时,反映 decode 阶段的稳定程度。端到端时延(end-to-end latency)是完整请求的总耗时。

这四个指标负责回答不同的问题:吞吐量回答“系统能扛多少负载”,TTFT 回答“用户等多久看到反应”,TPOT 回答“生成过程是否流畅”。一份合格的对比报告应该同时包含这四个指标,并且注明统计口径是平均值还是分位数。我一般会同时给 P50 和 P95,P95 对排队效应的敏感度远高于平均值,更能反映高并发下的真实体验。

3. 并发压测实操:从零写一个可复用的压测脚本

工具选型很有讲究,很多压测工具的并发模型和请求模型跟大模型推理服务不匹配。这里我给出一套我自己的、可以直接套用的压测方案。

3.1 工具选型:为什么不用 ab,而用异步客户端

ab(ApacheBench)是很多人本能想到的第一选择,但它在两个关键点上不适合 LLM 推理压测。第一,ab 的并发模型是进程/线程级的,高并发下客户端本身的调度开销会污染测试结果;第二,ab 把 HTTP 响应读完后才算完成一次请求,而流式响应场景里,你需要的是“边收边统计 token 到达时间”的能力,ab 做不到。

推荐使用基于 asyncio 的 Python 客户端,配合 aiohttp 或 httpx 库。原因有三:Python 写起来快、可定制性强;asyncio 单线程事件循环能承载上千并发连接,客户端不会成为瓶颈;流式响应解析简单,能直接计算 TTFT 和 TPOT。

如果你的服务端支持 OpenAI 兼容接口,压测脚本可以直接打/v1/chat/completions或/v1/completions接口,这比写 gRPC 客户端省事得多,而且 vLLM 和 SGLang 都兼容这个协议,天然适合做对比。

3.2 压测脚本核心实现

下面这个脚本是我压测时用的简化版本,支持并发数控制、预热、流式响应统计和结果导出。核心逻辑不依赖特定框架,只要服务端暴露 OpenAI 兼容接口就能跑。

import aiohttp import asyncio import json import time import statistics BASE_URL = "http://127.0.0.1:8000/v1/completions" MODEL = "demo-7b-chat" PROMPT = "The quick brown fox jumps over the lazy dog. " * 40 # 约 400 token MAX_TOKENS = 256 CONCURRENCY = 32 TOTAL_REQUESTS = 200 WARMUP_REQUESTS = 20 def make_payload(): return { "model": MODEL, "prompt": PROMPT, "max_tokens": MAX_TOKENS, "temperature": 0, "stream": True, } async def one_request(session, payload, results): t_start = time.perf_counter() first_token_time = None token_count = 0 try: async with session.post(BASE_URL, json=payload) as resp: async for line in resp.content: if line.startswith(b"data: ") and line.strip() != b"data: [DONE]": token_count += 1 if first_token_time is None: first_token_time = time.perf_counter() - t_start except Exception as e: return total_time = time.perf_counter() - t_start results.append({ "total_time": total_time, "ttft": first_token_time, "tpot": (total_time - first_token_time) / max(token_count - 1, 1), "tokens": token_count, }) async def run_test(concurrency: int, total: int): results = [] async with aiohttp.ClientSession() as session: sem = asyncio.Semaphore(concurrency) payload = make_payload() async def worker(): async with sem: await one_request(session, payload, results) tasks = [asyncio.create_task(worker()) for _ in range(total)] await asyncio.gather(*tasks) return results if __name__ == "__main__": # 预热 print("warmup...") asyncio.run(run_test(4, WARMUP_REQUESTS)) for conc in [1, 4, 8, 16, 32, 64]: results = asyncio.run(run_test(conc, TOTAL_REQUESTS)) total_tokens = sum(r["tokens"] for r in results) total_time = sum(r["total_time"] for r in results) rt_list = [r["total_time"] for r in results] ttft_list = [r["ttft"] for r in results if r["ttft"] is not None] tpot_list = [r["tpot"] for r in results] print(f"concurrency={conc}, " f"qps={len(results) / (total_time / len(results)):.2f}, " f"throughput={total_tokens / (total_time / len(results)) / 1000:.2f} k tokens/s, " f"p50_rt={statistics.median(rt_list) * 1000:.1f} ms, " f"p95_rt={sorted(rt_list)[int(len(rt_list) * 0.95)] * 1000:.1f} ms, " f"p50_ttft={statistics.median(ttft_list) * 1000:.1f} ms, " f"p50_tpot={statistics.median(tpot_list) * 1000:.2f} ms/token")

这里有个细节值得展开:统计tpot时用的分母是token_count - 1,因为第一个 token 的时间已经被 TTFT 占用了,它属于 prefill 加排队的结果,不算纯 decode。如果你不扣除,TPOT 会被第一个 token 的耗时污染,得到比实际偏大的数据。

另外脚本里的吞吐计算,我用的是“总 token 数 / 平均单请求耗时”,这个算法不是标准吞吐定义,更严谨的做法是用总墙钟时间作为分母。上面的简化版只是快速看趋势用的,正式报告里建议用固定时间窗口内的总 token 数除以窗口时长。

3.3 数据记录与统计口径处理

脚本跑完后,不要把结果只打印在终端,一定要落盘。我的习惯是把每次实验的原始明细存成 JSON 或 CSV,字段包括时间戳、并发数、每请求的 TTFT、TPOT、总耗时、输出 token 数。之后画图、做分位数统计、排查异常值,都靠这份明细。

异常值过滤要谨慎。如果某个请求因为网络闪断导致耗时为 0 或者异常大,直接剔除是合理的,但要在报告中注明剔除比例。如果剔除比例超过 5%,说明压测环境本身不稳定,这次实验的数据可信度就要打问号。

4. vLLM 与 SGLang 并发实测对比:一条有代表性的吞吐曲线

接下来才是重头戏。我以某个开源 7B 模型为例,在同一台机器上、固定环境、固定请求集的条件下,分别用 vLLM 和 SGLang 部署,跑并发梯度测试。下面的数字不是为了证明谁强谁弱,而是为了演示“数字如何解释”。

4.1 低并发阶段:两者差距不大,但细节已经出现分化

并发数为 1 到 4 时,两个框架的吞吐量差距通常很小,毕竟单请求时调度开销占比低,瓶颈基本都在 GPU 算力上。但注意观察 TTFT:vLLM 在这个区间通常略低或持平,SGLang 的优势还没发挥出来。

这个阶段的另一个观察点是单请求的 TPOT 稳定性。打印每次请求的 TPOT 分布会发现,两个框架都会出现少量“慢请求”,即 P95 明显高于 P50 的情况。这些慢请求往往对应的是 prefill 和 decode 阶段切换时的显存分配抖动。低并发下的偶发抖动,恰恰是后续高并发性能拐点的早期信号。

4.2 高并发阶段:吞吐拐点和下降模式开始分化

并发数从 8 往 32、64、128 走的时候,两个框架的表现开始分化。我实测的一个典型趋势如下(数据只用于说明趋势,不具备普适性,不同模型和硬件上差异很大):

并发数vLLM 吞吐(tokens/s)SGLang 吞吐(tokens/s)观察要点
1约 45约 44单流性能几乎一致
8约 320约 330线性增长阶段
32约 580约 620差距扩大到约 7%
64约 640约 720SGLang 仍在爬升
128约 610约 680两者都出现回落,vLLM 回落更明显

真正值得分析的从来不是“谁在某个点更高”,而是“为什么回落点不同”。vLLM 在高并发下吞吐回落的常见原因是显存池耗尽后触发了频繁的 block 回收和重新分配,调度器的争用也随并发上升。SGLang 在那个实验环境里回落更平缓,跟它的内存管理方式和调度策略有关系,这一点在第五节展开讲。

4.3 长 prompt 场景与共享前缀场景:差异可以被放大

如果请求集换成 4096 token 的长 prompt,并且多个请求共享相同的前缀,两者的差异会被显著放大。SGLang 的 RadixAttention 能直接复用前缀对应的 KV 缓存,新请求只需要计算新增部分的 prefill,TTFT 可以下降 50% 以上。vLLM 也有自动前缀缓存,但它的缓存粒度、命中策略和 eviction 策略不太一样,效果取决于具体负载。

这个场景提示我们:没有“绝对更快”的框架,只有“在某个负载特征下更合适”的框架。对比实验如果只跑一种负载,结论一定是有偏的。我建议每次对比至少覆盖三种场景:短 prompt 高并发、长 prompt 低并发、共享前缀高并发。只有把三种结果放在一起看,才能对框架的适用边界有完整的感知。

5. 数字出来了,怎么解释:从调度机制里找答案

性能数字只是表象,把数字归因到框架的具体机制,才算“可解释”。这一节我拆解两个框架最核心的机制差异,这些机制是性能曲线分化的根本原因。

5.1 vLLM 的 continuous batching 与分页注意力

vLLM 的核心是 continuous batching:请求不再按 batch 固定分组,而是只要有 token 级完成,就立刻把新请求插入空闲位置。这种调度方式在请求时长相对均匀时表现很好,吞吐利用率高。

它的显存管理用的是类似操作系统的分页思想,把 KV cache 切成固定大小的 block,用 block table 管理。优点是可以近乎零浪费地利用显存碎片,缺点是高并发下 block table 的查找和更新开销会增长。当并发数很高、每个请求占用的 block 分散时,调度器在“找 block、释放 block”上的时间占比会上升,这就是吞吐在高并发下出现拐点的一个原因。

5.2 SGLang 的 RadixAttention 与协作调度

SGLang 最出名的是 RadixAttention,它把 KV cache 组织成前缀树,相同前缀可以跨请求复用。这意味着同一个 prompt 前缀被反复使用时,SGLang 不需要重复计算 prefill,TTFT 能大幅下降。

另一个差异点在于 SGLang 的调度器更强调“先来先服务”和更细粒度的调度元数据管理。在纯 decode 高压场景下,它的调度开销更低,所以我的实验里它在高并发段的吞吐衰减更平缓。说到底,这不是魔法,而是数据结构和调度策略差异带来的必然结果。

你不需要把源码读完才能解释数字,但至少要能描述“每个框架在请求调度、显存管理、前缀缓存上分别做了什么”,否则性能对比就是黑盒对黑盒。

5.3 用分层观测确认瓶颈到底在哪

解释数字的另一种手段是分层观测。当 vLLM 在并发 128 时的 P95 显著恶化时,不要只盯着“框架慢”,要回答“慢在哪个环节”。我常用的分层方法是:

  • 看客户端侧耗时构成:TTFT 高,说明瓶颈在请求排队或 prefill;TPOT 高,说明瓶颈在 decode。
  • 看服务端侧指标:GPU 利用率是否打满,显存是否有 swap,CPU 占用是否异常高。
  • 看框架日志:vLLM 的日志里有详细的排队时间;SGLang 可以用 metrics 接口拿到 token 级统计。

比如有一次我排查 SGLang 高并发下 TPOT 变高的问题,第一反应是 decode 太慢,结果用nvidia-smi一看 GPU 利用率只有 60%,反而是 CPU 占用接近打满。追下去发现是预处理阶段 tokenizer 在高并发下成了瓶颈。这个结论如果只看吞吐数字是永远得出来的。

6. 问题排查速查表:常见抖动原因与处理建议

压测过程中我踩过的坑不少,这里整理成一张速查表,遇到类似症状可以直接对照排查。

症状常见原因排查手段处理建议
相同并发下两次运行结果差异 > 10%环境未固定,或存在后台任务对比环境快照、检查 CPU 占用统一容器镜像、固定框架版本、压测时关闭无关进程
并发升高但吞吐不涨请求未真正到达服务端看服务端日志和 GPU 利用率检查客户端异步代码,确认并发数没有被打折扣
TTFT 高且波动大请求排队严重或前缀缓存未命中拆解 TTFT=排队+prefill降低并发、启用缓存、拆分长 prompt 为前缀+后缀
TPOT 高decode 阶段计算或访存瓶颈观察 GPU 利用率和显存带宽换更小模型、减少max_tokens、检查是否开启 speculative decoding
显存不足 OOM并发过高或 KV cache 分配过大看显存占用曲线、日志中的显存统计调整gpu_memory_utilization、降低并发、开启显存自动回收
P95 比 P50 高一个数量级偶发的阻塞、GC、网络抖动打印慢请求明细,定位耗时分布排查网络、关闭日志、考虑客户端连接复用

多说一句,排查延迟抖动时不要急着改框架参数,先把复现条件控制住。很多时候抖动来源根本不在框架里,而在你压测脚本所在的那台机器、那张网络上。客户端和服务端最好放在同一个内网,避免公网延迟噪声混进数据。如果条件允许,压测机和推理机绑定 CPU、关闭 NUMA 干扰,效果会更好。

在这个基础上,我还有两个很实用的经验。第一个是每次跑完压测后,保存一份nvidia-smi的输出快照,它能帮你事后回答“当时显存到底用了多少”。第二个是压测脚本里加一个“请求间隔单调性校验”,检查每个请求的实际启动时间是否均匀分布。如果启动时间出现明显分段,说明客户端侧的并发模型可能已经失效了。

性能实验这件事,做得糙和做得细,花费的时间其实差不了一个数量级,但结论的可靠程度差了很远。一次可复现、可解释的对比实验,胜过十次“感觉这个框架更快”的主观判断。我自己在跑完这轮实验后的体会是:技术选型最难的不是跑出数字,而是确认这个数字在三个月后、换个人、换批机器,还能不能重复出来。把环境快照、请求集、指标口径和原始数据都沉淀下来,才是实验真正的产出。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询