vLLM 多卡部署实战:从 OOM 到稳定上线的完整踩坑记录

作者:钝挫力PROGRAMER | 7 年后端开发,正在一线做 AI 落地

AI/RAG 实战系列 #2 | 上一篇:从零搭建企业级 RAG 系统:架构设计与技术选型实战


写在前面

上一篇文章讲了 RAG 系统的整体架构和技术选型。有读者问了一个很实际的问题:“你们 LLM 推理服务是怎么部署的?直接调 API 还是自建?”

答案是:我们两条路都走了。早期验证阶段直接调 DeepSeek 和通义的 API,快速跑通了 RAG 流程。但到了日均请求量上来之后,三个问题逼着我们考虑自建推理服务:

  • 数据合规:内部文档不能出内网,API 方案意味着数据要传到外部
  • 成本考量:当调用量稳定在每天几万次之后,自建 GPU 集群的边际成本开始低于 API 调用费
  • 延迟要求:内网调用外网 API 的网络往返至少多出 50-100ms

我们最终选择了 vLLM 作为推理引擎。原因不复杂——OpenAI 兼容接口(迁移成本低)、PagedAttention(显存利用率高)、原生支持张量并行(多卡开箱即用)、社区活跃度在所有推理框架里排第一。

但从"能跑起来"到"稳定上线",中间经历的过程比预想的曲折得多。这篇文章把整个过程记录下来,重点放在踩过的坑和解决方法,希望能帮后来人少走弯路。


一、环境准备:第一关就卡住了

1.1 CUDA 版本兼容性问题

这是遇到的第一个坑,也是最容易被忽视的。

当时服务器上装的是 CUDA 11.8,直接 pip install vllm 之后启动就报错:

RuntimeError: CUDA error: no kernel image is available for execution on the device

这个错误信息非常具有误导性。第一反应是显卡驱动问题,查了 nvidia-smi 显示一切正常,CUDA 版本也对得上。折腾了半天才发现:vLLM 的 pip wheel 是预编译的,它绑定了特定版本的 CUDA toolkit

比如 vLLM 0.7.x 默认用 CUDA 12.1 编译,你机器上是 CUDA 11.8,运行时加载的 CUDA kernel 和驱动对不上,就会报上面这个错。

解决方案有两种:

方案一:指定 PyTorch CUDA 版本(推荐,最稳妥)

# CUDA 12.1(vLLM 0.6.x/0.7.x 默认编译版本)
pip install vllm==0.6.6.post1 --extra-index-url https://download.pytorch.org/whl/cu121

# CUDA 11.8(老环境)
pip install vllm==0.6.6.post1 --extra-index-url https://download.pytorch.org/whl/cu118

这是 vLLM 官方文档推荐的方式。--extra-index-url 让 pip 从 PyTorch 官方预编译 wheel 仓库下载与目标 CUDA 版本匹配的 PyTorch,vLLM 会自动依赖对应版本。

为什么不用 uv pip install --torch-backend auto

--torch-backend 是 uv 扩展参数(非标准 pip 参数),虽然能自动探测驱动版本来选择 wheel,但在某些环境下可能匹配到不稳定的组合或找不到对应的预编译包。对于生产部署,显式指定 CUDA 版本更可控、可复现。

方案二:Docker 一把梭(生产环境首选)

docker run --gpus '"device=0,1"' \
  -v /data/models:/models \
  -p 8000:8000 \
  --ulimit nofile=65536:65536 \
  vllm/vllm-openai:v0.6.6 \
  --model /models/deepseek-llm-7b-chat \
  --tensor-parallel-size 2 \
  --max-model-len 8192

官方镜像已经把 CUDA、PyTorch、vLLM 的版本关系理顺了。只要宿主机驱动版本不低于容器内的 CUDA 版本就能正常跑。这也是我们生产环境最终采用的方式。

1.2 另一个隐蔽坑:文件句柄限制

这个问题在单机低并发时不会暴露,一旦并发量上来立刻现原形:

OSError: [Errno 24] Too many open files

vLLM 的异步引擎(AsyncLLMEngine)每个请求会打开多个文件描述符用于日志和通信。Linux 默认的 ulimit -n 只有 1024,高并发下瞬间就被耗尽了。

修复方式:

# 临时生效(重启后失效)
ulimit -n 65536

# 永久生效,写入 limits 配置
echo "* soft nofile 65536" >> /etc/security/limits.conf
echo "* hard nofile 65536" >> /etc/security/limits.conf

如果是 Docker 部署,启动时加上 --ulimit nofile=65536:65536 即可。

建议把这个检查项加到部署 checklist 里——很多人都是线上出了问题才回过头来排查这个。


二、单卡起步:先让它跑起来

在谈多卡之前,先把单卡部署的基线跑通。这一步看似简单,但有几个参数设置不当同样会让你卡很久。

2.1 第一道坎:启动就 OOM

我们的测试模型是 DeepSeek-LLM-7B-Chat(BF16 精度),显卡是 A100 40GB。理论上 7B 模型 BF16 大约需要 14GB 显存,40GB 的卡绰绰有余。但实际启动命令一敲下去:

torch.cuda.OutOfMemoryError: CUDA out of memory.
Tried to allocate 48.00 GiB (GPU 0; 39.60 GiB total capacity)

48GB?模型本身才 14GB,剩下的 34GB 去哪了?

答案在 vLLM 的显存预分配机制 上。vLLM 启动时先把显存拆分如下:

组成部分 估算显存 说明
模型参数 (BF16, 7B) ~14 GB 加载权重本身
KV Cache block pool ~20-22 GB A100 40G × 0.9 利用率 = 36G 可用,减模型参数后剩余
CUDA context + 激活值 buffer + 其他 ~2-4 GB 推理中间结果、框架运行时开销
合计 ~36-40 GB

Block pool 是什么?

vLLM 使用 PagedAttention 机制管理 KV Cache:它把可用显存预先分配为一组固定大小的 block(默认每块 16 个 token),组成一个内存池。当请求到来时,从池中动态申请 block 来存放该请求的 K、V 状态。Block pool 就是 KV Cache 的物理存储区——它不是 KV Cache 之外的额外开销。

那为什么 max_model_len 会影响 block pool 的大小?因为 vLLM 会同时考虑两个维度:

  • 单条序列的 KV 上限:由 max_model_len 决定,即一个请求最多能占多少个 block
  • 总 token 容量max_num_seqs × max_model_len,即所有并发请求的 token 容量总和

关键在于后者。以 DeepSeek-LLM-7B-Chat 某些 checkpoint 为例,max_position_embeddings 可能设在 16384 甚至更高max_num_seqs 默认值也很大(通常 256)。若不加限制,vLLM 会计算出需要容纳 256 × 16384 ≈ 420 万 tokens 的 KV Cache 总量——这远超 20 GB block pool 能承载的范围,导致启动阶段就尝试分配 48 GB。

所以 Tried to allocate 48.00 GiB 这行日志并不是一次纯粹的 KV Cache 分配,而是 vLLM 根据大预设值计算出的总容量需求。设了 --max-model-len 8192 之后,单条 KV 上限 + 总 token 容量双双压缩,block pool 的需求量自然降回合理范围。

这也引出一个重要原则:永远不要用模型默认的 max_model_len 上生产,必须根据业务需求手动限制。

解决方法很直接,手动限制上下文长度:

python -m vllm.entrypoints.openai.api_server \
  --model /models/deepseek-llm-7b-chat \
  --dtype bfloat16 \
  --max-model-len 8192 \
  --gpu-memory-utilization 0.90

--max-model-len 8192 把最大上下文限制在 8K token,KV Cache 的预分配大幅减少。如果还不确定该设多少,vLLM 新版支持 --max-model-len auto,它会自动根据可用显存推算一个最大值。

这里补充一个经验表格(以 Llama 系列典型 7B 模型为例,32 层、32 头、头维度 128、BF16 精度):

KV Cache 是怎么算的?

每个 token 的 KV Cache 占用 = 层数 × 2(K+V) × 注意力头数 × 头维度 × 字节数
= 32 × 2 × 32 × 128 × 2(BF16) = 524,288 字节 ≈ 0.5 MB/token

所以 max_model_len = 4096 时,KV Cache ≈ 4096 × 0.5 MB ≈ 2 GB

不同模型的层数和头维度略有差异,下表取的是典型值,实际以模型配置为准。

max_model_len 7B 模型额外 KV Cache 占用 (BF16) 适用场景
2048 ~1 GB 短问答、分类任务
4096 ~2 GB 常规对话
8192 ~4 GB RAG 场景推荐(检索结果较长)
16384 ~8 GB 长文档分析
32768 ~16 GB 需要大显存卡

注意:DeepSeek-V2/V3 等采用 MLA(Multi-Head Latent Attention)架构的模型,KV Cache 占用远低于上表估算值,这是它们的核心优势之一。

RAG 场景因为要把检索到的文档片段塞进 prompt,一般 8K 是个比较安全的起点。如果业务确认不需要超长上下文,设成 4096 能省下一半 KV Cache 显存给并发用。

2.2 量化方案:AWQ 还是 GPTQ?

当模型变大之后(14B、32B、70B),全精度部署的显存压力陡增。量化是必然的选择。目前 vLLM 原生支持两种主流格式:AWQ 和 GPTQ。

先看一组实测数据(基于 A100 80G + Qwen2.5-7B):

精度方式 模型显存占用 吞吐量 (tokens/s) 相对 BF16 的吞吐损失
BF16 全精度 ~16 GB 2800 基准
FP16 全精度 ~16 GB 2650 -5%
AWQ Int4 ~6 GB 1900 -32%
GPTQ Int4 ~6.5 GB 1700 -39%

AWQ Int4 用了不到 BF16 40% 的显存,换来了约 30% 的吞吐损失。对于消费级显卡(RTX 3090/4090)来说这是关键差异——省下来的显存可以直接用来跑更大的模型或支撑更多并发。

但在使用 AWQ 时有一个致命细节容易踩坑:

# 正确写法
python -m vllm.entrypoints.openai.api_server \
  --model /models/Qwen2.5-7B-Instruct-AWQ \
  --quantization awq \
  --dtype float16    # AWQ 必须搭配 float16!

# 错误写法(会报错)
--quantization awq --dtype bfloat16
# ValueError: AWQ quantization is not supported with bfloat16

AWQ 量化只支持 FP16 加载,用 BF16 会直接抛异常。这个限制没有写在明显的文档位置,第一次遇到很容易懵。

GPTQ 则两种都支持,但同级别下吞吐量比 AWQ 低一些。我们的选择原则是:有 AWQ 权重就用 AWQ,没有再用 GPTQ


三、多卡 Tensor Parallel:真正的硬仗

前面的坑虽然烦但都有明确解法。真正花时间的,是从单卡扩展到多卡的 Tensor Parallel 配置。

3.1 什么是 Tensor Parallel

先解释一下概念,避免后面参数讨论时有理解偏差。

Tensor Parallel(张量并行)的核心思想很简单:把模型的每一层按矩阵维度切分到多张 GPU 上。比如一个矩阵乘法 Y = XW,把 W 切成 W_1 和 W_2 分别放在两张卡上,每张卡算一半结果,再通过通信同步合并。

对于使用者来说,只需要设一个参数:

--tensor-parallel-size 2   # 或者简写 -tp 2

vLLM 会自动完成模型切分、通信初始化、调度协调等所有工作。但"自动"不代表"不会出错"。

3.2 NCCL 超时:最常见的多卡启动失败

配置好双卡启动之后,最常遇到的错误是这个:

Watchdog caught collective operation timeout:
WorkNCCL(SeqNum=xxx, OpType=ALLREDUCE) ran for 600000 milliseconds

NCCL(NVIDIA Collective Communications Library)是多卡之间做数据同步的底层库。ALLREDUCE 是一种集合通信操作——每轮推理中,各卡需要把自己计算的梯度/激活值汇总同步。当这个过程超过默认超时时间(通常 600 秒 = 10 分钟),就直接杀掉进程。

造成超时的常见原因有三个:

原因一:卡间带宽不够

Tensor Parallel 对卡间通信带宽极其敏感。每生成一个 token,各卡之间都要做多次 ALLREDUCE 同步。

互联方式 带宽 双卡扩展效率 实际体验
NVLink (A100/H100 SXM) 600-900 GB/s ~92% 几乎无感
PCIe Gen4 x16 (RTX 3090/4090) ~32 GB/s ~75% 可用但有明显损耗
PCIe Gen3 x16 (老卡) ~16 GB/s ~60% 不推荐

如果用的是 PCIe 连接的消费级卡(比如两块 RTX 4090),通信走的是 PCIe 总线而不是 NVLink,带宽差了将近 30 倍。这种情况下需要调整 NCCL 参数:

# 无 NVLink 时,关闭 P2P 让通信走系统内存(有时反而更快)
export NCCL_P2P_DISABLE=1

# 增加超时时间(单位毫秒)
export NCCL_TIMEOUT=1800000   # 30 分钟

原因二:防火墙或网络隔离

如果是跨节点多机部署(不在同一台物理机上),确保参与训练/推理的所有机器之间的端口互通。NCCL 默认使用随机端口,可以通过以下方式固定:

export NCCL_SOCKET_IFNAME=eth0          # 指定网卡
export NCCL_PORT_RANGE=10000-10005      # 限定端口范围,方便防火墙放行

原因三:GPU 拓扑问题

有时候物理上插了两张卡但它们之间不通。用这个命令检查:

nvidia-smi topo -m

输出里关注 PIX 这一列——它表示两张卡之间的互联类型:

含义 Tensor Parallel 表现
NVL NVLink 连接 最优
PHB 同一 PCI-E Host Bridge 较好
PIX 同一 P2P 桥(经 CPU) 一般
SYS 需要经过系统内存/QPI 差,不建议用于 TP

如果你看到目标 GPU 对之间显示 SYS,说明这两张卡之间没有直接的 P2P 通路,Tensor Parallel 效果会很差。这种情况下要么换插槽重新插卡,要么放弃多卡改用量化压缩到单卡。

3.3 tp_size 与 Attention Heads 数量必须整除

这是一个纯数学约束,但经常被忽略:

ValueError: Total number of attention heads (28) must be divisible by tensor parallel size (4).

Tensor Parallel 把模型的注意力头均分到各张卡上。如果模型有 28 个注意力头(比如 Qwen2.5-7B),你想用 4 张卡做 TP——28 除以 4 等于 7,没问题。但如果想用 3 张卡呢?28 / 3 = 9.33,除不尽,直接报错。

常见模型的 heads 数量和可选 TP 值速查:

模型 Attention Heads 可用的 TP 值
Qwen2.5-7B 32 1, 2, 4, 8, 16, 32
Qwen2.5-14B 40 1, 2, 4, 5, 8, 10, 20, 40
Qwen2.5-7B (老版) 28 1, 2, 4, 7, 14, 28
Llama-3-8B 32 1, 2, 4, 8, 16, 32
DeepSeek-7B 32 1, 2, 4, 8, 16, 32

注意 Qwen2.5-7B 有两个版本的 head 数不同(新版改为 32),选 TP 值之前最好确认一下自己用的具体 checkpoint。

另外一条铁律:多卡 Tensor Parallel 必须用相同规格的 GPU。混用 A100 80G 和 A100 40G 的话,vLLM 会按最小显存的卡来分配,大卡的大量显存被浪费。

3.4 一个完整的多卡启动命令

综合以上要点,这是我们最终在生产环境中使用的启动配置:

CUDA_VISIBLE_DEVICES=0,1,2,3 \
NCCL_P2P_LEVEL=NVL \
python -m vllm.entrypoints.openai.api_server \
  --model /models/deepseek-vl-chat \
  --tensor-parallel-size 4 \
  --dtype bfloat16 \
  --max-model-len 8192 \
  --gpu-memory-utilization 0.90 \
  --max-num-seqs 256 \
  --enable-chunked-prefill \
  --host 0.0.0.0 \
  --port 8000

逐行解释一下关键参数的含义:

参数 为什么这么设
tensor-parallel-size 4 我们用了 4 张 A100,TP 必须等于实际卡数
max-model-len 8192 RAG 场景 8K 够用,再大会吃掉太多 KV Cache 显存
gpu-memory-utilization 0.90 比 0.85 默认值略激进,留 10% 给 CUDA 运行时足够了
max-num-seqs 256 最大并发序列数;我们的峰值 QPS 约 50,256 绰绰有余
enable-chunked-prefill 开启 当长短请求混合时(有的 prompt 2K 有的 8K),chunked prefill 可以避免长请求阻塞短请求

四、性能调优:从能跑到好用

服务跑起来只是第一步。要让它在生产环境扛住真实流量,还需要一系列调优。

4.1 首 Token 延迟(TTFT):不要开 enforce-eager

vLLM 默认使用 CUDA Graph 来优化推理。CUDA Graph 把 GPU 操作录制下来 replay,避免了每次推理的 kernel launch 开销。

但有个参数 --enforce-eager 会强制禁用 CUDA Graph,退回到 eager mode 执行。这个参数的唯一用途是 debug——生产环境绝对不能开

实测对比(A100 + DeepSeek-7B + TP=2):

模式 首 Token 延迟 (TTFT) 吞吐量 (tokens/s)
默认(CUDA Graph 开启) ~85 ms 2800
--enforce-eager ~620 ms 980

首 token 延迟差了 7 倍,吞吐量掉了接近 65%。如果你发现线上 TTFT 异常高,先检查是不是误开了这个参数。

4.2 吞吐量与并发的关系

下面是我们压测得到的一组数据(4×A100 + DeepSeek-7B-BF16 + TP=4 + max_model_len=8192):

并发数 P50 延迟 P99 延迟 吞吐 (tok/s) GPU 利用率
1 120ms 150ms 1800 25%
8 350ms 520ms 4200 72%
16 580ms 850ms 5100 88%
32 1200ms 2100ms 4800 91%
64 OOM - - -

几个观察:

  1. 甜蜜点在 8-16 并发:GPU 利用率上了 80%,P99 延迟还在可接受范围(< 1s)
  2. 超过 32 并发后收益递减:GPU 已经接近饱和,继续加并发只会增加排队延迟
  3. 64 并发直接 OOM:KV Cache 被撑爆了。这时应该降低 max_num_seqsmax_model_len

对于 RAG 这种场景,大部分请求的生成长度不长(100-500 tokens),并发控制在 16-32 之间是比较合理的范围。

4.3 监控指标:怎么判断服务是否健康

上线之后需要持续监控这几个核心指标:

vLLM 自带 metrics 端点GET /metrics 返回 Prometheus 格式的指标

关键指标清单:

指标名 含义 告警阈值
vllm:num_requests_running 当前正在处理的请求数 > max_num_seqs * 0.8
vllm:num_requests_waiting 排队等待的请求数 > 20 立即告警(说明服务过载)
vllm:gpu_cache_usage_perc KV Cache 显存使用率 > 0.95 需要扩容或降参
vllm:avg_generation_throughput_toks 平均生成吞吐量 低于基线 50% 说明异常

其中 gpu_cache_usage_perc 这个指标最值得盯。如果长期高于 0.95,说明 KV Cache 压力很大,随时可能 OOM。应对措施依次为:

  1. 降低 max_model_len
  2. 降低 max_num_seqs
  3. 增加 GPU 数量

五、完整踩坑清单

最后整理一份从零到上线的完整 checklist。每一个条目都是我们实际踩过的坑:

# 坑位 具体现象 解决方法
1 CUDA 版本不匹配 no kernel image is available for execution on the device pip install vllm --extra-index-url https://download.pytorch.org/whl/cu121 指定 CUDA 版本;或直接用 Docker 官方镜像
2 ulimit 文件句柄不足 [Errno 24] Too many open files(高并发时触发) ulimit -n 65536,Docker 加 --ulimit nofile=65536:65536
3 启动即 OOM Tried to allocate 48 GiB...out of memory 检查模型 config.json 中 max_position_embeddings 的值,设置 --max-model-len 8192 覆盖默认值;必要时调低 --gpu-memory-utilization 0.85
4 AWQ 量化 dtype 错误 AWQ quantization is not supported with bfloat16 AWQ 必须配 --dtype float16,不能用 bfloat16
5 NCCL 通信超时 ALLREDUCE ran for 600000 milliseconds 无 NVLink 设 NCCL_P2P_DISABLE=1 + NCCL_TIMEOUT=1800000;检查 nvidia-smi topo -m
6 TP 与 Heads 不整除 must be divisible by tensor parallel size 查模型 head 数(Qwen2.5-7B 新版 32 / 老版 28),选能整除的 TP 值
7 混用不同规格 GPU 大卡显存严重浪费 务必使用完全相同的 GPU 型号和显存大小
8 误开 enforce-eager TTFT 从 85ms 飙升到 620ms 生产环境删掉此参数,仅 debug 时使用
9 swap-space 参数报错 unrecognized argument: --swap-space 较新版本已移除此参数(CPU offload 方案改为其他实现),照抄老教程会踩坑
10 Java/客户端连接池太小 高并发时 Connection pool exhausted OkHttp 连接池调到 50+;Requests 库加 pool_connections=50

六、什么时候该自建,什么时候老老实实调 API?

文章最后聊一个决策层面的问题。

不是所有场景都适合自建推理服务。根据我们的实践经验,给出一个简单的决策参考:

建议自建的场景:

  • 数据不能出内网(金融、医疗、军工等行业)
  • 日均调用量稳定在百万级以上(自建成本开始低于 API)
  • 需要运行微调后的私有模型(API 不可能帮你托管自定义权重)
  • 对延迟极度敏感(内网 < 50ms vs 外网 API > 150ms)

建议继续用 API 的场景:

  • 日均调用量 < 10 万(按当前 DeepSeek V4 Flash ¥1/M token 的价格,成本很低)
  • 团队没有 GPU 运维能力
  • 业务处于快速迭代期,模型可能频繁更换
  • 只是偶尔试用,不想维护一套基础设施

如果决定自建,建议从单卡 + 小模型开始验证,跑通全链路后再逐步扩展到多卡。本文记录的就是这条路径上的完整踩坑过程。


下一篇预告:向量数据库选型深度对比:Faiss vs Milvus vs ChromaDB(附 benchmark)


如果你也在自建 vLLM 推理服务,欢迎在评论区交流你的硬件配置和遇到的问题。

Logo

免费领 100 小时云算力,进群参与显卡、AI PC 幸运抽奖

更多推荐