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 ID | Tokenizer |
| 排队 | 请求进入等待队列 | Request Queue |
| 调度 | 决定哪些请求运行 | Scheduler |
| Prefill | 一次性处理 Prompt,生成初始 KV Cache | Model Worker |
| Decode | 每次生成一个新 Token | Model 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 的差异可以放在一张表里看:
| 维度 | Prefill | Decode |
|---|---|---|
| 处理对象 | 整个 Prompt | 当前新 Token |
| 注意力形状 | 约 N × N | 约 1 × 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_seqs 和 max_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 高吞吐推理服务的核心。
