vLLM系统拆解-07-Worker执行链:调度决策如何变成GPU上的一步计算

前几篇讲的都是 Scheduler 侧的事:哪些请求本轮进入 batch,token budget 怎么分配,prefix cache 怎么命中。但到这里,Scheduler 只是完成了"决策"——GPU 还没有真正动起来。

把"这一轮调度谁"翻译成"GPU 实际执行一步 forward"的,是 Worker 执行链。这篇文章沿着这条链从头走一遍:为什么要有单独的 Worker 进程,Worker / Model Runner / Model / Sampler 四层各自解决什么问题,以及那些常被忽视但实际很重要的工程细节——input tensor 准备、persistent batch、Sampler 的完整执行步骤。


四层分工:先建立整体视图

在深入每一层之前,先把层次关系建立清楚。

flowchart TD
    A["Engine Core\n调度协调,维护全局请求状态"] --> B["Worker\n单 GPU 进程,设备归属与显存管理"]
    B --> C["Model Runner\n执行编排:状态更新 + 输入准备 + 调用协调"]
    C --> D["Model\ntorch.nn.Module,执行网络计算,输出 logits"]
    C --> E["Sampler\n解码决策:从 logits 到最终 token 选择"]
    E --> F["写回请求状态\n未结束→进入下一轮 decode\n已结束→释放资源"]
    F --> A

官方架构文档对 Worker 的定位非常直接:每个 GPU 对应一个 Worker 进程,Worker 负责加载模型权重、执行 forward、管理 GPU 内存。但这四层的划分背后有明确的设计原因,不只是简单的代码分组。


为什么需要单独的 Worker 进程

这个设计选择直接决定了 vLLM 在多 GPU 和分布式场景下的可扩展性。

设备归属清晰。 哪张卡由谁管、这张卡上的显存由谁分配、这张卡加载了什么权重、当前 batch 是什么——这些都归一个 Worker 管。如果多个上层组件同时直接操作 GPU,责任边界会混乱,显存生命周期难以追踪,多卡通信逻辑会散落在各处。

更适合分布式扩展。 当引入 Tensor Parallelism(TP)、Pipeline Parallelism(PP)、Data Parallelism(DP)后,不同 rank 有不同的张量分片和计算职责。把"每个设备的执行与状态"收口到 Worker,可以让 Engine Core 只关心调度与协调,不必亲自管每张卡的细节。这是多 GPU 系统能够保持可维护性的基础。

分离 CPU 控制路径与 GPU 执行路径。 高并发推理里,一个常见瓶颈是 CPU 端做了太多杂事而 GPU 被饿住。API server 处理请求、Engine Core 处理调度、Worker 处理模型执行——这三路分开后,不同性质的瓶颈更容易被隔离,也更容易各自优化。


Model Runner 才是执行链的核心,不是 Model 本身

这个判断对很多人来说是反直觉的。

模型本体(torch.nn.Module)只做一件事:给定输入张量,算出输出张量。它不知道外面在跑多少并发请求,不知道哪些是 prefill 哪些是 decode,不知道 KV cache 怎么索引,不知道当前 batch 是怎么组成的。

但一个推理系统真正要处理的远比这复杂:

  • 这一轮 batch 里有哪些请求,各自当前在哪一步
  • 哪些是 prefill,哪些是 decode
  • 本轮输入 token 怎么组织成张量
  • attention backend 要什么格式的 metadata
  • KV cache 的 block table / slot mapping 如何表达
  • 是否可以复用 persistent state,是否启用 cudagraph
  • Sampler 需要哪些 request-level metadata

这些都不是 torch.nn.Module 该处理的。Model Runner 就是连接"调度世界"和"模型世界"的翻译层——Scheduler 输出的是"这一轮该处理哪些请求",Model 需要的是"适合 kernel / backend / module 的张量输入",Model Runner 负责把前者翻译成后者。

Model Runner 必须保持通用和最小化

官方 API 文档对 Model Runner 有一个明确要求:这个文件是所有模型共享的,它既服务文本模型也服务多模态模型,既服务 generative 也服务 embedding。因此这里只放所有模型都通用的逻辑,模型特定行为应放到各自的模型文件里。

这个约束有清晰的工程价值:

如果 Model Runner 保持稳定 如果 Model Runner 被模型差异污染
新增模型只需补充模型文件 每次加模型都要改执行主干
persistent batch、cudagraph 等优化可以稳定落点 框架级优化难以实施
主执行路径行为可预测 系统随时间趋向失控

这是一种很典型的 infra 克制:不是"功能能不能加",而是"加完后主路径会不会变成垃圾场"。


执行链的完整流程

下面这条链是这篇文章的核心,逐步讲清楚每一步的实质。

sequenceDiagram
    participant EC as Engine Core
    participant W as Worker
    participant MR as Model Runner
    participant M as Model
    participant S as Sampler

    EC->>W: 本轮调度结果\n(谁执行/谁继续/谁结束)
    W->>MR: 执行本轮 batch
    MR->>MR: 更新 active requests 状态\n同步 persistent state
    MR->>MR: 准备输入张量\n(input_ids / positions / attention metadata\nKV 索引 / block table / sampler metadata)
    MR->>M: model.forward(prepared inputs)
    M-->>MR: logits
    MR->>S: logits + request metadata
    S-->>MR: SamplerOutput(next tokens + logprobs)
    MR->>MR: 写回请求状态\n判断 EOS / max_tokens\n更新 KV cache 引用
    MR-->>EC: 本轮结果\n已结束请求通知释放资源

第一步:接收 Scheduler 的执行计划

Scheduler 做完本轮选择后,把"本轮执行计划"交给 Worker,而不是自己去跑模型。

这是控制面(control plane)与执行面(execution plane)的分离。Engine Core 负责决定哪些请求执行、它们的 KV block 如何分配、哪些请求结束或被抢占;Worker 只负责按照这份计划执行。

如果让 Worker 自己也做调度判断,每个 Worker 都需要更多全局视角,多卡系统会更复杂。vLLM 的设计是:调度统一收口到 Engine Core,执行统一落到 Worker,让全局决策点更集中,系统行为更可预测。

第二步:Model Runner 更新执行状态

Model Runner 接到本轮调度结果后,第一件事不是立刻 forward,而是更新执行状态

推理不是一次性静态 batch,而是持续变化的动态 batch:有些请求刚加入,有些刚结束,有些在继续 decode,有些被 preempt 后又恢复。所以 Model Runner 要先回答:

  • 当前 active requests 是谁,在本轮 batch 里的顺序是什么
  • persistent state 里对应哪一行需要更新
  • sampler metadata 是否需要同步

这一步的作用是把 Scheduler 的离散调度事件,转换成模型执行所需的连续运行状态。

第三步:准备输入张量——最容易被低估的一步

很多人会以为"输入不就是 token ids 吗,直接喂给 model 不就行了"。实际上这是执行链里一大块工程工作。真正要准备的包括:

1
2
3
4
5
6
7
8
9
10
input_ids               - 本轮实际输入的 token id
positions/position_ids - 每个 token 在序列中的位置
attention metadata - attention backend 所需的格式信息
KV cache 索引 - 当前请求的 KV 存储位置
block table - 逻辑 block → 物理 block 的映射
slot mapping - 具体的 block slot 分配
请求长度信息 - 各请求当前的 prefill/decode 状态
sampler metadata - 各请求的采样参数(temperature、top-k 等)
多模态特征(如有) - 图像/音频等输入的特征表示
backend 特定布局 - 不同 attention backend 的 layout 要求

这些内容大多不属于"模型数学定义",而属于"执行系统如何把动态请求集合包装成一次可计算的 forward"。很多性能问题根本不出在 matmul 上,而出在:每步重建大量 CPU 端状态、频繁的张量重排、大量 host-to-device 小拷贝、batch 变化破坏执行稳定性。

这也是 Model Runner 的核心价值之一:让"输入准备"更统一、更少冗余、更接近后端需求。

第四步:model.forward

输入准备完成后,才真正进入模型计算。Model 就是标准的 torch.nn.Module:Embedding → Transformer blocks(Attention + MLP + Norm)→ 输出 logits。

Model 不应该承担系统逻辑,原因是模型是高变的:不同模型家族的 attention 结构、RoPE 处理、KV layout 细节、多模态支持、任务目标都不同。如果把系统逻辑塞进 model,执行框架就会因模型差异而碎片化。vLLM 的分工是:Model Runner 负责"怎样喂",Model 负责"怎么算"。

Prefill 与 Decode 在 Worker 侧的差异

这两种模式对 Worker 的要求差异很大:

Prefill Decode
输入规模 整段 prompt,可能很多 token 通常每请求 1 个新 token
计算特性 更偏 compute-bound(大矩阵乘法) 更偏 memory-bound(频繁读 KV Cache)
KV Cache 操作 写入整段历史的 K/V 读取长历史 + 写入当前步的 K/V
forward 输出 最后位置的 logits,用于采样第一个生成 token 当前位置的 logits,用于采样下一个 token

Model Runner 的重要职责之一就是把这两种模式的差异吸收进统一执行框架中——对 Scheduler 来说是"混合 batch",对 Model 来说是"一次 forward",中间的转换在 Model Runner 里完成。

第五步:Sampler——不是末尾随手写几行 softmax

model.forward 输出 logits,只是"下一 token 的分布依据",不是最终决策。从 logits 到实际 token 选择,是 Sampler 的工作。

根据 vLLM API 文档,Sampler 处理一次 next token 的完整步骤是:

  1. 如果用户请求 logprobs,先按模式准备要返回的 logprobs 信息
  2. 把 logits 转成 float32
  3. 应用 allowed token ids 白名单
  4. 应用 bad words exclusion
  5. 应用会影响 greedy 结果的 logit processors(最小生成长度、logit bias 等)
  6. 应用 penalties:repetition / frequency / presence penalty
  7. 进入实际采样:
    • greedy 模式:直接取 argmax
    • 非 greedy:先做 temperature scaling → 应用 argmax-invariant processors(如默认的 min_p)→ top-k / top-p → 从概率分布中采样
  8. 如有需要,收集 top logprobs 和 sampled token 的 logprobs
  9. 返回 SamplerOutput

Sampler 实际承担了三个层面的职责:策略控制(怎么选 token)、约束控制(哪些 token 可选、哪些词禁止)、输出组织(当用户要求 logprobs 等附加信息时整理结果)。

把 Sampler 从 Model 里拆出来的核心理由是:采样是解码策略,不是模型数学。“预测”(模型输出 logits)和"决策"(选哪个 token)是两个层次的问题,混在一起会让模型实现承担不该承担的逻辑。

第六步:写回请求状态

采样完成后执行链还没结束,还要:

  • 把 sampled token 追加到对应请求的生成结果
  • 更新请求长度
  • 判断是否命中 EOS / stop 条件,或达到 max_tokens
  • 更新 sampler / request 的相关状态
  • 已结束的请求:通知上层释放其 KV blocks,从 active set 移除
  • 未结束的请求:保留在 active set,下一轮 decode 时只输入新 token,但仍需读完整历史 KV Cache

这一步的本质是:把"本轮 forward 的结果"转换成"下一轮调度所需的请求状态"。这和 KV Cache 生命周期、Scheduler 状态机是强相关的——请求结束意味着 block 释放,请求继续意味着 KV 引用仍然有效。

第七步:输出回传给上层

Worker 不直接提供 HTTP 服务。本轮执行结果会先回到 Engine Core,再由 API 层做输出组织和流式返回。

这一步也体现职责分离:Worker 只关心模型执行和设备状态,API 层关心用户可见的输出格式和流式协议(包括 HTTP 连接管理、request bookkeeping、OpenAI-compatible 格式)。如果混在一起,设备进程会承担太多非计算逻辑,性能隔离会变差。


Persistent Batch:CPU 开销也是推理瓶颈

连续 decode 的相邻两步,batch 通常变化不大:上一步 batch 里有 N 个请求,下一步往往还是那 N 个,只有少数结束、少数加入,大多数请求只是"继续生成下一个 token"。

如果每一轮都把整个 batch 的所有输入张量从头构造一遍,会产生明显的 CPU 开销。vLLM V1 中的 Model Runner V2 使用了 persistent batch 思路:持久保存 batch 相关状态张量,每轮只做增量更新,利用"相邻 step batch 大体相同"这一事实来减少 CPU 侧 bookkeeping 成本(官方 Model Runner V2 设计文档明确指出这一点)。

这说明 vLLM 的优化不只在 GPU kernel 层,它同样重视 host 端执行开销。这也是 Model Runner 必须保持稳定的另一个原因:persistent batch 这类优化需要一个不随模型差异而变化的稳定执行框架来落脚。


这条执行链追求的四个目标

理解了每一步之后,可以把这条链的设计意图归结为四点:

连接动态服务世界与静态模型世界。 服务世界是动态的(请求随时来去,batch 持续变化),模型世界需要规整张量和稳定执行路径。Model Runner 是两者之间的桥。

让高频执行路径尽量轻。 Decode 是一步步不断重复的,每步多浪费一点 CPU 开销,累积起来代价很高。persistent state、最小化输入准备、通用执行路径稳定——这些都是为了让这条高频路径尽可能少做无用功。

支持大量模型而不让主干失控。 Worker 作为设备层,Model Runner 作为通用执行层,Model 作为模型定义层,Sampler 作为解码决策层——这个分层是为"多模型、多任务、长期演化"做的准备,不是过度设计。

为高性能优化留下稳定的落脚点。 cudagraph、persistent batch、未来的 sampler backend 优化——这些都依赖执行链边界清晰、主路径足够稳定。如果一开始不分层,后面这些优化的落脚点就不存在了。


源码对应位置

对应这条执行链的主要源码:

路径 职责
vllm/v1/worker/gpu_worker.py 设备级 Worker 进程逻辑
vllm/v1/executor/multiproc_executor.py 多进程执行器,连接 Engine Core 和多个 Worker
vllm/v1/worker/gpu/model_runner.py Model Runner 主执行逻辑(输入准备、编排、采样器衔接)
vllm/v1/sample/sampler.py Sampler 主逻辑
vllm/engine/llm_engine.py 更上层的 Engine 入口与整体流程

结语

Worker 执行链的本质不是"把模型跑一下",而是把动态请求集合稳定、高效地翻译成 GPU 可执行的一步计算,再把结果正确映射回请求状态

这条链的工程价值主要体现在三个地方:Model Runner 作为翻译层吸收了调度世界和模型世界之间的阻抗差;input tensor 准备这一步决定了高频路径上的 CPU 开销是否可控;Sampler 的独立存在让解码策略可以在不改动模型的情况下灵活演化。

这套分层设计的局限在于:层次越多,调试时跨层追踪问题越麻烦;Model Runner 的"通用最小化"要求需要持续约束才能维持,随着功能增加这个约束会承受压力。


系列导航