副标题:vLLM 本地部署 · 硬核实战
本文为 vergex.cn 原创首发,转载请注明出处。
去年我们把一台 8×A100 上架,准备用 vLLM 承接内部长文本推理服务。压测第一天就遇到两个经典现象:显卡占用率明明不高,首 token 延迟却忽高忽低;把 --gpu-memory-utilization 拉到 0.95 后,服务在 warmup 阶段直接 OOM。这篇文章把当时在显存、并发和报错排查上踩过的坑整理出来,全是部署现场才会遇到的细节。
01 先算三本账:权重、KV Cache、CUDA Graph 各占多少
vLLM 启动时会把可用显存切成三块:模型权重、KV Cache 预留、以及运行时开销(激活值 + CUDA Graph + 碎片)。很多人只看模型大小,结果 KV Cache 一涨就崩,或者权重加载完发现 CUDA Graph 捕获失败。部署前必须先列出这三本的预算。
模型权重的估算依赖量化格式:
| 量化类型 | 权重大致字节/参数 | KV Cache 默认精度 | 典型适用场景 | 注意点 |
|---|---|---|---|---|
| FP16 / BF16 | 2 字节 | FP16 / BF16 | 追求精度、短上下文 | 显存压力最大 |
| AWQ / GPTQ-Int4 | 约 0.5 字节(含 group scale) | 仍为 FP16 / BF16 | 消费级/边缘 GPU | 权重小了,但 KV Cache 没小 |
| FP8 W8A8 | 约 1 字节 | 可选 FP8 | Ada/Hopper 数据中心卡 | 需 SM89+ 且要开 --kv-cache-dtype fp8 才压缩 KV |
KV Cache 的占用可以用下面这个公式快速估算:
每 token KV ≈ 2(K+V) × num_layers × num_kv_heads × head_size × bytes_per_element
总 KV Cache ≈ 每 token KV × block_size × num_gpu_blocks
vLLM 默认 block_size=16,启动日志里会打印 GPU blocks: N, CPU blocks: M,那个 N 就是根据剩余显存算出来的可分配块数。注意:剩余显存不是 总显存 - 权重,而是 总显存 × gpu-memory-utilization - 权重 - 运行时开销。运行时开销里最不可控的就是 CUDA Graph 捕获,它会为多个 batch size 各保存一份图,这部分经常被低估。
至于激活值,vLLM 在 memory profiling 阶段会用 max_num_batched_tokens 和 max_num_seqs 跑一个峰值 forward,如果这两个值设得太大, profiler 会直接爆掉。因此部署时不要先调 gpu-memory-utilization,而是先把 max_num_batched_tokens 压到合理范围。
02 避坑:别把 --gpu-memory-utilization 直接拉满
新手最容易犯的错是:显存不够?那就把 --gpu-memory-utilization 从默认 0.9 改成 0.95 甚至 0.99。结果往往是权重加载成功,CUDA Graph 捕获到一半就 OOM,或者运行一段时间后因为碎片/激活峰值被 kill。
vLLM 的这个参数含义是“允许引擎使用的显存比例”,但它不包含 CUDA runtime 的额外分配。当值设得太高时,留给 CUDA Graph、cuBLAS workspace、临时激活张量的余量就很小。特别是在多卡 TP(Tensor Parallelism)场景下,每块卡都要独立捕获图,任何一块卡的峰值超了都会让整个进程退出。
我们当时摸索出的稳妥做法:
python -m vllm.entrypoints.openai.api_server \
--model /models/Qwen2.5-72B-Instruct \
--served-model-name qwen72b \
--tensor-parallel-size 4 \
--dtype bfloat16 \
--gpu-memory-utilization 0.88 \
--max-model-len 8192 \
--max-num-seqs 128 \
--max-num-batched-tokens 2048 \
--enable-chunked-prefill \
--swap-space 4
如果仍然 OOM,优先尝试下面两步,而不是继续拉高 gpu-memory-utilization:
- 降低
max-num-seqs。它直接决定 CUDA Graph 捕获的 batch size 上限,对运行时峰值影响最大。 - 加
--enforce-eager。这会禁用 CUDA Graph,虽然会牺牲一部分小 batch 下的延迟,但能显著降低显存占用,先把服务跑起来再优化。
关于这套启动参数,我把它们整理成了一套带注释的一键部署模板,模型路径、TP 大小、量化类型都抽成了变量,完整版见站点「vLLM 一键部署脚本」页面。
03 max-model-len 与 max-num-seqs 不是越大越好
vLLM 的 max-model-len 并不只是“限制输入长度”这么简单。它会影响 memory profiler 的初始张量形状,也间接决定了系统愿意为单个序列预留多少 KV Cache block。如果模型卡上写支持 128K context,你就直接设 --max-model-len 131072,大概率会在 profiling 阶段被系统下调,或者直接 OOM。
正确的做法是:先根据业务确定真实需要的最大长度。比如内部客服场景最长 4K,那就不要开 32K。如果业务确实需要长上下文,再逐步上调,并观察日志里的这两行:
INFO: GPU blocks: 2048
INFO: Maximum concurrency for 8192 tokens per request: 16
第二行是 vLLM 根据 max-model-len 和可用 block 数算出的理论最大并发。如果这个数字远低于你的目标 QPS,说明需要加卡、开 TP/PP,或者启用 KV Cache 压缩。
max-num-seqs 则是调度器同时处理的序列数上限。它越大,batch 越容易凑满,吞吐越高;但对应的 CUDA Graph 捕获数量和调度开销也越大。一个经常被忽略的细节:max-num-seqs 和 max-num-batched-tokens 共同决定 profiling 时的峰值。假设 max-num-seqs=256 但 max-num-batched-tokens=4096,profiler 会尝试构造一个 256 seq × 16 tokens 的虚拟输入,如果模型很大,这一步就能占掉好几 GB 显存。
我的习惯是把 max-num-seqs 从 256 开始往下压,每次减 64,直到 warmup 稳定;然后再压 max-num-batched-tokens,最后才考虑调 gpu-memory-utilization。
04 并发上不去?先看 Scheduler 与 Chunked-Prefill
很多人以为并发低就是 GPU 算力不够,实际上 vLLM 的 Scheduler 设计让 Prefill 和 Decode 会互相抢资源。一个长 prompt 的 prefill 阶段会一次性吃掉大量算力,导致同 batch 里的 decode 请求被阻塞,表现为 TPOT(Time Per Output Token)飙升。
vLLM 从 0.4 版本开始支持 --enable-chunked-prefill,它的作用是把长 prefill 切成多个小 chunk,每个 scheduler step 只处理一部分,让 decode 请求能插进来。对于高并发在线服务,建议默认开启。但要注意两个副作用:
- 切分 prefill 会带来额外的调度开销,极端小 batch 下可能反而降低吞吐。
- 如果 chunk 太小,GPU kernel 利用率下降,需要结合
max-num-batched-tokens一起调。
另一个提升并发吞吐的手段是 --num-scheduler-steps(多步调度)。它让 scheduler 一次调度多个 decode token,减少 CPU 与 GPU 之间的同步次数。典型用法是:
--num-scheduler-steps 10 --multi-step-stream-outputs
--multi-step-stream-outputs 允许在多步调度中间继续流式输出,否则客户端会感觉到明显的卡顿。开启后建议用 benchmark 观察 TTFT 和 TPOT 的 trade-off:步数越大,吞吐越好,但首 token 等待时间可能变长。
多节点场景还要选对并行策略。单节点多卡优先用 --tensor-parallel-size,跨节点再用 --pipeline-parallel-size。TP 的通信走 NVLink/PCIe,延迟低;PP 的通信量小但 bubble 大,适合超长模型。
05 量化与 KV Cache 压缩的硬件门槛
权重量化能显著降低显存占用,但不要把 AWQ/GPTQ 当成万能药。这类权重量化只压缩模型参数,KV Cache 仍然以 FP16/BF16 存储。如果你的业务是长上下文高并发,KV Cache 才是显存大头,权重减半带来的收益会被快速稀释。
真正能压缩 KV Cache 的是 --kv-cache-dtype fp8。但这条路线有两个硬性门槛:
- GPU 算力架构:FP8 需要 SM89 及以上,也就是 Ada Lovelace 或 Hopper 系列。A100/V100 不支持。
- 模型与量化配套:不是所有 FP8 checkpoint 都能直接塞进 vLLM,需要确认模型卡是否声明了
quant_config为fp8,并且--quantization fp8与--kv-cache-dtype fp8同时生效。
python -m vllm.entrypoints.openai.api_server \
--model /models/Meta-Llama-3.1-70B-FP8 \
--quantization fp8 \
--kv-cache-dtype fp8 \
--dtype auto \
--tensor-parallel-size 2
如果硬件不支持却强行开启,启动时会直接报错 FP8 is not supported on this GPU。这时回退到 BF16 + 降低 max-model-len 更现实。
另一个被高估的功能是 --enable-prefix-caching。它能把重复 prompt 前缀的 KV Cache 共享,对 RAG、多轮对话非常有用。但如果你的 prompt 前缀重复率很低,它会带来额外的 hash 和 block 管理开销,反而拖慢服务。开启前先用真实日志统计一下前缀重复率,超过 30% 再开比较划算。
06 避坑:多卡 Docker 部署必改 --shm-size
vLLM 在单卡模式下可以直接用官方镜像跑,但只要开启 TP 或 PP,NCCL 进程间通信会用到 /dev/shm。Docker 默认的 shm-size 只有 64MB,tensor parallel 一启动就会遇到各种匪夷所思的错误,比如:
RuntimeError: NCCL error: unhandled system error
# 或
OSError: [Errno 28] No space left on device
这类错误不是显存不够,而是共享内存不够。正确的启动命令必须显式扩容:
docker run --rm -it \
--gpus all \
--shm-size=8g \
--ipc=host \
-p 8000:8000 \
-v /data/models:/models \
vllm/vllm-openai:latest \
--model /models/Qwen2.5-72B-Instruct \
--tensor-parallel-size 4 \
--gpu-memory-utilization 0.88
如果上 K8s,也要给 Pod 挂一个 emptyDir 到 /dev/shm:
volumeMounts:
- mountPath: /dev/shm
name: dshm
volumes:
- name: dshm
emptyDir:
medium: Memory
sizeLimit: 8Gi
另外,多卡容器里建议把 NCCL_DEBUG=INFO 打开排错:
-e NCCL_DEBUG=INFO
这样如果 NCCL 初始化失败,日志会明确告诉你是 P2P 被禁、拓扑检测异常,还是 IB/RDMA 驱动问题。我们曾经遇到主机内核参数 vm.max_map_count 不够导致 NCCL 初始化卡住,调大到 655300 后解决。
07 压测与观测:别只看 nvidia-smi
nvidia-smi 里的显存占用对 vLLM 几乎没有参考价值,因为 vLLM 会在启动时就把 gpu-memory-utilization 比例内的显存预分配掉,看起来永远是 80% 以上。真正要关注的是三个指标:TTFT、TPOT、GPU Cache 命中率。
vLLM 内置了 Prometheus 风格的 /metrics 端点:
curl -s http://localhost:8000/metrics | grep -E \
"vllm:gpu_cache_usage_perc|vllm:num_requests_running|vllm:num_requests_waiting|vllm:time_to_first_token_seconds|vllm:time_per_output_token_seconds"
关键指标解释:
vllm:gpu_cache_usage_perc:KV Cache 使用率。长期低于 50% 说明max-model-len或max-num-seqs开大了,浪费显存;长期接近 100% 说明随时可能触发驱逐或 OOM。vllm:num_requests_waiting:排队请求数。如果持续大于 0,说明调度器已经满载,需要扩容或降低单请求长度。vllm:time_to_first_token_seconds:首 token 延迟,直接反映 prefill 阶段体验。vllm:time_per_output_token_seconds:每个输出 token 的间隔,反映 decode 阶段体验。
压测推荐用 vLLM 自带的 benchmark_serving.py:
python benchmarks/benchmark_serving.py \
--backend vllm \
--host 127.0.0.1 \
--port 8000 \
--model /models/Qwen2.5-72B-Instruct \
--dataset-name random \
--num-prompts 1000 \
--request-rate 20 \
--max-concurrency 64 \
--random-input-len 1024 \
--random-output-len 512
--request-rate 控制 Poisson 到达率,--max-concurrency 限制同时飞行请求数。建议先做一组并发扫描:从 16、32、64、128 逐步增加,记录 TTFT P99 和 TPOT P99 的拐点。拐点出现的位置往往就是 max-num-seqs 的合理上限。
另外,如果服务出现偶发超时,建议打开 vLLM 的 scheduler 日志级别:
export VLLM_LOGGING_LEVEL=DEBUG
可以看到每个 step 里 prefill/decode 各分了多少 token,帮助判断是不是 chunked-prefill 切分过粗。
08 上线前反模式清单
把上面的坑浓缩成一份自查表,部署前逐条过一遍:
- [ ]
--gpu-memory-utilization没有超过0.92,给 CUDA Graph 和激活值留有余量。 - [ ]
max-model-len不是直接抄模型卡上限,而是按业务真实需求设定。 - [ ]
max-num-seqs和max-num-batched-tokens经过 warmup 验证,没有因为 profiling 峰值 OOM。 - [ ] 多卡/多节点部署已经扩容
/dev/shm,并检查NCCL_DEBUG=INFO无报错。 - [ ] 如果使用 FP8 KV Cache,已确认 GPU 为 Ada/Hopper 且模型量化配置匹配。
- [ ] 高并发在线服务已开启
--enable-chunked-prefill,并观察 TTFT/TPOT 是否在可接受范围。 - [ ] 压测脚本使用了 Poisson 到达率而不是固定并发,避免低估排队效应。
- [ ]
/metrics关键指标已接入告警,特别是vllm:num_requests_waiting和vllm:gpu_cache_usage_perc。
vLLM 部署的核心不是把参数堆到最大,而是让“模型权重 + KV Cache + 运行时开销”在显存里形成稳定的三角平衡;任何一边冒进,都会在并发上来时以 OOM 或延迟抖动的形式还回来。

