← All notes

vLLM V1 架构:一条请求怎样穿过推理服务

Table of contents

本篇要回答什么

一个 PyTorch 自回归循环已经能生成文本,vLLM 为什么还需要前端、EngineCore、Scheduler、KVCacheManager、ModelExecutor、GPUModelRunner 和 attention backend?答案在于推理服务处理的不是一个固定 tensor,而是持续到达、长度不同、随时完成或断开的请求集合。

1. 正确生成与高吞吐服务是两件事

假设 A、B、C 的 prompt/output 长度分别为 4/2、4/8、20/2:

  • 串行执行让 C 的首 token 等待 A、B 全部结束;
  • 静态 batch 可能把 prompt pad 到 20,并在 A、C 提前完成后留下空 row;
  • iteration-level scheduling 在每次模型执行结束后重新选择工作,完成的请求退出,新请求可在下一轮加入;
  • C 的长 prefill 还能拆成 chunks,避免一次占满整轮。

因此 vLLM 除模型 forward 外至少还要解决:KV 容量、动态调度、变长 GPU 执行,以及 HTTP、tokenizer、流式输出、指标与断连处理。

2. Offline 与在线入口

Offline API 由调用者直接拥有 engine:

from vllm import LLM, SamplingParams

llm = LLM(model="Qwen/Qwen3-0.6B")
outputs = llm.generate(
    ["请用一句话解释 KV Cache。"],
    SamplingParams(temperature=0.0, max_tokens=32),
)

LLM.generate() 是同步接口,但 prompts 列表仍由 engine 调度,不等于逐 prompt 串行执行。v0.20.0 可从 vllm/entrypoints/llm.pyLLM.generate 向下阅读。

在线服务则多一层长期运行的网络前端:

HTTP POST /v1/chat/completions
  → JSON 校验 / chat template / tokenizer
  → EngineCoreRequest
  → EngineCore + Scheduler
  → GPU execution + sampling
  → RequestOutput
  → JSON 或 SSE streaming

端口只是操作系统接收连接的编号,不对应一张 GPU。/health 成功说明前端能响应健康检查,不证明完整生成路径、负载下 p99 或输出正确性。

Offline 调用链:同步 API 不等于串行执行

按 v0.20.0 的职责边界,可以这样读 LLM.generate()

用户 Python
  → LLM.generate(prompts, sampling_params)
  → 输入预处理 / request ids
  → LLMEngine.add_request(...)
  → while unfinished: LLMEngine.step()
  → EngineCoreClient.get_output()
       ├─ InprocClient: 同进程驱动 EngineCore
       └─ MP client: 经 IPC 读取后台 EngineCore 的输出
  → RequestOutput 列表

generate() 对调用者是阻塞函数,但内部可以把 prompts 同时加入 engine,并在 iteration 边界动态组成 batch。它返回的是完成后的 Python 对象,不含 HTTP、SSE、客户端断连和网络 backpressure。

Online 调用链:同一 core 外面多了服务生命周期

HTTP client
  → FastAPI route / OpenAI 协议校验
  → chat messages --chat template--> prompt text/token ids
  → tokenizer + 参数校验
  → AsyncLLM / EngineCoreClient.add_request
  → 后台 EngineCore schedule→execute→update
  → EngineCoreOutput
  → OutputProcessor / detokenize / stop strings
  → SSE chunk 或最终 JSON

chat template 是把 {role, content} 消息转换成模型训练时约定的文本格式;tokenizer 再把文本变成 token ids。两份肉眼相同的消息若模板、special tokens 或 tokenizer 版本不同,得到的 token prefix 也可能不同,从而影响长度、logits 与 APC 命中。

健康检查、一次 HTTP 200、输出语义正确与负载性能是四种不同证据:

检查 能证明 不能证明
/health 前端进程可响应 模型 forward 成功
一次 200 完整路径至少成功一次 并发稳定、p99 达标
固定输入比对 当前配置下输出/分布符合预期 高吞吐
压测 + profiler 指定 workload 的性能 所有模型与硬件通用

3. async task、进程和 GPU worker 不要混为一谈

  • async task 是事件循环里的协作任务;await 在 I/O 等待时让出执行权;
  • 进程拥有独立地址空间和 PID,跨进程需要 IPC;
  • GPU worker 是某个 device/rank 的模型执行角色;
  • rank 是分布式进程在全局或并行组中的编号,不是 HTTP request id。

v0.20.0 的 EngineCoreClient.make_client 会依据配置选择同进程或多进程客户端。InprocClient 可在当前进程调用 core;MP client 则通过后台 core 和 IPC 交换小型消息。共同接口让上层不必针对每种拓扑重写 add request/get output。

完整进程图应把“并发单位”和“模型分片单位”分开:

server process
  ├─ HTTP event loop
  │    ├─ async task: request A
  │    ├─ async task: request B
  │    └─ async task: streaming writer
  └─ EngineCoreClient
        │ inproc call 或 IPC
        ▼
EngineCore process
  └─ executor
       ├─ worker process / global rank 0 → GPU 0
       ├─ worker process / global rank 1 → GPU 1
       └─ ... TP/PP/DP collective groups

await 只表示当前协程在等待时让出事件循环,不会凭空创建 OS process,也不会让同一 GPU 同时无限执行 kernels。每个 worker 内还可能有 CPU 准备、CUDA streams、CUDA Graph 和 device kernels 的并行/重叠;这与“有几个 worker”是不同层次。

常见 client 选择可归纳为:

client core 所在位置 上层看到的接口 主要边界
InprocClient 同进程 add/abort/get output Python 直接调用
SyncMPClient 后台进程 同步调用 IPC 阻塞边界
AsyncMPClient 后台进程 async 调用 event loop + IPC

DP、外部 launcher 或其他 executor 可能增加子类/进程,表格不是所有拓扑枚举。准确表述应是 LLMEngine.step → EngineCoreClient.get_output,然后才根据 client 类型落到同进程 core 或 IPC;不能无条件画成 LLMEngine.step → EngineCore.step

4. 一条请求的表示怎样变化

固定 prompt ids [11,12,13,14],最多生成 [21,22,23]

用户文本 / chat messages
        │  template + tokenize + validate
        ▼
EngineCoreRequest
        │  Request.from_engine_core_request
        ▼
Request(status=WAITING,
        all_token_ids=[11,12,13,14],
        num_computed_tokens=0)
        │  Scheduler.add_request
        ▼
waiting/running queues + block metadata
        │  Scheduler.schedule
        ▼
SchedulerOutput
        │  ModelExecutor / Worker
        ▼
GPUModelRunner: packed input tensors + attention metadata
        │  model forward + sampling
        ▼
ModelRunnerOutput(sampled ids/logprobs)
        │  Scheduler.update_from_output
        ▼
EngineCoreOutput → detokenize → SSE/JSON

EngineCoreRequest 是跨边界消息,Request 是 Scheduler 持续修改的运行状态,SchedulerOutput 是单次 iteration 的执行计划。三者生命周期不同。

每种对象的输入、输出与所有者

对象 创建者 所有者/修改者 典型字段 生命周期
HTTP schema route 前端 messages、model、stream 一次网络请求
EngineCoreRequest 前端/engine facade 作为消息传给 core prompt ids、sampling params、request id 跨边界传输
Request Request.from_engine_core_request Scheduler status、all ids、computed、spec ids 整个推理请求
SchedulerOutput Scheduler.schedule() executor 消费 scheduled counts、new/cached/finished、block ids 一次 iteration
ModelRunnerOutput model runner Scheduler 消费 sampled ids、logprobs 等 一次 iteration
EngineCoreOutput Scheduler/core 前端消费 新 token ids、finish reason 一次更新
RequestOutput output processor API 调用者 文本、token、metrics 对外返回

ModelRunnerOutput.sampled_token_ids 跨进程时适合用小型 Python lists;这不表示 sampling 必然在 CPU。设备上可以先产生 tensor,再在必须推进控制状态的边界做 copy/转换。反过来,把巨大 K/V payload 放进每轮 IPC 消息会引入地址空间、同步和传输问题,所以消息通常传 block ids,而 K/V 常驻 worker 的 GPU pool。

5. 为什么最终 num_tokens=7,computed 可能只有 6

prefill 处理 4 个 prompt tokens 后采样 21;随后:

iteration 输入 采样输出 执行后 num_computed_tokens all_token_ids 长度
prompt 4 tokens 21 4 5
21 22 5 6
22 23 6 7

请求在 23 返回后结束,23 没有再次进入模型。于是逻辑 token 总数为 7,真正经过模型的为 6。这不是少算了结果,而是 next-token prediction 的停止边界。

6. 核心组件的所有权边界

组件 主要拥有/产生什么 刻意不做什么
前端 HTTP、模板、tokenize、streaming 不决定 GPU 每轮公平性
EngineCore core 生命周期、命令、schedule→execute→update 循环 不实现具体 Qwen attention
Scheduler 请求状态、队列、token budget、单轮计划 不执行 GPU kernel
KVCacheManager block 查找、分配、引用与释放 不做 softmax
ModelExecutor/Worker 分布式执行与设备 worker 编排 不解析 HTTP JSON
GPUModelRunner 把变长计划压成 device batch,准备 metadata 不决定服务策略
Attention backend cache layout、能力约束和 attention kernel 不拥有全局请求队列

一次 iteration 的主干是:

EngineCore
  ├─ Scheduler.schedule()        产生本轮 token/block 计划
  ├─ ModelExecutor.execute()     把计划交给 workers
  │    └─ GPUModelRunner         打包 tensors 并执行模型
  └─ Scheduler.update(...)       接收 sampled tokens,推进请求状态

“基于真实输出的 token/stop 更新必须在 execute 之后”是一个正确性约束:schedule 只承诺准备算多少 token;真正的 sampled ids、推测解码接受长度、模型错误和部分停止条件要等执行结果。要注意 v0.20.0 的 Scheduler 会在计划构造后先把 scheduled positions 乐观计入 num_computed_tokens,把它当作“已完成或已提交的调度前沿”;这不是提前伪造 sampled token。失败、拒绝或异步差异仍要在 output 处理时校正。

# 教学伪代码:同步核心主干
while has_work():
    plan = scheduler.schedule()              # 读权威状态,预留资源
    try:
        runner_output = executor.execute(plan)
    except Exception:
        scheduler.rollback(plan)             # 不能保留虚假的进度
        raise
    core_outputs = scheduler.update_from_output(plan, runner_output)
    publish(core_outputs)

输入是 Scheduler 的长期请求状态和资源状态;输出是本轮可发布的新 token。schedule() 可能修改预留/队列元数据,update 才按真实结果提交 token 进度。真实 v0.20.0 还处理 grammar bitmask、执行期间 abort、KV connector、错误诊断、pipeline/batch queue 等分支;教学伪代码不能证明某个运行配置走同步路径。

为什么先初始化真实 KV 容量,再构造依赖它的调度器

启动主线可以概括为:

create ModelExecutor
  → get_kv_cache_specs()
  → determine_available_memory()
  → get_kv_cache_configs(...)
  → generate scheduler KV-cache config
  → executor.initialize_from_config(...)  # worker 建真实 GPU cache
  → create Scheduler(config)               # 管同一容量的控制面 blocks

Scheduler 判断能否准入/增长请求,必须先知道 pool 有多少 blocks、block size 与 cache groups。控制面 KVCacheManager 保存 block ids、hash、ref count 等;worker 保存 K/V tensor。两者由 SchedulerOutput 同步映射。如果 Scheduler 认为请求拿到 physical block 7,而 worker-side BlockTable 没收到更新,kernel 就可能寻址错误。

无 KV 的模型、connector、elastic expert parallel、PP batch queue 等有条件分支;上述流程只表达资源依赖,不把所有配置写成同一条无条件路径。

7. 控制面与数据面

Scheduler 长期操作 request id、status、token counts、block ids、hash、ref count 和队列。这些对象小、不规则、分支多,适合 CPU 控制面。

模型 weights、KV pool、input ids/positions、hidden states、logits、workspace 与 graph buffers 是 GPU 数据面,需要稳定 dtype、shape 与 layout。

block_table 能同时存在两种表示:Scheduler 侧是 Python block-id lists;runner/backend 可把它提交成 device tensor。分类取决于当前表示和消费者,不取决于变量名。

控制面消息只携带:request 42 使用 blocks [3, 8]

GPU 数据面长期保存:
KV pool[physical block 3]
KV pool[physical block 8]

大体积 K/V 不会跟随每个 SchedulerOutput 在进程之间来回复制。消息传递的是位置和本轮工作量。

block table 从 Python metadata 到 device tensor

Scheduler / KVCacheManager
  request A → physical block ids [7, 3, 11]
          │ SchedulerOutput(小型控制消息)
          ▼
GPUModelRunner._update_states
  更新 worker-side request state / BlockTable
          │ commit / copy
          ▼
device block_table tensor [num_reqs, max_blocks_per_req]
          │ attention metadata
          ▼
backend kernel: logical position → block id → slot → KV pool address

概念布局可写成 kv_cache[layer][K_or_V][physical_block][offset][kv_head][head_dim],但真实轴顺序、是否 K/V 合并、page/block 大小和 dtype 必须查选中的 cache spec/backend。这张图只说明地址翻译,不是 ABI。

CPU 适合不规则、分支多的请求队列和引用状态;GPU 适合批量 tensor 计算。GPU 并非“总是更快”:把 Scheduler 的大量 Python 分支改成许多微小 kernels 可能增加 launch/sync,而每步从 CPU 经 PCIe 搬权重/KV 又会让传输成为瓶颈。

一次请求的端到端时序

client      front end       core/scheduler       runner/GPU
  | POST       |                   |                  |
  |----------->| template/tokenize |                  |
  |            |---add request---->| WAITING          |
  |            |                   |--schedule------->|
  |            |                   |  prompt + blocks | forward/sample 21
  |            |                   |<--runner output--|
  |            |                   | update computed=4|
  |<--SSE 21---|<--core output-----|                  |
  |            |                   |--schedule 21---->|
  |            |                   |<--sample 22------|
  |<--SSE 22---|<--update----------|                  |
  |            |                   |--schedule 22---->|
  |            |                   |<--sample 23------|
  |<--SSE 23---|<--finish/free-----|                  |

客户端断连不等于 core 请求自动消失。前端要检测断连并发出 abort/cancel;若请求已在执行,本轮仍可能完成后才观察到取消。server TTFT 可包含网络、解析和排队,而 Offline TTFT 没有同一边界,比较时必须声明计时起点。

8. 性能问题应回到所属层

  • num_scheduled_tokens 不合理:先查 Scheduler;
  • block id/ref count 异常:查 KVCacheManager/BlockPool;
  • slot mapping 或 packed shape 错:查 GPUModelRunner/BlockTable;
  • kernel/dtype/layout 不支持:查 attention backend;
  • HTTP 200 但内容异常:还要检查模板、采样、finish reason 与 detokenization;
  • GPU 间出现大段空隙:区分 queue、CPU preparation、IPC、launch gap 和 kernel device time。

推荐断点与日志字段

目标 v0.20.0 阅读/断点入口 建议记录
外部请求形成 entrypoints/llm.py::LLM.generate 或 OpenAI route request id、prompt tokens、sampling params
client 拓扑 v1/engine/core_client.py::make_client client class、MP/DP 配置
core 迭代 v1/engine/core.py 的 step 主干 iteration、schedule/execute/update 时间
调度计划 v1/core/sched/scheduler.py::schedule waiting/running、budget、per-request scheduled tokens
KV 分配 v1/core/kv_cache_manager.py::allocate_slots blocks、hits、free blocks、preemption
worker 同步 v1/worker/gpu_model_runner.py::_update_states new/cached/finished requests、block ids
device 输入 runner input/metadata builder input ids、positions、query starts、block table shape
backend v1/attention/backends/registry.py 与选中 backend backend 名、dtype、layout、block size

这些断点分别证明静态调用或某次动态路径。类存在于注册表只证明它是候选;必须看到启动日志/trace 才能说本次运行选中了它,必须有 profiler 才能归因性能。

常见误区

  • “async 表示开了新进程”:异步任务与进程不是同一种并发。
  • “一个请求对应一个 GPU worker”:请求可被 scheduler 组织进共享 iteration batch。
  • “SchedulerOutput 里有 block id,所以包含 KV 数值”:block id 只是控制面地址身份。
  • “vLLM 加速训练”:vLLM 主线是 inference/serving;训练系统可以调用它生成 rollout,但它不执行反向传播和 optimizer。

源码阅读入口(v0.20.0)

  • vllm/entrypoints/llm.py:Offline API;
  • vllm/entrypoints/openai/api_server.py:在线服务入口;
  • vllm/v1/engine/core_client.py:同进程与多进程 client;
  • vllm/v1/engine/core.py:核心循环;
  • vllm/v1/request.py:运行时 Request;
  • vllm/v1/core/sched/scheduler.py:调度状态和单轮计划;
  • vllm/v1/worker/gpu_model_runner.py:device batch 与模型执行。

本篇总结

vLLM V1 的关键不是多套重复的“engine”,而是清晰的边界:前端把外部请求翻译成 core 消息,Scheduler 在 CPU 控制面决定单轮工作,KVCacheManager管理物理块,GPUModelRunner 将变长计划变成 tensors,backend 执行具体 kernel。沿同一请求的表示变化阅读源码,比按目录逐个背类名更可靠。


上一篇:KV Cache · 系列首页 · 下一篇:PagedAttention

资料