你如果做过网关测试、游戏服务器框架或高并发 RPC,就不要把 vLLM 当成“一个会调 PyTorch 的 HTTP 服务”。更准确的看法是:vLLM 是一个把不可预测的用户请求流,持续转换成 GPU 可吃满的固定节奏批处理循环的运行时。
这句话里有三个系统问题:
- 网关层怎样把 OpenAI 兼容请求变成可调度的内部请求,同时保持流式返回和取消语义?
- 调度器怎样在每个引擎 tick 里混合新请求、长提示词、解码请求、前缀缓存命中和抢占?
- GPU 执行层怎样把这些动态请求压成可复用缓冲区、注意力元数据和采样结果?
这篇文章面向的读者不是模型算法研究员,而是做过生产网关、压测、游戏服框架或状态机调度的人。你可以把 vLLM 想成一个特殊的游戏服务器:HTTP 层像网关,AsyncLLM 像会话管理器,EngineCore 像主循环,Scheduler 像帧调度器,KV 缓存块池像地图分块和对象池,GPU worker 像真正跑战斗结算的逻辑线程。
本文分析的源码固定在 vllm-project/vllm 的 v0.24.0 tag,提交 ee0da84ab9e04ac7610e28580af62c365e898389。这是 GitHub API 在 2026-07-06 查询到的最新正式 release,对应发布页为 v0.24.0。所有源码链接都指向该提交。
为避免正文里反复打断阅读,本文把常用术语统一收拢到下表;后文默认直接使用简称。
| 简称/术语 | 全称 | 中文说明 |
|---|---|---|
| vLLM | vLLM | 高吞吐大语言模型推理与服务框架 |
| LLM | Large Language Model | 大语言模型 |
| OpenAI API | OpenAI-compatible API | vLLM 暴露的 OpenAI 兼容 HTTP 协议 |
| SSE | Server-Sent Events | 服务端事件流,vLLM 用它流式返回 token |
| Prefill | Prefill | 预填充:处理提示词阶段,把输入 token 写入 KV 缓存 |
| Decode | Decode | 解码:每轮生成新 token 的阶段 |
| KV Cache | Key-Value Cache | 注意力层缓存的 key/value 张量,避免每轮重复计算历史 token |
| PagedAttention | PagedAttention | vLLM 论文提出的分页式注意力/KV 缓存管理思想 |
| APC | Automatic Prefix Caching | 自动前缀缓存,复用多个请求共享的提示词前缀 |
| Chunked Prefill | Chunked Prefill | 分块预填充,把长提示词拆到多个调度轮次处理 |
| Spec Decode | Speculative Decoding | 推测解码,用草稿 token 提前猜测并验证以提升吞吐 |
| EngineCore | EngineCore | vLLM V1 引擎核心循环,负责调度、执行和状态更新 |
| Scheduler | Scheduler | 每个引擎轮次选择哪些请求、多少 token、哪些 KV 块参与计算 |
| Token Budget | Token Budget | 每轮调度可处理的 token 上限 |
| Kernel | CUDA Kernel | 在 GPU 上并行执行的函数 |
| SM | Streaming Multiprocessor | NVIDIA GPU 上执行线程块的硬件单元 |
| Warp | Warp | NVIDIA GPU 中通常 32 个线程组成的调度粒度 |
| HBM | High Bandwidth Memory | GPU 显存,带宽高但容量有限 |
| Pinned Memory | Page-locked Host Memory | 页锁定主机内存,常用于更高效的异步 CPU/GPU 拷贝 |
| CUDA Stream | CUDA Stream | GPU 上按序提交的一条异步工作队列 |
| CUDA Graph | CUDA Graph | CUDA 图,把稳定的 GPU 执行路径捕获后复用,降低 launch 开销 |
| TTFT | Time To First Token | 首 token 延迟 |
| TPOT | Time Per Output Token | 每输出 token 耗时 |
| DP | Data Parallelism | 数据并行 |
| TP | Tensor Parallelism | 张量并行 |
| PP | Pipeline Parallelism | 流水线并行 |
0. 推理与系统的核心分水岭:理清基础概念
为了让我们在后续源码剖析中不迷失在 AsyncLLM、Scheduler 和 GPUModelRunner 的海量细节里,本节先通过核心概念问答,为你搭建起对推理引擎和 Transformer 运行过程的全局认知。
0.1 第一部分:推理引擎是什么?
LLM 和推理引擎有什么区别?
- LLM(大语言模型):本质上是一个预训练好的模型,它由数以亿计的参数(Weights,即模型权重)和 Transformer 的数学网络结构(如注意力机制、前向反馈网络等)组成。它是执行推理计算的物理规则与计算图的大脑。
- 推理引擎:负责高效、安全、高吞吐地在物理硬件(如 GPU)上运行该模型的系统。如果把 LLM 比作一个精密的“赛车发动机图纸”,推理引擎就是整套支持这台发动机在赛道上稳定高并发飞驰的“传动系统、燃油控制与调度中心”,负责网络 I/O、请求调度、显存分配以及算子合并。
Transformer 在推理引擎里是什么?
在推理引擎中,Transformer 本质上是真正执行 forward(前向计算)计算的计算图。它接收输入张量(Tensors),在各层神经网络和注意力机制中进行矩阵乘法等数学运算,最终输出下一个 Token 的预测概率。 推理引擎的外围模块(如队列调度器、KV 块管理等)都是为了以最高效的节奏把数据喂给这个 Transformer 计算图而设计的。
为什么叫 forward?
在深度学习中,计算过程分为两个方向:
- 前向计算(Forward):输入数据沿着网络层从前往后计算,最终得到输出结果(在推理中即预测的 Token 概率)。
- 反向传播(backward):在训练阶段,根据输出结果与真实标签的误差(Loss),从后往前计算每个参数的梯度。 推理过程只有前向计算(Forward),因为模型的参数在服务运行期间是完全冻结、只读的,不需要计算梯度,也不存在反向传播。
模型训练和推理引擎是一个东西吗?
不是同一个东西。两者的设计目标和系统工程重点完全不同:
- 训练引擎(如 Megatron-LM、DeepSpeed):核心目标是**“学习参数”**。它需要支持反向传播,保存大量的中间激活值(Activations)以计算梯度,工程难点是超大规模集群下多机多卡的参数同步与 3D 并行(数据/张量/流水线并行)。
- 推理引擎(如 vLLM、TensorRT-LLM):核心目标是**“使用参数”**。参数在推理时完全只读,不需要反向传播,也不需要保存梯度激活值。工程重点是处理不确定性的高并发用户请求,优化单卡/多卡吞吐,降低首 Token 延迟(TTFT)与逐 Token 生成延迟(TPOT),以及高效分配极其有限的显存(KV Cache)。
0.2 第二部分:Transformer 推理过程
KV Cache 是什么?
在 Transformer 逐字生成(自回归)的过程中,每生成一个新 Token,模型都需要和之前所有已生成的 Token 计算注意力(Attention)。由于前面已经生成的 Token 的 Key(K)和 Value(V)张量在后续的计算中是完全相同且不再变化的,为了避免在每一步生成时都重新计算历史 Token 的 K 和 V,推理引擎会把它们保存在显存中。这部分缓存就是 KV Cache。 有了 KV Cache,后续生成步骤只需要为最新生成的 1 个 Token 计算 Q, K, V,然后直接与缓存中的历史 K, V 进行注意力匹配即可,极大地节省了重复计算的开销。
K 和 V 分别是什么?
在 Self-Attention 机制中,输入向量与权重矩阵相乘,分别生成 Query(Q)、Key(K)和 Value(V)向量:
- K (Key,键):用于被匹配的“检索键”。代表了该 Token 在序列中的位置和属性信息。
- V (Value,值):真正携带语义信息的“内容向量”。
- Q (Query,查询):当前最新 Token 产生的“查询向量”,用于去匹配历史上下文。 计算过程:当前 Token 的 Query(Q)会去和历史所有 Token 的 Key(K)做点积匹配,算出注意力权重分布(Attention Weights);然后,用这些权重对所有 Token 的 Value(V)进行加权求和,得到当前 Token 结合了上下文语义的最终表征。
Tokenizer 很关键吗?是不是它决定了 K?
- Tokenizer 的作用:它是文本进入模型之前的“切片机”,非常关键。它决定了原始文本如何被切分成 Token(如子词、字符),并将其映射为一串整数 ID 序列。
- K/V 的决定者:Tokenizer 不直接决定 K/V。它只输出 Token ID。这些 ID 传入 Transformer 后,首先通过 Embedding 层映射为连续向量,再通过多层 Transformer 内部的线性投射层(Projection Layers)参数进行复杂的矩阵运算,最终才产出 Key 和 Value 张量。K/V 的数值是由 Transformer 模型的参数和前向计算图决定的。
0.3 第三部分:推理引擎的工程优化
nano-vLLM 是干什么的?
nano-vLLM(或极其简化版的推理引擎原型)是一个用极少代码(通常数百行 PyTorch)实现的核心推理器原型。
它的存在不是为了在生产中追求极致性能,而是剥离了 C++、CUDA kernel 优化、多卡通信等工程细节,只保留请求队列、连续批处理(Continuous Batching)、自回归循环和基础 KV Cache 管理的核心逻辑。通过阅读此类项目,开发者可以非常直观地理解一个推理引擎从接收 Prompt 到持续生成 Token 的整体运转流程。
PagedAttention 是什么?
- 为什么普通 KV Cache 会导致显存碎片?
在传统的推理引擎中,为每个请求分配的 KV Cache 必须是在显存中物理连续的一大块空间。因为无法预知请求最终会生成多少 Token,系统通常只能按照该请求可能达到的最大长度(如 4K 甚至 8K 长度)来预先申请连续显存。这带来了三类严重的显存浪费:
- 内部碎片(Internal Fragmentation):请求实际生成的 Token 数远小于预分配长度,未用到的空间被白白浪费。
- 保留碎片(Reservation Fragmentation):系统为后续步骤提前保留了显存,这部分显存当前虽为空闲,但由于被绑定给特定请求,其他并发请求无法使用。
- 外部碎片(External Fragmentation):随着不同请求交替启动和释放,显存空间变得支离破碎,即使总空闲显存很大,也无法为新请求分配一块足够大的连续显存。
- 为什么要分页管理 KV Cache? 为了彻底解决上述碎片问题,vLLM 借鉴了操作系统虚拟内存(Virtual Memory)的分页设计,提出了 PagedAttention。 它将每个序列的 KV Cache 拆分成固定大小的“物理块”(Physical Blocks,例如每个块只存 16 个 Token 的 K/V 数据),这些块在显存中不需要物理连续。 推理引擎通过维护一个 Block Table(块映射表),将逻辑上连续的 Token 映射到这些散落在显存各处的物理块上。 在进行注意力计算时,PagedAttention Kernel 能够高效地从非连续的显存地址中抓取 Key 和 Value,从而将显存碎片率降到接近 0%,显存利用率提升到 96% 以上。这使得 GPU 能够并行承载数倍于以往的 Batch Size,极大地提升了系统的吞吐量。
1. 从一个最小请求开始
先看 vLLM 对外暴露的样子。启动一个 OpenAI 兼容服务通常是:
1 | vllm serve Qwen/Qwen2.5-0.5B-Instruct --host 0.0.0.0 --port 8000 |
然后用普通网关测试脚本请求:
1 | curl http://127.0.0.1:8000/v1/chat/completions \ |
流式响应不是一次性 JSON,而是一串 SSE 片段,形态类似:
1 | data: {"id":"chatcmpl-...","choices":[{"delta":{"role":"assistant"}}]} |
这对网关开发很熟悉:连接被持有,后端每产出一段就推给客户端;客户端断开时,后端必须尽快取消内部任务,释放队列和 GPU 资源。
这里的 token 可以粗略理解成模型处理的最小文本片段:prompt tokens 是输入成本,output tokens 是生成成本;max_tokens 限制的是最多生成多少 output token。
从源码看,这个请求先进入 FastAPI 路由 vllm/entrypoints/openai/chat_completion/api_router.py:40-74。路由层做三件事:
1 | # vllm/entrypoints/openai/chat_completion/api_router.py:40-74 (v0.24.0) |
注意两个装饰器:with_cancellation 对应连接取消,load_aware_call 对应负载感知。这不是算法细节,而是服务端工程的第一道边界:坏 JSON、错误模型、取消连接、过载指标都必须在进入 GPU 主循环前处理。
2. 产物分析:vLLM 不是只产出文本,而是产出一个持续调度流
从外部看,产物是文本 token;从内部看,vLLM 每轮产出的是 SchedulerOutput、ModelRunnerOutput、EngineCoreOutputs 这类状态差量。它们共同描述:
| 产物 | 由谁产生 | 解决的问题 | 关键源码 |
|---|---|---|---|
ChatCompletionResponse 或 SSE generator | OpenAI serving 层 | 协议兼容、流式输出、错误包装 | serving.py:235-380 |
RequestOutputCollector | AsyncLLM.add_request | 每个请求独立收集输出,避免 HTTP 层阻塞 GPU | async_llm.py:375-380 |
SchedulerOutput | Scheduler.schedule | 把本轮要执行的请求、token 数、KV 块和推测 token 打包 | scheduler.py:1059-1076 |
ModelRunnerOutput | GPUModelRunner 的执行/采样路径 | 采样 token、logprobs、结构化输出结果 | engine/core.py:479-508, gpu_model_runner.py:4435-4475 |
EngineCoreOutputs | Scheduler.update_from_output | 更新请求状态、释放资源、交给输出处理器 | scheduler.py:1464-1803, async_llm.py:659-679 |
这就是读 vLLM 的第一个关键:不要只追“哪里调用了模型”。真正的主线是状态流:
1 | HTTP Request |
3. 架构与数据流
vLLM V1 的核心架构可以拆成四层:
| 层 | 职责 | 对网关/游戏服读者的类比 | 关键源码 |
|---|---|---|---|
| OpenAI 入口层 | HTTP 协议、请求校验、认证、流式响应 | 网关接入层 | api_router.py:40-74, api_server.py:257-262 |
AsyncLLM | 输入转换、每请求队列、后台输出处理、取消传播 | session manager / actor mailbox | async_llm.py:70-153 |
EngineCore | 主循环:调度、提交 GPU、采样、更新状态 | 游戏服 tick loop | engine/core.py:479-508 |
Scheduler + KV manager | 决定本轮谁上车、分配 KV 块、抢占、前缀缓存 | 帧预算调度 + 对象池 | scheduler.py:388-1100 |
GPUModelRunner | 构造 batch、注意力元数据、CUDA Graph、采样 | 真正的逻辑执行器 | gpu_model_runner.py:4056-4315 |
入口层创建引擎客户端在 vllm/entrypoints/openai/api_server.py:77-155。这里有一个版本事实:build_async_engine_client_from_engine_args 直接导入 vllm.v1.engine.async_llm.AsyncLLM,并通过 AsyncLLM.from_vllm_config 创建 V1 引擎。
1 | # vllm/entrypoints/openai/api_server.py:122-150 (v0.24.0) |
在高并发服务里,这个边界很重要:HTTP 进程不是直接持有一堆 GPU 状态,而是通过 EngineCoreClient 和后台核心通信。EngineCoreClient 的注释明确列出三种客户端:进程内、同步多进程、异步多进程,其中 AsyncMPClient 是 AsyncLLM 使用的形态 vllm/v1/engine/core_client.py:71-80。
3.1 GPU 推理背景:为什么 vLLM 要把动态请求压成稳定形状
如果你主要做网关或游戏服,最容易误解的一点是:CPU 服务端的优化目标通常是“把每个请求尽快推进到下一行代码”,而 GPU 推理的优化目标则是“让一大批数据在同一时刻做同样的矩阵运算”。这就是 vLLM 里调度器、KV 块池、persistent batch 和 CUDA Graph 都围绕“稳定形状”设计的原因。
3.1.1 没有 GPU 背景?用大白话理清核心概念
如果你从没接触过 GPU 编程,不要被复杂的 AI 术语吓退。你只需要理解以下这几个通俗类比:
- CPU 与 GPU 的工作模式差异:
- CPU(逻辑指挥官):核心少但极其强悍,擅长处理复杂的
if/else分支逻辑、网络 I/O、队列管理和快速任务取消。 - GPU(并行工厂):拥有数以千计的微小计算核心(ALU),它并不聪明,无法高效跑复杂的条件分支。它最擅长的就是“同时间对大量数据执行同一种计算”(这种模式在 NVIDIA CUDA 中被称为 SIMT,单指令多线程[\9])。
- CPU(逻辑指挥官):核心少但极其强悍,擅长处理复杂的
- 算子(Operator)与 Kernel(内核):
- 算子(Operator,简称 Op):指的是大模型中的基础数学运算单元。比如:矩阵相乘(MatMul)、加法(Add)、激活函数(ReLU/GELU)、归一化(LayerNorm)、Softmax 等等。这就像写代码时封装的一个个底层的数学工具函数(如
math.sin()或自定义的vector_add())。 - Kernel(计算任务 / 核心代码):是在 GPU 上跑的、用 CUDA/C++ 编写的具体并行计算函数。你可以把它类比为 CPU 提交给 GPU 执行的一批并行执行的二进制作业。
- 算子(Operator,简称 Op):指的是大模型中的基础数学运算单元。比如:矩阵相乘(MatMul)、加法(Add)、激活函数(ReLU/GELU)、归一化(LayerNorm)、Softmax 等等。这就像写代码时封装的一个个底层的数学工具函数(如
- 算子融合(Operator Fusion):
- 在深度学习优化中,这是一个核心概念。如果模型每跑一个算子(比如先做加法,再做激活函数,最后乘一个系数),都需要把中间结果写回显存(HBM),再从显存读出来传给下一个算子,显存带宽就会被严重榨干。
- 这就像下面两段测试开发工程师非常熟悉的 CPU 代码对比:
- 未融合写法(低效):
跑了 3 次循环,申请释放了多次临时数组,非常慢。
1
2
3
4temp1 = [x + 1 for x in data] # 循环第 1 次,申请内存 temp1
temp2 = [x * 2 for x in temp1] # 循环第 2 次,从内存读写 temp2
output = [clamp(x, 0, 10) for x in temp2] # 循环第 3 次,从内存读写 output - 算子融合写法(高效):
1
2output = [clamp((x + 1) * 2, 0, 10) for x in data] # 只跑 1 次循环,0 临时内存
- GPU 算子融合会把多个数学运算合并进一个 Kernel,尽量让中间结果停留在寄存器或共享内存中,减少中间张量写回 HBM 和重复 launch。FlashAttention 更准确地说是一种 IO-aware 的注意力算法:通过分块计算与重计算减少 HBM 访问,而不只是把几个普通算子机械拼接起来。
- 未融合写法(低效):
- HBM(GPU 显存):显卡上的超快内存。大模型参数和运行时的临时数据(KV Cache)都存放在这。CPU 内存和 GPU 显存之间拷贝数据(H2D / D2H)是有较大传输成本的。
算子对 GPU 的硬件物理意义:为什么需要算子融合?
为了让非 GPU 背景的工程师彻底看懂,我们必须扒开软件外壳,看看 GPU 硬件在物理上到底在发生什么。
对于 GPU 硬件(显卡芯片和显存芯片)来说,它根本不知道什么是“算子”,它只知道两件事:“搬运数据(I/O)” 和 “执行计算(ALU)”。
我们可以通过对比 “算子没有融合(普通模式)” 与 “算子融合” 时,GPU 硬件的物理动作来理解:
场景:我们要做三个连续运算:
这里包含三个“算子”:矩阵相乘(MatMul)、加法(Add)、激活函数(ReLU)。
- 普通模式(算子不融合,GPU 跑 3 次):
- 步骤一:GPU 核心从显存(HBM)里把大矩阵 和 读出来,在 ALU 里做乘法,把算出来的中间结果 写回到显存。
- 步骤二:GPU 核心又去显存里把中间结果 和 重新读出来,在 ALU 里做加法,再把算出来的中间结果 写回到显存。
- 步骤三:GPU 核心再次去显存里把中间结果 读出来,在 ALU 里过滤负数,把最终结果 写回到显存。
- 物理问题:相较于片上寄存器和共享内存,访问显存(HBM)的延迟更高、带宽也更稀缺。这里多个 Kernel 需要反复读写中间结果,容易让执行受显存带宽限制。
- 算子融合模式(打包合并,GPU 只跑 1次):
- 编译器把“乘法 + 加法 + 激活”打包成一个合并好的 “融合 Kernel” 发送给 GPU。
- 步骤:GPU 核心从显存中读入数据 、 和 ,尽可能让中间结果停留在片上的**寄存器 / 共享内存(SRAM)**中。
- 在理想的融合实现中,乘法之后可直接完成 Bias 和 ReLU,让主要中间结果停留在寄存器或共享内存中;具体是否发生 spill 或额外访存取决于 Kernel 实现和寄存器压力。
- 最后写回最终结果 ,从而减少而不是绝对消除 HBM 往返。
- 物理好处:减少中间张量的显存往返和 Kernel launch 开销。实际收益取决于算子、张量形状、寄存器压力和具体硬件,不能用一个固定倍数概括。
这就是为什么像 FlashAttention 这样的大模型推理优化,本质上都是在重写和合并底层 GPU 运行代码(Kernel),想方设法减少数据对显存的存取往返。
[!TIP] 💡 初中生都能懂的数学草稿本类比:
老师给你出了一道题:输入一个数字 ,先把它乘以 3,接着加上 5,最后如果结果是负数,就变成 0(这三步就对应大模型里的三个“算子”)。
- 普通笨办法(算子不融合):
- 你先在草稿本 A 上算出: 的结果,写下来(写显存)。
- 你重新拿张草稿本 B,看着草稿本 A 上的数字,挨个加上 5,把结果写下来(又读又写显存)。
- 你最后拿来作业本,看着草稿本 B 上的数字,把负数擦掉改成 0,写下最终答案(再次读写显存)。
- 结果:你浪费了大量草稿纸,并且反复抬头、低头看纸写字,手都写酸了(GPU 被显存带宽卡死)。
- 聪明好办法(算子融合):
- 你把公式合并为:。
- 当你看到 时,你**在脑子里(GPU 寄存器)**算:,,负数变成 0。
- 你直接把“0”写到作业本(最终显存)上,中间的过程数字根本不需要写在任何草稿纸上。
- 结果:省下了所有的草稿纸,省去了所有抬头低头的抄写时间,速度极快!
3.1.2 什么是“稳定形状”?用“游戏渲染优化”做个通俗类比
在开发传统 CPU Web 服务时,用户请求的 Prompt 长度和输出 Token 长度是随机的(比如请求 A 只有 3 个词,请求 B 有 80 个词)。如果直接把这些参差不齐的请求发给 GPU,每次输入矩阵的尺寸(形状)都在变。
对于 GPU 来说,CUDA Graph 只能重放与捕获时兼容的执行路径和张量形状;不兼容的动态形状需要选择另一张已捕获的图,或者退回 eager 执行。这会增加调度、Kernel launch 和输入准备开销。
因此,vLLM 的核心工程目标之一,是把动态请求归并到有限数量、可复用的执行形状。
如果你是游戏测试或开发工程师,你可以用**“游戏引擎的 Draw Call 渲染优化”**来完美理解这个概念:
- 游戏开发中的痛点:假设你要在场景中渲染小兵。如果每一帧画面里小兵的数量都在变(第一帧 3 个,第二帧 10 个,第三帧 50 个),游戏引擎如果每一帧都动态去重新申请显存、重新下发不同大小的渲染指令(Draw Call),显卡很容易卡顿。
- 游戏引擎的优化方案(Instance Draw Call / 预分配 Buffer):游戏引擎通常会预先在显卡里申请一个固定大小的静态 Buffer(比如最多容纳 256 个小兵)。
- 如果当前画面里实际只有 5 个小兵,引擎并不会把 Buffer 动态缩小到 5,而是依然发送 256 个实体的 Buffer 给显卡!
- 剩下的 251 个空槽位,引擎会填充**“隐藏的/处于屏幕外的虚拟占位小兵”(Dummy/Invisible Entities)**。显卡照常跑完全相同的 256 渲染流程(这就是稳定形状),但玩家在屏幕上只看到 5 个兵。
- vLLM 也是一模一样的思路:
- 大模型(LLM)的每一步推理计算,就像游戏引擎渲染一帧画面。
- vLLM 会预先捕获一组不同 token 数量的 CUDA Graph,例如较小尺寸按更细粒度分桶。
- 运行时会选择能容纳当前 batch 的最近捕获尺寸,并对不足部分做 padding;如果没有合适的捕获图,则退回 eager 路径。
- 因而实际执行形状会在有限的捕获尺寸之间变化,而不是每轮都填满到同一个最大 batch。
这样,vLLM 用“捕获尺寸分桶 + padding + eager fallback”在动态图流量和 CUDA Graph 复用之间折中:同一张图的输入缓冲区和形状稳定,但不同调度轮次可以选择不同尺寸的图。
1. 压平一维张量(1D Packed Tensors)—— 消除内部的冗余 Padding
如果使用原生的二维矩阵 [Batch Size, Max Sequence Length],我们需要把短请求用零(Padding 0)填充到与最长请求相齐。这相当于在小兵渲染中,强行把短序列的空格子用无意义的占位符填满,白白浪费 GPU 算力。
vLLM 通过 packed/ragged batching,将 Batch 中所有请求的有效 Token 顺次“压平”并拼接成一个一维张量 [total_num_tokens],避免把每条序列填充到同一长度。PagedAttention 解决的是另一层问题:让注意力 Kernel 通过 block table 访问离散的 KV Cache 块。
对 vLLM 来说,这意味着 HTTP 请求不能直接“一请求一 kernel”。它必须先把许多请求在 EngineCore.step 里合并成一轮 SchedulerOutput,再由 GPUModelRunner 把它们整理成张量、位置、序列长度、KV slot 等 GPU 可执行输入。这个转换成本不低,但它让 GPU 能复用预分配缓冲区,并尽可能命中已捕获的执行形状。
3.1.3 LLM 推理为什么卡在 KV Cache 和显存带宽
LLM 生成文本是自回归的:每生成一个新 token,attention 都要重新读取历史所有已生成 token 对应的 KV 向量(KV Cache)。随着对话历史变长、并发增高,GPU 显存中存储的 KV Cache 极其庞大。 这意味着 decode 阶段每次只生成少量新 token,却要读取历史上下文对应的 K/V 张量。读取量取决于模型层数、KV head 数、上下文长度、KV dtype 和 batch;上下文较长或并发较高时,decode 往往更容易受显存带宽限制。Prefill 一次处理较多 token,通常具有不同的计算/带宽特征,不能一概而论。
这就是 PagedAttention 和 KV 块池的意义:不要把每个请求的 KV Cache 当作一整段连续内存,而是切成可引用、可淘汰的块。从游戏服视角看,它很像对象池和地图分块:启用前缀缓存且块已形成可匹配 hash 时,请求结束后可把这些块作为可淘汰缓存保留;正在被引用的块通过引用计数保护,其余空闲块放回队列。
3.1.4 CUDA Graph 预录脚本:消灭 CPU 下发任务的延迟
前面提到,GPU 运行计算时,CPU 需要通过 CUDA API 发射(Launch)一系列 Kernel。对于小模型、小 batch 或大量短 Kernel 的 decode,CPU launch 开销可能成为明显瓶颈;较大模型和 batch 则仍可能主要受 GPU 计算或显存带宽限制。
CUDA Graph(CUDA 执行图) 机制应运而生:
- 普通模式 (Eager Mode):CPU 像个啰嗦的监工,走一步下一道命令,每次都要跨越 Host/Device 边界,效率极低。
- CUDA 图模式 (CUDA Graph):CUDA runtime 捕获并实例化一组 GPU 操作及依赖关系。后续 CPU 可以用一次 graph launch 重放这组操作,减少逐个 Kernel 提交的开销。
同一张已捕获 CUDA Graph 的重放需要兼容的执行拓扑、缓冲区地址和运行形状。vLLM 因此在 GPUModelRunner 中预分配 persistent buffers,并结合多种 capture size、padding 和 eager fallback,让动态 batch 尽可能复用已实例化的图。相关缓冲区包括 input_ids、positions、query_start_loc、seq_lens、num_computed_tokens、num_scheduled_tokens 等 gpu_model_runner.py:720-768;执行时由 CudagraphDispatcher 选择 graph replay 或 eager 路径 gpu_model_runner.py:2825-2847。
这也是为什么有些功能会禁用 CUDA Graph。比如 calculate_kv_scales 会让 vLLM 走 eager forward,因为动态 KV scale 计算不兼容图捕获 gpu_model_runner.py:4294-4300。所以测试不同量化、LoRA、并行、长上下文配置时,不能假设 GPU 执行路径相同。
3.1.5 一次 decode step 在 GPU 上大概发生什么
把前面的服务端概念压缩成一帧来看,一次 decode step 不是“调用模型函数”这么简单,而是下面这条流水:
Prefill 像加载玩家进场所需的初始状态,decode 像之后每帧推进一步输出;生产环境里这两类工作会混在同一个 tick 里。可以先把模型 forward 当成“根据当前上下文算出下一个 token 的候选分数”:logits 是候选分数,sampling 是按策略从分数里选一个 token。
GPUModelRunner.execute_model 先更新请求状态、准备输入 token 和位置,再构建注意力元数据、选择执行路径并跑模型 forward gpu_model_runner.py:4056-4315。forward 之后,它从 hidden states 中选出需要计算 logits 的位置,调用 compute_logits,并把中间状态存在 ExecuteModelState 里,留给 sample_tokens 完成采样 gpu_model_runner.py:4310-4417。
为了让 CPU/GPU 之间少互相等待,vLLM 还准备了异步输出拷贝相关的 CUDA stream 和 event gpu_model_runner.py:692-700,并使用 pinned CPU buffer 存放采样 token gpu_model_runner.py:856-892。这对应到网关测试里的现象是:即使模型 forward 很快,Host 侧 batch 准备、采样、D2H 拷贝和流式 chunk 发送也可能影响 TPOT。
4. 核心机制
4.1 机制一:HTTP 层和 GPU 主循环解耦——网关线程不跑战斗逻辑
4.1.1 解决什么问题
OpenAI 兼容请求有两种天然不稳定性:请求长短不同、客户端可能随时断开。GPU 批处理则需要稳定节奏。如果 HTTP handler 直接等待模型执行,慢请求、断连、采样异常会污染核心循环。
4.1.2 如何实现
OpenAIServingChat._create_chat_completion 先渲染聊天模板、提取提示词 token、构造采样参数,再调用 self.engine_client.generate(...) vllm/entrypoints/openai/chat_completion/serving.py:251-372。
1 | # vllm/entrypoints/openai/chat_completion/serving.py:288-372 (v0.24.0) |
AsyncLLM.generate 并不直接计算 token。它先 add_request,拿到每请求队列,然后不断从队列里取 RequestOutput 并 yield 给 HTTP 层 vllm/v1/engine/async_llm.py:524-586。
1 | # vllm/v1/engine/async_llm.py:557-586 (v0.24.0) |
断连也在这里处理:CancelledError 或 GeneratorExit 会触发 abort vllm/v1/engine/async_llm.py:588-596。
4.1.3 为什么这样设计
这和游戏服网关类似:网关连接生命周期和逻辑 tick 生命周期不能绑死。HTTP 层只负责把输入变成消息、订阅输出、处理断连;核心循环只处理可调度请求,不关心某个 socket 怎么写回。
4.1.4 替代方案
最简单的替代方案是每个请求单独 await model.generate()。它实现容易,但 GPU batch 很难持续填满,断连释放也会散落在各处。vLLM 选择了多一层队列,把复杂度集中在 AsyncLLM 和 OutputProcessor。
4.1.5 Trade-off(取舍)
代价是调试链路更长:一个 HTTP 请求会跨越 FastAPI handler、AsyncLLM、后台 EngineCore、输出处理器和 SSE generator。压测时如果只看 HTTP span,会看不到真正排队点;必须同时看调度队列、KV Cache usage、TTFT 和 TPOT。
4.2 机制二:EngineCore 主循环——每个 tick 做调度、执行、更新
4.2.1 解决什么问题
推理服务的吞吐来自“每轮尽量把 GPU 喂满”,但每个请求的生命周期不同:有的还在预填充,有的只需解码一个 token,有的等待远程 KV,有的已经完成。需要一个统一主循环在每轮做最少的决策。
4.2.2 如何实现
EngineCore.__init__ 先创建模型执行器、初始化 KV Cache、创建调度器 vllm/v1/engine/core.py:122-158。
1 | # vllm/v1/engine/core.py:122-158 (v0.24.0) |
主循环单步在 step:无请求则返回;否则调度、提交模型、取 grammar bitmask、等待模型输出或采样、处理 abort、让 scheduler 根据输出更新状态 vllm/v1/engine/core.py:479-508。这里的 grammar bitmask 只服务结构化输出约束,不影响主循环理解。
1 | # vllm/v1/engine/core.py:486-508 (v0.24.0) |
4.2.3 为什么这样设计
这段代码非常像游戏服 tick:本帧收集可执行实体、执行逻辑、写回状态、处理被取消实体。它的好处是所有状态变化都通过同一个时钟推进,调度器能看到全局队列和 KV 块池,而不是让请求自己抢 GPU。
4.2.4 替代方案
可以把每个请求建成独立任务,由 GPU executor 自己合并。但那会把调度策略散落到 executor、请求对象和缓存管理器里。vLLM 把“谁上 GPU”留在 scheduler,这让 max_num_batched_tokens、max_num_seqs、抢占和前缀缓存能在同一个地方决策。
4.2.5 Trade-off(取舍)
主循环集中意味着单点复杂:SchedulerOutput 一旦设计不够完整,下游 GPU runner 就要回头查请求状态。vLLM 的做法是让 SchedulerOutput 带上新请求、已缓存请求、token 数、公共前缀块、抢占请求、KV connector 元数据等字段 vllm/v1/core/sched/scheduler.py:1059-1076,牺牲对象简单性,换取执行层少做全局查找。
4.3 机制三:统一 token budget 调度——不再区分两个世界
4.3.1 解决什么问题
传统理解里,推理有两个阶段:预填充和解码。问题是生产流量里这两个阶段会同时存在:新请求不断进来,老请求每轮生成 token,长提示词不能一次吃完整个预算,推测解码还会带草稿 token。硬拆两个队列会产生大量边界条件。
4.3.2 如何实现
vLLM 的 scheduler 注释直接说明:它没有独立的“解码阶段”和“预填充阶段”,每个请求只维护 num_computed_tokens 和 num_tokens_with_spec,每轮让前者追上后者 vllm/v1/core/sched/scheduler.py:388-399。
1 | # vllm/v1/core/sched/scheduler.py:390-399 (v0.24.0) |
调度先处理 running 请求,再接纳 waiting 请求。全局预算来自 max_num_scheduled_tokens,默认等于 max_num_batched_tokens vllm/v1/core/sched/scheduler.py:107-113。如果运行中请求需要新 KV 块但不足,优先级策略下会抢占低优先级请求;普通策略下会从尾部抢占 vllm/v1/core/sched/scheduler.py:522-565。
一个简化例子:
| 请求 | 当前状态 | 本轮调度含义 |
|---|---|---|
| A | prompt=100,已算 40,本轮预算给 16 | 只把 prefill 推到 56,剩下后续轮次继续 |
| B | 已进入 decode | num_tokens_with_spec 比已算 token 多 1,本轮只补 1 个新 token |
| C | 前缀缓存命中,仍差 20 | 只为未命中的尾部申请预算和 KV 块 |
等待队列逻辑会先看 max_num_running_reqs,再处理 blocked 状态、LoRA 限制、前缀缓存、本地/远程 KV、分块预填充和 KV 分配 vllm/v1/core/sched/scheduler.py:625-982。
4.3.3 为什么这样设计
这个统一模型的好处是扩展性。分块预填充只是“本轮只能补一部分 token”;前缀缓存只是“已有一段 token 已计算”;推测解码只是“目标 token 数包含草稿 token”。它们都可以落到同一个差值模型上。
对于游戏服读者,可以把它类比成同一帧预算内同时处理移动、AI、战斗和加载,但每个实体只暴露“还差多少工作量”。调度器不需要为每类实体写一套主循环。
4.3.4 替代方案
替代方案是显式两阶段调度:先批量预填充,再批量解码。它容易理解,但长提示词会阻塞短请求首 token,解码阶段也难以无缝插入新请求。Hugging Face 的连续批处理文档也强调每个生成步动态重排 batch,让完成的请求离开、新请求加入,以保持 GPU 利用率[2]。
4.3.5 Trade-off(取舍)
统一模型牺牲了直觉可读性。num_computed_tokens、num_tokens_with_spec、num_output_placeholders、num_lookahead_tokens 这些字段的组合很难第一眼看懂。测试时不能只构造“短 prompt + max_tokens=1”的 happy path;需要覆盖长提示词、分块预填充、前缀缓存命中、抢占后恢复、远程 KV 等状态组合。
4.4 机制四:KV 缓存块池——把显存当分页内存管理
4.4.1 解决什么问题
KV 缓存是推理服务的核心瓶颈。每个请求的历史 token 都要占用 key/value 张量,且长度动态增长。如果按请求连续分配,长短请求混合时会有碎片;如果不能共享前缀,系统 prompt、few-shot 模板、多轮对话的重复前缀会被反复计算。
PagedAttention 论文把这个问题类比为操作系统虚拟内存分页,并报告 vLLM 在相同延迟水平下相对 FasterTransformer 和 Orca 有 2-4 倍吞吐提升[1]。vLLM 早期博客还给出过相对 Hugging Face Transformers 最高 24 倍吞吐的场景结果[3]。这些是历史论文/博客数字,不等价于本文分析的 v0.24.0 在任意模型上的表现。
前缀缓存缓存的是历史上下文,不是最终的下一词概率;即使命中前缀,最后一个位置仍要跑一次 forward 才能得到下一 token 的 logits。
4.4.2 如何实现
KVCacheManager 创建 coordinator 和 block pool,并维护 watermark vllm/v1/core/kv_cache_manager.py:110-180。前缀缓存命中通过 get_computed_blocks 找最长缓存命中,但会保留最后一个 token 重新计算 logits vllm/v1/core/kv_cache_manager.py:202-242。
1 | # vllm/v1/core/kv_cache_manager.py:221-230 (v0.24.0) |
真正分配发生在 allocate_slots。源码注释把 token 区间拆成已计算、前缀命中、外部命中、新 token、lookahead token 五段 vllm/v1/core/kv_cache_manager.py:244-339。
BlockPool 管所有 KVCacheBlock,用 free_block_queue 存空闲块和可淘汰缓存块,用 cached_block_hash_to_block 支持前缀查找 vllm/v1/core/block_pool.py:144-186。每个块有 block_id、ref_cnt 和 block_hash vllm/v1/core/kv_cache_utils.py:118-162。
1 | # vllm/v1/core/block_pool.py:597-635 (v0.24.0) |
4.4.3 为什么这样设计
KV Cache 的生命周期比请求更复杂:启用前缀缓存并生成 block hash 后,一个请求结束时,其前缀块可能仍作为可淘汰缓存留给后续请求;另一个请求命中时会增加引用,防止对应块被淘汰。引用计数和 free queue 的组合让“请求持有”和“缓存可复用”两种语义共存。
这很像游戏服里的对象池和地图 chunk:对象不被任何玩家引用时可以回池,但如果它携带可复用热数据,回池顺序会影响下一次命中率。
4.4.4 替代方案
最简单的方案是按请求连续申请 KV 张量,请求结束即释放。它调试简单,但无法高效共享前缀,也容易浪费显存。SGLang 的 RadixAttention 选择用基数树管理共享前缀,LMSYS/SGLang 博文强调它用于自动、细粒度地复用 KV Cache[4]。vLLM 的路径更接近分页内存和块哈希。
4.4.5 Trade-off(取舍)
块池让吞吐更好,但也让测试更复杂。缓存命中与否不只取决于输入相同,还取决于 block size、hash、滑动窗口、请求是否被抢占、是否有外部 KV connector。压测时必须记录 prefix cache hit、KV Cache usage、preemption 次数;否则你看到的 TPOT 抖动可能只是缓存块被淘汰。
4.5 机制五:GPUModelRunner——把动态请求压进可复用执行形状
4.5.1 解决什么问题
调度器输出是动态的:每轮请求数不同、token 数不同、是否有多模态输入不同、是否走推测解码不同。GPU 喜欢稳定形状和少量 kernel launch。GPUModelRunner 的任务就是在这两者之间做转换。
4.5.2 如何实现
GPUModelRunner.__init__ 保存模型、缓存、并行、调度、推测解码等配置,预置 sampler、KV Cache、输入 batch、CUDA Graph 相关状态 vllm/v1/worker/gpu_model_runner.py:421-535。
执行时,execute_model 先更新 persistent batch,再准备输入、计算 cascade attention 前缀、选择 CUDA Graph/ubatch/eager 执行形态、构建 attention metadata,最后才进入模型 forward vllm/v1/worker/gpu_model_runner.py:4056-4315。
初读不用记住每个 tensor 名,只要抓住这几个词:
| 术语 | 先按这个理解 |
|---|---|
| persistent batch | 复用的执行槽位,避免每轮重新搭一套输入结构 |
| attention metadata | 告诉 attention kernel 去哪些 KV 块读历史上下文的索引表 |
| eager | 每轮动态现跑,不复用捕获好的执行图 |
| CUDA Graph replay | 复用已捕获的稳定执行路径 |
cascade attention、ubatch 属于更细的优化路径,第一遍可以跳过。
其中注意力元数据构建会缓存同类 KV Cache group 的 metadata,只更新 block table 和 slot mapping,减少重复构造成本 vllm/v1/worker/gpu_model_runner.py:2378-2445。
1 | # vllm/v1/worker/gpu_model_runner.py:2429-2445 (v0.24.0) |
采样被拆在 sample_tokens:如果 execute_model 延迟了采样,它会取出临时状态、应用结构化输出 bitmask、调用 _sample,再更新 batch 状态 vllm/v1/worker/gpu_model_runner.py:4435-4475。
4.5.3 为什么这样设计
GPU runner 的本质是“把不可预测流量变成尽量稳定的张量形状”。这也是 CUDA Graph 能发挥作用的前提。对框架开发者来说,这和把玩家输入收集成固定结构的 ECS batch 很像:逻辑层动态,执行层要稳定。
4.5.4 替代方案
替代方案是每轮完全动态构造 tensor 并 eager 执行。它代码短,但在高 QPS 下 CPU 组 batch、H2D 拷贝、kernel launch 都会成为瓶颈。TensorRT-LLM 官方文档也把 in-flight batching、paged attention、KV Cache 和 scheduler 放在生产优化核心路径中[5]。
4.5.5 Trade-off(取舍)
稳定执行形状需要大量预分配状态和分支。比如 calculate_kv_scales 会禁用 CUDA Graph,因为动态 KV scale 计算不兼容图捕获 vllm/v1/worker/gpu_model_runner.py:4294-4300。这意味着同一模型在不同量化、并行和输入模式下,执行路径可能完全不同;测试不能只用一个 prompt 代表全部生产流量。
4.6 机制六:采样器(Sampler)—— GPU 上的高性能多维概率筛选
4.6.1 解决什么问题
当 GPU 模型完成了前向计算(Forward)之后,它会在 hidden states 中计算出当前步每个请求的 logits(未归一化的词概率分布,其维度通常为 [Batch Size, Vocab Size],其中 Vocab Size 往往在数万到十万以上,如 150,000+)。
我们的目标是从这数十万个候选词中,根据用户指定的采样参数(如 Temperature, Top-P, Top-K, Min-P)以及结构化规则(如 Grammar/Regex,用于限制模型必须输出 JSON 或特定语法格式),选出最终的 Token ID。
如果在 CPU 上做这套逻辑(例如每次把巨大的 Logits 张量从显存拷贝到内存中,用 Python 的 torch.multinomial 算子逐个请求循环计算),会带来致命的后果:
- Host-Device 数据搬运开销巨大:每一步 Decode 都要从 GPU 往 CPU 搬运
[Batch Size, Vocab Size]级别的浮点数数据。 - CPU 启动瓶颈(Launch Overhead):逐个请求去跑 Top-K 过滤和随机采样,会产生数以百计的小算子(Kernel)发射,让 CPU 成为绝对的性能瓶颈,导致 GPU 处于半饥饿状态。
4.6.2 如何实现
vLLM 的方案是:全批次合并 + 全 GPU 算子化。
vLLM 将整个采样过程拆分为两个紧密协作的阶段,并实现了一个高度优化的统一采样层 Sampler:
- 统一 Logits 处理器(Logits Processor):
- vLLM 在
GPUModelRunner.execute_model结束后,会把所有请求的最后一个 Token 对应的 logits 提取出来,合并成一个扁平的[Batch Size, Vocab Size]矩阵。 - Sampler 会将整个 Batch 的采样元数据(Penalty 惩罚系数、Temperature 温度值等)打包传递给 GPU,在 GPU 上利用并行线程一次性应用所有调整逻辑,如:
- 重复惩罚(Repetition Penalty):根据已生成的历史 Token,降低对应词的 logit 分值。
- 结构化约束(Grammar Bitmask):在跑采样前,根据当前请求的语法树,生成一个二进制掩码(Bitmask),直接把所有不合规的词的 logit 设为
-inf(负无穷大),使其在随后的采样中绝对不会被选中。
- vLLM 在
- 批量 Top-K / Top-P 与随机采样:
- vLLM V1 通过
TopKTopPSampler统一处理整个 batch,并按运行环境选择 FlashInfer、Triton 或 PyTorch 实现。 - 采样主要留在 GPU 上完成,最终只需把选中的 token 等小规模结果送回 Host,避免搬运完整 logits。具体性能取决于采样参数、batch、词表大小和所选后端。
- vLLM V1 通过
在 vLLM V1 中,这套逻辑被优雅地封装在 GPUModelRunner.sample_tokens 方法里,它负责从 ExecuteModelState 提取前向计算产物,配合 sampler.py 完成高效采样 gpu_model_runner.py:4435-4475。
4.6.3 为什么这样设计(游戏测试开发类比)
如果你写过游戏服务端的**“爆率/掉落逻辑(Loot Drop Table)”**,就很容易理解这套设计:
- 常规做法:在游戏关卡结束时,有 1000 个怪物死掉了。如果系统用一个 Python 循环,对每个怪物的掉落概率表(如:大剑 5% 概率,布甲 10% 概率)挨个去生成随机数判定,整个服务器的 Tick 帧率会瞬间暴跌。
- 高性能优化方案:游戏引擎通常会把这 1000 个怪物的掉落概率打包成一个大的一维权重数组,直接调用 C++ / GPU 优化的并行随机采样算法(如别名采样算法 Alias Method,或者并行前缀和),在一个 Tick 内并行把 1000 个掉落结果全部产出。
- vLLM 的 Sampler 正是如此:它把 Batch 里的上百个请求,当作上百个“同时掉落物品的怪”。每一个怪的“掉落物候选”就是 Vocabulary 里的那十万个词,而“掉落权重”就是前向计算出的 Logits。用最顶级的 GPU 并行掉落算法,一瞬间判定出所有人的结果。
4.6.4 替代方案
最直接的替代方案是使用 PyTorch 原生实现,或者对每个请求独立调用采样算子。高并发下,这通常会增加 Host 侧调度、Kernel launch 和中间数据处理开销;实际对 TTFT/TPOT 的影响取决于 batch、词表大小、采样参数和后端,需要用目标 workload 测量。
4.6.5 Trade-off(取舍)
高性能批处理采样的局限在于:
- 后端能力存在差异:FlashInfer、Triton 和 PyTorch 路径支持的硬件与性能特征不同;跨平台时要验证功能覆盖和目标 workload 下的延迟,而不能假设所有后端等价。
- 结构化输出有额外 CPU 工作:复杂 JSON Schema 需要先编译 grammar,生成过程中还要逐步更新允许 token 的 bitmask。vLLM 会尽量并行或异步处理,但复杂约束仍可能增加 CPU 开销,应把 grammar 编译时间和逐 token mask 更新时间分别纳入测试。
4.7 机制七:配置默认值——吞吐、延迟和显存水位的旋钮
4.7.1 解决什么问题
除了算法设计与硬件算子优化,推理引擎的吞吐与延迟在很大程度上也取决于各类运行配置参数的动态平衡。推理引擎的调参不是“越大越好”。batch token 越大,吞吐可能更好,但首 token 延迟可能变差;流式间隔越小,用户体感更顺滑,但 Host 开销更大;显存水位越保守,抢占更少,但可接纳请求变少。
4.7.2 如何实现
SchedulerConfig 给出核心字段:max_num_batched_tokens 默认 2048、max_num_seqs 默认 128、enable_chunked_prefill 默认 true、policy 默认 fcfs、scheduler_reserve_full_isl 默认 true、watermark 默认 0.0、stream_interval 默认 1 vllm/config/scheduler.py:42-167。
这些旋钮可以先这样记:
| 配置 | 类比 |
|---|---|
max_num_batched_tokens | 本帧总工作量预算 |
max_num_seqs | 本帧最多同时处理的实体数 |
stream_interval | 几帧推一次包 |
watermark | 对象池保留水位 |
真实服务会按硬件和使用场景覆盖默认值。EngineArgs 在 H100/MI300X 这类 70GiB 以上且非 A100 的 GPU 上,把 OpenAI API server 的默认 max_num_batched_tokens 设为 8192,max_num_seqs 设为 1024;其他 GPU 的 OpenAI API server 默认是 2048 和 256 vllm/engine/arg_utils.py:2401-2423。
4.7.3 为什么这样设计
这些配置都在围绕一个生产事实:GPU 批处理不是孤立优化,它要和入口流式体验、显存稳定性、公平性一起平衡。stream_interval=1 默认偏向更平滑的流式输出;watermark=0.0 默认不额外保留空闲块,但源码注释明确说它可用于避免显存紧张时反复抢占 vllm/config/scheduler.py:146-151。
4.7.4 替代方案
可以把这些值隐藏成一个 --mode latency|throughput。vLLM 也有吞吐模式相关逻辑:当 enable_throughput_mode 打开且用户没有手动设置时,它会把 max_num_batched_tokens 和 max_num_seqs 翻倍 vllm/engine/arg_utils.py:2600-2605。但源码仍保留底层旋钮,因为不同硬件、模型、上下文长度和 SLA 的最优点不同。
4.7.5 Trade-off(取舍)
配置多,意味着测试矩阵变大。面向网关压测,至少应区分:短 prompt 高并发、长 prompt 低并发、多轮共享前缀、流式断连、max_tokens 大小混合、priority 混合。只跑平均吞吐会掩盖 TTFT 尾延迟和抢占抖动。
5. 一次请求的执行路径追踪
选择最常见的 /v1/chat/completions 流式请求,路径如下:
- FastAPI 命中
/v1/chat/completions,做 JSON 校验、取消包装和负载感知api_router.py:40-53。 OpenAIServingChat渲染聊天模板,构造request_id、采样参数、LoRA 和 trace headersserving.py:267-335。- serving 层调用
engine_client.generate,得到异步 generatorserving.py:358-372。 AsyncLLM.add_request用InputProcessor把输入转成EngineCoreRequest,启动 output handler,创建RequestOutputCollectorasync_llm.py:348-380。EngineCore.step调用Scheduler.schedule,得到本轮执行计划engine/core.py:486-491。Scheduler.schedule先推进 running,再接纳 waiting,期间可能命中前缀缓存、分配 KV 块、抢占请求scheduler.py:430-982。GPUModelRunner.execute_model更新 batch、准备输入、构建注意力元数据、运行模型gpu_model_runner.py:4092-4315。sample_tokens采样输出 token,并更新 batch 状态gpu_model_runner.py:4435-4475。Scheduler.update_from_output更新请求 token、停止条件和资源释放;AsyncLLM的 output handler 把结果放入每请求队列。AsyncLLM.generate从队列取RequestOutput,serving 层转成 SSE chunk,直到[DONE]。
如果客户端中途断开,第 10 步的 generator 会收到取消,回到 AsyncLLM.generate 的取消分支,调用 abort async_llm.py:588-596。这就是为什么网关压测必须覆盖断连:它不是外围小功能,而是资源回收路径。
6. 运行时内部:模型加载、内存与队列调度
6.1 模型加载:它是如何被安全地“塞进” GPU 的?
EngineCore 初始化早期会创建模型执行器 self.model_executor = executor_class(vllm_config)。执行器建立 worker 后,各 rank 进入 Worker.load_model,再由 GPUModelRunner.load_model 调用选定 loader 的 load_model;get_model 只是取得已经初始化好的模型实例。uniproc_executor.py:61-69,gpu_model_runner.py:5154-5176
对于百亿级别参数(如 LLaMA-70B)的模型,如果先构造完整 Hugging Face 模型再二次改写,会增加内存和初始化成本。vLLM 的原生实现路径把模型构造、并行布局与权重加载结合起来;实际磁盘 I/O 和每个 rank 是否读取完整 tensor,仍取决于 checkpoint 格式与 loader。
6.1.1 原生模型实现与并行层
选定的 ModelLoader 会在 initialize_model 中通过 registry 解析模型实现,然后才加载权重 base_loader.py:42-65,model_loader/utils.py:40-58。在原生实现路径中,vLLM 不是先创建 Hugging Face AutoModelForCausalLM 再静默替换算子;例如 Llama 类从构造阶段就使用 vLLM 的 Attention、MergedColumnParallelLinear 和 RowParallelLinear。默认 model_impl=auto 找不到原生实现时,也可以回退到 Transformers backend model_loader/utils.py:180-215。
6.1.2 Loader 与分片权重
模型类的 load_weights 会把 checkpoint 中的权重名称映射到 vLLM 参数,并由各层的 weight loader 为当前 TP rank 选择、切片或拷贝所需参数。它不意味着 checkpoint 文件预先被重新切成 rank 专属文件。具体 I/O 和内存行为取决于 checkpoint 格式、loader 类型和并行配置:safetensors 可以通过内存映射减少普通文件读取的额外复制,但这不等于从磁盘到 GPU 的“零拷贝”,也不能保证所有配置下每个 rank 都不会读取完整 tensor。
这回答了网关测试中常见的“冷启动时间”问题:冷启动的开销不仅是网络拉取模型,还包括磁盘 I/O、模型结构初始化、权重格式转换/分片以及 GPU 参数分配与拷贝。
6.2 KV Cache 初始化先做显存画像
EngineCore._initialize_kv_caches 会向模型执行器查询 KV Cache specs,并调用 determine_available_memory 估算可给 KV Cache 的显存 vllm/v1/engine/core.py:239-296。如果自动适配降低了 max_model_len,还会同步给 worker engine/core.py:291-303。
这解释了一个常见现象:启动时慢,不一定是“模型加载慢”,也可能在做显存画像和 KV Cache 规划。压测平台如果把启动耗时算进请求延迟,会误判。
6.3 抢占不是异常,而是显存不足时的正常调度动作
当 allocate_slots 返回 None,scheduler 会抢占低优先级或队尾请求,释放它的 KV 块,把状态改成 PREEMPTED,num_computed_tokens 归零,再塞回 waiting 队列 vllm/v1/core/sched/scheduler.py:1107-1128。
1 | # vllm/v1/core/sched/scheduler.py:1116-1128 (v0.24.0) |
这对测试很关键:如果显存压力下请求被抢占,它不是失败,但它会增加 TTFT/TPOT,并可能让日志出现“重复预填充”。测试报告应把 preemption 当作一等指标。
6.4 batch queue 用于减少流水线气泡
如果 max_concurrent_batches > 1,EngineCore 会启用 batch_queue,让调度和执行重叠,主要服务于流水线并行,避免 pipeline bubble vllm/v1/engine/core.py:192-203。step_with_batch_queue 的注释明确说明:优先填充 batch queue;如果不能调新 batch,再阻塞等最早结果 engine/core.py:519-533。
这像游戏服里的多帧流水:当前帧提交逻辑,下一帧取结果。好处是硬件利用率更高;坏处是故障定位和取消延迟更复杂。
7. 性能与取舍
主线到这里已经结束。下面三节可以先跳读,做压测、选型或读源码时再回来查。
下表只列源码或官方材料能支撑的事实,不把某个模型上的 benchmark 扩展成普遍结论。
| 机制 | 优化目标 | 可量化事实 | 牺牲 |
|---|---|---|---|
| PagedAttention / KV 块池 | 降低 KV Cache 碎片和重复前缀计算 | 论文报告同延迟水平下相对 FasterTransformer/Orca 2-4 倍吞吐[1] | 块管理、引用计数、前缀 hash 增加调试复杂度 |
| 自动前缀缓存 | 多请求共享系统 prompt / few-shot / 多轮前缀 | get_computed_blocks 查最长命中,并保留最后 token 重算 logits kv_cache_manager.py:221-230 | 命中率依赖 block size、hash、淘汰顺序 |
| 分块预填充 | 长提示词不一次吃完整轮预算 | enable_chunked_prefill 默认 true scheduler.py:84-90 | 长 prompt 的完成时间被拆散,日志更难读 |
| token budget | 控制每轮最多处理多少 token | 配置默认 2048;H100/MI300X OpenAI server 默认 8192 arg_utils.py:2404-2409 | 值越大不必然越好,A100 还专门避免过大默认值 arg_utils.py:2401-2404 |
stream_interval | 平衡流式顺滑与 Host 开销 | 默认 1;注释说明 1 更平滑,更大值可减少 Host overhead scheduler.py:163-167 | 流式体验和吞吐互相拉扯 |
| watermark | 降低显存紧张下重复抢占 | 默认 0.0;可按总 KV 块比例保留空闲水位 scheduler.py:146-151 | 保守水位会降低可接纳并发 |
vLLM v0.24.0 release notes 里还列出了一些具体优化数字,例如 DeepSeek-V4 的 FlashInfer sparse index cache 带来 2-4% TTFT 改善、prefill chunk-planning 优化带来 4% 端到端吞吐改善、SM90 CUTLASS FP8 odd-M kernel 有 180-290% kernel speedup 等[6]。这些数字属于特定模型/硬件/PR,不应泛化成“升级 v0.24.0 所有场景都快 X%”。
8. 和同类方案比较
| 维度 | vLLM | Hugging Face TGI | TensorRT-LLM | SGLang |
|---|---|---|---|---|
| 服务入口 | OpenAI 兼容 API,Python/Rust frontend 都在演进 | OpenAI 兼容、SSE、生产部署工具链[7] | 偏 NVIDIA 优化栈,常与 Triton backend 组合 | OpenAI 兼容服务 + Python 语言模型程序框架 |
| 批处理核心 | V1 Scheduler 每轮 token budget 调度 | 连续批处理,动态加入/退出请求[2] | in-flight batching / overlap scheduler[5] | zero-overhead CPU scheduler,连续批处理[8] |
| KV 复用 | PagedAttention + 自动前缀缓存 + block pool | 文档列出 paged attention、KV caching | paged attention + KV Cache | RadixAttention,用基数树复用前缀[4] |
| 适合关注点 | 多模型支持、OpenAI 服务、分布式 KV、生态快 | Hugging Face 生态部署和模型接入 | NVIDIA GPU 极致优化和生产 API | agent / 多轮 / 结构化 LM 程序的前缀复用 |
| 测试重点 | 队列、KV 命中、抢占、流式断连 | continuous batching 和服务端 streaming | backend 参数、engine build、Triton 部署 | RadixAttention 命中率、cache-aware workload |
对网关测试开发来说,选择哪一个不是只看单次吞吐。你要先问:
- 是否要完全兼容 OpenAI streaming 行为?
- 断连、限流、鉴权、trace、metrics 是否要和现有网关打通?
- workload 是短 prompt 问答,还是大量共享系统 prompt 的 agent 流?
- GPU 是单机单卡,还是 TP/PP/DP/专家并行混合?
- 你能否接受框架升级导致的调度行为变化?
9. 源码阅读地图
建议按下面顺序读,不要从模型目录开始,否则会被上千个模型适配文件拖走。
| 顺序 | 文件 | 读什么 |
|---|---|---|
| 1 | vllm/entrypoints/openai/chat_completion/api_router.py:40-74 | HTTP 入口、取消、StreamingResponse |
| 2 | vllm/entrypoints/openai/chat_completion/serving.py:251-380 | chat request 如何变成 engine request |
| 3 | vllm/v1/engine/async_llm.py:70-153 | AsyncLLM 构造、InputProcessor、OutputProcessor、EngineCoreClient |
| 4 | vllm/v1/engine/async_llm.py:524-625 | generate 的队列、yield、取消路径 |
| 5 | vllm/v1/engine/core.py:96-237 | EngineCore 初始化:模型、KV Cache、scheduler |
| 6 | vllm/v1/engine/core.py:479-508 | 单步主循环 |
| 7 | vllm/v1/core/sched/scheduler.py:388-1100 | running/waiting 调度、KV 分配、抢占 |
| 8 | vllm/v1/core/kv_cache_manager.py:202-458 | 前缀缓存命中和 slots 分配 |
| 9 | vllm/v1/core/block_pool.py:144-186 | KV block pool 数据结构 |
| 10 | vllm/v1/worker/gpu_model_runner.py:4056-4315 | GPU 执行前的 batch 形状、CUDA Graph 决策、attention metadata |
| 11 | vllm/config/scheduler.py:42-167 | 调参字段和默认值 |
10. 给网关测试和游戏服框架开发的实践建议
10.1 压测指标不要只看 tokens/s
至少同时记录:
| 指标 | 为什么 |
|---|---|
| TTFT | 反映排队、预填充、前缀缓存和首轮调度 |
| TPOT | 反映解码阶段稳定性 |
| prefix cache hit | 解释多轮/共享 prompt 的吞吐差异 |
| preemption count | 解释显存压力下的延迟尖刺 |
| KV Cache usage | 解释是否接近抢占/淘汰边界 |
| stream chunk interval | 解释用户体感与 Host 开销 |
| disconnect/abort latency | 解释断连后资源释放是否及时 |
10.2 构造 workload 要像构造游戏场景
不要只跑随机短 prompt。至少要有:
- 大量短问答:模拟普通 API 流量。
- 少量超长 prompt:验证分块预填充是否拖垮短请求 TTFT。
- 共享系统 prompt:验证前缀缓存命中。
- 多轮会话:验证相同前缀和不同尾部混合。
- 断连流:验证
CancelledError到abort的资源回收。 - 大
max_tokens和小max_tokens混合:验证 running 队列公平性。 - priority 混合:验证低优先级请求是否会被频繁抢占。
10.3 网关限流要理解 token budget,而不是只限 QPS
两个请求的成本可能差一个数量级:max_tokens=16 的短问答和 prompt=32k + max_tokens=1024 的请求不能只按 QPS 计费或限流。更合理的网关策略是至少估算输入 token、输出 token 上限和是否流式,然后映射到后端队列或优先级。
10.4 不要把断连当边缘路径
流式 LLM 服务里,用户刷新页面、移动端网络切换、上游超时都会触发断连。vLLM 的取消路径会进入 AsyncLLM.generate 的 abort 分支 async_llm.py:588-596。你的网关如果吞掉取消、继续读后端流,会让后端无意义地生成 token。
10.5 升级 vLLM 要回归调度语义
v0.24.0 release notes 里,调度、KV Cache、Rust frontend、P/D disaggregation、structured outputs 都有大量改动[6]。升级测试不应只验证 API schema;要回归:
- 相同 workload 的 TTFT/TPOT 分布。
- prefix cache hit 是否变化。
- 抢占次数是否变化。
- 流式 chunk 是否仍符合网关超时和客户端解析预期。
/pause、/resume、/abort_requests等运维接口是否被网关正确保护。
11. 总结
vLLM 的核心不是某一个 CUDA kernel,而是一个端到端运行时:入口层把 OpenAI 请求变成内部请求,AsyncLLM 把 HTTP 生命周期和引擎生命周期解耦,EngineCore 用固定主循环推进系统状态,Scheduler 用统一 token budget 同时处理预填充、解码、前缀缓存和抢占,KV Cache manager 把 HBM 显存当分页块池管理,GPU runner 再把动态 batch 压成 SM/warp 更容易执行、CUDA Graph 更容易复用的稳定形状。
对做网关测试和游戏服务器框架的人来说,最值得借鉴的是两个设计:第一,连接生命周期和核心调度生命周期解耦;第二,所有动态工作都归一到一个可度量预算,而不是为每种请求路径开独立主循环。前者让断连和流式输出可控,后者让吞吐、延迟、显存和公平性可以在一个调度器里取舍。
也正因为如此,测试 vLLM 不能只测“能不能返回文本”。你要像测游戏服一帧调度那样测它:队列是否积压,预算是否合理,缓存是否命中,抢占是否失控,断连是否释放资源,升级是否改变调度语义。
版本与参考资料
本文源码分析版本:vllm-project/vllm tag v0.24.0,commit ee0da84ab9e04ac7610e28580af62c365e898389,release 发布时间为 2026-06-29。
[1] Woosuk Kwon 等,Efficient Memory Management for Large Language Model Serving with PagedAttention,arXiv 2309.06180,https://arxiv.org/abs/2309.06180
[2] Hugging Face,Continuous batching,https://huggingface.co/docs/transformers/en/continuous_batching
[3] vLLM Blog,vLLM: Easy, Fast, and Cheap LLM Serving with PagedAttention,https://vllm.ai/blog/2023-06-20-vllm
[4] LMSYS,Fast and Expressive LLM Inference with RadixAttention and SGLang,https://www.lmsys.org/blog/2024-01-17-sglang/
[5] NVIDIA TensorRT-LLM Documentation,Paged Attention, IFB, and Request Scheduling,https://nvidia.github.io/TensorRT-LLM/advanced/gpt-attention.html
[6] vLLM v0.24.0 Release Notes,https://github.com/vllm-project/vllm/releases/tag/v0.24.0
[7] Hugging Face Text Generation Inference Documentation,https://huggingface.co/docs/text-generation-inference/en/index
[8] SGLang GitHub Repository,https://github.com/sgl-project/sglang
[9] NVIDIA,CUDA C++ Programming Guide,https://docs.nvidia.com/cuda/cuda-c-programming-guide/index.html