☰
vLLM基准测试文本构造指南:从PagedAttention到吞吐量指标的避坑实践
2026/10/21 4:11:42 网站建设 项目流程

刚把 vLLM 部署好,第一件事往往不是调业务,而是想弄清楚这台机器到底能扛多大并发、一个请求从发出去到看见第一个字要多快。但很多人跑完 benchmark 后对着输出结果一脸懵,或者更头疼的是:同一份模型,在不同人手里测出来的吞吐量能差出两三倍。问题往往不在 GPU,而在你喂给基准测试的那份“测试文本”。

这个坑我踩了无数次。vLLM 的 PagedAttention 和 Continuous Batching 让推理性能变成一个极度依赖输入输出长度、批次大小和并发数量的动态过程,拿几段随手写的话去压测,测出来的数字基本没有参考价值。所以这篇文章就从“基准测试文本”这个容易被忽略的点切入,把 vLLM 基准测试里的数据集构造、指标原理、实操命令和避坑经验一次性讲透。无论你是刚入门的大模型应用开发者,还是专门做推理服务压测的性能工程师,照着这套流程跑一遍,拿到的数据至少能经得起同行追问。

1. 先搞清楚:vLLM 基准测试究竟在测什么

很多人以为基准测试就是“给 GPU 施加压力,看它每秒能吐出多少字”。这么说太粗糙了。vLLM 之所以能做到高吞吐,靠的是 PagedAttention 把 KV Cache 切分成固定大小的块来管理显存,配合 Continuous Batching 在每一步迭代后动态调整请求批次。这两个机制直接导致:请求的输入长度、输出长度、批次里的请求数量,会显著影响最终性能。理解这一点,你才能明白为什么“测试文本”这么重要。

1.1 为什么测出来的吞吐量差异这么大

先看 vLLM 的推理过程分成两个阶段。第一阶段叫 Prefill,也就是把输入文本一次性喂给模型,做全量计算,把所有请求的 KV Cache 写入显存。这个阶段计算密集,而且输入文本越长,单请求占用的计算量和显存瞬间峰值就越高。第二阶段叫 Decode,模型开始逐个 token 地生成输出。这个阶段访存密集,本质上是不断读取已缓存的 KV 来做自回归解码,吞吐量高度依赖当前批次里同时有多少个请求在排队生成。

Continuous Batching 的调度逻辑是:某个请求一旦生成完,马上从批里拿出来,把新请求塞进去,而不是傻等整批结束。所以一批请求的“平均生成时长”越短,调度频率越高,吞吐越高。这也就解释了为什么同样是 100 个请求,如果输出长度分别是 64 和 512,测出来的 tokens/s 会差出一大截——你测的根本不是同一个负载。

vLLM 的性能测试因此不是单点指标,而是一条或多条曲线。离线批量跑吞吐,看的是整批请求从开始到结束的总体生成速度;在线服务压测,看的是并发请求下的首字延迟和端到端延迟;显存维度还要看 KV Cache 有没有被换出、会不会触发 OOM。如果测试文本长度乱来,这些指标全部失真。

1.2 四个绕不开的核心指标

不管用什么脚本跑基准测试,最终都会落到下面几个指标上,先把它们弄清楚,后面分析结果才不会跑偏。

指标全称 / 含义主要关注场景量级参考
TTFTTime To First Token,从发出请求到收到第一个输出 token 的耗时在线对话、实时应用百毫秒级
ITL / TPOTInter-Token Latency,解码阶段相邻两个 token 生成的时间间隔流式输出体感几十毫秒级
Throughput每秒生成的 token 数,单位 tokens/s离线批量、吞吐评估数百到数千
E2E Latency一个请求从发出到全部内容返回的总耗时综合体验秒级或更长

TTFT 是最容易被新手忽略的。vLLM 在 High Batching 下能同时处理很多请求,但一个新请求进来,可能不会立刻被调度,而是进入排队状态。所以 TTFT 里既包含了模型 Prefill 的计算时间,也包含了调度器的排队等待时间。TTFT 的均值很容易被极端值拉高,看中位数更有意义。

ITL 反映的是 Decode 阶段的稳定性。如果模型生成长文本时中后期速度变慢,ITL 会逐渐变大。把这个指标拆开看,能判断是显存里的 KV Cache 碎片化严重,还是批次里混入了超长输出请求拖慢了整体步调。

Throughput 要特别注意单位是“输出 token 数”还是“输入加输出 token 数”。不同工具统计口径不一样,对比之前必须确认,否则差一半都有可能。E2E Latency 则是用户视角的总时长,在线场景下和 Throughput 通常是矛盾的:并发上去吞吐高了,每个请求的延迟也跟着涨,需要做取舍。

1.3 测试文本在指标里扮演的角色

测试文本的核心作用是把 Prefill 和 Decode 的负载特征变成可量化的数据。输入文本长度直接决定 Prefill 阶段的计算量,比如同样 100 个请求,平均输入长度从 512 token 变成 2048 token,Preffil 的耗时可能翻三倍以上,TTFT 当然跟着涨。输出长度决定 Decode 阶段要跑多少步,每步一个 token,输出越长,整批请求占住 GPU 的时间越长,整体吞吐自然下降。

更隐蔽的是文本内容对 tokenizer 的影响。同样的字符数,英文可能拆出 100 个 token,中文可能只有 50 个,代码片段又跟自然语言完全不是一个分布。如果拿一份全英文测试文本去压一个中文优化过的模型,input 和 output 的 token 数完全对不上,最后测出的吞吐量根本没法跟别的模型横向比较。这一点在后面构造测试集时一定要当回事。

2. 测试文本的选择与构造:一份能说明问题的文本比 GPU 还重要

标题里的“基准测试文本”往深了说,不是几段话,而是一套能代表线上真实请求分布的数据集。构造它的目标很明确:让压测负载尽可能贴近真实业务,同时把无关变量控制住。做好这一步,后面所有数据才谈得上可靠性。

2.1 三种可用的基准测试文本来源

第一类是线上真实请求日志。这是最理想的来源,因为它自带真实的会话结构、长度分布和内容主题。比如从推理服务的访问日志里抽 500 到 1000 条真实 prompt,按会话拆分,简单脱敏后转成统一格式。缺点是需要做数据清洗和格式转换,而且日志里一般只有 input 没有 output,output 长度要靠配置指定。第二类是公开对话数据集,一般也是多轮问答格式,内容质量有保障,拿来做通用压测很方便。但它的语言分布和业务场景未必匹配,如果线上服务是中文技术问答,拿英文通用数据压出来的结果就会失真。第三类是程序生成的合成文本,通过重复拼接和随机裁剪控制长度,优点是能精确生成任意长度的文本,缺点是内容无意义,可能让模型在 Prefill 阶段的注意力行为跟真实请求不一样。

我自己的做法是混合方案:以真实请求日志为主,合成文本兜底。线上日志样本不够时,用合成文本把长尾长度补上。这样既保持了真实业务的内容分布,又能覆盖到低概率但影响性能的长文本场景。纯靠合成文本做压测的问题在于,它测出来的是“这个模型在固定模板下的极限吞吐”,不是“用户真实体验到的吞吐”。

2.2 格式要求:vLLM 基准脚本怎么读数据

vLLM 主仓库的 benchmarks 目录下有两个常用脚本:benchmark_throughput.py 测离线批量吞吐,benchmark_serving.py 测在线服务压测。两个脚本都支持通过 --dataset 指定一个 JSON 文件,默认兼容公开对话数据集用的 ShareGPT 格式。ShareGPT 格式本质是一个数组,数组里每个元素代表一段多轮对话,每轮消息用 from 和 value 区分角色。对新版脚本来说,一般只需要 human 和 gpt 两条消息就能跑起来。

下面这个就是最精简的多轮对话结构:

[ { "conversations": [ { "from": "human", "value": "请用通俗的语言解释什么是分布式事务。" }, { "from": "gpt", "value": "分布式事务指的是跨多个数据库或服务完成的单个事务,它需要保证所有参与者要么全部成功,要么全部回滚。" } ] }, { "conversations": [ { "from": "human", "value": "vLLM 的 Continuous Batching 是怎么工作的?" }, { "from": "gpt", "value": "Continuous Batching 允许在生成过程中动态调度请求,一个请求生成完毕后立即插入新请求,避免 GPU 空转。" } ] } ]

要注意的是,这份文件里的 gpt 回复内容其实不会参与模型生成,它只是被用来标记“这条样本对应的输出期望”。真正跑压测时,模型输出是实时生成的。所以你可以把 gpt 的 value 随便写成什么,但最好不要留空字符串,因为某些解析逻辑会把它识别成不完整对话。如果不想带多轮结构,也可以用只含单个 prompt 字段的 JSONL 文件,一行一个请求,具体格式以你下载的 vLLM 版本里的脚本 --help 输出为准。

2.3 我自己平时怎么造测试文本

造测试文本是一个五步流程,每一步都有配套的小操作。

第一步是收集原始素材。线上日志如果没有现成的分词统计,就直接把所有 prompt 存成文本文件,一行一条。第二步是打好长度分布,把样本按输入长度分成短、中、长三个桶,比如 128 以下、128 到 1024、1024 以上。第三步是用模型自带的 tokenizer 统计每一条 prompt 的真实 token 长度,这一步非常关键。token 长度跟字符数完全不是一回事,中文一句话可能十几个字符但只有几个 token,英文缩写和空格的处理也会让字符长度和 token 长度严重偏离。第四步是根据统计结果挑选样本,保证整份测试集在长度分布上跟线上一致。第五步是转成 ShareGPT 格式的 JSON 文件,供 benchmark 脚本读取。

统计 token 长度的参考脚本如下,换成你自己的模型路径就能跑:

from transformers import AutoTokenizer tokenizer = AutoTokenizer.from_pretrained("/path/to/model", trust_remote_code=True) texts = [] with open("prompts.txt", encoding="utf-8") as f: for line in f: line = line.strip() if line: texts.append(line) lens = [len(tokenizer.encode(t)) for t in texts] sorted_lens = sorted(lens) count = len(sorted_lens) print(f"样本数: {count}") print(f"平均长度: {sum(sorted_lens) / count:.1f}") print(f"p50: {sorted_lens[count // 2]}") print(f"p90: {sorted_lens[int(count * 0.9)]}") print(f"p95: {sorted_lens[int(count * 0.95)]}") print(f"最大长度: {max(sorted_lens)}")

跑完这个脚本,你就能回答一个基本问题:“线上请求有多长”。做基准测试时,测试集的平均输入长度应尽量贴近这个数字,长尾请求也不能丢掉,否则压测结果会非常乐观,线上却还是会频繁超时。

2.4 控制变量:长度、数量、随机种子

测试文本的数量也不是越多越好。vLLM 自带的离线吞吐测试会在每一条 prompt 上执行真实生成,如果测试集有 2000 条超长文本,跑完可能要一小时,而压缩到 500 条,结果几乎不会有变化。我的经验是:短文本场景 300 到 500 条就够,长文本场景 200 条左右也行,关键是长度分布要稳定,不要忽长忽短。

还有一个容易忽略的控制变量是随机种子。vLLM 的采样器在生成时如果开了采样参数,结果会有随机性,同一条 prompt 多跑两次,吞吐量可能差几个百分点。跑对比实验时一定要固定 --seed,最好同时关闭采样使用贪心解码,这样每次生成的内容一致,测出来的时间差异才真正来自系统负载而非模型随机性。

3. 完整实操:从零跑一次 vLLM 吞吐基准测试

理论说完了,直接上命令。这一节我会给出一套可以直接复制的操作流程,覆盖离线吞吐和在线服务两种场景,并把我平时在真实环境里用到的参数组合一并写出来。

3.1 环境准备清单

开始之前先列一个清单,缺一项后面都会出问题。

GPU 显存至少要能塞下模型权重加 KV Cache。以 7B 量级的开源模型为例,FP16 权重大约 14GB,建议总显存不低于 24GB。显存不够时,可以调低 --gpu-memory-utilization,但吞吐和并发上限会明显下降。驱动和 CUDA 版本跟着 vLLM 官方要求走,Python 用 3.10 及以上,建议用虚拟环境隔离依赖。

安装命令很直接:

python -m venv .venv source .venv/bin/activate pip install --upgrade pip pip install vllm

安装完成后验证一下:

python -c "import vllm; print(vllm.__version__)"

能正常打印版本号,环境就算通了。模型权重建议存在本机 SSD 上,不要在机械硬盘上跑首轮加载,否则光加载权重和 tokenizer 的时间就够你怀疑人生。如果只有机械硬盘,至少把 benchmark 脚本放在 SSD,模型文件路径用绝对路径。

3.2 离线吞吐测试:benchmark_throughput 实操

离线吞吐测试模拟的是“我有一批离线任务,一次性丢给模型全部跑完,看总吞吐是多少”的场景。它的好处是能把服务端调度和网络开销剔除,只看 GPU 推理本身的能力。

基本命令:

python benchmarks/benchmark_throughput.py \ --model /path/to/model \ --tokenizer /path/to/model \ --input-len 1024 \ --output-len 256 \ --num-prompts 500 \ --max-num-seqs 32 \ --gpu-memory-utilization 0.9 \ --trust-remote-code \ --seed 42

参数逐个解释一下。--input-len 指定每个请求的输入 token 数,--output-len 指定生成 token 数,这两个参数决定了 Prefill 和 Decode 的负载比重,是测试文本的核心控制旋钮。--num-prompts 是总共跑多少条请求。--max-num-seqs 是调度器允许的最大并发批次,这个值越低越接近串行,越高越吃显存,也越能体现 vLLM 的批处理优势。--gpu-memory-utilization 是 KV Cache 能用到显存比例,默认 0.9,显存紧张可降到 0.8。

如果不指定 --dataset,脚本会用 --input-len 和固定模板自动生成随机 prompt。选这个模式只能控制长度,控制不了内容,适合快速摸底。想用真实测试文本,就改成:

python benchmarks/benchmark_throughput.py \ --model /path/to/model \ --tokenizer /path/to/model \ --dataset ./data/bench_sharegpt.json \ --output-len 256 \ --num-prompts 500 \ --max-num-seqs 32 \ --trust-remote-code

注意这里的 --output-len 和 --max-num-seqs 仍然是必填项,输入长度则来自 --dataset 里每条样本的 token 化长度。如果样本输入长度分布和线上一致,这个模式跑出来的吞吐才是有业务参考价值的数。

脚本跑完后会输出类似下面的结果:

Total num prompts: 500 Total num generated tokens: 128000 Elapsed time (s): 151.19 Throughput: 846.6 tokens/s

看到 Throughput 一行时,记得同时记录当时的平均输入长度和输出长度。没有这两个上下文,只给一个 tokens/s 毫无意义。

3.3 在线服务测试:benchmark_serving 实操

在线压测更接近真实使用场景。它先启动一个 vLLM 服务,然后由压测客户端以指定速率发送请求,统计 TTFT、ITL 等指标。

第一步,启动服务:

vllm serve /path/to/model \ --served-model-name my-llm \ --gpu-memory-utilization 0.9 \ --trust-remote-code

vLLM 新版本直接提供了 vllm serve 子命令,底层就是 OpenAI 兼容服务。服务起来后,端口默认 8000。

第二步,新开一个终端跑压测脚本:

python benchmarks/benchmark_serving.py \ --backend vllm \ --model my-llm \ --base-url http://127.0.0.1:8000/v1 \ --dataset ./data/bench_sharegpt.json \ --tokenizer /path/to/model \ --request-rate 2 \ --num-prompts 200 \ --output-len 256 \ --max-concurrency 16

--request-rate 表示每秒发多少请求。设成 0 意味着不限速,客户端会尽可能快地压满服务端,用来测最大吞吐;设成 2 就模拟每秒两个新请求的匀速负载,更接近真实场景。--num-prompts 是总请求数,--max-concurrency 限制客户端同时在飞的请求数,防止压测进程自己把 CPU 打满。--output-len 是每个请求的输出 token 上限,和数据集本身没有关系,纯控制生成长度。

输出里你除了能看到吞吐,还会看到一整套延迟分布:

Successful requests: 200 Requests/sec: 1.98 Total input tokens: 82460 Total output tokens: 51200 Output tokens/sec: 507.3 Mean TTFT: 645.2 ms Median TTFT: 588.7 ms Mean ITL: 22.4 ms Median ITL: 20.9 ms Mean E2E latency: 5.94 s

在这些数字里,Requests/sec 只是发请求的速率,不代表模型吞吐,真正衡量模型生成速度的是 Output tokens/sec。Median TTFT 不到 Median E2E latency 的十分之一,说明大部分时间花在逐个生成输出 token 上,这是正常形态。

3.4 一份可复用的测试文本接入模板

把自定义测试文本接入上面的流程,最省事的方式是准备一个 bench_sharegpt.json 文件,里面放 100 到 500 条对话记录。下面是一个最小可用的模板,只有三条样本,每条都包含 human 和 gpt 两个角色。

[ { "conversations": [ {"from": "human", "value": "请写一段关于Raft协议选举过程的说明,要求结合超时机制展开。"}, {"from": "gpt", "value": "Raft的选举依赖随机超时机制,候选人通过任期递增和投票请求收敛到唯一领导者。"} ] }, { "conversations": [ {"from": "human", "value": "机器学习模型上线后如何做数据漂移监控?请给出具体指标。"}, {"from": "gpt", "value": "可以从特征分布、预测分布和标签反馈三个维度监控,常用PSI和KL散度衡量漂移程度。"} ] }, { "conversations": [ {"from": "human", "value": "用一两句话解释什么是向量数据库,并说明两个典型使用场景。"}, {"from": "gpt", "value": "向量数据库用于存储和检索高维向量,典型场景包括语义搜索和推荐召回。"} ] } ]

把这个文件和压测脚本放在同一个工作目录下,把 --dataset 指过去,就能跑起来了。整份文本的输入长度最好提前用节里的统计脚本跑一遍,确认平均输入长度在目标范围附近,不要直接拿一个只有几十 token 短句的小文件去压测。

3.5 一次实测记录:现场脚本输出示例

有一次我在一台 24GB 显存的卡上跑 7B 模型,测试集是 500 条来自线上的真实请求,平均输入长度约 700 token,输出长度固定 256,请求注入速率设为每秒 5。启动服务后先让它空跑 20 秒预热,等显存里的 KV Cache 进入稳定状态后再压。实测结果大概是这样的形态:

请求数 500,成功 499,一条因为触发了输出长度截断被标记失败。总输入 token 约 35 万,总输出约 12.8 万。Output tokens/s 在 780 到 830 之间浮动,Median TTFT 在 520 毫秒附近,Median ITL 约 20 毫秒。如果把 --max-num-seqs 从 32 调到 64,Output tokens/s 能涨到 900 以上,但 TTFT 的 p95 会从 800 毫秒涨到 1.3 秒。这个现象非常典型:批次越大、吞吐越高、首字延迟越长,在线场景必须做取舍。

跑完一次测试后,我习惯把三个东西一起存档:环境信息、测试文本的长度分布、原始输出日志。这样一周后再跑,或者换了模型再跑,才有足够上下文做对比。

4. 指标解读、调参与避坑手册

基准测试最让人头大的不是跑命令,而是“跑完不知道怎么解释”和“解释完发现数据互相矛盾”。这一节把常见坑和排查思路整理成速查手册。

4.1 结果怎么看:三个最容易误读的地方

第一个误读是把 Requests/sec 当成模型能力。Requests/sec 是客户端发请求的速率,由测试工具自己决定;真正反映模型输出能力的只有 Output tokens/sec。第二个误读是拿不同输出长度下的 Throughput 做横向比较。都是 500 条请求,输出长度 128 跑出的每秒 token 数一定高于输出长度 512,这不是模型变强了,是 Decode 步数变少了。要比较就必须固定输入输出长度分布。第三个误读是只看均值不看分布。TTFT 的均值容易被少数长 Prefill 请求拉高,ITL 均值会被偶发的显存换页拉高,所以中位数才是体感,p95 才是风险。看到 Mean 和 Median 差距很大时,优先查是不是有一条超长输入请求混进了测试集。

4.2 测试中容易踩的七个坑

坑现象解决方案
测试文本全是短句吞吐虚高,上线后长文档请求超时测试集加入 1024 token 以上的长文本样本
输出长度不固定同批请求生成 token 数差异大,Decode 负载不稳定显式指定 --output-len,关闭 --ignore-eos 自动截断
机器上还有别的任务显卡被部分占用,吞吐波动大压测前用 nvidia-smi 确认显存和算力空闲
采样参数不统一开 top-p 采样比贪心解码慢,对比失真基准测试统一用 greedy,设置 --seed
不预热直接压测首个阶段数据明显偏低启动服务后空跑 20 秒再计时,或先跑 2 个 warmup 请求
模型权重在机械硬盘首轮加载和 tokenizer 初始化极慢权重和数据集放 SSD,加载后重跑一次再取数
只比吞吐不比延迟高吞吐但 TTFT 爆炸,线上体验差同时记录 Median TTFT、p95 TTFT,评估时两个维度都看

这七个坑我在真实压测里都踩过。最典型的是“线上日志样本太少,直接拿 50 条短句测试集去跑,然后跟别人的官方 benchmark 比吞吐”。测试文本长度分布完全不同,这个对比从一开始就是无效的。所以每次跑完,我都会把测试集的平均输入长度、平均输出长度、样本数量一起贴到结果旁边。

4.3 拿测试文本优化推理服务的扩展思路

基准测试跑完不是终点,后面可以拿同一份测试文本做更细的调优验证。第一步是把测试集按长度分桶,分别跑短文本和长文本的吞吐与延迟,找出模型在哪个长度区间性能掉得最厉害。如果 2048 token 以上的 TTFT 突然翻倍,大概率是 Prefill 阶段的显存预留不足,或者 chunked prefill 配置没有打开。第二步是调整调度参数,比如 --max-num-seqs 和 --max-num-batched-tokens,用同一份测试集跑网格搜索,找“高吞吐、且 TTFT 在可接受范围”的平衡点。第三步是验证前缀缓存是否生效,如果测试文本里很多请求有相同开头,开启 prefix caching 后长文本场景的吞吐会有明显提升。

我自己踩了几次坑之后,现在每次跑基准测试都会先确认两件事:一是测试文本的长度分布和线上是否一致,二是输出长度是否固定。就这两点,能过滤掉一半以上的无效对比。剩下的时间,才是真正调模型、调参数的时间。

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

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

立即咨询