2、vLLM基础架构:核心组件(LLMEngine、ModelRunner、Worker)、推理流程与请求调度机制
vLLM 之所以能在高并发推理场景下保持高吞吐与低延迟,核心并不在于某个单一的优化技巧,而在于其精心设计的分布式运行时架构。理解 LLMEngine、ModelRunner、Worker 这三个核心组件的职责边界与协作关系,是后续自定义模型加载与适配的基石。本章将从组件职责、推理流程、调度机制三个维度展开,帮助你在源码层面建立清晰的认知地图。
2.1 核心组件职责与边界
vLLM 的架构设计遵循了"控制与执行分离"的原则。LLMEngine 作为控制平面,负责所有高层决策;ModelRunner 与 Worker 构成执行平面,负责具体的张量计算。理解这一分层,是避免在自定义模型时"改错地方"的关键。
2.1.1 LLMEngine:请求生命周期管理者
LLMEngine 是 vLLM 对外提供服务的核心入口,它封装了从请求进入到结果返回的全部逻辑。在源码中,LLMEngine 类维护了 scheduler、tokenizer、input_preprocessor 等关键成员。它的核心职责包括:
- 请求注册与状态管理:每个请求被封装为
Sequence对象,Engine 跟踪其生成进度、已用 token 数、是否终止等状态。 - 调度决策:调用 Scheduler 决定下一步执行哪些序列,以及如何分配 KV Cache 空间。
- 输入输出处理:负责 tokenization、模板渲染、以及最终输出的 detokenization。
- 分布式协调:在张量并行或流水线并行场景下,Engine 负责向多个 Worker 广播执行指令并收集结果。
值得注意的是,LLMEngine 本身不执行任何 GPU 计算。它更像是一个"导演",而 Worker 是"演员"。这种设计使得 vLLM 可以在不修改推理逻辑的情况下,灵活扩展并行策略。
2.1.2 ModelRunner:GPU 执行的直接驱动者
ModelRunner 是连接 LLMEngine 与 Worker 的桥梁。在单卡场景下,它直接调用模型的前向方法;在多卡场景下,它负责将输入张量分发到各个 Worker 并汇总输出。ModelRunner 的关键职责包括:
- 输入张量构造:将 Scheduler 传来的序列组(Sequence Group)转换为模型所需的
input_ids、position_ids、attention_mask等张量。 - KV Cache 管理:为每个序列分配或释放 KV Cache 块,并维护其物理块到逻辑块的映射。
- 采样与输出处理:调用模型的 logits 输出,执行 top-k、top-p 等采样策略,生成新的 token。
在自定义模型时,ModelRunner 是最常需要修改的组件。例如,如果你的模型需要额外的输入特征(如图像 embedding),就需要在 ModelRunner.execute_model 方法中构造对应的张量。
2.1.3 Worker:模型权重与计算的载体
Worker 是真正持有模型权重并执行前向传播的组件。在分布式场景下,每个 Worker 负责模型的一部分(张量并行)或某一层(流水线并行)。Worker 的核心功能包括:
- 模型加载与初始化:从 HuggingFace Hub 或本地路径加载权重,并分配到对应的 GPU 设备。
- 前向传播执行:调用
torch.nn.Module.forward完成 Transformer 层的计算。 - 通信协作:在张量并行中,通过 NCCL 进行 all-reduce 等集合通信操作。
组件边界速查表
| 组件 | 主要职责 | 是否接触 GPU | 自定义模型时修改频率 |
|---|---|---|---|
| LLMEngine | 请求管理、调度、输入输出处理 | 否 | 低(通常无需修改) |
| ModelRunner | 张量构造、KV Cache 管理、采样 | 间接(通过 Worker) | 高(需适配新输入格式) |
| Worker | 权重加载、前向计算、通信 | 是 | 中(需实现加载逻辑) |
2.2 推理流程:从请求到响应的完整链路
理解推理流程,关键在于把握"迭代"的概念。vLLM 的推理不是一次前向传播就结束,而是通过多次迭代逐步生成 token。每次迭代,Scheduler 都会重新评估所有活跃序列,决定哪些继续生成、哪些等待、哪些终止。
2.2.1 请求进入与预处理
当客户端发送请求时,LLMEngine 首先执行 add_request 方法。该方法会:
- 对输入文本进行 tokenize,得到
input_ids。 - 应用 chat 模板(如果配置了
tokenizer.apply_chat_template)。 - 创建
Sequence对象,并分配一个唯一的序列 ID。 - 将序列加入 Scheduler 的等待队列。
这里有一个容易被忽略的细节:vLLM 支持 prompt_logprobs 参数,如果需要返回每个 prompt token 的对数概率,Engine 会在预处理阶段额外保存 prompt 的 logits 索引。
2.2.2 调度与批次构建
Scheduler 是 vLLM 性能的核心。它采用 连续批处理(Continuous Batching) 策略,与传统的静态批处理不同,它允许序列在生成过程中动态加入或退出批次。具体流程如下:
- 步骤 1:从等待队列中取出可运行的序列,检查是否有足够的 KV Cache 空间。
- 步骤 2:将新序列与正在生成的序列合并为一个执行批次(
SequenceGroup)。 - 步骤 3:为每个序列分配物理块,并更新块映射表。
- 步骤 4:将批次信息传递给 ModelRunner。
这种机制的优点在于:当一个序列生成完毕(遇到 EOS 或达到 max_tokens),它的 KV Cache 会被立即释放,新请求可以马上占用这些空间,从而显著提高 GPU 利用率。
2.2.3 模型执行与采样
ModelRunner 接收到批次后,执行以下操作:
# 伪代码示意(非实际源码)
def execute_model(seq_group_metadata_list):
input_tokens = prepare_input_tokens(seq_group_metadata_list) # 构造 input_ids
positions = prepare_positions(seq_group_metadata_list) # 构造 position_ids
# 调用模型前向
hidden_states = model(input_tokens, positions)
# 采样生成新 token
next_tokens = sampler(hidden_states)
return next_tokens
采样阶段,vLLM 支持多种策略:greedy、sampling(temperature、top-p、top-k)、beam search 等。对于自定义模型,如果采样逻辑与标准 LMHead 不同(例如需要额外的约束解码),则需要重写 Sampler 类。
2.2.4 输出后处理与返回
每次迭代生成的 token 会追加到对应的 Sequence 对象中。当序列满足终止条件时,LLMEngine 会将其从 Scheduler 中移除,并执行 detokenize 操作,将 token ID 转换回文本。最终,通过异步回调或同步返回将结果发送给客户端。
实战提示:在自定义模型时,如果发现输出乱码或 token 不匹配,优先检查 tokenizer 的配置。vLLM 默认使用 tokenizer_config.json 中的 add_bos_token 和 add_eos_token 设置,这些参数会影响输入序列的构造。
2.3 请求调度机制:连续批处理与 KV Cache 管理
调度机制是 vLLM 区别于传统推理框架的核心创新。它解决了两个关键问题:如何最大化 GPU 利用率 和 如何避免 KV Cache 碎片化。
2.3.1 连续批处理(Continuous Batching)
传统批处理(如 HuggingFace 的 generate 方法)要求同一批次的所有序列同时开始、同时结束。这会导致两个问题:
- 短序列需要等待长序列生成完毕,造成 GPU 空闲。
- 批次大小固定,无法动态调整。
vLLM 的连续批处理打破了这一限制。它允许序列在任意迭代轮次加入或退出。具体实现上,Scheduler 维护了三个队列:
| 队列 | 状态 | 调度策略 |
|---|---|---|
| Waiting | 等待首次执行 | 优先调度,按到达时间排序 |
| Running | 正在生成中 | 持续执行,直到终止 |
| Swapped | 被抢占,KV Cache 已换出到 CPU | 当 GPU 空间不足时触发 |
调度器在每个迭代轮次执行以下逻辑:
- 检查 Running 队列中是否有序列终止,释放其 KV Cache。
- 计算当前 GPU 剩余空间,决定能从 Waiting 队列中调度多少新序列。
- 如果空间不足,将部分 Running 序列换出到 Swapped 队列(抢占)。
- 合并 Running 与 Waiting 中可执行的序列,形成新的批次。
2.3.2 PagedAttention 与 KV Cache 管理
KV Cache 是推理过程中的中间状态,存储了每个 token 的 Key 和 Value 向量。对于长序列,KV Cache 可能占用数 GB 显存。vLLM 借鉴操作系统虚拟内存分页的思想,实现了 PagedAttention:
- 将 KV Cache 划分为固定大小的物理块(默认 16 个 token 一块)。
- 每个序列维护一个逻辑块表,映射到物理块。
- 物理块可以非连续存储,避免碎片化。
这种设计带来了两个直接好处:
- 显存利用率接近 100%:不再需要为最长序列预留连续空间。
- 高效共享:在并行采样(如 beam search)中,多个序列可以共享相同的 KV Cache 块,减少重复计算。
风险提示:在自定义模型时,如果你的模型修改了 attention 机制(例如加入了跨层注意力或自定义 mask),必须同步修改 PagedAttention 的实现。否则,KV Cache 的索引计算会出错,导致生成结果错乱。建议在开发初期就运行 python -m vllm.entrypoints.openai.api_server --model your_model 进行冒烟测试。
2.4 案例拆解:自定义模型接入时的组件修改路径
假设我们需要接入一个多模态模型,输入包含图像和文本。这个案例将展示如何通过修改三个核心组件来适配。
2.4.1 背景与约束
我们的模型基于 LLaVA 架构,输入为 image_embedding + text_embedding 的拼接。约束条件:
- 模型权重以 HuggingFace 格式保存,包含
vision_tower和language_model两个子模块。 - 图像需要预处理为固定尺寸(336x336),并通过 CLIP 的 vision encoder 提取特征。
- 推理时,图像特征需要与文本 token 的 embedding 拼接后输入语言模型。
2.4.2 初始现象与目标
直接使用 vLLM 默认的 LLM 类加载模型,报错 KeyError: 'image_embeds'。目标:让 vLLM 能够正确处理图像输入,并生成文本响应。
2.4.3 分析判断过程
通过阅读源码,我们定位到以下关键点:
ModelRunner.execute_model中,输入张量只包含input_ids和positions,没有图像特征。Worker.load_model中,默认调用AutoModelForCausalLM.from_pretrained,无法加载多模态模型。- LLMEngine 的
tokenizer只处理文本,不处理图像。
2.4.4 实际操作步骤
我们进行了以下修改:
# 1. 自定义 ModelRunner,重写 execute_model
class MultimodalModelRunner(ModelRunner):
def execute_model(self, seq_group_metadata_list):
# 从 seq_group 中提取图像输入
image_inputs = [m.get('image_embeds') for m in seq_group_metadata_list]
# 构造图像张量并移动到 GPU
image_tensor = torch.stack(image_inputs).to('cuda')
# 调用模型前向,传入图像特征
hidden_states = self.model(input_ids, positions, image_embeds=image_tensor)
return self.sampler(hidden_states)
# 2. 自定义 Worker,重写 load_model
class MultimodalWorker(Worker):
def load_model(self):
self.model = MyMultimodalModel.from_pretrained(self.model_config.path)
self.model.to('cuda')
# 3. 在 LLMEngine 中注册自定义组件
engine = LLMEngine(
model_runner_cls=MultimodalModelRunner,
worker_cls=MultimodalWorker,
...)
2.4.5 结果对比与复盘
修改后,模型能够正常接收图像输入并生成响应。性能对比:
| 指标 | 修改前 | 修改后 |
|---|---|---|
| 加载时间 | 失败 | 12.3s |
| 首 token 延迟 | N/A | 0.8s |
| 吞吐量 | N/A | 45 tokens/s |
风险与代价:修改 ModelRunner 后,需要确保 KV Cache 的分配逻辑仍然正确。由于图像特征不参与 KV Cache 计算,我们必须在 prepare_input_tokens 中排除图像 token 的位置索引。
复盘经验:自定义模型适配的核心在于理解 vLLM 的"数据流"——从请求到 token,再到张量,最后到输出。只要沿着这条链路逐层检查,就能快速定位需要修改的组件。
2.5 常见误区与总结
在 vLLM 自定义模型的过程中,开发者常陷入以下误区:
- 误区一:直接修改 LLMEngine 的代码。实际上,大多数场景只需通过继承并重写 ModelRunner 或 Worker 即可,无需动 Engine 层。
- 误区二:忽略 KV Cache 的适配。如果你的模型使用了自定义 attention mask,必须同步修改 PagedAttention 的索引逻辑。
- 误区三:认为所有模型都支持张量并行。部分模型(如某些 MoE 架构)需要额外的通信逻辑,直接使用默认的
tensor_parallel_size可能导致死锁。 - 误区四:混淆 tokenizer 与模型。vLLM 的 tokenizer 是独立于模型的,修改模型时不要忘记同步更新 tokenizer 配置。
本章核心要点:
- LLMEngine 负责控制流,ModelRunner 负责张量构造,Worker 负责计算。
- 连续批处理是 vLLM 高性能的关键,它允许序列动态加入/退出批次。
- PagedAttention 通过分页管理 KV Cache,实现接近 100% 的显存利用率。
- 自定义模型时,优先修改 ModelRunner 和 Worker,避免触碰 Engine 核心逻辑。