芥末
发布于 2026-05-07 / 0 阅读
0
0

vLLM 推理全流程详解:Prefill、Decode、PagedAttention 和连续批处理

vLLM 解决的是大语言模型推理服务里的一个核心问题:在显存有限、请求长度不一、并发不断变化的情况下,怎样让 GPU(Graphics Processing Unit,图形处理器)尽量少空转,同时让每个请求都能稳定地产生输出。

一次 LLM(Large Language Model,大语言模型)请求看起来只是“输入一段文本,模型返回一段文本”,但推理系统内部会经历分词、排队、调度、Prefill、Decode、KV Cache 管理、流式输出等多个环节。只盯着某一个函数或者某一个优化点,很容易看不清全局。

vLLM 的端到端推理链路可以用一张流程图串起来:

图片

图里最关键的线索有两条:

  • 横向主流程:请求从用户输入进入系统,经过 Tokenizer、vLLM Engine、模型推理,最终被 Detokenizer 转成文本并流式返回。
  • 纵向核心机制:模型推理阶段又拆成 Prefill、Decode、PagedAttention、KV Cache 管理和 Scheduler 调度。

可以先把完整链路抽象成下面这个结构:

flowchart LR
    U[用户输入] --> A[API / WebSocket 接入层]
    A --> T[Tokenizer<br/>文本转 Token ID]
    T --> E[vLLM Engine]
    E --> S[Scheduler<br/>请求调度]
    S --> P[Prefill<br/>处理 Prompt]
    P --> K[(KV Cache)]
    K --> D[Decode<br/>逐 Token 生成]
    D --> K
    D --> O[Detokenizer<br/>Token ID 转文本]
    O --> R[流式返回]

一次请求在 vLLM 中经历什么

从外部看,一个请求通常来自 API(Application Programming Interface,应用程序编程接口)或者 WebSocket 长连接。接入层负责负载均衡、路由和连接管理,真正进入模型之前,还要先经过 Tokenizer。

Tokenizer 的作用是把自然语言文本变成模型能理解的数字序列。例如:

用户输入:帮我写一首诗

Tokenizer 输出:
[8448, 225, 102, 3074, 13, 116, 502, ...]

模型不直接处理汉字、英文单词或者标点,而是处理 Token ID。进入 vLLM Engine 后,请求不会立即占用 GPU 开始计算,而是先进入调度体系。Scheduler 会根据当前 GPU 负载、KV Cache 剩余空间、请求长度和调度策略,决定哪些请求进入 Running 状态。

完整路径可以拆成几个阶段:

阶段主要工作关键组件
接入接收请求、路由、维持连接API / WebSocket
分词文本转 Token IDTokenizer
排队请求进入等待队列Request Queue
调度决定哪些请求运行Scheduler
Prefill一次性处理 Prompt,生成初始 KV CacheModel Worker
Decode每次生成一个新 TokenModel Worker + KV Cache
输出Token ID 转文本,流式返回Detokenizer

这里最容易混淆的是 Prefill 和 Decode。它们都在跑 Transformer,但计算模式完全不同,优化方向也不同。

Prefill:一次性理解整个 Prompt

Prefill 是推理的第一个计算阶段。用户输入的 Prompt 被分成 N 个 Token 后,模型会一次性处理这 N 个 Token,并为每一层生成对应的 K 和 V,写入 KV Cache(Key-Value Cache,键值缓存)。

在 Transformer 中,每一层都会基于输入向量生成三组向量:

  • Q:Query,当前 Token 要查询什么信息。
  • K:Key,历史 Token 提供什么索引。
  • V:Value,历史 Token 提供什么内容。

注意力机制可以简化理解成:用 Q 去和 K 做匹配,再根据匹配结果加权汇总 V。

flowchart TD
    X[Prompt Token Embeddings<br/>N × d_model] --> L1[Transformer Layer 1]
    L1 --> KV1[写入 Layer 1 的 K/V]
    L1 --> L2[Transformer Layer 2]
    L2 --> KV2[写入 Layer 2 的 K/V]
    L2 --> LN[...]
    LN --> KVL[写入 Layer L 的 K/V]
    LN --> Logits[预测下一个 Token 的概率分布]

Prefill 的特点是并行度高。因为 Prompt 中的所有 Token 已经给定,模型可以一次性对整段上下文做计算。注意力矩阵大致是 N × N,Prompt 越长,Prefill 的计算量增长越明显。

它的主要瓶颈通常在算力,而不是显存带宽。也就是说,GPU 的矩阵乘法能力能不能吃满,会直接影响 Prefill 的速度。

Prefill 结束后,系统拿到了两个结果:

  • 下一个 Token 的概率分布,可以开始生成第一个输出 Token。
  • 每一层、每个 Prompt Token 的 K/V,后续 Decode 会反复读取。

如果没有 KV Cache,每生成一个新 Token 都要重新计算完整 Prompt 和历史输出的 K/V,推理成本会迅速失控。KV Cache 的意义就是把历史计算结果存下来,让 Decode 阶段只处理新增 Token。

Decode:自回归地逐 Token 生成

Decode 是自回归生成阶段。所谓自回归,就是模型每次生成一个 Token,然后把这个 Token 拼回上下文,再用新的上下文生成下一个 Token。

Decode 的单步过程可以表示为:

sequenceDiagram
    participant D as Decode Step t
    participant K as KV Cache
    participant M as Transformer
    participant O as Output

    D->>M: 输入上一步生成的 Token
    M->>K: 读取历史 K/V
    K-->>M: 返回历史上下文
    M->>K: 写入新 Token 的 K/V
    M->>O: 输出下一个 Token 概率
    O-->>D: 采样得到新 Token,进入下一步

Decode 和 Prefill 最大的区别在于处理对象不同:

  • Prefill 一次处理整个 Prompt。
  • Decode 每次只处理一个新增 Token。

不过,“每次只处理一个 Token”不等于计算完全是常数成本。Decode 在第 t 步时,新 Token 的 Q 仍然要和历史长度为 t 的 K/V 做注意力,因此它会持续读取越来越长的 KV Cache。KV Cache 避免的是“重算历史 Token 的 K/V”,不是让历史上下文彻底免费。

Prefill 和 Decode 的差异可以放在一张表里看:

维度PrefillDecode
处理对象整个 Prompt当前新 Token
注意力形状N × N1 × t
KV Cache 行为批量写入 Prompt 的 K/V每步读取历史 K/V,并写入新 K/V
主要计算类型GEMM(General Matrix-Matrix Multiplication,矩阵-矩阵乘法)GEMV(General Matrix-Vector Multiplication,矩阵-向量乘法)和小批量计算
常见瓶颈计算密集显存带宽密集
并行度受自回归顺序限制,天然串行

这张表解释了很多推理系统的设计取舍:Prefill 要尽量提高大矩阵计算效率,Decode 要尽量减少 KV Cache 的浪费、搬运和不连续访问。

KV Cache 为什么会成为显存压力源

KV Cache 保存的是每一层、每个 Token 的 Key 和 Value。它的大小和模型层数、上下文长度、KV head 数、head 维度、数据类型字节数都有关。

比较准确的估算公式是:

KV Cache bytes
= num_layers × seq_len × 2 × num_kv_heads × head_dim × dtype_bytes

其中:

  • num_layers:模型层数。
  • seq_len:当前序列长度,包括 Prompt 和已经生成的 Token。
  • 2:K 和 V 两份缓存。
  • num_kv_heads:KV head 数。使用 GQA(Grouped-Query Attention,分组查询注意力)的模型通常小于 attention head 数。
  • head_dim:每个 head 的维度。
  • dtype_bytes:数据类型占用字节数,例如 FP16/BF16 通常是 2 字节。

用一个接近 Llama 系模型的配置估算:

num_layers = 32
num_kv_heads = 8
head_dim = 128
dtype_bytes = 2
seq_len = 4096

单请求 KV Cache
= 32 × 4096 × 2 × 8 × 128 × 2
= 536,870,912 bytes
≈ 512 MB

单个 4096 长度请求就可能占用约 512 MB KV Cache。并发 64 个类似请求时,仅 KV Cache 就可能接近 32 GB;并发 128 个时,可能接近 64 GB。模型权重、临时激活、CUDA runtime、通信 buffer 等还要额外占显存。

这也是 vLLM 必须重点优化 KV Cache 的原因。显存里真正昂贵的不只是模型权重,还有不断增长、不断释放、长度差异很大的请求缓存。

PagedAttention:用分页思想管理 KV Cache

传统做法如果为每个请求预留一段连续 KV Cache,会遇到两个问题:

  • 请求长度不可预测,预留少了会不够,预留多了会浪费。
  • 不同请求结束时间不同,显存中会产生碎片,明明总剩余空间够用,却找不到一段连续空间。

PagedAttention 的思路接近操作系统虚拟内存:逻辑上一个请求的上下文是连续的,物理显存里的 KV Block 不需要连续。系统通过 Page Table 维护“逻辑页到物理块”的映射。

flowchart LR
    subgraph Logical[请求的逻辑 KV 空间]
        L0[逻辑页 0]
        L1[逻辑页 1]
        L2[逻辑页 2]
    end

    subgraph PT[Page Table]
        M0[0 -> Block 3]
        M1[1 -> Block 8]
        M2[2 -> Block 15]
    end

    subgraph Physical[GPU 物理 KV Blocks]
        B0[Block 0]
        B3[Block 3]
        B8[Block 8]
        B15[Block 15]
        B20[Block 20]
    end

    L0 --> M0 --> B3
    L1 --> M1 --> B8
    L2 --> M2 --> B15

如果 Block Size 是 16 tokens,一个请求的前 16 个 Token 可以放在逻辑页 0,接下来的 16 个 Token 放在逻辑页 1。逻辑页号连续,但映射到的物理 Block 可以是 3、8、15 这种不连续的位置。

这种设计带来几个直接收益:

能力具体含义
减少碎片不要求为每个请求分配连续大块显存
按需增长上下文变长时再分配新的 Block
便于回收请求结束后释放它占用的 Block,放回空闲池
支持共享相同前缀或系统 Prompt 有机会复用物理缓存
提高并发相同显存下可以容纳更多长度不一的请求

PagedAttention 的价值不在于让注意力计算消失,而是让 KV Cache 的分配和访问更适合真实在线服务:请求长度不同、生成长度不同、进出时间不同,显存管理必须足够细粒度。

流式输出不是“一个字一个字返回”

Decode 每一步产生的是 Token ID,而不是用户最终看到的字符。很多模型使用 BPE(Byte Pair Encoding,字节对编码)或类似分词方式,一个 Token 可能对应一个字、半个词、多个字符,甚至是某个 UTF-8 字节片段。

流式输出通常会经历这样的路径:

flowchart LR
    T[新 Token ID] --> D[Detokenizer]
    D --> B[缓冲不完整片段]
    B --> C[形成完整字符 / 词片段]
    C --> W[通过 WebSocket 或 HTTP Stream 推送]
    W --> U[前端展示]

因此,前端看到的并不一定是稳定的“每生成一个 Token 就显示一个字”。有时会短暂停顿,然后一次显示几个字符;有时英文单词会分成几段出现;中文也可能因为编码或分词边界而需要等待拼接完成。

这个细节对调试流式接口很重要。服务端如果直接把不完整 Token 片段推给前端,可能出现乱码、半个词或者无法正确渲染的字符。

Continuous Batching:让 GPU 不被慢请求拖住

传统 Static Batching 的做法是把一批请求凑在一起运行,等整批都完成后再处理下一批。问题在于,同一批里的请求长度往往不同:短请求很快结束,长请求还在 Decode,短请求释放出来的计算位置却不能马上给新请求使用。

Continuous Batching(连续批处理)把调度粒度变成“每一轮 Decode 迭代”。每一轮结束后,Scheduler 都可以把完成的请求移出,把等待队列里的新请求补进来。

gantt
    title Static Batching 与 Continuous Batching 的差异
    dateFormat  X
    axisFormat %s

    section Static Batch
    Request A :0, 3
    Request B :0, 7
    Request C :0, 5
    Next Batch waits :7, 3

    section Continuous Batch
    Request A :0, 3
    Request D enters :3, 4
    Request C :0, 5
    Request E enters :5, 3
    Request B :0, 7

Continuous Batching 的关键不是“把 batch 做大”,而是让 batch 持续流动:

  • 有请求结束,立刻释放 KV Cache Block。
  • 有空位出现,立刻从等待队列补新请求。
  • Prefill 和 Decode 可以交错调度,避免 GPU 在请求边界处空等。
  • 吞吐量提高的同时,需要控制单个请求的延迟。

它带来的代价是调度逻辑更复杂。Scheduler 不只要看 batch size,还要看每个请求的阶段、剩余 KV Cache、最大上下文长度、是否会造成长尾等待。

Scheduler:调度策略之间的取舍

vLLM 的 Scheduler 不是简单地“谁先来就先跑”。在线推理有几个互相拉扯的目标:

策略好处风险
短请求优先降低大量短请求的排队时间长请求可能长期等待
Prefill 优先更快产生首个 Token,降低首 token 延迟Decode 可能被挤占,输出变慢
Decode 优先已开始输出的请求更平滑新请求首 token 延迟变高
公平调度避免长请求饥饿吞吐或平均延迟可能下降
显存优先避免 KV Cache 超分配有些可计算请求会被延后

调度器真正做的是资源分配:有限的 GPU 计算时间、有限的显存、不断到来的请求,怎样排才能满足当前服务目标。

一个简化的请求状态机可以这样表示:

stateDiagram-v2
    [*] --> Waiting
    Waiting --> Running: 被调度且 KV Block 分配成功
    Running --> Waiting: 被抢占或资源不足
    Running --> Finished: 生成 EOS / 达到最大长度 / 请求取消
    Finished --> [*]: 释放 KV Block

请求从 Waiting 进入 Running 之前,Scheduler 通常要和 KV Cache Manager 协作确认:是否还有足够的 Block 放下这个请求的 Prompt 或下一步 Decode。如果显存不够,请求不能强行进入运行态,否则会造成缓存分配失败,甚至触发更严重的显存错误。

组件之间的关系可以概括为:

flowchart TD
    Q[Request Queue] --> S[Scheduler]
    S --> K[KV Cache Manager]
    K -->|分配 Block| S
    S --> W[Worker / GPU Executor]
    W -->|Prefill / Decode| K
    W --> O[Output Processor]
    O --> C[Client Stream]

    W -->|请求完成| K
    K -->|回收 Block| S

Scheduler 负责决定“谁运行”,KV Cache Manager 负责判断“有没有地方放”,Worker 负责真正执行模型计算。三者耦合很紧,任何一个环节处理不好,吞吐和延迟都会受影响。

Prefill 与 Decode 分离为什么会出现

由于 Prefill 和 Decode 的瓶颈不同,把它们放在同一组 GPU 上并不总是合适。

Prefill 更偏计算密集,适合通过大 batch、大矩阵乘法提高 GPU 算力利用率;Decode 更偏显存带宽密集,每步要读取历史 KV Cache,并且自回归顺序限制了并行度。

PD 分离(Prefill-Decode Disaggregation,预填充与解码分离)的思路是:让一部分 GPU 专门处理 Prefill,另一部分 GPU 专门处理 Decode。这样可以针对两个阶段分别调参,例如:

阶段更关注常见优化
Prefill首 token 延迟、长 Prompt 计算效率Chunked Prefill、较大的 batched tokens
Decode每 Token 延迟、KV Cache 访问效率PagedAttention、连续批处理、缓存复用
跨阶段传递KV Cache 或中间状态转移成本高速互联、合理的请求分流

PD 分离不是所有场景都需要。请求量不大、Prompt 不长、部署规模较小时,单组 GPU 混合处理更简单;当长 Prompt 和高并发同时出现,Prefill 与 Decode 的资源冲突会变明显,分离部署才更有意义。

最小化启动一个 vLLM 服务

理解内部链路后,再看 vLLM 的启动方式会更清楚。一个最小化的 OpenAI 兼容服务可以这样启动:

pip install vllm

vllm serve meta-llama/Llama-3.1-8B-Instruct \
  --tensor-parallel-size 1 \
  --max-model-len 8192 \
  --gpu-memory-utilization 0.9

发送一个聊天请求:

curl http://localhost:8000/v1/chat/completions \
  -H "Content-Type: application/json" \
  -d '{
    "model": "meta-llama/Llama-3.1-8B-Instruct",
    "messages": [
      {"role": "user", "content": "用三句话解释 KV Cache 的作用"}
    ],
    "stream": true
  }'

几个参数会直接影响前面讲到的机制:

参数影响
--max-model-len决定最大上下文长度,越大 KV Cache 压力越高
--gpu-memory-utilization控制 vLLM 可使用的显存比例
--max-num-seqs限制同时运行的序列数量
--max-num-batched-tokens限制一次调度中处理的 Token 总量
--enable-chunked-prefill将长 Prompt 的 Prefill 拆块,减少对 Decode 的阻塞

线上调参时不能只看吞吐。max_num_seqsmax_num_batched_tokens 调大后,吞吐可能上升,但单请求延迟也可能变差;max_model_len 调大后,可支持更长上下文,但 KV Cache 预留和运行时显存压力会明显增加。

常见坑

盲目把最大上下文调得很大

最大上下文长度不是免费参数。即使平均请求很短,只要允许极长上下文,调度器和 KV Cache 管理都要为最坏情况留出空间。显存紧张时,请求会更容易排队,甚至触发抢占。

只关注模型权重大小

很多部署估算只算模型权重,例如 8B 模型用 FP16 大约十几 GB,但在线服务还要考虑 KV Cache、临时 buffer、并发请求和框架开销。并发一高,KV Cache 往往成为主要显存压力。

把流式输出理解成逐字符输出

服务端生成的是 Token,Detokenizer 需要把 Token 拼成合法文本片段再返回。流式接口出现短暂停顿,不一定是模型没有生成,也可能是分词边界和字符拼接导致的缓冲。

Prefill 和 Decode 混在一起调优

Prefill 慢通常要看 Prompt 长度、batch token 数、Chunked Prefill 和矩阵计算效率;Decode 慢通常要看 KV Cache 访问、并发序列数、显存带宽和调度策略。两个阶段的瓶颈不同,用同一套指标判断容易误判。

把整条链路串起来

vLLM 推理可以压缩成一句话:请求进入系统后先被分词和调度,Prefill 一次性处理 Prompt 并写入 KV Cache,Decode 每步读取历史 KV Cache 生成新 Token,PagedAttention 用分页方式降低显存碎片,Continuous Batching 让新旧请求在每轮迭代中动态进出,从而提高 GPU 利用率。

真正理解 vLLM,要把四组关系放在一起看:

  • Prefill 负责建立上下文,Decode 负责逐步生成。
  • KV Cache 避免重复计算,PagedAttention 负责让缓存更好地占用显存。
  • Scheduler 决定请求什么时候运行,KV Cache Manager 决定运行前有没有足够空间。
  • Continuous Batching 提高吞吐,但需要在吞吐、首 token 延迟和单 Token 延迟之间做取舍。

这些机制合在一起,才构成了 vLLM 高吞吐推理服务的核心。


评论