☰
LLM推理并发性能测试不可复现?从实验设计到环境锁定全解析
2026/10/12 3:32:45 网站建设 项目流程

跑过 vLLM 和 SGLang 并发实验的朋友,大概都有过这样的体验:同一个脚本、同一个模型、同一个并发数,上午压出来 A 比 B 快 20%,下午再压发现 B 比 A 快 10%;把两个服务都重启一遍,数字又变了个样。性能数字像薛定谔的猫,打开报告之前你永远不知道答案。这个问题不是玄学,也不是两个框架谁更好,而是实验本身没有被“锁住”。这篇文章不讲广告式 benchmark,而是从实验设计、指标口径、环境记录、调度机制和结果解读这条链路,聊一聊怎么让 LLM 推理服务的并发性能数字真正可复现、可解释。适合正在做推理框架选型、容量评估和回归测试的工程师。

1. 数字漂移不是玄学:并发实验不可复现的根源

1.1 一次令人崩溃的对比翻车现场

先说个真实场景。去年我给某实验室做推理框架选型,计划用同一组 prompt 和固定输出长度,分别压 vLLM 和 SGLang。第一天晚上跑完,vLLM 的吞吐比 SGLang 高 18%,我把结果发给组里,大家都准备按这个结论定方案。第二天早上我想补一组并发 64 的数据,顺手把两个服务用同一份 docker 镜像重启,结果完全反过来了:SGLang 比 vLLM 高 22%。我当时的第一反应是脚本写错了,但检查三遍没有逻辑问题。

后来把两次实验的细节拉出来逐项对比,才找到原因:第一天 vLLM 服务里启动了一个长连接测试任务,占住了部分显存和调度 slot;第二天重启后缓存状态被清掉,SGLang 的前缀缓存命中率比前一天高了一大截。也就是说,两次跑的不是同一个实验。这个案例很典型:性能数字本身没问题,问题是实验对象在静默变化。锁不住前置条件,数字就是不可复现的。

1.2 四类干扰源:状态、请求、并发与环境的叠加

大多数人以为不可复现只是 GPU 差异,其实干扰源通常来自四个层面。第一是服务端状态,包括输入到 KV cache 前缀缓存、连续批处理队列里的请求水位、显存里其他任务残留、调度器的 batch 排列,这些都会直接影响每一轮推理的吞吐。第二是请求模型,prompt 长度、输出长度、采样参数、stop 逻辑只要有一个变化,性能就会有系统偏差。

第三是客户端并发模型,线程数、连接池大小、请求超时、发起节奏,决定了你测的到底是不是框架的极限,还是客户端自己的极限。第四是环境状态,共享 GPU 的邻居任务、CPU 抢占、网络带宽波动、电源管理降频,这些都很难用程序控制。把这些排一遍,你会发现很多 benchmark 报告根本没有记录它们。想让数字可复现,第一步就是把“没有记录的东西”变成“有记录的东西”。

1.3 为什么LLM推理性能对比格外难做

相比普通 Web 服务,LLM 推理是“动态批处理”加上“状态化缓存”的组合,计算量和延迟只有运行到那一刻才能确定。同一个请求排在批次的第一个和第三个,消耗的时间完全不同;请求之间的 prompt 相似度,决定前缀缓存命中多少;prefill 阶段占显存、decode 阶段占计算,调度器切换策略会大幅改变资源分配。

这些都不是两个模型在“算法层面”的差异,而是“工程调度层面”的差异。vLLM 和 SGLang 虽然都做连续批处理和 KV cache 复用,但实现细节不一样,数字自然会随 workload 形态产生锯齿。先理解了这一点,再谈对比才有意义,否则你只能拿到一组数字,无法解释数字为什么是这个形状,更别提复现了。

2. 实验设计第一课:把指标、请求和并发模型钉死

2.1 性能指标到底看哪个

不同指标回答不同问题。吞吐量回答系统能压多少;首 token 延迟(TTFT)回答用户第一次看到字要等多久;单 token 延迟(TPOT/ITL)回答输出过程是不是流畅;端到端延迟回答一条完整请求的耗时。很多对比报告只给一个吞吐数字,这远远不够。在并发实验中,我建议至少同时记录四类指标:吞吐量(token/s)、TTFT 均值与 p99、TPOT 均值与 p99、端到端延迟 p50/p95。

需要特别标注口径:吞吐量按生成的 token 数算,还是按请求数算?TTFT 是从客户端发出请求到收到第一个 token 的时间,还是从调度器开始处理的时间?同样的数据,口径不同结论会完全不同。我自己的习惯是在记录文件的表头写明“tokens, not requests”这类注释,并在结果报告里同时保留原始数据和统计脚本,方便别人交叉验证。

2.2 请求模型:固定shape是复现的基石

做可复现实验最简单的做法,是使用固定输入长度和固定输出长度的请求集。比如一组 512 token 输入、256 token 输出,共 200 条;另一组 128 输入、128 输出,共 200 条。每条 prompt 明确指定 max_tokens,并把采样参数设为 temperature=0,关闭随机采样。这样每次发送的请求内容完全一样,输出长度也完全一样,框架层无法通过“少生成几个 token”来偷性能。

实际压测当然要覆盖真实分布,但那是第二阶段。先固定 shape 跑通,再逐步引入长度分布,你才能知道性能波动是框架差异还是数据差异。另外要注意 stop 参数,如果 prompt 里碰巧包含了 stop token,输出可能提前结束,长度就不可控。请求集文件建议用 JSONL 存储,并计算文件 hash,实验记录里带上这个 hash,后续任何人拿到文件就能确认数据集没变。

2.3 并发模型:并发数、QPS与客户端线程的关系

并发实验最常见的误区是把“每秒请求数”当成“并发数”。比如用脚本 for 循环发 100 个请求,耗时 10 秒,平均 10 QPS,这不代表并发是 10。改成用 10 个线程同时发请求,每个线程连续发送,才能近似“恒定 10 并发”。更严谨的做法是设置并发数 N,开启 N 个客户端协程/线程,每个线程循环发送一定数量的请求,并让服务端保持持续处理状态。

这里有个细节:如果客户端用线程池,请求在队列里等待,线程数才是并发上限;如果客户端用异步,连接数限制可能成为隐形瓶颈。压测机最好单独一台,至少不要让压测客户端和服务端抢同一个 GPU 机器的 CPU。你可以在脚本里打印客户端的 CPU 占用和发送间隔,确保瓶颈只在服务端。下面是一个精简骨架,核心是“固定并发 + 固定轮数”,而不是无限 while True。

import concurrent.futures import requests import time PROMPT = "the quick brown fox jumps over the lazy dog " * 40 # 固定输入 MAX_TOKENS = 256 URL = "http://127.0.0.1:8000/v1/completions" def one_call(worker_id): payload = { "model": "default", "prompt": PROMPT, "max_tokens": MAX_TOKENS, "temperature": 0, } t0 = time.perf_counter() resp = requests.post(URL, json=payload, timeout=300) dt = time.perf_counter() - t0 body = resp.json() usage = body.get("usage", {}) return dt, usage.get("completion_tokens", 0) def run_concurrency(concurrency, rounds_per_worker=20): results = [] with concurrent.futures.ThreadPoolExecutor(max_workers=concurrency) as ex: futures = [ ex.submit(one_call, i) for _ in range(concurrency) for i in range(rounds_per_worker) ] for f in concurrent.futures.as_completed(futures): results.append(f.result()) return results

这个脚本没有统计逻辑,但它保证了每个 worker 发固定轮数,不会因为客户端重试导致负载无限堆积。统计时再按 p50、p95、平均值分组计算即可。

3. 环境锁定:框架版本、引擎参数和显存状态都要入档

3.1 从镜像到pip freeze,环境记录怎么做

可复现实验的根基是“环境快照”。我通常用一套脚本自动生成:容器镜像 ID、Python 版本、pip freeze 输出、框架版本、git commit 号、驱动版本、CUDA 版本、GPU 卡数量与显存 ID。把这些写进一个env.txt,和实验结果放在同一个目录。很多坑都来自版本漂移,比如 vLLM 某个 commit 修复了显存碎片,SGLang 某个版本改了调度默认值,半个月后重跑,数字自然不一样。

如果你不用 docker,至少用 venv,并固定依赖;如果用了 docker,最好把镜像 tag 和 digest 一起记录,因为同名 tag 可能被覆盖。给环境打标签的习惯,比任何统计修正都管用。我在某公司做推理平台时,就是靠这一套环境快照,把一次“看起来像框架回退”的线上问题定位成了依赖包版本漂移,省了一整周的排查时间。

3.2 vLLM与SGLang的引擎参数对齐清单

对比两个框架,最容易被忽略的是“引擎参数没有对齐”。比如 vLLM 最大批处理序列数是 max-num-seqs,SGLang 对应的是 max-running-requests;显存上限一个叫 gpu-memory-utilization,一个叫 mem-fraction-static;上下文长度、并行度也都有对应配置。如果一方默认 batch 更大,显存利用率更高,性能自然不同,但那不是框架本身的差异。

我的做法是打开两个框架的帮助信息,把影响批处理、显存、前缀缓存、并行度的参数列成一张表,逐个对齐:

参数意图vLLM 侧SGLang 侧
最大并行请求数--max-num-seqs--max-running-requests
显存上限比例--gpu-memory-utilization--mem-fraction-static
前缀缓存开关--enable-prefix-caching默认启用 RadixAttention
张量并行--tensor-parallel-size--tp-size
最大上下文长度--max-model-len--context-length 或等价配置

列完之后,每个参数都确认“这个值是否相同、为什么不同”。如果某个参数必须不同,结果解读时就要说明差异来源,不能混为一谈。第一次做这个对齐花了我一个下午,但之后每次对比都省了很多扯皮。

3.3 采样参数与随机种子的隐藏影响

性能对比还有一个容易被忽略的隐藏变量:采样器。temperature、top_p 等采样行为一般不影响计算量,但会影响输出长度分布。如果一方使用了贪婪解码,另一方使用了随机采样但 max_tokens 兜底,最终输出 token 数可能不一样,吞吐数字直接失真。更稳妥的做法是让两个服务都使用相同的 temperature=0 和相同的 max_tokens,必要时关闭 stream,使用非流式接口统计整条延迟。

如果用 stream 模式,TTFT 和 TPOT 才能分别统计,但客户端解析逻辑要统一。请求里的随机性也要控制,生成请求集的 prompt 时固定随机种子,让每次实验的 prompt 集合完全一致。这样至少排除了数据分布带来的随机波动,后续分析也能更干净。

4. 压测进行时:调度器、前缀缓存和并发拐点

4.1 连续批处理的水位如何影响吞吐

vLLM 和 SGLang 都实现了 continuous batching,也就是一条请求完成生成后,batch 里立刻补进新请求,不用等整个 batch 结束。但“补进新请求”的时机和策略不同,直接影响吞吐。当并发数低时,batch 很浅,GPU 利用率不足,吞吐由 prefill 阶段的 token 处理速度决定;并发升高后,batch 变满,decode 阶段的 token 产出成为瓶颈,框架的调度开销开始显现。

你会发现,如果两个框架的批处理水位不同,一个先进入满 batch 状态,一个还在半空状态,同一并发下吞吐差距会被放大。实验时我建议在服务端把max-running-requests和max-num-seqs设为相同值,同时观察显存占用和当前 running 请求数,才能判断吞吐差异来自调度策略还是容量限制。

4.2 前缀缓存:从“共享前缀”到性能放大镜

LLM 推理有个很容易被忽视的加速器:prompt 之间的共享前缀。SGLang 的 RadixAttention 会对 KV cache 做树状前缀复用,vLLM 的 prefix caching 也会哈希并缓存公共前缀。当你的压测数据集里所有 prompt 都从同一段固定文本开头时,前缀命中率很高,缓存带来的加速会非常明显;如果每次 prompt 都是完全随机的,双方都命中不了,性能差距又会变小。

所以同样的代码,换一个数据集,结论可能直接反转。为了可解释,压测数据集要记录“前缀命中率”或“prompt 重复程度”,并在报告里说明。如果目标是评估真实业务,就混合一部分共享前缀请求和一部分随机请求;如果目标是复现一个公开结论,必须使用完全一致的 prompt 集合。

4.3 并发拐点:不是并发越高越好看

高并发不等于高吞吐。随着并发上升,请求会排队,显存占用接近上限,调度器要频繁做抢占和 batch 重构,开销上升,最终吞吐出现拐点甚至下降。把并发从 1 逐步增加到 128,你会得到一条类似倒 U 的吞吐曲线。vLLM 和 SGLang 的拐点位置通常不一样,这不是“谁快”的简单问题,而是“在什么负载形态下谁的资源调度更顺畅”的问题。

跑分时不要只测一个并发点,至少要测 4 个点,比如 1、8、32、64,并把每秒的吞吐曲线画出来。如果只在某个并发点下比较,结论很容易被拐点位置误导。我曾经见过一份报告只测了并发 16,结论是 A 远好于 B;后来补了并发 64 的数据,结论几乎反过来。这提醒我,拐点附近的数字是最脆弱的,必须多看几个点。

5. 读懂数字:一份可解释的跑分记录应该包含什么

5.1 一次完整实验的配置示例与结果表格

下面给出我在实验里用的记录模板,数据和环境都是虚构的,但结构可以直接抄。环境:80GB 数据中心级 GPU 一块,驱动版本 535.x,CUDA 12.2,模型为 7B 规模 dense 模型,请求集固定 512 输入/256 输出,并发从 1 到 64,每个并发点跑 3 次取中位数。客户端和两个服务端分别跑在不同容器,避免 CPU 抢占污染结果。

并发数vLLM 吞吐 (token/s)vLLM TTFT avg/p99 (ms)vLLM E2E p95 (ms)SGLang 吞吐 (token/s)SGLang TTFT avg/p99 (ms)SGLang E2E p95 (ms)
136845/52175037242/491720
8154088/1202050149096/1382160
322380210/34030502610180/2802780
642470520/78051002790340/5204200

这个表里的数值只是用来演示曲线形态,不具备普适意义。低并发时两边接近;并发 32 以后 SGLang 的吞吐优势显现,TTFT 也更低,说明它的调度策略在高水位下能更快地让新请求进入 prefill;vLLM 并发 64 时吞吐增幅很小,显存占用接近上限,调度开销开始吃性能。

5.2 结果解读:差异出现的机制而不是胜负

看到数字之后,不要急着写“A 比 B 好”,而要问“差异从哪个环节来”。我通常按三步走。第一步,看低并发,如果 TTFT 差异明显,大概率是 prefill 阶段 kernel 实现、chunked prefill 策略或数据搬运方式不同。第二步,看高并发,如果吞吐差异明显,大概率是连续批处理的水位、KV cache 复用、调度器抢占策略造成的。

第三步,看显存占用曲线,如果某个框架在并发 64 时显存已经逼近上限而另一个还有余量,那吞吐差异有一部分就是容量问题,不是引擎快慢问题。把这些机制写进报告,读者才能知道“为什么是这个数字”,而不是只知道“谁赢谁输”。一份没有机制解释的 benchmark,本质上只是日志摘抄。

5.3 把实验存档,让任何人能复现

最后,把复现路径写进 README:使用的镜像 digest、启动服务的完整命令、压测脚本版本、请求集文件 hash、每个并发点运行时长、丢弃了多少个预热请求、统计方法(均值还是中位数)、p99 计算方式。建议把每次实验结果存成 JSON/CSV,文件名带时间戳和 git commit,原始日志不要覆盖。

这样即使三个月后有人拿着报告来问“这个数字怎么来的”,你也能还原每一步。我还要把实验脚本本身纳入版本管理,而不是放在临时目录里跑完就丢。这是我在某公司做推理平台时候最受益的一个习惯。经验教训:所谓可复现,不是“我把代码贴给你,你跑一下就能得到一样的结果”,而是“你按照我的步骤和环境,能进入同一个现象空间”。

6. 踩坑实录:五个最容易被忽略的复现杀手

6.1 没预热就跑数,显存分配和CUDA图还没稳定

LLM 框架通常在服务启动后会经历一个“冷启动”阶段:显存分配、CUDA graph 捕获、模型权重加载,都会让前几个请求特别慢。如果不预热,直接开始压测,吞吐会偏低,而且不同框架预热速度不同,差距会被放大。我的做法是先用低并发(比如 4)发 50-100 个请求,观察最近几轮吞吐和延迟是否进入稳态,然后再开始正式记录。

丢弃的请求数要写进实验日志,正式结果只统计稳态之后的数据。有些框架支持启动时预热,也可以用,但要在报告里说明。这个步骤花不了两分钟,但对数字稳定性的帮助是巨大的。

6.2 GPU降频与功耗波动

GPU 不是一直跑在标称频率上的,温度、功耗墙、其他容器负载都会让实际频率上下浮动。跑压测时如果不开“性能模式”或者不锁频率,早晨和下午测出的数字可能差出 10%-20%。可以在启动压测前用 GPU 厂商的显存与时钟查询工具把时钟和功耗模式固定下来;如果权限不允许,退一步也要在实验前后各记录一次时钟频率、温度、利用率,并把异常波动的数据剔除。

我见过一个团队因为没锁频率,同一个实验连续三天数字每天都在下滑,最后才发现是散热问题。性能实验和环境实验是同时进行的,不能只看算力,不看物理状态。

6.3 客户端自己先扛不住了

并发 64 的请求可能不算什么,但如果每个请求都带 2048 个 prompt token,响应几千个 token,客户端网络栈、内存和解析 JSON 的 CPU 都会成为瓶颈。如果压测机和服务端共用一个物理机,CPU 抢占了,谁的框架都跑不快。观察方法是:压测期间看客户端 CPU 是否接近 100%,检查单个请求的客户端处理时间是否超过了服务端返回时间的合理阈值。

可以适当裁剪响应,只保留 usage 字段,减少 JSON 解析开销。让客户端简单,是让结果干净的第一步。另外,压测机和服务端之间的网络要走内网,不要经过公网网关,否则延迟曲线里会混进完全无关的网络抖动。

6.4 队列无限堆积,性能数字被“排队”污染

很多压测脚本是“持续发送”模式,只要请求失败就重试,服务端队列越来越长。最终吞吐看起来很高,但端到端延迟已经被排队时间撑爆,TTFT 不再有参考价值。要区分“系统吞吐极限”和“用户可接受时延下的吞吐”,必须在客户端设置超时并统计失败的请求。如果服务端有请求排队日志,也要记录队列长度。

我在跑并发对比时,会固定“每个 worker 发 N 轮请求后结束”,而不是让 while True 无限发。这样每个并发点的负载是可控的,数字才可解释。否则极端情况下,两个框架都会进入 OOM 前的不稳定状态,结果没有任何可比性。

6.5 实验记录里没有commit号,版本漂移后只能重跑

最后一个坑最不起眼,但最致命。框架更新迭代非常快,两周前装的 vLLM 和你今天新装的最新版,可能已经是两个世界。如果不记录镜像 ID、pip 包版本、git commit,实验结果只能当作“当时的性能切片”,无法回溯。我现在做实验前会固定一个 requirements 文件,并直接把pip freeze > env.txt、git rev-parse HEAD输出放进结果目录。

每次跑完对比,先检查两份 env.txt 是否完全一致,不一致就中止分析。这一步虽然土,但比任何复杂的统计手段都更能保证可复现性。最后分享一个让我少踩很多坑的习惯:每次实验前,花两分钟把环境和版本快照打进日志目录,跑分过程中随时看显存、时钟和客户端 CPU,跑完后别急着删临时文件。性能数字最终会骗人,但完整的记录不会。

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

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

立即咨询