LLM推理性能测试指南:并发压测与指标归因实战

发布时间:2026/10/15 5:18:12

LLM推理性能测试指南:并发压测与指标归因实战 性能测试这件事最怕的不是结果不好看而是结果说不出为什么换个环境就崩。上个月我把 vLLM 和 SGLang 两个推理引擎拉到一起做并发实验前前后后折腾了两周最后发现坑全不在引擎本身而在实验设计和指标口径。这篇东西想把我用的那套方法完整交代一遍服务端参数怎么锁、压测客户端怎么写、指标怎么算、结果怎么归因让性能数字既能复现又能解释。如果你是做 LLM 推理服务选型、要写性能报告或者遇到“这次测得比上次快”却始终找不到原因可以照着这套思路重新跑一轮。1. 实验目标拆解性能对比到底在对比什么1.1 动手前先回答三个问题在做并发实验之前先别急着装环境、拉版本。我这次最大的教训是目标不清晰实验设计一定会跑偏。你至少要回答清楚三个问题这次对比是为了选型、回归验证还是定位瓶颈结论要服务谁能接受多长的实验周期这三个目标对应的做法完全不同。选型看重的是容量边界要测出在可接受的延迟分位下系统能扛住多大并发和吞吐。回归则是固定配置、固定请求集比较新版本有没有变慢任何变量都要尽量锁死。定位瓶颈则需要更细的监控比如把 prefill、decode、排队时间分开看而不是只看一个总延迟。我这次的目标很明确并发压力下的稳态行为。也就是并发从 1 逐步增加到 64 的过程中两个引擎的吞吐和延迟怎么变化以及这些变化能不能落到具体机制上比如连续批处理、KV 缓存复用、PD 分拆这些调度策略。先把目标定了后面所有参数和指标才有的放矢。1.2 并发、QPS、连接数是三个不同的东西很多同学做压测时会把并发数理解成 QPS或者直接用压测工具里的连接数来代表并发。这是个很常见的误区。并发数指的是同一时刻服务端正在处理的请求数量QPS 是每秒完成的请求数连接数则是空闲和活动连接的总和三个根本不是一个量纲。在 LLM 推理场景里我们真正要控制的是 in-flight requests也就是已经发出去、还没收到最终响应的请求数量。如果只是开一堆 HTTP 连接但请求是串行发的并发数依然只有 1。我用的方法是固定 in-flight 数量等于目标并发每完成一个请求立即补发一个形成一个“掉队补位”的稳态模型。这比一次性发 N 个请求更接近线上网关的真实负载模式也方便观察排队行为。另外客户端和服务端一定要放在两台机器上。我之前图省事用同一台机器压测结果 CPU 抢占、网络栈竞争、日志落盘全混在一起数据完全没法看。把压测机独立出来是保证可复现性的第一道门槛。1.3 可复现性的三层含义我理解的可复现性有三层时间上同一台机器、同一套参数隔一小时重跑核心指标差异小于 5%环境上换一台配置差不多的机器趋势相对一致归因上每一个数字都能指向某个参数或者某个调度机制而不是“这次运气好”。后一层才是可解释性的关键。比如两个引擎在 32 并发下吞吐差 10%如果你只知道“SGLang 快一点”这个结论没法迁移。但如果能说清楚“差别主要来自前缀缓存命中率关闭缓存后差距缩到 2%”这个结论就立住了。所以整篇实验设计都要围绕这三层可复现性展开环境、参数、指标、归因缺一不可。2. 实验配置从服务端到客户端的约束清单2.1 模型、权重、请求语料三者锁死实验的第一件事是把输入物料固定住。模型权重选同一个开源权重加载路径一致下载后记录文件哈希。两个引擎加载的是同一份权重不能一个用量化版一个用原始版那样比的是量化策略而不是引擎。请求语料是很多人忽略的点。不要用随机自然语言做并发测试因为 prompt 的 token 数浮动非常大prefill 阶段的开销和 token 数强相关一旦输入长度不一致吞吐数字就失去意义。我的做法是固定一个长模板比如同一段技术问答指令加同一段代码片段重复发给服务端。实验前用 tokenizer 分别打印 input token 数量确认在同一个值附近。输出长度也要控制。给每个请求设固定的 max_tokens比如 256同时尽量用会生成满长度响应的固定模板。这样每个请求的输出 token 数都接近上限吞吐指标才稳定。如果你用随机短句模型可能在 20 个 token 就输出了 EOS平均输出长度只有几十 token那算出来的“每秒 token 数”就会虚高而且没法定量归因。2.2 服务端参数对齐清单两个引擎的默认参数不一样直接启动就是不公平对比。我在实验前专门列了一张对齐清单逐项核对对齐项vLLM 常见参数SGLang 常见参数为什么必须对齐上下文窗口长度--max-model-len--context-length长度影响 KV cache 分配最大批处理请求数--max-num-seqs--max-running-requests决定并发上限前缀缓存--enable-prefix-caching--enable-radix-cache缓存命中直接影响 TTFTchunked prefill--disable-chunked-prefill / 默认开启--chunked-prefill-sizeprefill 与 decode 交错策略张量并行--tensor-parallel-size--tensor-parallel-size影响显存分配和通信开销显存利用率--gpu-memory-utilization--mem-fraction-static影响 KV cache 余量采样参数temperature0, do_samplefalse同左避免生成随机性参数名在不同版本里可能略有出入动手之前先看两个--help输出以实际版本为准。但我建议不要只看默认值而是做一个“开关正交”实验比如先两边都关闭前缀缓存测一轮再同时开启测一轮。这样你才能把差异拆开知道到底是引擎调度更快还是缓存命中带来的数字红利。2.3 客户端并发模型和连接池压测客户端我推荐用 Python asyncio配合兼容 API 的流式接口。核心是控制 in-flight 数量而不是盲目开协程。具体做法是初始化一个asyncio.Semaphore每个请求发出前先获取信号量请求完成再释放信号量大小就是目标并发。连接池也要单独设置。连接池大小最好等于目标并发数同时确认 HTTP keep-alive 行为一致。如果连接池太小请求会在客户端排队你测出来的不是服务端性能而是客户端线程调度性能。如果连接池太大又会引入 TIME_WAIT 和连接建立开销同样污染数据。正式采集数据前一定要预热。我先发几十个请求让模型权重加载、CUDA kernel 初始化、图捕获全部完成观察 TTFT 稳定后再清空状态等队列排空才开始正式计时。不预热就跑的话第一个请求会因为权重加载多出好几秒完全会把 P95 拉到失真。3. 指标口径与统计方法数字本身会说谎3.1 四个核心指标的口径LLM 性能报告里最常出现的四个指标是端到端延迟、TTFT、TPOT 和吞吐量。口径不同数字天差地别。端到端延迟是从客户端发出请求到收到最后一个 token 的完整时间包含网络传输、排队、prefill、decode。TTFT 是请求发出到第一个 token 到达的时间它最能反应排队和首次 prefill 的代价。TPOT 是排除 TTFT 后相邻 token 之间的平均间隔相当于“稳定出字速度”。吞吐量我习惯同时记录两个口径一个是每秒完成请求数 RPS另一个是每秒输出 token 数。一个很容易犯的错误是用端到端延迟除以输出 token 数来估算 TPOT。这在低并发下勉强可用高并发下完全不对因为端到端延迟里包含了排队等待会把 TPOT 拉高好几倍。要测准 TPOT必须在流式响应里逐个记录 token 到达时间然后取中段稳定区间的间隔而不是用总时长做除法。3.2 为什么分位数比平均值重要给管理层汇报性能平均值是最没有说服力的一个数字。想象一个场景100 个请求其中 99 个延迟 50ms剩下 1 个因为踩到慢调度路径花了 20 秒。平均值是 250ms看起来非常健康但 P99 是 20 秒线上真实体验大概率是有人在骂。在大并发下TTFT 的分位分布尤其关键。排队理论告诉我们当系统利用率升高尾延迟会指数级恶化P99 翻几倍是常态。所以我的报告里固定给 P50、P95、P99 三组数缺一不可。如果只写平均延迟等于把最严重的短板藏掉了。另外异常点不要随手删除。你可以标注哪些请求属于冷启动、哪些出现了超时但必须在正文里保留原始计数。删掉异常点之后再下结论那就不是实验报告是粉饰数据。3.3 吞吐量的绝对值和归一说到吞吐量有一个很隐蔽的坑如果请求的输出长度不固定纯吞吐不可比。假设 A 引擎平均输出 200 tokenB 引擎平均输出 180 token单位时间 token 数差了 10%但请求数一致。你很难说 B 慢因为它就是提前停了。所以我在每次运行后都会统计实际平均输出长度并写进报告。比如“max_tokens 固定 256实测平均输出 token 252方差 3”这样吞吐量才有基准。报告里同时给 RPS 和输出 token 吞吐两个数字一个反映请求处理能力一个反映生成吞吐互相印证避免被单一指标误导。还有一个细节如果是多卡并行TPOT 会被通信开销影响。记录吞吐时最好同时标注张量并行数否则你测出来的“每秒钟 token 数”到底是几卡合力完成的读者完全看不出来。3.4 一个反例举个我实际见过的错误报告写法某次实验里直接报了“系统吞吐 500 tokens/s优于另一引擎 12%”。但细看请求集用的是一批长度从 50 到 2000 token 不等的随机 prompt平均输出长度也没有记录。这个 500 tokens/s 根本没法定量可能是 prompt 短了也可能是 EOS 提前命中。正确的做法是固定输入输出长度记录平均输出 token打开流式逐 token 采时间戳。指标口径不统一结论就没有讨论价值。4. 实操推进从启动服务到拿到稳定数据4.1 服务端启动命令参考下面是我在实验里实际使用的启动命令按当前常见版本参数写具体字段以你本地版本的--help为准。vLLM 启动侧vllm serve /path/to/model \ --max-model-len 8192 \ --max-num-seqs 64 \ --tensor-parallel-size 8 \ --gpu-memory-utilization 0.9 \ --enable-prefix-caching \ --disable-chunked-prefill \ --port 8000SGLang 启动侧python -m sglang.launch_server \ --model-path /path/to/model \ --max-running-requests 64 \ --tensor-parallel-size 8 \ --mem-fraction-static 0.9 \ --enable-radix-cache \ --chunked-prefill-size 4096 \ --port 30000注意两个命令里的显存预留、并行数、批大小都是对齐过的。除此之外我建议把--port、--host显式固定防止服务漂到别的端口。日志级别调到 warning压测期间不要开 debug 或每秒一条的访问日志。4.2 压测脚本骨架客户端脚本我用 Python asyncio 写固定并发、预热、阶段计时三步。核心骨架大致是import asyncio import time import json from compat_client import AsyncClient # 用兼容接口的 SDK SEM None SINK [] async def bench_one(client, payload): async with SEM: start time.perf_counter() first_token_time None async for chunk in client.stream(payload): if first_token_time is None: first_token_time time.perf_counter() end time.perf_counter() SINK.append({ e2e: end - start, ttft: first_token_time - start, tpot_avg: (end - first_token_time) / max(output_tokens - 1, 1), output_tokens: output_tokens, }) async def main(): global SEM SEM asyncio.Semaphore(CONCURRENCY) tasks [] for _ in range(TOTAL_REQUESTS): tasks.append(loop.create_task(bench_one(client, payload))) await asyncio.gather(*tasks)这段代码里最关键的是流式逐块记录首 token 时间而不是等整个响应结束才拿数据。输出 token 数要从响应末尾的 usage 字段取很多兼容接口都支持流式末尾返回用量信息取不到就退而求其次用客户端自己统计的 delta 文本长度去估算。预热阶段我会额外跑 20 个请求确认 TTFT 不再显著下降后再等队列清空开始正式并发。正式轮次里我会固定一个时间窗口比如 120 秒而不是跑到某个请求数为止这样天然能算出稳态 RPS。4.3 统计脚本拿到一批 JSON Lines 记录后统计脚本要输出 RPS、平均输出 token、延迟分位数、TTFT 分位数、TPOT 平均值。核心逻辑如下import json import numpy as np records [json.loads(line) for line in open(run.jsonl)] window end_ts - start_ts rps len(records) / window output_tokens np.mean([r[output_tokens] for r in records]) ttft [r[ttft] for r in records] tpot [r[tpot_avg] for r in records] print(RPS:, round(rps, 2)) print(avg output tokens:, round(output_tokens, 1)) print(TTFT P50/P95/P99:, np.percentile(ttft, [50, 95, 99])) print(TPOT P50/P95/P99:, np.percentile(tpot, [50, 95, 99]))为了快速发现异常我还会多打印一个“吞吐时间线”把窗口切成 10 秒一段分别统计每个小窗口的 RPS。如果某个窗口掉到平均值的 70% 以下说明中间发生了显存驱逐、日志刷盘或者其他环境干扰。发现问题就去查日志修环境而不是手滑把这段数据删了。4.4 审计清单和 metadata可复现性的最后一环是审计。我每次跑完都会生成一个 metadata 文件包含Git commit 或代码版本、依赖包版本列表、GPU 状态查看工具的输出快照、启动命令全文、请求模板、tokenizer 打印的 prompt token 数、日期时间和并发档次。第一次做实验时我没记驱动版本结果换了显卡驱动之后同一套程序的吞吐漂了 10%我花了整整一天才定位到漂移来源。从那以后metadata.txt就成了必需品。别人来问“这个数字怎么来的”直接把整个实验目录打包扔过去比任何口头解释都有说服力。5. 结果解读与机制归因为什么并发上去之后差这么多5.1 队列、prefill、decode 三阶段模型解读并发实验数据脑子里要先有一个三阶段模型请求进入服务端后先排队再 prefill最后 decode。prefill 阶段并行处理 prompt 生成长度为 1 的 KV 状态是重型整矩阵运算decode 阶段逐 token 生成是访存密集型的小矩阵运算。可以拿餐厅出餐类比prefill 是备菜decode 是出餐窗口并发数就是堂食人数。刚开始并发翻倍出餐总量会跟着涨涨到一定程度厨房人手和灶台GPU 算力和显存都满了再增加人数就只增加排队时间出餐速度不再提升这就是典型的饱和曲线。TTFT 主要受排队时长和首次 prefill 影响TPOT 则体现稳定出餐速度。高并发下 P99 TTFT 指数级上升大部分时间不是 GPU 算不过来而是请求在队列里等待调度。理解了这一点你才能在报告里准确描述“为什么并发 64 时端到端延迟从 1 秒涨到 6 秒”而不是笼统地说“系统不行”。5.2 同并发下两个引擎的差异来源vLLM 和 SGLang 都是优秀的推理引擎但调度机制不同。vLLM 的核心是连续批处理和 PD 分拆调度器按序列状态动态决定哪些请求进入 prefill哪些进入 decode。SGLang 则在前缀缓存上做得更激进RadixAttention 把 KV 缓存的复用粒度做得更细细到能复用任意前缀子树。这带来一个明显影响如果压测请求有共享前缀SGLang 的缓存命中率可能更高第一个 token 的生成代价大幅下降TTFT 会显著变好。这不代表它推理比 vLLM 快只代表它在“前缀复用”这个维度上做得更好。反过来如果请求前缀完全不重合缓存优势就变小双方差距会拉近。所以我在实验里专门做了一个缓存开关正交项关掉缓存测一轮、开启缓存再测一轮。这样得到的性能差距才能被清晰地归因到“调度效率”还是“缓存命中率”而不是混成一锅粥。5.3 一组用于解释趋势的样例数据下面这组数字是我在固定环境下得到的一批样本不代表任何官方基准不同模型、不同 GPU、不同显存配置下都会变。但趋势非常典型适合用来理解怎么解读并发数vLLM RPSSGLang RPSvLLM P99 TTFT (s)SGLang P99 TTFT (s)112120.20.2888920.80.7161401581.51.2321902103.22.6642052186.85.5低并发下两个引擎几乎没有差异因为硬件资源远远富余瓶颈在模型本身。并发到了 16 以上调度器开始成为瓶颈差距开始显现。再看 P99 TTFT并发翻倍之后尾延迟接近翻倍这是排队堆积的典型特征。RPS 的增速在 32 到 64 之间大幅放缓说明系统已经靠近饱和拐点。我的建议是这种实验只报 32 并发以下的数据作为选型依据64 并发以上的长尾延迟已经超过大多数线上 SLA。5.4 如何用正交实验定位差异如果要在两份数据之间做归因正交实验是不可缺少的。我会跑一张 2x2 的矩阵缓存开/关、chunked prefill 开/关再把两组引擎在同一矩阵下各跑一遍。每个格子固定请求集重复三次取中位数。在关闭缓存的前提下如果 SGLang 依然在 32 并发快 10%那可以判断是调度器或者内核实现层面的差异。如果差距完全来自缓存命中那结论就得写“在带前缀复用的工作负载下SGLang 的缓存机制带来 X% 收益”。这种报告才是可解释的。很多时候你不需要知道“谁快”你只需要知道“在什么工作负载下什么参数让谁快”。6. 常见问题排查与避坑清单6.1 两遍压测结果偏差超过 5% 先查什么如果同一套命令隔一小时跑核心指标偏差超过 5%从高到低排查GPU 温度与频率是否漂移有没有开启节能模式压测机是不是有人在登录操作服务端有没有别的进程占 GPU连接池是不是被 TIME_WAIT 堵住以及日志级别是不是被动过。我见过最多的还是 GPU 频率漂移解决方案是锁定运行时钟频率能接受的话直接在启动命令里固定频率档位。6.2 显存爆了导致吞吐像过山车max-num-seqs设得过大时一旦 KV cache 占满引擎会驱逐缓存或者拒绝新请求表现为吞吐周期性跌到接近 0然后恢复再跌。GPU 利用率看起来很高但 RPS 完全不稳定。解决方式是把并发上限调小一点同时观察显存里的 KV cache 剩余量。别把max-num-seqs当成越大越好它是容量边界不是性能旋钮。6.3 随机 prompt 是最大的不可控变量随机自然语言 prompt 的 token 长度方差极大同一段话里可能因为符号、空格、换行导致长度差出 30%。这种请求做小规模冒烟可以做并发对比完全不合格。我会提前把 prompt 模板 tokenize确认长度一致再固定 max_tokens最后在 metadata 里记下实测 token 数。多花十分钟控制输入后面省下的解释成本是十倍。6.4 日志污染比你想的严重把服务端日志级别从 warning 调到 debug响应耗时能涨 10% 到 30%。访问日志每请求写一行在高 RPS 下也会显著占 CPU。压测期间我习惯把访问日志全部关掉只保留启动信息和错误信息。如果实在要记录也在压测机侧记录客户端视角而不是服务端逐请求打印。6.5 计量窗口不要太短也不要太长太短的窗口受冷启动和批处理首波影响太长又会混入温度升高、内核内存碎片等因素。我习惯取 120 秒前 10 秒丢弃后 10 秒也丢弃只统计中间稳定区段。窗口再长的话我会在最后把时间线图放出来辅助说明系统是否保持了稳态。拿这批样本跑完几轮之后我个人体会最深的是性能数字如果不是用来决策的那它只是数字如果是用来决策的就必须能被另一个人复现。我现在做任何一次压测都坚持把实验脚本化、参数记录化、归因正交化不做“跑出来了但不知道怎么回事”的实验。如果你正准备做推理引擎选型建议先别急着比谁快把并发压力下的延迟分布画出来再把缓存开关做一轮正交实验等你拿到那张能解释的趋势图结论自然就出来了。
延伸阅读

更多相关文章

2026/10/15 5:18:12

一文搞懂分类问题:算法选型、评估指标与实战避坑

1. 分类问题到底在解决什么:先想清楚边界在哪很多人一上来就背模型:逻辑回归、决策树、SVM、随机森林……但真到项目里,第一个卡住的往往不是模型不会调,而是"这个问题到底该不该当成分类来做"。我见过不少同学把回归问…

2026/10/15 5:13:12

PostgreSQL生产库备份方案:Barman全量备份与WAL归档实战

很多DBA做PostgreSQL备份,第一反应就是写个 cron 脚本,定时调 pg_dump。这套逻辑在几十 GB 的小库上确实够用,但当你手里管着几百 GB 甚至上 TB 的生产库,还要回答“我能恢复到什么时间点”“备份到底能不能用”这两个问题时&…

2026/10/15 6:18:14

VMamba医学图像分割实战:SS2D四向扫描机制与工程落地指南

1. 从序列建模到视觉主干:为什么要把状态空间模型搬进图像分割医学图像分割这个方向,做过的朋友都知道,它跟自然图像分割完全不是一个难度级别。CT、MRI、超声这些模态,目标边界模糊、对比度低、不同器官之间的灰度差异可能只有几…

2026/10/15 6:18:14

Gatling企业级压测报告定制实战:从stats.json到决策可视化

你要是做过企业级压测,多半见过这种场面:压测执行得挺顺利,Gatling 跑完,默认报告也生成了,可一到汇报和复盘,会议室里全是尴尬。技术自己盯着 95 分位、吞吐量、错误率明细看,领导却只想知道一…

2026/10/15 6:18:14

VMamba医学图像分割实战:SS2D四向扫描机制与调参避坑指南

医学图像分割这个方向,做久了会发现一个很拧巴的现实:Transformer 的自注意力机制在全局建模上确实强,但它的计算复杂度是序列长度的平方级,一张 512512 的 CT 或 MRI 切片,patch 展开后动辄几千个 token,显…

2026/10/15 6:18:14

Spark SQL读取MySQL的两种核心模式:快照式与分片式

1. 为什么“读取MySQL”这件事在Spark SQL里值得单独讲清楚很多人刚接触Spark SQL时,第一反应是:“不就是用spark.read.jdbc()一行代码搞定的事吗?”——我最早也是这么想的。直到在某次模拟项目X中,需要从一个千万级订单表里做实…

2026/10/15 6:18:14

Gatling企业级测试报告定制开发:可视化与决策力落地指南

做性能测试最怕的不是脚本写不出来,而是压测跑了几个小时,结果一堆原始数据摆在面前,汇报的时候却说不出“到底能不能上线”。Gatling本身是很优秀的开源性能测试工具,自带的HTML报告看起来也漂亮,但真放到企业环境里&…

2026/10/15 6:13:14

TensorFlow 1.x模型保存恢复不重定义结构实战指南

1. 项目概述:为什么“保存-恢复模型不重定义结构”是TensorFlow 1.x时代的关键生存技能在2017—2019年TensorFlow 1.x主流应用期,我带过三个不同方向的落地项目:某高校实验室的遥感图像语义分割系统、某工业质检公司的缺陷检测轻量模型、还有…

2026/10/14 11:31:03

Python调用Gemini Structured Outputs实现工单路由门禁

客服工单最怕的不是模型“答错一句话”,而是它给出一段看起来合理的说明,程序却从中猜错优先级。通俗做法是:要求模型只交 JSON(JavaScript Object Notation,轻量数据格式),再让代码验证它。Gem…

2026/10/14 17:41:07

Spring Boot超市进销存系统毕设实战:从需求拆解到答辩通关

最近带的一个学生项目组里,有A同学跑来问我:选什么毕设题目最稳妥,既能让评审老师觉得工作量够,又不会在答辩时被问到语无伦次。我第一反应就是推荐基于Spring Boot的超市仓库管理系统——也就是超市进销存系统。这个题目乍一看平…

2026/10/14 2:32:55

Flutter StatefulWidget 生命周期核心解析

很多刚开始接触 Flutter 的朋友,在看完一堆“Hello World”和基础组件之后,大概率都会撞上同一堵墙:StatefulWidget 里那堆 initState、build、dispose 方法,到底什么时候被调用?为什么顺序是那样?在里面到…

2026/10/15 0:02:22

防关联浏览器哪个好?三款产品实测差异解析

新手做多店铺,最纠结的就是防关联浏览器选哪个。飞跨、网易、站斧这三款被问得最多,先给结论:要一体化省心、长期便宜,选站斧;要网易招牌和稳,选网易;预算紧做东南亚 Lazada,选飞跨。…

2026/10/15 0:02:22

影刀RPA深度实战:大模型Fine-tuning定制RPA专用Prompt与指令生成

影刀RPA深度实战:大模型Fine-tuning定制RPA专用Prompt与指令生成 一、技术背景 大模型(LLM)在通用场景下表现强大,但当我们将 GPT-4 或 Qwen 直接用于生成影刀RPA操作指令和 XPath 定位时,准确率通常只有 40%-60%。原因…

还想了解更多?直接咨询顾问

免费诊断 + 免费方案 + 透明报价。

全国咨询热线400-8866-253
免费获取方案
☎咨询二维码 ☎ ↑