2、vLLM基础架构:核心组件(LLMEngine、ModelRunner、Worker)、推理流程与请求调度机制

vLLM 之所以能在高并发推理场景下保持高吞吐与低延迟,核心并不在于某个单一的优化技巧,而在于其精心设计的分布式运行时架构。理解 LLMEngine、ModelRunner、Worker 这三个核心组件的职责边界与协作关系,是后续自定义模型加载与适配的基石。本章将从组件职责、推理流程、调度机制三个维度展开,帮助你在源码层面建立清晰的认知地图。

vLLM 核心组件协作与请求调度链路 Client / API HTTP / gRPC LLMEngine Scheduler 连续批处理 Input Preprocess Tokenize / 模板 Output Postprocess Detokenize / 采样 Seq Group Manager 状态跟踪 请求 响应 ModelRunner GPU 执行 / 采样 KV Cache 管理 Worker 模型加载 / 权重 张量并行 / 通信 执行批次 驱动 调度循环(持续迭代) 关键协作关系: 1. LLMEngine 是唯一入口,负责请求生命周期管理,不直接接触 GPU。 2. ModelRunner 是 LLMEngine 与 Worker 之间的桥梁,将逻辑批次转换为 GPU 指令。 3. Worker 持有模型权重与 KV Cache,执行真正的张量计算与通信。 4. Scheduler 通过连续批处理(Continuous Batching)动态插入/退出序列,最大化 GPU 利用率。 5. 自定义模型适配时,通常只需修改 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 方法。该方法会:

  1. 对输入文本进行 tokenize,得到 input_ids。
  2. 应用 chat 模板(如果配置了 tokenizer.apply_chat_template)。
  3. 创建 Sequence 对象,并分配一个唯一的序列 ID。
  4. 将序列加入 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 空间不足时触发

调度器在每个迭代轮次执行以下逻辑:

  1. 检查 Running 队列中是否有序列终止,释放其 KV Cache。
  2. 计算当前 GPU 剩余空间,决定能从 Waiting 队列中调度多少新序列。
  3. 如果空间不足,将部分 Running 序列换出到 Swapped 队列(抢占)。
  4. 合并 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 分析判断过程

通过阅读源码,我们定位到以下关键点:

  1. ModelRunner.execute_model 中,输入张量只包含 input_ids 和 positions,没有图像特征。
  2. Worker.load_model 中,默认调用 AutoModelForCausalLM.from_pretrained,无法加载多模态模型。
  3. 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/A0.8s
吞吐量N/A45 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 核心逻辑。