vllm 推理引擎
vllm 和triton 都是在独立的物理机上部署,因为项目刚来,没时间搭建流水线。这个中国区的项目,和 ai agent是两个项目
1. tritonserver ollama vllm
vllm 和 ollama 仅LLM(大语言模型), tritonserver 其他模型
ollama 上手快,快速验证,性能太差
vllm 高吞吐,一个对话机器人能每秒应对几百人的提问。gpu 利用率也 高于其他引擎
═══════════════════════════════════════════════════════════
第一部分:基础概念(确认他真的用过 vLLM)
═══════════════════════════════════════════════════════════
1. vLLM 和直接用 HuggingFace Transformers 推理比,核心优势是什么?
→ vLLM 用 PagedAttention 把 KV Cache 像操作系统管内存一样按页分配消除碎片,
加上 Continuous Batching 让 GPU 永远不空转,
同样的显存能多服务 3-5 倍并发,吞吐量高 10-24 倍。
一、先搞懂"传统 Static Batching"为什么烂
假设同时来了4个请求,传统做法(HuggingFace generate):
请求A: "今天天气" → 需要生成 2 个token → "很好"
请求B: "给我讲个笑话" → 需要生成 8 个token → "从前有座山山里有座庙..."
请求C: "1+1=" → 需要生成 1 个token → "2"
请求D: "写一首诗关于春天" → 需要生成 6 个token → "春风拂面柳丝长轻..."
Static Batching: 4个请求凑成一个batch,一起算,一起结束
时间步: t1 t2 t3 t4 t5 t6 t7 t8
请求A: [算] [算] [等] [等] [等] [等] [等] [等] ← t2就生成完了,但要等B
请求B: [算] [算] [算] [算] [算] [算] [算] [算] ← t8才生成完
请求C: [算] [等] [等] [等] [等] [等] [等] [等] ← t1就完了,等到死
请求D: [算] [算] [算] [算] [算] [算] [等] [等] ← t6完了,还要等B
问题:
├── 请求C在t1就完了,但必须等到t8才能返回给用户(等了7步白等)
├── GPU在t2之后,位置A和C的计算单元都在空转(padding浪费)
├── 批次没跑完,新来的请求E、F只能排队等着
└── GPU利用率: 实际有效计算 / 总计算 = (2+8+1+6)/(8×4) = 17/32 = 53%
二、Continuous Batching 怎么解决的
核心思想:哪个请求生成完了就立刻踢出去,空出来的位置立刻塞新请求进来
GPU batch 槽位
时间步 │ 槽位1 │ 槽位2 │ 槽位3 │ 槽位4 │ 事件
─────────┼──────────┼──────────┼──────────┼──────────┤──────────────────
t1 │ A(1/2) │ B(1/8) │ C(1/1) │ D(1/6) │ 4个请求一起prefill
t2 │ A(2/2)✓ │ B(2/8) │ ← C完了 │ D(2/6) │ C完→返回用户→槽3空了
t3 │ ← A完了 │ B(3/8) │ E(1/3) │ D(3/6) │ A完→返回→槽1空 | E插入槽3
t4 │ F(1/4) │ B(4/8) │ E(2/3) │ D(4/6) │ F立刻插入槽1(不用等!)
t5 │ F(2/4) │ B(5/8) │ E(3/3)✓ │ D(5/6) │ E完→返回→槽3空
t6 │ F(3/4) │ B(6/8) │ G(1/2) │ D(6/6)✓ │ D完→返回→槽4空 | G插入
t7 │ F(4/4)✓ │ B(7/8) │ G(2/2)✓ │ H(1/5) │ F和G都完了 | H插入
t8 │ I(1/?) │ B(8/8)✓ │ J(1/?) │ H(2/5) │ B终于完了 | I,J插入
│ │ │ │ │
对比:
├── 同样8个时间步,Static Batching只完成了4个请求(A,B,C,D)
├── Continuous Batching完成了8个请求(A~H),吞吐翻倍
├── 请求C在t1就返回了(延迟=1步),不用等到t8
├── GPU每一步的每个槽位都在做有效计算,利用率接近100%
└── 新请求随到随插,不用等当前batch跑完
2. 什么是 PagedAttention?它解决了什么问题?
→ 考察:KV Cache 显存碎片化问题,类比操作系统虚拟内存分页
PagedAttention 是一种管理 KV Cache 的算法。
请求来了,根据最大序列长度比如2048 申请了一块连续的显存,请求A B C 各占一块,b释放了,
中间一块飞地,新来的D请求想申请连续的大空间,明明B 那有空间了,但是被 C 隔开,用不了。
→ PagedAttention 的解决方案
类似活字印刷术。把显存切开固定大小的页,编好号,请求A要多少,就给他几页,这些页不需要连续。
═══════════════════════════════════════════════════════════
PagedAttention 到底在做什么?
═══════════════════════════════════════════════════════════
先看没有 PagedAttention 时 KV Cache 怎么分配:
传统方式(HuggingFace Transformers):
请求来了 → 按最大序列长度(如 2048)预分配一整块连续显存
请求 A(实际只生成了 50 个 token):
┌──用了50──┬────────浪费1998个位置──────────┐
│██████████│ │
└──────────┴────────────────────────────────┘
预分配了 2048 个 token 的空间,但只用了 50 个 → 97% 浪费
请求 B 结束了,释放显存,留下一个"洞":
┌────A────┐┌──空洞──┐┌────C────┐┌──空洞──┐
│█████████││ ││█████████││ │
└─────────┘└────────┘└─────────┘└────────┘
新请求 D 需要连续的大块显存 → 放不进这些零散空洞 → 显存碎片
PagedAttention 的做法(类比操作系统虚拟内存):
把显存切成固定大小的"页"(如每页存 16 个 token 的 KV):
┌──页0──┬──页1──┬──页2──┬──页3──┬──页4──┬──页5──┬──页6──┐
│ │ │ │ │ │ │ │
└───────┴───────┴───────┴───────┴───────┴───────┘───────┘
请求 A(生成了 50 个 token):只分配 4 页(够存 64 个 token)
请求 A 的页表:[页0, 页3, 页5, 页2] ← 不需要连续!
请求 B(生成了 30 个 token):只分配 2 页
请求 B 的页表:[页1, 页4]
┌──页0──┬──页1──┬──页2──┬──页3──┬──页4──┬──页5──┬──页6──┐
│ A-0 │ B-0 │ A-3 │ A-1 │ B-1 │ A-2 │ 空闲 │
└───────┴───────┴───────┴───────┴───────┴───────┘───────┘
好处 1:按需分配 → 生成 50 个 token 就只用 4 页,不浪费
好处 2:不需要连续 → 没有碎片问题
好处 3:请求结束 → 释放页回空闲池 → 新请求立刻能用
→ 显存利用率从 50-60% 提升到 95%+
3. 什么是 KV Cache?为什么它是大模型推理的显存瓶颈?
→ Transformer 自回归推理中每个 token 都要缓存 Key/Value,不缓存
就要重新算一遍,慢。 query就是你想找什么?key是书名,value是书里的内容。KV:是推导过程中的“中间记忆”。
在 Transformer 推理中,每处理一个 Token,模型都会生成对应的 Key (K) 和 Value (V) 向量。
prefill:你输入10个词,模型生成10组 KV 存入 Cache.
decoding: 模型每预测出一个新词,就生成新的 KV 存入 Cache.
模型要预测下一个词了,先query之前的 kv cache, 算好后,一口气输出 新的token, KV
已经生成的token越多,处理的并发请求越多,KV Cahe 占的显存越大。
每个 token → 在每一层 → 生成一对 (K, V)
比如70B的模型有80层 Attention,那么
→ 每个 token 的 KV Cache 大小 = 2(K和V)× 80层 × 8192维度 × 2字节(FP16) = 2.6MB/token
→ 2048 个 token = 2048 × 2.6MB ≈ 5.2GB(之前说 2.5GB 是简化估算)
→ 100 并发 = 520GB → 远超模型本身的 140GB
模型支持的最大上下文是不是就是这个vllm的 kv cache的容量?
不是,模型“支持的最大上下文” 是模型结构决定的,即模型能不能理解这么长。
而 KV Cache容量是系统资源决定的
═══════════════════════════════════════════════════════════
第二部分:部署实操(确认他能独立部署上线)
═══════════════════════════════════════════════════════════
7. 一个 70B 模型(如 Llama-2-70B)你怎么部署?
→ Llama-2-70B FP16 参数占 140GB,单张 A100 80GB 放不下,
最少 TP=2(两张卡各放一半),推荐 TP=4 留 ~50% 显存给 KV Cache 撑并发,
四张卡必须在同一台机器上走 NVLink 通信。
训练的本质就是一件事:算出每个参数的梯度,然后更新参数
前向传播: 输入 → 逐层算 → 得到loss(算出"错了多少")
反向传播: loss → 逐层倒推 → 得到每个参数的梯度(算出"每个参数该往哪调")
参数更新: 新参数 = 旧参数 - 学习率 × 梯度(实际调参数)
这三步不断循环,模型就越来越准
激活函数就是神经网络的“弯曲器”,没了它,人工智能就只是个大号的计算器。
没有激活函数: 无论你的神经网络有多少层,由于线性组合的叠加依然是线性的,整个网络其实等价于一个单层的线性回归模型
(只能画直线),无法处理图像识别、语言理解等复杂逻辑。
推理(只有一步):
═══════════════
输入 → 前向传播 → 输出token
DP — 数据并行
DP是每张卡都有完整的80层模型,各自独立算完全部80层,
最后才同步。不是算一层同步一次。
核心思想:每张卡都有完整模型,但各自算不同的数据
训练链条:
═════════
一个Batch = 1024条数据
│
├── GPU 0: 算第1~256条 ──→ 前向 → Loss → 反向 → 得到梯度₀
├── GPU 1: 算第257~512条 ──→ 前向 → Loss → 反向 → 得到梯度₁
├── GPU 2: 算第513~768条 ──→ 前向 → Loss → 反向 → 得到梯度₂
└── GPU 3: 算第769~1024条 ──→ 前向 → Loss → 反向 → 得到梯度₃
│
All-Reduce
梯度₀+₁+₂+₃ ÷ 4
│
每张卡得到平均梯度
│
各自更新参数(结果一样)
通信发生在哪:反向传播结束后,All-Reduce同步梯度
通信量:整个模型的参数量大小(70B模型 = 传140GB数据)
通信频率:每个step一次
通信可以和什么重叠:反向传播(算完一层的梯度就可以开始同步,不用等全部算完)
TP — 张量并行
也就是前向传播的时候,TP行切的时候,gpu之间传输的前向传播的时候是每层的输出求和。
而反向传播的时候是每层的激活梯度求和后往下一层传。
列切的时候就是拼接的结果
X = 输入(这一层收到的数据)
W = 权重(这一层要学习的参数,也叫weight)
Y = 输出(这一层算完的结果,送给下一层)
具体例子:
假设你在做情感分类,输入一句话"这部电影很好看"
X = 这句话的数字表示(向量)
比如经过Embedding后变成 [0.3, 0.8, 0.1, 0.5]
W = 这一层的参数矩阵(模型要学习的东西)
┌ 0.2 0.7 ┐
│ 0.5 0.1 │
│ 0.9 0.3 │ ← 这些数字就是"模型的知识"
└ 0.4 0.6 ┘ 训练的目的就是调整这些数字
让模型越来越准
Y = X × W = 输出
[0.3, 0.8, 0.1, 0.5] × W = [0.89, 0.63]
Y就是这一层的输出,送给下一层作为输入
所以:
上一层的Y = 下一层的X
Layer1: Y₁ = X₀ × W₁ ← X₀是原始输入
Layer2: Y₂ = Y₁ × W₂ ← 上一层的输出Y₁就是这一层的输入
Layer3: Y₃ = Y₂ × W₃ ← 同理
反向传播每一层都算出两个东西:
┌──────────────────────────────────────────────────────────────┐
│ │
│ 参数梯度 dW = dL/dW │
│ ═══════════════ │
│ 是什么: Loss对这一层权重W的导数 │
│ 回答: "这层的权重W该怎么调" │
│ 留给谁: 留在本层 → 用来更新本层的W │
│ 之后去哪: 哪也不去!就在本地更新参数用 │
│ │
│ │
│ 激活梯度 dX = dL/dY(也叫 dL/dX,因为这层的Y就是下层的X) │
│ ═══════════════ │
│ 是什么: Loss对这一层输出Y的导数 │
│ 回答: "这层的输出如果变一点,Loss会怎么变" │
│ 留给谁: 传给前一层 → 前一层用它来算自己的参数梯度 │
│ 之后去哪: 传给前一层!是链式法则的"链" │
│ │
└──────────────────────────────────────────────────────────────┘
前向传播
═════════
假设只有1层: Y = X × W
W矩阵太大,切成左右两半:
W (原始) W₀ (左半) W₁ (右半)
┌─────────────────┐ ┌──────────┐ ┌──────────┐
│ │ → │ │ │ │
│ 4096×4096 │ │ 4096×2048│ │ 4096×2048│
│ │ │ GPU 0 │ │ GPU 1 │
└─────────────────┘ └──────────┘ └──────────┘
前向传播(行并行的情况,最常见):
输入X(两张卡都有完整的X)
│ │
▼ ▼
GPU 0: GPU 1:
Y₀ = X × W₀ Y₁ = X × W₁
(部分结果) (部分结果)
│ │
│ │
▼ ▼
Y₀ = [3.2, 1.5] Y₁ = [0.8, 2.1] ← 各自只有部分结果
│ │
└─── All-Reduce ─────┘ ← 加起来!
│
▼
Y = [4.0, 3.6] ← 完整结果(两卡都拿到)
│
▼
送入下一层
注意:不是"拼接"!是"求和"(All-Reduce = 先Reduce求和再Broadcast)
├── 拼接(Concat): [3.2, 1.5] + [0.8, 2.1] = [3.2, 1.5, 0.8, 2.1] ← 变长了
└── 求和(Reduce): [3.2, 1.5] + [0.8, 2.1] = [4.0, 3.6] ← 长度不变 ✓
这取决于W怎么切的,行切就是求和,列切就是拼接
反向传播
═════════
反向传播的目的:算出 dW₀(GPU0那半参数的梯度) 和 dW₁(GPU1那半参数的梯度)
同时算出 dX(输入的梯度) 传给前一层
前一层的反向传播要用dX → dX必须是完整的 → 需要All-Reduce
具体过程(还是那一层 Y = X × W):
从后面传来 dY(两张卡都有完整的dY,因为前向最后做了All-Reduce)
│ │
▼ ▼
GPU 0: GPU 1:
┌──────────────────┐ ┌──────────────────┐
│ dW₀ = Xᵀ × dY │ │ dW₁ = Xᵀ × dY │ ← 各自算各自那半参数的梯度
│ (留在GPU0不动) │ │ (留在GPU1不动) │ 不需要通信!
│ │ │ │
│ dX₀ = dY × W₀ᵀ │ │ dX₁ = dY × W₁ᵀ │ ← 各自算出部分的输入梯度
│ (部分结果) │ │ (部分结果) │
└────────┬─────────┘ └────────┬─────────┘
│ │
└─── All-Reduce ─────┘ ← dX₀ + dX₁ 求和得到完整dX
│
▼
dX (完整的输入梯度)
│
▼
传给前一层继续反向传播
→ ✓ "每层各自算部分梯度,All-Reduce求和出完整dX,传给前一层"
参数梯度dW留在本地不传!只有激活梯度dX需要通信
PP — 流水线并行
PP 反向传播的时候也是传输的 激活梯度
核心思想:把模型按层切开,前面几层在GPU 0,后面几层在GPU 1
训练链条:
═════════
70B模型有80层,切到4张卡,每张20层:
GPU 0: Layer 1~20 GPU 1: Layer 21~40 GPU 2: Layer 41~60 GPU 3: Layer 61~80
(Stage 0) (Stage 1) (Stage 2) (Stage 3)
一个Micro-batch的前向传播:
时间 →→→→→→→→→→→→→→→→→→→→→→→→→→→→→→→→→→→→→→→→→
GPU 0: [算Layer1~20]───发送激活值──→
GPU 1: (等着...) [算Layer21~40]───发送激活值──→
GPU 2: (等着...) (等着...) [算Layer41~60]───发送激活值──→
GPU 3: (等着...) (等着...) (等着...) [算Layer61~80]→Loss!
│
反向传播(倒回来): │
▼
GPU 3: [反向Layer80~61]──发送梯度──→
GPU 2: [反向Layer60~41]←──收到梯度───
GPU 1: [反向Layer40~21]←──收到梯度───
GPU 0: [反向Layer20~1]←──收到梯度---
│ │ │ │
得到dW₁~₂₀ 得到dW₂₁~₄₀ 得到dW₄₁~₆₀ 得到dW₆₁~₈₀
│ │ │ │
各自更新各自的20层参数
问题:看到了吗?大量"等着..."的气泡(Bubble)!
├── GPU 0算完了在等GPU 3的反向传播传回来
├── GPU 3在等GPU 0的前向传播传过来
└── 解决:把一个Batch切成多个Micro-batch,流水线起来
流水线优化后(4个micro-batch穿插):
GPU 0: [F₁][F₂][F₃][F₄] [B₄][B₃][B₂][B₁] ← 气泡大大减少
GPU 1: [F₁][F₂][F₃][F₄] [B₄][B₃][B₂][B₁]
GPU 2: [F₁][F₂][F₃][F₄] [B₄][B₃][B₂][B₁]
GPU 3: [F₁][F₂][F₃][F₄][B₄][B₃][B₂][B₁]
↑
这里还是有少量气泡
通信发生在哪:Stage之间(GPU 0→GPU 1, GPU 1→GPU 2...)传激活值/梯度
通信量:一个Micro-batch的激活值(比Full batch小)
通信频率:每个micro-batch经过stage边界时通信一次
通信特点:点对点(P2P),不是All-Reduce → 带宽要求比TP低
训练时怎么选
训练的选择逻辑:
问题1: 一张卡能装下整个模型吗?
├── 能(7B FP16=14GB < 80GB)→ 不需要TP和PP,只用DP
└── 不能(70B FP16=140GB > 80GB)→ 必须切模型
│
├── 问题2: 怎么切?
│ ├── TP: 每层横着切 → 通信频繁但延迟低 → 放机内NVLink
│ ├── PP: 按层纵着切 → 通信少但有气泡 → 可跨机器走IB
│ └── 通常组合: 机内TP + 机间PP
│
└── 问题3: 切完了还要更多卡加速怎么办?
└── DP: 复制多份,各算不同的数据 → 跨机器走IB同步梯度
总结
训练用 TP+PP+DP 三件套(TP在机内切层,PP跨机切段,DP多份并行算不同数据最后同步梯度);
┌─────────────────────────────────────────┐
│ 数据并行 DP (多个副本) │
│ ┌───────────────────────────────────┐ │
│ │ 流水线并行 PP (不同层) │ │
│ │ ┌─────┐ ┌─────┐ ┌─────┐ │ │
│ │ │TP卡1│ │TP卡2│ │TP卡3│ ... │ │
│ │ └─────┘ └─────┘ └─────┘ │ │
│ │ 张量并行 TP (单层切分) │ │
│ └───────────────────────────────────┘ │
└─────────────────────────────────────────┘
推理主要用TP(放下大模型+低延迟),偶尔加PP(提吞吐);
选择的核心依据就两个:训练看"怎么塞下模型并最大化MFU",推理看"怎么最快输出token"。
3P 应用
推理一般不用 PP,因为延迟高。
DP优势在吞吐
即使你的模型只有 7B,单张 A100 (80GB) 能轻送塞下,你依然需要多张卡跑 DP(数据并行),核心原因只有两个字:吞吐 (Throughput)。
推理场景: 假设你有 1000 个用户同时在问问题。一张卡每秒只能处理 10 个请求(10 TPS)。如果你用 8 张卡跑 DP,每张卡都装一个
完整的模型,那你每秒就能处理 80 个请求。这叫高并发负载均衡。
训练场景: 训练需要看几千亿个 Token。一张卡吞吐慢,要跑一年;8 张卡跑 DP,每张卡喂不同的数据,梯度最后聚合,只要跑一个多月。
这叫缩短训练周期。
TP推理时候对付一张卡装不下的情况。
9. 你怎么估算一个模型需要多少显存?
→ 总显存 ≈ 模型参数(参数量×字节数,70B FP16=140GB)
+ KV Cache(层数×2×hidden_size×序列长度×并发数×字节数)
+ 临时激活值(约模型参数的 5-10%),
其中 KV Cache 是变量,并发越多序列越长占得越多。
10. vLLM 启动时有哪些关键参数需要调?
→ 考察:
--max-model-len(最大序列长度,直接影响 KV Cache 显存占用)
--gpu-memory-utilization(显存使用比例,默认 0.9)
--max-num-seqs(最大并发请求数)
--dtype(精度:auto/float16/bfloat16)
--quantization(量化方式:awq/gptq/squeezellm)
--enforce-eager(关闭 CUDA Graph,调试用,可以直接看到哪一行报错。
默认开启,占点显存,但是照图办事快,延迟低)
假设你设置 --max-model-len 2048:
场景 A(新对话):
你问了 100 个 Token,模型回答了 500 个。总计 600 < 2048,正常运行。
场景 B(深度长对话):
对话进行了 20 轮,累计历史记录已有 1800 个 Token。
当你再次提问时,如果你的问题加上历史记录已经达到 1900 个,那么模型最多只能回答 148 个 Token 就会被强制截断。
场景 C(超长文本):
你扔了一篇 3000 字的论文(约 4000 Tokens)让它总结。
因为 4000 > 2048,系统会直接报错,或者强制切掉你论文的前半部分,因为它分配的 KV Cache 空间装不下这么多信息。
11. 你用过量化部署吗?AWQ 和 GPTQ 有什么区别?
→ 两者都产出 INT4 模型 GPTQ 是通过校准数据来“抹平误差”,而 AWQ 是通过校准数据来“寻找重点”。
发生在训练好模型之后,不是推理时动态做,而是部署前的一次离线处理。
AWQ 根据激活值分布保护重要权重通道再量化(推理速度快,INT4 比 FP16 快 2-3 倍),
GPTQ 用校准数据集逐层最小化量化误差(精度略好但推理比 AWQ 慢 10-15%),
实际部署优先选 AWQ 因为 vLLM 对 AWQ 的 kernel 优化更好。
═══════════════════════════════════════════════════════════
第三部分:性能调优(区分普通和高级工程师)
═══════════════════════════════════════════════════════════
12. 你怎么衡量 vLLM 的推理性能?关注哪些指标?
→ 四个核心指标:TTFT(首 token 延迟,用户感知的"反应速度"),
TPOT(每个输出 token 耗时,决定"打字速度"),
Throughput(tokens/s,衡量集群吞吐能力),
P99 延迟(尾部延迟,衡量最差体验)。99% 的请求都比这个时间更快,只有最慢的 1% 会超过它
前 99 个人,店员动作麻利,每人 1 分钟就拿到了咖啡。
第 100 个人,正好赶上店员要换咖啡豆、洗机器,等了 20 分钟。
P99:1 分钟(99% 的人都在 1 分钟内拿到了)。
P100 (最大值):20 分钟。
13. 用户反馈"第一个字出来很慢",你怎么排查?
→ TTFT 高 = Prefill 慢,先看 prompt 长度(4096 token 的 prompt prefill
本身就要几百毫秒),再看是否有排队(并发太高新请求在等前面的 prefill 完成),
最后看是否触发了 CUDA Graph 重编译(新的 batch size 第一次出现要重新录制)。
LLM 推理的本质:每次 iteration 只生成 1 个 token。每个 iteration 都是一次完整的前向传播
iteration N 结束后:
→ 检查哪些请求生成了 <EOS>(结束符)
→ 把这些请求踢出 batch
→ 立刻把等待队列里的新请求塞进来
→ iteration N+1 开始,batch 组成已经变了
根本没有"batch 开始/结束"的显式信号,本质是一个无限循环,每轮的输入集合在动态变化:
# vLLM 调度器的核心循环,伪代码
while True:
# 这一轮的 batch 就是此刻 running 队列里的所有请求
batch = running_queue # ← batch 的"边界"就在这里
outputs = model.forward(batch) # 一次前向 = 一个 iteration
for req, new_token in outputs:
if new_token == EOS:
running_queue.remove(req) # 结束的请求移出
done_queue.add(req)
else:
req.append(new_token) # 继续留在 running
# 尽量从 waiting 里补充新请求
while waiting_queue and 显存够用():
new_req = waiting_queue.pop()
running_queue.add(new_req)
# 循环回去,下一轮 batch 自动就是新的 running_queue
从用户视角 vs 系统视角看"batch"
用户视角: 系统视角:
我发了一个请求 iteration 1: batch={我的请求, 其他请求A, B}
等待... iteration 2: batch={我的请求, A, C, D}
等待... iteration 3: batch={我的请求, D, E}
收到回复 iteration N: batch={...} 我的请求生成EOS,完成
用户感知不到 batch 的存在 系统里从来没有"batch开始/结束"
只有 iteration 的无限推进
这一轮iteration:batch也就是现在同时在处理的请求数
vLLM 里根本没有“固定 batch size”。continuous batching(连续动态拼批)
这一轮 iteration:batch = 3 个请求
下一轮 iteration:batch = 7 个请求
再下一轮:batch = 12 个请求
CUDA Graph 录制是一个“慢操作”(几十到几百 ms)。当出现新的 batch size. 请求 → 触发 graph capture → 延迟飙升
→ 第一个 token 很慢
解决方式是,预测:batch = 1, 2, 4, 8, 16 各跑一遍。
或者不让batchsize任意变化,只允许:1, 2, 4, 8, 16
解决方案:
1)Batch Padding(批次对齐), 向上取整+ Padding(放0)
2)预录制(Warmup): 在服务启动时,直接把 1, 2, 4, 8, 16, 24, 32 这些常用档位的 CUDA Graph 全部录好,存进内存。
动态匹配: 运行时,根据当前请求数 N,选择 min(Bucket >= N)
Batch Size 主要在多人同时请求时产生,分桶和padding 结合使用
vLLM 内部已经内置了这套 CUDA Graph + Padding 的逻辑,而且它比你手动改源码要智能得多。你只需要通过启动参数
和配置文件来调整它的行为。
vLLM 默认已经配置好了预录制(Pre-recording)和动态匹配逻辑
--cuda-graph-padding-steps (默认通常是 8)
含义: 两个录制位点之间的间隔。
调优: 如果设为 1,它会尝试为每一个 Batch Size 都录图(显存开销极大,不推荐)。
如果设为 16,Padding 浪费的计算量会变大,但显存占用更低,录制更少。
max-num-seqs 既是模型的“物理并发上限”,也是 CUDA Graph 动态匹配中的“最大桶(Bucket)”边界
--max-num-seqs (默认通常是 256) 默认最大的桶是256
含义: 这决定了 vLLM 会预备多大的“桶”。如果你调大这个值,vLLM 会尝试录制更大的 CUDA Graph,以支持更高的并发。
14. 并发上去后吞吐量不涨反降,可能是什么原因?
→ 最常见原因是 KV Cache 显存打满了,新请求只能排队等老请求释放页,
表现为 vllm:num_requests_waiting 持续增长而 running 不变,
解决方案是降 --max-model-len(通过牺牲单个请求的“长度上限”,换取了更多的“并发车位”)、
用量化省显存、或加机器加副本。
并发数 (Concurrency):是同一时刻正在被系统处理的请求数量(即**“同时在跑多少人”**)。
吞吐量 (Throughput):是单位时间内系统成功处理掉的总请求数或 Token 数(即**“一秒钟吐出多少字”**)。
15. 什么是 Prefix(前缀) Caching?什么场景下用?效果怎么样?
→ 多个请求共享相同的 system prompt(如 RAG 场景每个请求前面都有 2000 token
的知识库内容),开启后第一个请求算完的 KV Cache 被缓存,
后续请求直接复用不用重新 prefill,TTFT 降 50-80%。
RAG(检索增强生成)是通过先从外部知识库中“查资料”,再让大模型结合查到的信息来“写回答”
开启 --enable-prefix-caching,
对 system prompt 很长的场景(如 RAG)TTFT 能降 50%+
之前理解那个文档了,存起来,以后只需读取这段记忆,否则来一个就得从头读一遍文档,生成同样的 KV 对。
还有公司内部手册,比如年假怎么算,报销流程等等
16. 什么是 Chunked Prefill?解决什么问题?
→ 一个 8000 token 的长 prompt 做 prefill 要独占 GPU 200ms,
期间所有正在 decode 的请求被卡住(TPOT 抖动),
Chunked Prefill 把它切成 8 个 1000 token 的小块和 decode 交替执行,
TTFT 略慢但 decode 延迟稳定不抖。
也就是说prefill和decode交替进行是对于多个用户请求的场景,让其他用户尽快地得到回复,而对于这个用户还是
要等全部prefill完,再得到回复,所以TTFT变慢。但是同样他得到的每个回复的token速度比较稳定
17. CUDA Graph 在 vLLM 中起什么作用?什么时候要关掉?
→ 考察:CUDA Graph 把多次 kernel launch 录制成一个图一次提交,
减少 CPU→GPU 的 launch 开销,decode 阶段提速 10-20%;
但 batch size 变化时需要重新录制,如果 batch size 变化频繁
反而更慢,调试时用 --enforce-eager 关掉
Kernel Launch 是 CPU 给 GPU 派活的过程。
Kernel(内核): 是跑在 GPU 上的程序段(比如:矩阵加法、激活函数、Softmax)。
Launch(启动): 指的是 CPU 下达指令:“嘿,GPU,去执行这个矩阵加法,数据在地址 A,线程开 1024 个。”
每次 Launch,CPU 都要通过 PCIe 总线 向 GPU 发送一连串的指令包。这个过程虽然快(微秒级),但它有固定的开销
Prefill 阶段(预填充): 算力密集。一次处理几千个 Token,GPU 算一个 Kernel 可能要花几百毫秒。此时 CPU 下达指令的那
几微秒开销,就像是大海里的一滴水,完全可以忽略不计。
Decode 阶段(逐字生成): 访存/延迟密集。由于一次只生成 1 个 Token,每个 Kernel 的计算时间变得极短(可能只有几十微秒)。
每生成一个token 都得cpu给gpu 下次指令。那得下很多次,录成图后,cpu告诉gpu 照着图做,一次就行了。cpu就能腾出来做网络请求,
管理k8s调度等等工作。
CPU 必须确认前一个活已经“交待清楚了”,或者需要根据前一个活的状态准备下一个 Tensor 的地址。
即使派活(10μs)比干活(20μs)短,由于它们不是完全重叠的(存在调度开销和上下文切换),GPU 依然会在两个算子之间出现“空窗期”。
═══════════════════════════════════════════════════════════
第四部分:生产运维(区分高级和顶级工程师)
═══════════════════════════════════════════════════════════
21. vLLM 跑着跑着 OOM 了,你怎么排查?
→ 先看 vllm:gpu_cache_usage_perc 是否 100%(KV Cache 打满 = 并发太高或序列太长),
再看 nvidia-smi 确认实际显存占用是否超过 --gpu-memory-utilization 的预留,
最后检查 --dtype 是否真的生效(曾遇到 auto 没正确识别 BF16 退回 FP32 导致 OOM)
--gpu-memory-utilization 是指除去权重参数,显存留多少给 KV Cache. 但它管不住三人,这三人有可能造成OOM
1) 录制 CUDA Graph 是需要额外显存来存储指令序列和中间张量的。如果你的 --max-num-seqs 设得很大,或者分桶
(Bucketing)太多,这部分开销会悄悄吃掉几个 GB
2) 推理过程中,每一层计算产生的临时激活值、Softmax 的中间结果等,需要占用额外的显存。如果你的 Prompt 特别长(Prefill 阶段),
这些临时张量的峰值可能会瞬间冲破预留水位。
3) 虽然有 PagedAttention,但底层的 CUDA 内存分配器(PyTorch 用的那个)依然可能产生碎片。当它无法分配出一块连续的物理内存时,
即使总数没超,系统也会报错 OOM。
当你在生产环境遇到 OOM,按照这个顺序查:
查 max-model-len: 是不是有人发了一个超级长的 Prompt,导致 Prefill 阶段的临时张量撑爆了显存?
降低 gpu-memory-utilization: 尝试从默认的 0.9 降到 0.8 或 0.7。这会给 CUDA Graph 和临时张量留出更多的“呼吸空间”。
禁用 CUDA Graph 测试: 加上 --enforce-eager 启动。如果这样不报 OOM 了,那就实锤是 CUDA Graph 录制时占用了太多显存。
强制指定 dtype: 启动时显式加上 --dtype bfloat16,不要让它用 auto 瞎猜。
22. 你怎么监控 vLLM 服务的运行状态?
→ vLLM 内置 /metrics 端点暴露 Prometheus 格式指标,
核心看四个:
1)gpu_cache_usage_perc(KV Cache 水位)、防止 OOM-- 加副本 replicas/降 max-num-seqs(并发)
2)num_requests_waiting(排队数)、 防用户等和请求堆积雪崩。>0 说明gpu满载,新用户在排队-- HPA 水平扩容
3)avg_generation_throughput(平均生成吞吐量)、监控每秒生成多少token -- 检查显卡是否病了,同步管道是否堵塞。
结合 nvidia-smi 检查 GPU 功耗和频率,或者排查 NCCL 通信是否有瓶颈。
4)e2e_request_latency(端到端延迟),防用户体验和SLA 违约。记录从用户发请求到收到最后一个字的总时长。防止因为
模型配置不当(如没开 Chunked Prefill)导致长文本请求把其他所有用户卡死。
接入 Grafana 画板 + AlertManager 告警。
23. 你有没有遇到过 vLLM 的 bug 或性能回退?怎么处理的?
→ 这题没有标准答案,考察的是真实经验,
比如"vLLM 0.3.x 升 0.4.x 后某些模型 Prefill 速度回退 30%,
定位到是 FlashAttention 版本不兼容,锁定 flash-attn==2.5.6 解决"
总结我的排查三板斧,遇到 vLLM “不正常”时:
1) 看日志: 找 NCCL ERROR、Dtype 识别、Capturing 耗时。
2) 看 Metrics: 检查 vllm:num_requests_swapped(如果 > 0,说明显存管理已经崩了)。
3) 做对比: 关掉 CUDA Graph、关掉 Prefix Caching,看问题是否消失。
═══════════════════════════════════════════════════════════
第五部分:架构设计(顶级工程师必答)
═══════════════════════════════════════════════════════════
24. 如果让你设计一个日均 1000 万次调用的大模型推理平台,
用 vLLM 作为推理引擎,你怎么设计整体架构?
→ API Gateway(限流鉴权)→ Kafka 消息队列(削峰)→
多组 vLLM 推理集群(按模型分池,每组独立扩缩)→
结果回写 Redis → 回调通知客户端,
加上 Prometheus 全链路监控和多可用区容灾。
1)api gateway(流量看门狗) :每秒发起1000次请求,网关拦截
2)Kafka 请求高峰期,防止一下 OOM, 先放Kafka,根据自己的能力慢慢处理
3)逻辑池: 通过 Label(标签) 和 Selector(选择器) 将 GPU 节点划分为不同的 Deployment。
路由转发: API Gateway 接收到请求时,根据参数(如 model_type)决定把请求发给哪个 Service 地址。
场景: 此时,一家保险公司正在用你的平台批量分析 100 份合同(每份 2 万字),配置max-model-len=32k, 开启 Prefix Caching
同时有 500 个普通用户在用 APP 聊天。开启 Chunked Prefill,追求低延迟。
如果没有逻辑池: 合同分析的长 Prompt 会瞬间占满所有 GPU 的 Prefill 算力,导致那 500 个聊天的用户发现模型“卡死了”,
半天不出字。
有了逻辑池: 合同任务在“长文本池”排队慢慢跑,聊天用户在“对话池”秒回。
4)Redis:
断点续传:模型写2000字的回复,写到1500字网络断了或 pod 重启了。因为token 已经存储redis,用户刷新页面后,前端先拉取已有
的1500字,后端带着这1500字作为 prefix caching,接着算
长连接:浏览器和服务器一直在打电话,只要断了,字就传不过来了。而且连着还占资源,不能每个请求都这样,否则服务器句柄和内存
耗尽,就会断开,用户还得重来。
解决方案:
用户发完请求,网关回执一个 task_id. 然后就断开了。 vllm每生成一个token,就往这个task_id里塞。用户页面则每100ms去redis
里看有没有新字。
5) Prometheus:触发HPA 扩容 GPU 节点,确保排队时间不超过 500ms.
25. vLLM 和 TensorRT-LLM、TGI(Text Generation Inference)
各自的优劣是什么?什么场景选哪个?
→ 考察:
vLLM:生态好、模型支持广、PagedAttention 显存效率高、
适合通用场景
TensorRT-LLM:NVIDIA 官方优化、单卡性能最强、
但模型适配复杂、只支持 NVIDIA GPU
TGI:HuggingFace 出品、和 HF 生态集成好、
但性能和 vLLM 比略逊
26. Speculative Decoding(投机解码)在 vLLM 中怎么用?
原理是什么?什么场景收益大?
→ 考察:用一个小模型快速生成多个候选 token,
再用大模型一次性验证,命中的直接用,不命中的重新生成,
适合小模型和大模型输出分布接近的场景(如代码补全),
vLLM 用 --speculative-model 指定小模型
这一招让回答的更快
大模型“验证”的速度确实比“生成”快得多
普通生成(Auto-regressive):大模型每一步只能吐出 1 个 Token。为了这一个字,GPU 必须把整层模型权重(比如 70B 模型的 140GB)
从显存里搬运一遍。
大模型搬运一次权重,原本只能算 1 个字,现在能同时验证 5 个字。搬运成本被平摊了,所以单字平均耗时大幅下降。
27. 多模态模型(如 LLaVA)在 vLLM 上部署和纯文本模型有什么不同?
→ 考察:图片要先过 Vision Encoder 生成 embedding,
这部分计算量大但只在 prefill 阶段,
显存要额外预留给图片 embedding,
--max-model-len 要考虑图片 token 数量
28. 如果推理集群要同时服务多个不同大小的模型(7B、13B、70B),
你怎么设计资源分配和调度?
→ 考察:如何让“不同大小的模型”,在同一集群里既不打架,又不浪费钱
假设你在做一个 AI 服务平台,对外提供:
🟢 7B:聊天 / 摘要(高QPS)
🟡 13B:复杂问答(中QPS)
🔴 70B:高质量推理(低QPS但贵)
你的硬件:
一批 A100(80GB)
一批 T4(16GB)
1)设计哪个模型用哪块显卡
T4池(低成本):
→ 跑 7B(单卡)
A100池(高性能):
→ 跑 13B(1~2卡)
→ 跑 70B(4卡TP)
2)让模型deployment调度到对应显卡上
给 node 打标签
node1 → gpu-type=T4
node2 → gpu-type=A100
deployment 分开
7B 模型
nodeSelector:
gpu-type: T4
70B模型
nodeSelector:
gpu-type: A100
3)定义每个模型用多少显卡
4)请求来了:用户请求 → 判断模型类型 → 路由到对应服务
/chat → 7B
/reason → 13B
/expert → 70B
5)服务起来该根据情况进行扩缩容
7B:
replicas: 10 → 30(高峰)
70B:
replicas: 2 → 4(小幅调整)
6)LoRA 多模型共享
100个微调模型(客服、医疗、法律…)每个独立部署,显存爆炸
一个 base model(7B)+ 多个 LoRA adapter
请求A → 加载 LoRA_A
请求B → 切换 LoRA_B
GPU 显存 (80GB)
┌───────────────────────────────────────────────────────────┐
│ │
│ ┌─────────────────────────────────────────┐ │
│ │ Base Model (LLaMA-7B) │ 14 GB │
│ │ 所有请求共享,永不卸载 │ │
│ │ W_q, W_k, W_v, W_o, W_up ... │ │
│ └─────────────────────────────────────────┘ │
│ │
│ ┌─────────────────────────────────────────┐ │
│ │ KV Cache │ ~40 GB │
│ │ 所有请求共用 pool │ │
│ └─────────────────────────────────────────┘ │
│ │
│ ┌──────── LoRA GPU Cache ────────────────┐ │
│ │ ┌──────┐ ┌──────┐ ┌──────┐ ┌──────┐ │ 20×30MB │
│ │ │ kefu │ │yiliao│ │ falv │ │jiaoyu│ │ = 600 MB │
│ │ │ 30MB │ │ 30MB │ │ 30MB │ │ 30MB │ │ │
│ │ └──────┘ └──────┘ └──────┘ └──────┘ │ │
│ │ ┌──────┐ ┌──────┐ │ │
│ │ │jinrong│ │ ... │ (最多 20 个在 GPU) │ │
│ │ │ 30MB │ │ │ │ │
│ │ └──────┘ └──────┘ │ │
│ └────────────────────────────────────────┘ │
│ │
│ 剩余 80 个 LoRA 在 CPU 内存中待命 │
│ CPU 内存: 80 × 30MB = 2.4GB(很小) │
│ │
│ 空闲: ~25 GB │
└───────────────────────────────────────────────────────────┘
python -m vllm.entrypoints.openai.api_server \
--model meta-llama/Llama-3-8B \
--enable-lora \
--lora-modules \
kefu=./lora_adapters/kefu \
yiliao=./lora_adapters/yiliao \
falv=./lora_adapters/falv \
--max-loras 20 \ # GPU 上最多驻留 20 个
--max-lora-rank 64
LoRA 就像插件: 每个业务(医疗、法律、客服)都是一个小插件。
Rank 就是插件的大小: * 简单的业务用小插件(Rank = 8)。
复杂的业务用大插件(Rank = 64)。
rank没有单位,代表了矩阵的宽度
ZeRO 的核心思想是:既然大家都在跑数据并行(DP),每张卡都存一份完整的模型参数,太浪费了!
不如大家把这些参数“切碎”,每人领一块,用的时候再互相借。
优化器状态:训练模型时,不仅存权重,还要存为了让模型学习而产生的额外数据。比如梯度移动的方向和波动的幅度
每一个参数,优化器都要额外花 12 字节 来伺候它。这就是为什么一个 70B 模型(140GB 权重),训练时起步就要
占用接近 1TB 显存的原因。
ZeRO-1: 大家都有完整的模型,但优化器状态切开领,每人只记一小块,省了大头,几乎没副作用。
ZeRO-2: 在 1 的基础上,连梯度也切开领,每人只负责自己那一块的更新,显存更省,依然很快。
ZeRO-3: 彻底“家徒四壁”,连模型参数都切开领,用到哪层才管兄弟借哪层,能练无限大的模型,但通信很累(慢)。
═══════════════════════════════════════════════════════════
显存估算详解
═══════════════════════════════════════════════════════════
参数和TP有关,就是除以TP
KV Cache 取决于并发数和序列长度
规定的gpu使用率
以 Llama-2-70B FP16,TP=4(4 张 A100-80GB)为例:
1️⃣ 模型参数显存:
70B 参数 × 2 字节(FP16) = 140GB
TP=4 → 每张卡 140/4 = 35GB
2️⃣ KV Cache 显存(变量,取决于并发和序列长度):
每个 token 的 KV Cache = 2(K和V) × 80层 × 8192维度 / 4(TP) × 2字节
= 0.65MB/token/卡
如果 max_model_len=4096,最大并发=32:
KV Cache = 0.65MB × 4096 × 32 = 85GB/卡 ← 比模型还大!
实际做法:不会每个请求都用满 4096,PagedAttention 按需分配
gpu_memory_utilization=0.9 → 可用 80×0.9=72GB
减去模型 35GB → 剩 37GB 给 KV Cache
37GB / 0.65MB = ~56000 个 token 的 KV Cache 容量
即:可以同时服务约 56000/平均序列长度 个并发请求
3️⃣ 临时激活值显存:
→ 约 1-3GB/卡(模型参数的 3-5%)
总结:每张卡 35(模型) + 37(KV Cache) + 3(激活) ≈ 75GB → 80GB 卡够用
═══════════════════════════════════════════════════════════
什么是临时激活值?
═══════════════════════════════════════════════════════════
激活值是前向计算留下的“中间笔记”,反向传播(学习)时必须翻看这些笔记才能算出怎么改进模型。
→ 就是模型前向计算过程中每一层的中间结果(矩阵乘法的输出、
LayerNorm 的输出、Attention Score 矩阵等),
算完当前层传给下一层后就可以丢弃(所以叫"临时"),
推理时不需要保留(训练才需要保留用于反向传播),
占显存很小但不为零。
═══════════════════════════════════════════════════════════
Throughput
═══════════════════════════════════════════════════════════
Throughput(吞吐量)= 整个集群每秒能生成多少 token
→ 比如 4 个用户同时问问题,每人每秒生成 40 token,
集群吞吐量 = 160 tokens/s
═══════════════════════════════════════════════════════════
什么是 Prefill 和 Decode?
═══════════════════════════════════════════════════════════
大模型推理分两个阶段:
Prefill(预填充)阶段:
用户发来 prompt"请介绍北京的天气"(假设 10 个 token)
→ 模型一次性并行处理这 10 个 token
→ 计算它们所有的 KV Cache
→ 生成第一个输出 token
→ 这个过程的耗时就是 TTFT(Time To First Token)
→ prompt 越长,计算量越大,TTFT 越高
Decode(解码)阶段:
→ 有了第一个 token 后,一个一个生成后续 token
→ 每次只算 1 个 token(查之前缓存的 KV Cache)
→ 每个 token 的生成时间就是 TPOT(Time Per Output Token)
→ 这个阶段用户看到的是"字一个一个蹦出来"
TTFT(Time To First Token)= 用户等第一个字出现的时间
TPOT(Time Per Output Token)= 后续每个字的间隔时间
TTFT 高 = "点了发送后要等好久才开始回复"
TPOT 高 = "回复的时候字蹦得很慢"
═══════════════════════════════════════════════════════════
TTFT 高怎么排查?(具体怎么看)
═══════════════════════════════════════════════════════════
# 1. 看 Prometheus 指标确认 TTFT 确实高
查 Grafana 面板:vllm:e2e_request_latency_seconds 的 P99
或者直接 curl /metrics | grep time_to_first_token
# 2. 看 prompt 长度
查日志:vLLM 启动时加 --log-requests
日志里会打印每个请求的 prompt_tokens 数量
如果 prompt 都是 4000+ token → prefill 本身就慢,正常
# 3. 看排队情况
curl http://localhost:8000/metrics | grep waiting
vllm:num_requests_waiting > 0 说明有请求在排队
→ 要么降并发、要么加副本
═══════════════════════════════════════════════════════════
并发上去后吞吐不涨反降?详解
═══════════════════════════════════════════════════════════
显存不够了,在排队
具体发生了什么:
并发 10 个请求:
显存:模型 35GB + KV Cache 15GB = 50GB(80GB 卡够用)
吞吐量:1000 tokens/s ✅
并发 50 个请求:
显存:模型 35GB + KV Cache 60GB = 95GB → 超过 80GB 了!
vLLM 的处理:
→ KV Cache 页用完了 → 新请求进不来 → 放入等待队列
→ 必须等老请求生成完释放页 → 新请求才能开始
→ vllm:num_requests_waiting 从 0 涨到 20+
→ 用户感受:发请求后好几秒没反应(在排队)
→ 吞吐量反而可能下降(因为排队开销 + 调度开销)
解决:
方案 1:--max-model-len 从 4096 降到 2048 → KV Cache 减半 → 能多服务一倍
方案 2:用 AWQ INT4 量化 → 模型从 35GB 降到 9GB → 多 26GB 给 KV Cache
方案 3:加机器,多部署几个 vLLM 副本,Nginx 分流(api gateway)
═══════════════════════════════════════════════════════════
FlashAttention 和 Prefill 速度的关系
═══════════════════════════════════════════════════════════
FlashAttention 是一种通过减少 GPU 显存读写次数(IO)来加速 Attention 计算的算法。
PagedAttention (vLLM 核心):KV Cache 的存储管理。
通过减少 GPU 显存(HBM)与高速缓存(SRAM)之间的数据搬运,实现了推理和训练的倍速增长。
通过“分块计算(Tiling)”技术,显著降低了中间结果的显存占用。
Prefill 阶段干的事 = 对整个 prompt 做一次完整的 Attention 计算,
注意力矩阵(Attention Matrix)就是大模型在处理一句话时,心里画的一张“关系打分表”。
想象大模型正在读这句话:“老师 走进 教室,他 拿起了 书。” 这个关系表告诉它老是=他。
但“传统做法”和“FlashAttention”的区别,不在于“搬不搬”,而在于“搬运的次数”和“存不存中间垃圾”。
FlashAttention搬一次,就算出结果,然后存到显存里。
如果把 Prefill 比作“读题”,那么 Attention 就是“思考过程”,而 KV Cache 就是“做完题留下的笔记”。
vLLM 的 Prefill 依赖 FlashAttention 加速, 只要 cuda driver支持 cuda 11.7以上和pyTorch>=2.0, 就自带,
也就是自动的,不需配置
Attention 计算量 = O(n²),prompt 越长计算量越大
FlashAttention 是一种优化 Attention 计算的算法:
传统 Attention:把整个 n×n 的注意力矩阵算出来存在显存里,GPU 的高速缓存(SRAM)
放不下,必须频繁地在显存(HBM,比较慢)和核心之间搬运数据。
→ 显存占用 O(n²),访存多,慢
FlashAttention:把计算分成小块(tiling),在 GPU SRAM(高速缓存) 里算完
→ 显存占用 O(n),访存少,快 2-4 倍
═══════════════════════════════════════════════════════════
显卡驱动 CUDA Driver CUDA Toolkit
═══════════════════════════════════════════════════════════
[GPU 硬件] →\rightarrow→ [显卡驱动] →\rightarrow→ [CUDA Driver] →\rightarrow→ [CUDA Toolkit] →\rightarrow→ [你的大模型 (vLLM/PyTorch)]
就是pytorch调用的cuda tookit里面的工具,工具调cuda driver, cuda driver调 显卡驱动
显卡驱动(NVIDIA Driver)是什么?
这是最底层的软件。它的主要任务是让 操作系统(Windows/Linux) 认出这张显卡。
你在 Linux 里输入 nvidia-smi,看到最顶上的 Driver Version: 535.xx.xx,这就是显卡驱动版本。
CUDA 其实分为两部分
1) CUDA Driver 是 显卡驱动 nvidia driver的一部分, 它的文件通常是 libcuda.so。它是给程序员提供的“翻译官”。
当 vLLM 想要让 GPU 算矩阵乘法时,它会调用 CUDA Driver 的 API。
nvidia-smi 右上角显示的 CUDA Version 就是这个“翻译官”最高能支持的版本。
2) CUDA Toolkit (Runtime):这是开发人员安装的工具包(比如 CUDA 11.8, 12.1)。
它的文件通常是 libcudart.so。
作用: 包含编译器(nvcc)和各种预先写好的数学库。
═══════════════════════════════════════════════════════════
Kafka → 用户看到"排队中"而不是"服务器错误"
═══════════════════════════════════════════════════════════
═══════════════════════════════════════════════════════════
Speculative Decoding 实用场景
═══════════════════════════════════════════════════════════
大模型每生成一个字都要读取一遍巨大权重,速度慢,让小模型(1B)猜,大模型(70B)审。
小模型预测5个词,大模型一次接收,并行计算它们的概率。 选择接收,纠正还是丢弃
从纠正开始下一轮猜测
核心在于大模型一次验证5个词和生成一个词的时间几乎一样。
这是一种“以空间换时间”的策略——多花一点显存和计算资源给小模型,换取大模型更快的生成速度。
场景 1:代码补全(最佳场景)
→ 代码的确定性高(if 后面大概率是条件表达式),
小模型猜 5 个 token 命中率 70-80%,
平均每步出 3.5 个 token 而不是 1 个 → 3.5 倍加速
场景 2:翻译(较好场景)
→ 源语言限制了翻译的可能性,小模型猜测命中率 60-70%
场景 3:开放式创意写作(差场景)
→ "写一首关于月亮的诗",下一个词有无限可能
→ 小模型猜测命中率 30-40%,大部分猜测被浪费
→ 反而比普通 decode 更慢(多了小模型的计算开销)
场景 4:客服/FAQ(极佳场景)
→ 回复模式固定("您好,您的问题我已收到..."),
小模型猜测命中率 80-90%
═══════════════════════════════════════════════════════════
多模态模型部署详解
═══════════════════════════════════════════════════════════
vllm部署多模态的模型是就是用户请求了多占了些显存,然后图片最终也是转换成了token
纯文本模型流程:
用户输入文字 → Tokenizer 转成 token → 进 Transformer → 出文字
多模态模型流程(以 LLaVA 为例):
用户输入文字 + 图片
→ 图片先过 Vision Encoder(CLIP ViT-L/14)
→ 一张 336×336 的图片被编码成 576 个 image token 的 embedding
→ 这 576 个 embedding 和文字 token 拼在一起
→ 一起进 Transformer → 出文字
和纯文本部署的区别:
1. 显存多占:
Vision Encoder(CLIP)本身占 ~1GB 显存
576 个 image token 的 KV Cache = 576 × 0.65MB ≈ 375MB/请求
→ 如果 10 个请求每个带一张图 → 多占 3.75GB
→ --gpu-memory-utilization 可能要调低一点预留空间
2. Prefill 更慢:
一张图 = 576 个额外 token 参与 Prefill
→ TTFT 显著增加(相当于 prompt 多了 576 个 token)
→ 多张图更夸张:4 张图 = 2304 个额外 token
3. max-model-len 要算上图片:
用户发"请描述这张图"+1 张图
→ 文字 5 token + 图片 576 token = 581 token 实际输入
→ max-model-len 必须 ≥ 581 + 最大输出长度
4. 预处理多了图片处理:
CPU 要做图片解码 (JPEG→Tensor)、resize、normalize
→ CPU 预处理可能成为瓶颈(尤其是高并发多图请求)
→ 可能需要更多 CPU 核心
vLLM 部署多模态的配置:
python -m vllm.entrypoints.openai.api_server \
--model llava-v1.6-34b \
--tensor-parallel-size 4 \
--max-model-len 4096 \ # 注意: 这里的 4096 包含(文字 Token +
# 图片转换后的 Feature Token)。如果图片占了 576,留给文字的就只有 3500 左右。
--image-input-type pixel_values \ # 告知 vLLM 视觉编码器接收的是预处理后的像素矩阵。
--image-token-id 32000 \ # 图片占位符的 token id
# 在文本序列中,模型需要一个特定的 ID 来标记“这里是一张图”,32000 是 LLaVA 默认的。
--image-input-shape "1,3,336,336" \ # 图片尺寸 1,3,336,336 代表(Batch, 通道, 高, 宽)
# 这决定了视觉特征提取的粒度。
--image-feature-size 576 # 图片编码后的 token 数
推荐 4 张 RTX 5880(正如你脚本里写的 --tensor-parallel-size 4):
每张卡仅承担约 17GB 的模型权重。
剩余约 31GB 的显存可以全部拨给 vLLM 的 PagedAttention (KV Cache)。
HPA: 监控数据 →\rightarrow→ 聚合计算 →\rightarrow→ 决策扩缩 →\rightarrow→ 执行操作
- 路径 A:标准路径(只看 CPU/内存)这是 K8s 原生支持的最短路径。
[Pod/Node] →cAdvisor\xrightarrow{\text{cAdvisor}}cAdvisor [Kubelet] →HTTPS\xrightarrow{\text{HTTPS}}HTTPS [Metrics Server] →Metrics API\xrightarrow{\text{Metrics API}}Metrics API [HPA Controller]
spec:
metrics:
- type: Resource
resource:
name: cpu # 这里告诉 HPA:去找 metrics.k8s.io 这个接口要 cpu 数据
target:
type: Utilization
averageUtilization: 50
- 路径 B:高级路径(看 GPU/并发量) GPU 数据在 Prometheus 里。
[GPU Exporter] →\rightarrow→ [Prometheus] →\rightarrow→ [Prometheus Adapter] →\rightarrow→ [Custom Metrics API] →读取\xrightarrow{\text{读取}}读取 [HPA Controller]
要连Prometheus,HPA 的定义会从 Resource 变成 Pods(自定义指标):
spec:
metrics:
- type: Pods
pods:
metric:
name: vllm_avg_prompt_throughput # 这是 Prometheus 里的指标名
target:
type: AverageValue
averageValue: "100" # 目标:每个 Pod 处理 100 个 Token/s
cAdvisor (Container Advisor) 是由 Google 开源的工具,它被原生内置在 K8s 的 Kubelet 组件中。
核心任务:它负责采集当前节点上每一个容器的硬指标。
采集内容:CPU 使用率、内存占用、网络流量、磁盘 IO。
═══════════════════════════════════════════════════════════
HPA 的流程和解决的问题
═══════════════════════════════════════════════════════════
Metrics Server 是 K8s 集群中一个轻量级的资源监控组件。它通过 API Server 收集每个节点(Kubelet)报告的 CPU 和内存
实时消耗数据,并将其存储在内存中(不落盘)。
它的角色:它是 HPA 能够正常工作的预设条件。
局限性:它只认识 CPU 和内存。如果你想根据 GPU 利用率 扩缩容,它搞不定,必须请出 Prometheus。
先说没有 HPA 时的痛点:
早上 9 点:用户少,10 个请求/分钟
→ 3 个 vLLM 副本绰绰有余,GPU 利用率只有 20%
→ 2 张 A100 在空转 → 每小时烧几十块钱电费白浪费
下午 2 点:全公司都在用,200 个请求/分钟
→ 3 个副本根本扛不住,排队数飙到 50+
→ 用户等 30 秒才出结果 → 体验极差 → 投诉
运维只能手动扩容:
→ 下午 2 点收到告警 → 手动 kubectl scale → 等 Pod 启动 → 等模型加载
→ 5 分钟后才扩好 → 这 5 分钟用户一直在骂
HPA 自动做的事:
┌─────────────────────────────────────────────────────┐
│ 每 15 秒循环检查一次: │
│ │
│ 1. Prometheus 采集 vllm:num_requests_waiting 指标 │
│ → 当前每个 Pod 平均排队数 = 8 │
│ │
│ 2. HPA 对比目标值:8 > 5(目标 averageValue: 5) │
│ → 需要扩容 │
│ │
│ 3. HPA 计算需要几个副本: │
│ 当前 3 副本 × (当前值8 / 目标值5) = 4.8 → 向上取 5 │
│ → kubectl scale deployment vllm-7b --replicas=5 │
│ │
│ 4. K8s 创建 2 个新 Pod → 调度到有 GPU 的节点 │
│ → 模型加载完成 → readinessProbe 通过 → 接流量 │
│ │
│ 5. 晚上 10 点:用户少了,排队数降到 1 │
│ → 1 < 5 → HPA 缩容 → 从 5 副本缩到 1 副本 │
│ → 省 4 张 A100 的电费 │
└─────────────────────────────────────────────────────┘
解决的问题:
✅ 高峰期自动扩容 → 用户不排队
✅ 低谷期自动缩容 → 不浪费 GPU
✅ 无需人工干预 → 运维不用半夜起来扩容
═══════════════════════════════════════════════════════════
多模型混合部署在公司的实际使用场景
═══════════════════════════════════════════════════════════
公司内部实际有这些 AI 业务,每个用不同大小的模型:
┌────────────────┬──────────┬─────────────┬──────────────┐
│ 业务场景 │ 模型大小 │ 对速度的要求 │ 对质量的要求 │
├────────────────┼──────────┼─────────────┼──────────────┤
│ 客服机器人 │ 7B │ 极快(实时) │ 中等(FAQ) │
│ 内部知识问答 │ 13B │ 快 │ 较高 │
│ 合同/报告生成 │ 70B │ 慢点也行 │ 极高(不能错) │
│ 代码补全插件 │ 7B │ 极快(打字时) │ 中等 │
│ 领导用的高级助手 │ 70B │ 慢点也行 │ 极高 │
└────────────────┴──────────┴─────────────┴──────────────┘
Nginx 路由规则:
/v1/chat/completions 请求体里的 model 字段:
"model": "customer-service" → 转发到 7B 池
"model": "knowledge-qa" → 转发到 13B 池
"model": "contract-gen" → 转发到 70B 池
"model": "code-assistant" → 转发到 7B 池
解决的核心问题:
✅ GPU 资源利用率从 30% 提升到 70%+
✅ 不同业务按需求用不同大小的模型,不浪费算力
✅ HPA 让每个池按流量自动伸缩,高峰低谷都能应对
✅ LoRA 共享底座让多个 70B 微调版本只占一份显存
4. 命令解析与性能调优
逐行解释
docker run -d \ # 后台运行容器
--name qwen3.5-27b \ # 容器名称
--gpus '"device=2,3"' \ # 使用第2、3号GPU
--shm-size=32g \ # 共享内存32GB(GPU间通信需要)
-p 8777:8000 \ # 宿主机8777端口 → 容器8000端口
-v /home/admin/.../Qwen3.5-27B:/models/Qwen3.5-27B \ # 挂载模型目录
--restart unless-stopped \ # 异常退出自动重启
swr.cn-north-4.myhuaweicloud.com/.../vllm-openai:nightly-xxx \ # 华为云镜像源的vLLM镜像
vLLM 参数
--model /models/Qwen3.5-27B \ # 模型路径
--served-model-name Qwen3.5-27B \ # API中显示的模型名
--tensor-parallel-size 2 \ # 张量并行=2(模型切分到2张GPU)
--trust-remote-code \ # 信任模型自带的python代码,默认关闭因为安全
--dtype bfloat16 \ # 使用bf16精度(27B×2字节≈54GB,2卡分担)
--max-model-len 32768 \ # 最大上下文长度 32K tokens
--gpu-memory-utilization 0.95 \ # GPU显存利用率95%
--max-num-seqs 32 \ # 最大并发请求数32
--enable-auto-tool-choice \ # 启用工具调用(function calling)
# 开启后,模型可以根据用户的输入,自主决定是否需要调用工具。
--tool-call-parser hermes \ # 用hermes格式解析工具调用 统一模型输出与系统接口
# 之间的“语言”
--host 0.0.0.0 \ # 监听所有网络接口
--port 8000 # 容器内监听8000端口
显存分配示意
RTX 5880 规格:48GB GDDR7 / Blackwell架构 / FP8/FP4原生支持
1. 用 FP8 量化(最大优化,强烈推荐)
RTX 5880 (Blackwell) 原生支持 FP8,性能几乎翻倍:
docker run -d \
--name qwen3.5-27b \
--gpus '"device=2,3"' \
--shm-size=32g \
-p 8777:8000 \
-v /home/admin/Liu_work/modelscope/models/Qwen/Qwen3.5-27B:/models/Qwen3.5-27B \
--restart unless-stopped \
swr.cn-north-4.myhuaweicloud.com/ddn-k8s/docker.io/vllm/vllm-openai:nightly-d106bf39f56cdc59d08a84094c0de41a0be9ad0f \
--model /models/Qwen3.5-27B \
--served-model-name Qwen3.5-27B \
--tensor-parallel-size 2 \
--trust-remote-code \
--quantization fp8 \
--dtype auto \
--max-model-len 65536 \
--gpu-memory-utilization 0.95 \
--max-num-seqs 64 \
--enable-chunked-prefill \
--enable-prefix-caching \
--enable-auto-tool-choice \
--tool-call-parser hermes \
--host 0.0.0.0 \
--port 8000
2. 单卡就能跑(FP8 下 27B ≈ 27GB,48GB 绰绰有余)
docker run -d \
--name qwen3.5-27b \
--gpus '"device=2"' \
--shm-size=16g \
-p 8777:8000 \
-v /home/admin/Liu_work/modelscope/models/Qwen/Qwen3.5-27B:/models/Qwen3.5-27B \
--restart unless-stopped \
swr.cn-north-4.myhuaweicloud.com/ddn-k8s/docker.io/vllm/vllm-openai:nightly-d106bf39f56cdc59d08a84094c0de41a0be9ad0f \
--model /models/Qwen3.5-27B \
--served-model-name Qwen3.5-27B \
--trust-remote-code \
--quantization fp8 \
--dtype auto \
--max-model-len 32768 \
--gpu-memory-utilization 0.95 \
--max-num-seqs 32 \
--enable-chunked-prefill \
--enable-prefix-caching \
--enable-auto-tool-choice \
--tool-call-parser hermes \
--host 0.0.0.0 \
--port 8000
好处:省出一张卡跑其他模型
关键优化参数对比
| 参数 | 原配置 | 优化后 | 说明 |
|---|---|---|---|
--dtype |
bfloat16 | auto | 配合FP8自动选择 |
--quantization |
无 | fp8 | 显存减半,Blackwell原生加速 |
--max-model-len |
32768 | 65536 | FP8省出的显存给更长上下文 |
--max-num-seqs |
32 | 64 | 更多并发 |
--enable-chunked-prefill |
无 | 有 | 长文本分块预填充,减少延迟 |
--enable-prefix-caching |
无 | 有 | 相同前缀的请求复用KV Cache |
--tensor-parallel-size |
2 | 1(可选) | FP8下单卡就够 |
3. 总结建议
方案A(性能最大化): 2卡 + FP8 → 65K上下文 + 64并发
方案B(效率最大化): 1卡 + FP8 → 省出1卡跑别的模型
方案C(精度最大化): 2卡 + BF16 → 就是你现在的配置
推荐方案A,RTX 5880 的 Blackwell FP8 是最大亮点,不用就浪费了。
quantization
-
场景 A:普通 BF16 模型(不带量化)
如果你下载的是原版 Llama-3 权重:
你设置 --dtype bfloat16。
不设置 --quantization。
结果: 模型以 16 位运行,1 个参数占 2 字节。 -
场景 B:使用量化模型(如 AWQ/GPTQ)
如果你下载的是已经做过量化的权重(比如 Llama-3-8B-AWQ):
你必须设置 --quantization awq,否则 vLLM 认不出这是压缩包,会报错。
此时设置 --dtype 依然有意义:它决定了非权重的部分(如激活值、LayerNorm)用什么精度算。通常设为 auto 或 half。 -
场景 C:特殊的 FP8(H100/H20 必备)
quantization fp8,硬件级FP8 加速,老显卡(如 A100/V100): 硬件电路是为 FP16/BF16 设计的。
如果你强行跑 FP8,它得用软件模拟,不仅不快,反而更慢。新显卡(如 H100/H20/4090):
它们的 Tensor Core 内部直接焊了处理 FP8 的电路。
效果: 算力直接翻倍。比如 H100 跑 BF16 是 1000 TFLOPS,跑 FP8 就能冲到 2000 TFLOPS。
只有在计算那一瞬间变模糊一点点,算完后的结果再转回高精度。这样既享受了 FP8 的速度,又维持了 BF16 的“智商”。
如果你在 H100 上跑原版 BF16 模型,并加上 --quantization fp8:
结果: vLLM 会动态地把计算过程转为 8 位。
这时 --dtype bf16 仍然重要,因为模型的基础底色还是 BF16,只是计算时临时“降档”提速。
底色是 BF16(存储状态):
模型在硬盘里、以及加载到显存里的一瞬间,每个数字依然是 BF16(2 字节)。这保证了模型的“知识”没有因为压缩而永久受损。
就像一张 4K 高清原图 存在硬盘里。
计算时临时降档(运行状态):
当 vLLM 准备让 GPU 进行矩阵乘法(计算 Attention)时,它会在进入核心(Tensor Core)的前一秒,动态地把这些数字转成 FP8
(1 字节)。
就像你为了让视频播放更流畅,虽然源文件是 4K,但显示器实时以 1080P 的速度在渲染。
- FP8 真实情况:到底损失了多少?
在实际的 Benchmark(跑分)中:
BF16 (原版): 100 分。
FP8 (H100/H20): 99.5 - 99.8 分。(肉眼几乎不可见,业务无感)
INT4 (AWQ/GPTQ): 95 - 98 分。(复杂逻辑可能会开始胡言乱语)
精度确实有损失,但损失被控制在了“不影响智商”的范围内。 用 0.5% 的精度损失 换取 50% 的速度提升
float16(FP16) bfloat16(BF16)之间的区别
它们虽然都是 16 位(2 字节),但内部的“地盘划分”完全不同。
BF16 能表达的数字范围和 FP32 完全一致。永远不会溢出。模型训练稳定。否则像 FP16那样训练时梯度超过范围数字变成了Inf(无穷大)
导致整个模型崩溃(NaN)
但是把 FP32 的“尾巴”直接砍掉得到的。精度略逊
如果是 A100/H100/H20: 启动 vLLM 时请闭眼选 --dtype bfloat16。
如果是老旧的 V100/T4: 硬件不支持 BF16,你只能选 --dtype float16,否则速度会慢得离谱。
想象一个 16 位的存储空间是一条长度固定的尺子,它们划分成了三部分:符号位(正负)、指数位(范围)、尾数位(精度)。
| 格式 | 符号位 | 指数位 (Exponent) | 尾数位 (Fraction) | 特点 |
|---|---|---|---|---|
| FP32 (基准) | 1 | 8 | 23 | 精度高,范围广 |
| FP16 | 1 | 5 | 10 | 精度尚可,范围极窄 |
| BF16 | 1 | 8 | 7 | 精度较低,范围极广 |
3. 为什么现在大模型(如 Llama-3)全用 BF16?
在你的 A100/H100/H20 集群里,BF16 是绝对的主流,原因有三:
- 硬件原生支持: 从 NVIDIA Ampere 架构(A100)开始,硬件对 BF16 做了专门优化,跑起来飞快。
- 收敛更稳定: 训练几千亿参数的模型,最怕的就是跑到一半由于数字溢出崩了。BF16 的宽广范围让它像“越野车”一样,路况再烂(梯度波动再大)也能跑过去。
- 无缝转换: 因为 BF16 的指数位和 FP32 一样,在计算过程中和 FP32 互相转换时,不会出现严重的数值突变。
AtomGit 是由开放原子开源基金会联合 CSDN 等生态伙伴共同推出的新一代开源与人工智能协作平台。平台坚持“开放、中立、公益”的理念,把代码托管、模型共享、数据集托管、智能体开发体验和算力服务整合在一起,为开发者提供从开发、训练到部署的一站式体验。
更多推荐
所有评论(0)