深入vLLM:高吞吐量LLM推理系统剖析(2025)
摘要
深入探讨vLLM用于高吞吐量LLM推理的架构与组件,涵盖调度、分页注意力、连续批处理、高级特性、扩展、服务及基准测试。
暂无内容
查看缓存全文
缓存时间: 2026/08/06 23:05
# vLLM 内部探秘:解剖一个高吞吐量 LLM 推理系统 - Aleksa Gordić
来源:https://www.aleksagordic.com/blog/vllm
在这篇文章中,我将逐步介绍构成现代高吞吐量 LLM 推理系统的所有核心系统组件和高级特性。具体来说,我会对 vLLM 的工作原理进行拆解[\[1\]](https://www.aleksagordic.com/blog/vllm#ref-1)。这篇文章是一个系列的第一篇。它从宏观入手,然后逐层加入细节(采用倒金字塔方式),让你在不被细枝末节淹没的情况下,形成一个准确的高层系统心智模型。后续文章将深入探讨各个子系统。
这篇文章分为五个部分:
1. [LLM 引擎与引擎核心](https://www.aleksagordic.com/blog/vllm#cpt1):vLLM 的基础(调度、分页注意力、连续批处理等)
2. [高级特性](https://www.aleksagordic.com/blog/vllm#cpt2):分块预填充、前缀缓存、引导式解码与推测解码、分离式 P/D
3. [横向扩展](https://www.aleksagordic.com/blog/vllm#cpt3):从单 GPU 到多 GPU 执行
4. [服务层](https://www.aleksagordic.com/blog/vllm#cpt4):分布式/并发 Web 脚手架
5. [基准测试与自动调优](https://www.aleksagordic.com/blog/vllm#cpt5):测量延迟与吞吐量
📝 说明
- 本文分析基于 [commit 42172ad](https://github.com/vllm-project/vllm/tree/42172ad)(2025 年 8 月 9 日)。
- 目标读者:任何对最先进 LLM 引擎如何工作感到好奇的人,以及有兴趣为 vLLM、SGLang 等项目做贡献的人。
- 我将重点介绍 [V1 引擎](https://docs.vllm.ai/en/latest/usage/v1_guide.html)。我也探究过 V0(现已[弃用](https://github.com/vllm-project/vllm/issues/18571)),它对于理解项目如何演进很有价值,而且许多概念仍然适用。
- 第一部分“LLM 引擎与引擎核心”可能略显冗长/枯燥——但博客其余部分有大量示例和可视化内容。:)
## LLM 引擎与引擎核心
LLM 引擎是 vLLM 的基本构建模块。仅凭它本身,就已经能够实现高吞吐量推理——但这仅限于离线场景。你还不能通过 Web 向客户提供服务。我们将使用下面的离线推理片段作为贯穿全文的示例(改编自 [basic.py](https://github.com/vllm-project/vllm/blob/main/examples/offline_inference/basic/basic.py))。
```
from vllm import LLM, SamplingParams
prompts = [
"Hello, my name is",
"The president of the United States is",
]
sampling_params = SamplingParams(temperature=0.8, top_p=0.95)
def main():
llm = LLM(model="TinyLlama/TinyLlama-1.1B-Chat-v1.0")
outputs = llm.generate(prompts, sampling_params)
if __name__ == "__main__":
main()
```
📝 环境变量:
- `VLLM_USE_V1="1"` # 我们使用 V1 引擎
- `VLLM_ENABLE_V1_MULTIPROCESSING="0"` # 我们在单个进程中运行
这个配置是:
- **离线**(没有 Web/分布式系统脚手架)
- **同步**(所有执行都发生在一个阻塞式进程中)
- **单 GPU**(没有数据/模型/流水线/专家并行;DP/TP/PP/EP = 1)
- 使用标准 Transformer[\[2\]](https://www.aleksagordic.com/blog/vllm#ref-2)(支持像 Jamba 这样的混合模型需要更复杂的混合 KV 缓存内存分配器)
从这里开始,我们将逐步构建到一个在线、异步、多 GPU、多节点的推理系统——但仍然服务于标准 Transformer。
在这个示例中我们做两件事:
1. 实例化一个引擎
2. 调用 `generate` 来根据给定提示进行采样
让我们从构造函数开始分析。
## LLM 引擎构造函数
引擎的主要组件包括:
- **vLLM 配置**(包含用于配置模型、缓存、并行度等的所有旋钮)
- **处理器**(通过验证、分词和处理,将原始输入转换为 `EngineCoreRequest`)
- **引擎核心客户端**(在我们的示例中,使用的是 `InprocClient`,它基本上就等于 `EngineCore`;我们将逐步构建到 `DPLBAsyncMPClient`,它支持大规模服务)
- **输出处理器**(将原始 `EngineCoreOutputs` 转换为用户看到的 `RequestOutput`)
📝 注意:随着 V0 引擎被弃用,类名和细节可能会发生变化。我会强调核心思想,而不是精确的函数签名。我会在某种程度上抽象掉这些细节,但不是全部。
引擎核心本身由几个子组件组成:
- **模型执行器**(驱动模型的前向传播。我们目前处理的是 `UniProcExecutor`,它在单个 GPU 上有一个 `Worker` 进程。我们将逐步构建到支持多个 GPU 的 `MultiProcExecutor`)
- **结构化输出管理器**(用于引导式解码——我们稍后会介绍)
- **调度器**(决定哪些请求进入下一个引擎步骤)——它还包含:
1. 策略设置——可以是 **FCFS**(先到先服务)或 **priority**(高优先级请求优先被服务)
2. `waiting` 和 `running` 队列
3. **KV 缓存管理器**——分页注意力[\[3\]](https://www.aleksagordic.com/blog/vllm#ref-3)的核心
KV 缓存管理器维护着一个 `free_block_queue`——一个可用 KV 缓存块的池子(通常有几十万个,具体取决于 VRAM 大小和块大小)。在分页注意力过程中,这些块承担着索引结构的角色,将 token 映射到它们已计算的 KV 缓存块。
LLM 引擎构造函数
本节描述的核心组件及其关系
标准 Transformer 层(非 MLA[\[4\]](https://www.aleksagordic.com/blog/vllm#ref-4))的块大小计算如下:
`2(键/值) * block_size(默认=16) * num_kv_heads * head_size * dtype_num_bytes(例如 bf16 为 2)`
在模型执行器构造期间,会创建一个 `Worker` 对象,并执行三个关键流程。(之后使用 `MultiProcExecutor` 时,这些相同流程会在不同 GPU 上的每个 worker 进程中独立运行。)
1. **初始化设备**:
- 为 worker 分配一个 CUDA 设备(例如 `"cuda:0"`),并检查模型 dtype 是否受支持(例如 bf16)
- 根据请求的 `gpu_memory_utilization`(例如 0.8 → 总 VRAM 的 80%)验证是否有足够的 VRAM
- 设置分布式配置(DP / TP / PP / EP 等)
- 实例化一个 `model_runner`(持有采样器、KV 缓存,以及诸如 `input_ids`、`positions` 等前向传播缓冲区)
- 实例化一个 `InputBatch` 对象(持有 CPU 侧的前向传播缓冲区、用于 KV 缓存索引的块表、采样元数据等)
2. **加载模型**:
- 实例化模型架构
- 加载模型权重
- 调用 `model.eval()`(PyTorch 的推理模式)
- 可选:对模型调用 `torch.compile()`
3. **初始化 KV 缓存**:
- 获取每层的 KV 缓存规格。历史上这总是 `FullAttentionSpec`(同构 Transformer),但随着混合模型(滑动窗口、类似 Jamba 的 Transformer/SSM)的出现,它变得更加复杂(参见 Jenga[\[5\]](https://www.aleksagordic.com/blog/vllm#ref-5))
- 执行一次虚拟/分析前向传播,并拍摄 GPU 内存快照,以计算可用 VRAM 中可以容纳多少个 KV 缓存块
- 分配、重塑并将 KV 缓存张量绑定到注意力层
- 准备注意力元数据(例如将后端设置为 FlashAttention),供前向传播期间的内核使用
- 除非提供了 `--enforce-eager`,否则对每个预热批次大小执行一次虚拟运行并捕获 CUDA 图。CUDA 图会将 GPU 工作的整个序列记录到 DAG 中。之后在前向传播中,我们启动/重放预先烘焙好的图,从而削减内核启动开销并改善延迟
我在这里抽象掉了许多底层细节——但这些是我现在要介绍的核心部分,因为后续章节中会反复引用它们。
现在我们已经初始化了引擎,接下来进入 `generate` 函数。
## generate 函数
第一步是验证请求并将它们送入引擎。对于每个提示,我们会:
1. 创建唯一的请求 ID,并记录其到达时间
2. 调用输入预处理器,对提示进行分词,并返回一个包含 `prompt`、`prompt_token_ids` 和 `type`(text、tokens、embeds 等)的字典
3. 将此信息打包为 `EngineCoreRequest`,并添加优先级、采样参数和其他元数据
4. 将请求传入引擎核心,引擎核心将其包装为一个 `Request` 对象,并将其状态设置为 `WAITING`。然后该请求被添加到调度器的 `waiting` 队列中(FCFS 则追加,priority 则堆插入)
至此,引擎已经收到输入,可以开始执行了。
在同步引擎示例中,这些初始提示是我们唯一要处理的请求——没有在运行中途注入新请求的机制。相比之下,异步引擎支持这一点(也就是**连续批处理**[\[6\]](https://www.aleksagordic.com/blog/vllm#ref-6)):每步之后,新请求和旧请求都会被考虑。由于前向传播会将批次展平为单个序列,并由自定义内核高效处理,因此即使是同步引擎,也从根本上支持连续批处理。
接下来,只要有请求需要处理,引擎就会反复调用它的 `step()` 函数。每一步都有三个阶段:
1. **调度**:选择本轮要运行的请求(解码,以及/或者(分块)预填充)
2. **前向传播**:运行模型并采样 token
3. **后处理**:将采样得到的 token ID 追加到每个 `Request`,进行去词元化,并检查停止条件。如果请求已完成,则进行清理(例如将其 KV 缓存块归还到 `free_block_queue`),并提前返回输出
📝 停止条件包括:
- 请求超出其长度限制(`max_model_length` 或其自身的 `max_tokens`)
- 采样到的 token 是 EOS ID(除非启用了 `ignore_eos` -> 在基准测试中,当我们希望强制生成特定数量的输出 token 时很有用)
- 采样到的 token 与采样参数中指定的任何 `stop_token_ids` 匹配
- 输出中出现停止字符串——我们会将输出截断到第一个停止字符串之前,并在引擎中中止该请求(请注意,`stop_token_ids` 会出现在输出中,但停止字符串不会)
引擎循环
引擎循环
在流式模式下,我们会随着 token 的生成而发送中间结果,但这里先忽略这一点。
接下来,我们将更详细地考察调度器。
## 调度器
推理引擎处理的工作负载主要有两种类型:
1. **预填充请求**——对所有提示 token 执行一次前向传播。这类请求通常是**计算受限**的(阈值取决于硬件和提示长度)。最后,我们从最后一个 token 位置的概率分布中采样一个 token。
2. **解码请求**——仅对最近一个 token 执行前向传播。所有更早的 KV 向量都已缓存。这类请求是**内存带宽受限**的,因为即使只计算一个 token,我们仍然需要加载所有 LLM 权重(以及 KV 缓存)。
在[基准测试部分](https://www.aleksagordic.com/blog/vllm#cpt5),我们将分析所谓的 GPU 性能 Roofline 模型,更详细地探讨预填充/解码性能特征背后的原因。
得益于更聪明的设计选择,V1 调度器可以在同一步骤中混合处理两种类型的请求。相比之下,V0 引擎一次只能处理预填充或解码中的一种。
调度器优先处理解码请求——也就是已经在 `running` 队列中的请求。对于每个这类请求,它会:
1. 计算要生成的新 token 数量(并不总是 1,因为推测解码和异步调度——稍后会详细讨论)
2. 调用 KV 缓存管理器的 `allocate_slots` 函数(详见下文)
3. 通过减去步骤 1 中的 token 数量来更新 token 预算
之后,它处理来自 `waiting` 队列的预填充请求:
1. 获取已计算块的数目(如果前缀缓存被禁用则返回 0——稍后会介绍)
2. 调用 KV 缓存管理器的 `allocate_slots` 函数
3. 将请求从 waiting 中弹出并移动到 running,将其状态设为 `RUNNING`
4. 更新 token 预算
现在我们来看 `allocate_slots` 做了什么:
1. **计算块数量**——确定需要分配多少个新的 KV 缓存块(`n`)。默认情况下每个块存储 16 个 token。例如,如果一个预填充请求有 17 个新 token,我们需要 `ceil(17/16) = 2` 个块。
2. **检查可用性**——如果管理器池中没有足够的块,则提前退出。根据是解码还是预填充请求,引擎可能会尝试通过驱逐低优先级请求(调用 `kv_cache_manager.free`,将 KV 块返还给块池)来进行重计算抢占(V0 支持交换抢占),或者可能跳过调度并继续执行。
3. **分配块**——通过 KV 缓存管理器的协调器,从块池(之前提到的 `free_block_queue`
相似文章
@amitiitbhu:新文章:vLLM 是如何工作的?请在此阅读:https://outcomeschool.com/blog/how-does-vllm-work…
一篇详细的博客文章,解释了 vLLM 的工作原理,包括 PagedAttention、KV 缓存管理和连续批处理,以实现高效的 LLM 服务。
@athleticKoder:一篇关于LLM推理原理的1600字笔记,涵盖:1. 注意力机制——token交互的唯一场所 2. KV缓存——为何...
一篇详细阐述LLM推理关键概念的推文:注意力机制、KV缓存、分块预填充以及批处理技术,包括vLLM和SGLang中使用的连续批处理。
vLLM v0.28.0
vLLM v0.28.0 是用于快速高效 LLM 推理和服务的开源库的更新版本,具有 PagedAttention 等增强功能和广泛的硬件支持。
@h100envy: 前vLLM核心贡献者用34分钟解释如何将LLM推理成本降低10倍——比$3000的推理优化训练营更有效
一位前vLLM核心贡献者解释了如何通过LMCache将KV缓存卸载到CPU/SSD/远程存储,从而使LLM推理成本降低10倍,这一技术已被彭博等生产环境采用。
从推理引擎到推理控制平面:连接vLLM、llm-d与高效分布式LLM服务的演进
本文综合了高效分布式LLM服务的研究,将vLLM和llm-d连接为互补层,并提出一个推理执行规划器以用于未来调度器的开发。