你有没有过这种经历明明同一个模型、同一张卡、同样的并发数昨天跑出来的吞吐量还是 900 tokens/s今天换个环境跑只剩下 600或者 vLLM 和 SGLang 各跑一遍数字差得离谱但谁都不敢说这个差距是框架本身的差距还是测试方法造成的假象。如果你也卡在这种“数字对不上”的困境里这篇文章就是写给你的。我以 vLLM 与 SGLang 的并发压测实验为例子完整走一遍从环境固定、请求集设计、并发脚本、指标口径到机制解释的流程。不是给你看“谁跑得快”而是把“为什么这个数字是可信的”“差距到底出在哪”讲清楚。适合正在做推理服务选型、性能调优、容量规划的同学参考。1. 为什么你的压测数字总在变影响可复现性的四个隐蔽因素大多数性能对比做不严谨不是工具不行也不是框架不稳定而是测试条件里藏着太多没被发现的变量。我先列四个最容易被忽视、却能显著改写结论的因素建议你在做任何对比前都自查一遍。1.1 输入多样性让 prefill 与 decode 混在一起得出“不可比”的吞吐语言模型推理的时间构成和普通服务完全不同。一段请求里处理输入 token 的阶段叫 prefill逐 token 生成输出的阶段叫 decode。这两个阶段的单位成本差一个数量级以上prefill 是矩阵乘为主的并行计算decode 是访存受限的串行生成。如果你的压测脚本是从某个真实日志里随机抽了一堆长短不一的 prompt跑出来的吞吐数字其实是“混合吞吐”。这本身没问题问题在于你要对比两个框架时如果两边拿到的 prompt 分布不一样或者同一批请求的调度顺序不一样prefill 和 decode 的占比就会漂移最终数字差异根本没法归因到框架上。想复现就要把请求集固化。我习惯的做法是固定一份 prompt 文件分成不同长度档位比如短 prompt512 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 提到 32GPU 利用率纹丝不动说明你的“并发”根本没压到服务上。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_tokens200有的请求可能提前遇到结束符提前退出有的则完整生成 200 个。为了对齐我在测试数据集里用硬编码的 prompt 模板让模型必须生成固定长度的内容并在结果校验时过滤掉输出长度异常的样本。另外如果模型本身支持前缀缓存特性你在对比两个框架时要特别小心。SGLang 的前缀缓存命中逻辑和 vLLM 的自动前缀缓存策略不同同样的重复 prompt 会让其中一个框架占很大便宜。做对比时要么刻意使用互不重复的 prompt要么把“缓存命中率”作为一个显式的变量控制住否则你测出来的差异反映的是缓存策略差异而不是解码性能差异。2.3 指标口径定义吞吐、TTFT、TPOT、端到端时延性能对比之前先把话说清楚你到底在比什么。我用的指标是四个每个都有明确的定义。吞吐量throughput用 tokens/s 表示也可以细分为请求吞吐 req/s。TTFTtime to first token是用户发出请求到收到第一个输出 token 的耗时它反映系统的排队和 prefill 效率。TPOTtime per output token是每个输出 token 的平均耗时反映 decode 阶段的稳定程度。端到端时延end-to-end latency是完整请求的总耗时。这四个指标负责回答不同的问题吞吐量回答“系统能扛多少负载”TTFT 回答“用户等多久看到反应”TPOT 回答“生成过程是否流畅”。一份合格的对比报告应该同时包含这四个指标并且注明统计口径是平均值还是分位数。我一般会同时给 P50 和 P95P95 对排队效应的敏感度远高于平均值更能反映高并发下的真实体验。3. 并发压测实操从零写一个可复用的压测脚本工具选型很有讲究很多压测工具的并发模型和请求模型跟大模型推理服务不匹配。这里我给出一套我自己的、可以直接套用的压测方案。3.1 工具选型为什么不用 ab而用异步客户端abApacheBench是很多人本能想到的第一选择但它在两个关键点上不适合 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, jsonpayload) as resp: async for line in resp.content: if line.startswith(bdata: ) and line.strip() ! bdata: [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(fconcurrency{conc}, fqps{len(results) / (total_time / len(results)):.2f}, fthroughput{total_tokens / (total_time / len(results)) / 1000:.2f} k tokens/s, fp50_rt{statistics.median(rt_list) * 1000:.1f} ms, fp95_rt{sorted(rt_list)[int(len(rt_list) * 0.95)] * 1000:.1f} ms, fp50_ttft{statistics.median(ttft_list) * 1000:.1f} ms, fp50_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 算力上。但注意观察 TTFTvLLM 在这个区间通常略低或持平SGLang 的优势还没发挥出来。这个阶段的另一个观察点是单请求的 TPOT 稳定性。打印每次请求的 TPOT 分布会发现两个框架都会出现少量“慢请求”即 P95 明显高于 P50 的情况。这些慢请求往往对应的是 prefill 和 decode 阶段切换时的显存分配抖动。低并发下的偶发抖动恰恰是后续高并发性能拐点的早期信号。4.2 高并发阶段吞吐拐点和下降模式开始分化并发数从 8 往 32、64、128 走的时候两个框架的表现开始分化。我实测的一个典型趋势如下数据只用于说明趋势不具备普适性不同模型和硬件上差异很大并发数vLLM 吞吐tokens/sSGLang 吞吐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 缓存新请求只需要计算新增部分的 prefillTTFT 可以下降 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 不需要重复计算 prefillTTFT 能大幅下降。另一个差异点在于 SGLang 的调度器更强调“先来先服务”和更细粒度的调度元数据管理。在纯 decode 高压场景下它的调度开销更低所以我的实验里它在高并发段的吞吐衰减更平缓。说到底这不是魔法而是数据结构和调度策略差异带来的必然结果。你不需要把源码读完才能解释数字但至少要能描述“每个框架在请求调度、显存管理、前缀缓存上分别做了什么”否则性能对比就是黑盒对黑盒。5.3 用分层观测确认瓶颈到底在哪解释数字的另一种手段是分层观测。当 vLLM 在并发 128 时的 P95 显著恶化时不要只盯着“框架慢”要回答“慢在哪个环节”。我常用的分层方法是看客户端侧耗时构成TTFT 高说明瓶颈在请求排队或 prefillTPOT 高说明瓶颈在 decode。看服务端侧指标GPU 利用率是否打满显存是否有 swapCPU 占用是否异常高。看框架日志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的输出快照它能帮你事后回答“当时显存到底用了多少”。第二个是压测脚本里加一个“请求间隔单调性校验”检查每个请求的实际启动时间是否均匀分布。如果启动时间出现明显分段说明客户端侧的并发模型可能已经失效了。性能实验这件事做得糙和做得细花费的时间其实差不了一个数量级但结论的可靠程度差了很远。一次可复现、可解释的对比实验胜过十次“感觉这个框架更快”的主观判断。我自己在跑完这轮实验后的体会是技术选型最难的不是跑出数字而是确认这个数字在三个月后、换个人、换批机器还能不能重复出来。把环境快照、请求集、指标口径和原始数据都沉淀下来才是实验真正的产出。