副标题:vLLM 本地部署 · 硬核实战
本文为 vergex.cn 原创首发,转载请注明出处。
你刚把单机从单卡 4090 扩到双卡,Tensor Parallelism 拉满,结果 QPS 不升反降——nvidia-smi 显示显存占满了,可 GPU 利用率却低得可怜。这就是 vLLM 显存与吞吐最常见的认知错位:显存占满不等于算力用满,吞吐瓶颈也不在模型参数,而在调度、KV Cache 和 batch 形态。
一个能立刻复现的最小压测脚本
要聊吞吐,先有一个“能说人话”的基线。很多新手直接跑 benchmark_serving.py,结果被 --request-rate、--dataset-name 这些参数搞晕,最后测出来的数字和线上对不上。我的习惯是先用 Python API 做一个离线 batch 吞吐测试,确认模型本身在本地能跑到多少 token/s,再上 server 做并发测试。
下面这段代码不需要启动 OpenAI 服务,直接跑即可,核心只看三个指标:吞吐、峰值显存、输出长度稳定性。注意在构造 LLM 时把 enable_chunked_prefill、max_num_batched_tokens、max_num_seqs 显式写出来,避免被默认值带偏。
import time, torch
from vllm import LLM, SamplingParams
llm = LLM(
model="meta-llama/Llama-2-7b-hf", # 换成你的本地路径或 HuggingFace ID
tensor_parallel_size=1,
gpu_memory_utilization=0.85,
max_model_len=4096,
max_num_seqs=256,
max_num_batched_tokens=4096,
enable_chunked_prefill=True,
dtype="half",
)
sp = SamplingParams(temperature=0, max_tokens=256, ignore_eos=True)
# 构造 128 条同构长 prompt,便于观察 batching 效率
base = "把以下短文扩写到 300 字:" + "今天天气不错。" * 40
prompts = [base] * 128
torch.cuda.reset_peak_memory_stats()
t0 = time.perf_counter()
outputs = llm.generate(prompts, sp)
t1 = time.perf_counter()
total_tokens = sum(len(o.outputs[0].token_ids) for o in outputs)
print(f"throughput: {total_tokens/(t1-t0):.2f} tok/s")
print(f"peak memory: {torch.cuda.max_memory_allocated()/1024**3:.2f} GiB")
跑完后先别急着调参,把这个数字记下来。完整压测数据集和线上并发复现脚本会在 VergeX 站点放出,便于你直接用同一批 prompt 对比不同配置。
显存占用的四笔账,别只盯着 nvidia-smi
vLLM 刚启动时 nvidia-smi 就占掉一大块显存,很多人误以为是 KV Cache 在“预加载”。其实显存至少由四部分组成,各自的增长规律完全不同:
| 占用项 | 主要来源 | 是否随 batch 变化 | 推荐测量方式 |
|---|---|---|---|
| 模型权重 | fp16/bf16/fp8/量化参数 | 否 | 加载后立刻看 torch.cuda.memory_allocated |
| KV Cache | PagedAttention 的 block pool | 是 | 压测峰值 max_memory_allocated |
| 激活/工作区 | cuBLAS workspace、attention temp buffer | 弱相关 | 单次 forward 的 profile |
| Python/CUDA 运行时 | PyTorch caching allocator、CUDA context | 否 | nvidia-smi 静态差值 |
不要把 nvidia-smi 里的“已用显存”直接当成 KV Cache 占用。 vLLM 在初始化时会按 --gpu-memory-utilization 预分配绝大部分显存给 block pool,即使当前没有请求,这些 block 也已经挂在 CUDA allocator 上。真正反映工作负载压力的是 torch.cuda.max_memory_allocated() 在压测期间的增量,以及 vLLM 日志里的 GPU KV cache usage 百分比。
另一个容易忽略的点是 CUDA context 和 PyTorch caching allocator 的“预占”。在多进程或 Ray worker 场景下,每个 worker 都会有一份 context,导致单卡显存看起来比理论值多几百 MiB 到 1 GiB。做容量规划时要留出 5-10% 的余量,而不是把 0.99 当成目标。
避坑一:gpu-memory-utilization 拉满,吞吐反而下降
这是我问诊过最多的 case:用户把 --gpu-memory-utilization 从 0.8 调到 0.99,结果发现 TTFT 变长、整体吞吐下降。原因很简单——这个参数控制的是 vLLM 最多可以申请的显存比例,不是“利用率越高越好”。当显存上限变大时,vLLM 会分配更多的 KV Cache block,但你的并发请求数并没有同步增加,于是出现两个副作用:
第一,block pool 变大后,调度器会倾向于把更多序列塞进 batch,但如果这些序列的 prompt 长度差异大,反而会把一个本来可以连续计算的 prefill chunk 拆成多个小 chunk,GPU kernel launch 次数增加,矩阵乘法的有效尺寸下降。
第二,剩余显存被 KV Cache 占满后,留给 CUDA graph、cuBLAS workspace 和临时激活的空间变紧张,容易触发碎片化重分配,导致偶发的 latency spike。
我的经验法则是:先按“最大并发数 × 平均输出长度 × head_dim × layer_count × 2”估算 KV Cache 需求,再加 15% 余量,最后把 gpu-memory-utilization 设为刚好覆盖这个值。例如 7B 模型在 4090 上服务 64 并发、单条平均 1024 token,0.82-0.85 通常比 0.99 更稳。
batch 参数怎么调:max_num_batched_tokens vs max_num_seqs
vLLM 的调度器同时受两个上限约束:--max-num-batched-tokens 控制一次 forward 最多处理多少 token,--max-num-seqs 控制一次 forward 最多多少条序列。两者不是越大越好,而是要看你的输入分布:
| 场景 | max_num_batched_tokens | max_num_seqs | chunked prefill | 重点观察指标 |
|---|---|---|---|---|
| 高并发、短输入 | 大(4096-16384) | 大(256-512) | 可关 | throughput、TTFT p99 |
| 长上下文、低并发 | 与 prompt 长度相当 | 小(16-64) | 必开 | TTFT、显存碎片 |
| 长输入 + 高并发混合 | 中等(2048-4096) | 中等(128) | 开 | TPOT、调度抖动 |
| 投机采样 | 受 draft 模型限制 | 受 target batch 限制 | 通常关 | TPOT、accept rate |
Chunked prefill 的本质是把长 prompt 的 prefill 阶段拆成多个 chunk,和 decode 阶段一起做连续 batching。它在长上下文场景能显著降低 TTFT,但会带来一点吞吐 overhead:每个 chunk 都要重新做一次 attention 的 mask 和 indexing。如果你的输入普遍在 512 token 以下,开着 chunked prefill 往往测不出收益,反而因为调度器更复杂导致 TPOT 抖动。
调试时建议固定一个 workload,每次只改一个参数。例如先把 max_num_batched_tokens 从 2048 拉到 8192,观察吞吐是否线性增长;如果不再增长,说明已经触达算力瓶颈,再调 max_num_seqs 也不会有帮助。
避坑二:Prefix Caching 的“假命中”
--enable-prefix-caching 被很多人当成“自动缓存系统 prompt”的开关,上线后发现命中率几乎为零。Prefix Caching 的匹配粒度是 完整的 block(默认 16 token),它只会在 token ID 序列完全一致的连续 block 上生效。你的系统 prompt 如果因为聊天模板、空字符或 apply_chat_template 的差异导致 token ID 不一样,就不会命中。
更隐蔽的是“部分命中”:假设系统 prompt 是 100 token,block size 是 16,那么前 96 token(6 个完整 block)可以被缓存,最后 4 token 所在的 block 仍然要重新计算。这 4 token 的 prefill 会让 TTFT 看起来比“全命中”差很多。 所以接入 prefix caching 后,一定要把系统 prompt 长度对齐到 block size 的整数倍,或在 prompt 末尾补可控的 padding token。
验证命中率不要靠猜,可以直接看 vLLM 日志里的 Prefix cache hit rate,或者用自定义脚本记录每条请求的 prefill token 数。如果你发现大量请求的 prefill token 数等于“系统 prompt 长度 + 用户输入长度”,说明根本没命中。完整数据集里我会放出一段专门用来验证 prefix caching 的 prompt 集合,方便你本地复现这个“假命中”。
吞吐组合拳:TP / FP8 KV / Speculative Decoding 的真实收益
单卡调完之后,很多人会立刻上多卡或量化。这里有几个 2026 年主流版本下的真实收益边界:
Tensor Parallelism 能降低单卡权重显存、提升 memory bandwidth bound 场景的 decode 吞吐,但它会在每层 attention/MLP 后引入 all-reduce。对于 7B/13B 这种能被单卡装下的模型,TP 的主要收益不是算力翻倍,而是让 batch size 可以更大。如果你的 batch size 本来就喂不满,TP 只会增加通信开销,吞吐反而下降。
FP8 KV Cache 通过 --kv-cache-dtype fp8 把 KV Cache 砍半,能在长上下文高并发下显著扩大 batch capacity。但它需要 Ada/Hopper 架构支持,且对精度敏感的任务要配合 --quantization fp8 一起评估 perplexity 退化,不要只看吞吐数字。
Speculative Decoding 用 draft model 生成候选 token,再由 target model 一次性验证。收益取决于 accept rate,而 accept rate 又和 draft 与 target 的分布差异、温度、输出领域强相关。配置时不要只调 --num-speculative-tokens,如果 draft 质量差,5 个候选 token 可能被全部拒绝,反而因为双模型调度拖慢 TPOT。我的做法是先跑 1000 条真实请求,统计 accept rate,再决定是否开启。
一句话总结:先让单卡 batching 吃饱,再上 TP;先确认 accept rate,再上投机采样;先评估精度损失,再上 FP8 KV。
可观测:用什么指标验证,而不是猜
最后,很多性能问题不是参数错了,而是观测指标错了。vLLM 自带的日志已经够用了,但要学会看:
- TTFT(Time To First Token):反映 prefill 调度效率,长上下文场景最敏感。
- TPOT(Time Per Output Token):反映 decode 阶段 batching 效率,低并发时如果 TPOT 抖,多半是 batch 太小或 KV Cache 访问不连续。
- inter-token latency 分布:不要只看平均值,p95/p99 才是线上体验的真相。
- GPU KV cache usage / Running / Waiting / Swapped 序列数:vLLM 日志里会直接输出,看到
Swapped非零就说明 KV Cache 不够用,该降max_model_len或上多卡。 - nccl 通信时间:在双卡/多卡 TP 场景下,用 Nsight Systems 或
VLLM_TRACE_FUNCTIONAL抓一次 timeline,确认 all-reduce 是否成了瓶颈。
如果只做一件事,我推荐把 nvidia-smi dmon -s u 和 vLLM 日志同时打开,压测 5 分钟。你看到 GPU 利用率很高但吞吐上不去,说明是 memory bandwidth bound;如果 GPU 利用率低但请求排队,说明是调度或 batch 参数问题。
反模式清单与核心总结
在把结果交给业务之前,先过一遍这个清单:
- [ ] 把
nvidia-smi的已用显存直接当成 KV Cache 占用 - [ ] 无脑把
--gpu-memory-utilization拉到 0.99 - [ ] 只改
--max-num-seqs却不改--max-num-batched-tokens - [ ] 开启
--enable-prefix-caching却不验证 prompt 前缀是否 block 对齐 - [ ] 用离线 batch 吞吐数字直接推导在线 QPS
- [ ] 把 Tensor Parallelism 当成线性扩容开关,不关注通信和单卡 batch size
- [ ] 上线 FP8 KV 或投机采样前,不评估精度退化和 accept rate
核心总结:vLLM 的吞吐不是“显存越大越好”,而是“在正确的 batch 形态下,把活跃 token 持续喂满 GPU”。 调参之前先建基线,调参之后先看调度指标,比任何玄学经验都靠谱。

