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(),prompt 越长计算量越大

FlashAttention 是一种优化 Attention 计算的算法:
  传统 Attention:把整个 n×n 的注意力矩阵算出来存在显存里,GPU 的高速缓存(SRAM)
  放不下,必须频繁地在显存(HBM,比较慢)和核心之间搬运数据。
                 → 显存占用 O(),访存多,慢
  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 执行操作

  1. 路径 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
  1. 路径 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

  1. 场景 A:普通 BF16 模型(不带量化)
    如果你下载的是原版 Llama-3 权重:
    你设置 --dtype bfloat16。
    不设置 --quantization。
    结果: 模型以 16 位运行,1 个参数占 2 字节。

  2. 场景 B:使用量化模型(如 AWQ/GPTQ)
    如果你下载的是已经做过量化的权重(比如 Llama-3-8B-AWQ):
    你必须设置 --quantization awq,否则 vLLM 认不出这是压缩包,会报错。
    此时设置 --dtype 依然有意义:它决定了非权重的部分(如激活值、LayerNorm)用什么精度算。通常设为 auto 或 half。

  3. 场景 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 的速度在渲染。
  1. 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 是绝对的主流,原因有三:

  1. 硬件原生支持: 从 NVIDIA Ampere 架构(A100)开始,硬件对 BF16 做了专门优化,跑起来飞快。
  2. 收敛更稳定: 训练几千亿参数的模型,最怕的就是跑到一半由于数字溢出崩了。BF16 的宽广范围让它像“越野车”一样,路况再烂(梯度波动再大)也能跑过去。
  3. 无缝转换: 因为 BF16 的指数位和 FP32 一样,在计算过程中和 FP32 互相转换时,不会出现严重的数值突变。
Logo

AtomGit 是由开放原子开源基金会联合 CSDN 等生态伙伴共同推出的新一代开源与人工智能协作平台。平台坚持“开放、中立、公益”的理念,把代码托管、模型共享、数据集托管、智能体开发体验和算力服务整合在一起,为开发者提供从开发、训练到部署的一站式体验。

更多推荐