vLLM 的请求是怎么跑完的:从入口到 GPUModelRunner 的源码链路

基于 vLLM v0.25.0 源码,串起 LLM.generate、AsyncLLM、LLMEngine、EngineCore、Scheduler、KVCacheManager、Executor、Worker、GPUModelRunner 和 OutputProcessor 的完整推理链路。

vLLM 的请求是怎么跑完的:从入口到 GPUModelRunner 的源码链路

这篇只做一件事:沿着 vLLM v0.25.0 的源码,把一个 generate 请求从入口一路追到 GPU runner,再追着输出回到用户。

源码版本是 vLLM v0.25.0,commit 702f4814fe54fabff350d43cb753ae3e47c0c276。阅读范围是 V1 引擎、dense generate 主路径、离线 LLM.generate() 和在线 AsyncLLM.generate()。这不是全后端枚举,而是一条可从源码复核的主链路。

0. 先给一张总图

flowchart TD
  A[User or API request] --> B[LLM.generate or AsyncLLM.generate]
  B --> C[InputProcessor: prompt to EngineCoreRequest]
  C --> D[OutputProcessor: register request state]
  C --> E[EngineCoreClient]
  E --> F[EngineCore.add_request]
  F --> G[Scheduler waiting queue]
  G --> H[EngineCore.step]
  H --> I[Scheduler.schedule]
  I --> J[KVCacheManager allocate slots]
  I --> K[Executor.execute_model]
  K --> L[Worker.execute_model]
  L --> M[GPUModelRunner.execute_model]
  M --> N[model forward and attention]
  N --> O[GPUModelRunner.sample_tokens]
  O --> P[Scheduler.update_from_output]
  P --> Q[EngineCoreOutputs]
  Q --> R[OutputProcessor.process_outputs]
  R --> S[RequestOutput or streaming queue]

一句话:vLLM 不是“请求进来后直接调用模型”。它先把输入变成 EngineCoreRequest,再由 Scheduler 按 token budget 和 KV cache block 安排本轮要算的 token,执行层把 SchedulerOutput 发给 worker 和 model runner,模型输出再回到 Scheduler 更新请求状态,最后由 OutputProcessor 做 detokenize、stop、logprobs 和流式分发。

1. 必须先钉住的概念

Engine / EngineCore:Engine 是上层对外的推理引擎,负责接请求、处理输入输出、日志和统计。EngineCore 是更内层的调度和执行核心,拿 EngineCoreRequest,吐 EngineCoreOutputs。源码入口看 LLMEngine.__init__

Scheduler:调度器,不是简单 batch builder。它决定每一步哪些请求能跑、每个请求跑多少 token、是否复用 prefix cache、是否需要 preempt、是否有足够 KV blocks。关键源码是 Scheduler.schedule()

KV cache:自回归生成时,历史 token 的 key/value 会被保存下来,后续 token 不用重复算历史。vLLM 早期成名的 PagedAttention 就是把 KV cache 当分页内存管理。

KV block / block table:KV cache 的分页单位和映射表。逻辑 token 序列可以映射到不连续的物理 KV blocks,从而减少碎片和预留浪费。

Prefix caching:如果新请求和旧请求共享前缀,就复用旧前缀已经算好的 KV cache。官方文档叫 Automatic Prefix Caching

Prefill / decode:prefill 是处理 prompt,decode 是逐步生成新 token。但 vLLM V1 Scheduler 的源码注释明确说,它内部不再把这两者写成硬分离阶段,而是让 num_computed_tokens 追赶 num_tokens_with_spec

Chunked prefill:长 prompt 不必一次算完,可以切成多步和 decode 请求混排,降低长请求阻塞。vLLM V1 guide 也把它和 prefix caching、speculative decoding 放在同一套 token budget 调度语境里。

Speculative decoding:小模型或 draft 方法先猜 token,大模型验证,目标是降低 inter-token latency。vLLM 官方文档见 Speculative Decoding。在源码里会影响 scheduled_spec_decode_tokens、rejection sampling 和请求状态更新。

CUDA Graph:把稳定 shape 的 GPU 执行图捕获后重复 replay,减少 CPU launch overhead。vLLM 设计文档见 CUDA Graphs

Tensor / Pipeline / Data Parallel:TP 切权重张量,PP 按层切模型,DP 复制模型服务不同请求。vLLM 的部署文档见 Parallelism and ScalingData Parallel Deployment

LoRA:低秩适配器,不改主模型大权重,用小矩阵叠加改变模型行为。vLLM 文档见 LoRA Adapters。Scheduler 会限制同批次 LoRA 数量,GPUModelRunner 会激活对应 LoRA。

Structured outputs / grammar bitmask:受约束输出时,用 grammar mask 约束 logits,让模型只能采样合法 token。EngineCore.step() 会在模型 forward 后、采样前从 Scheduler 取 grammar bitmask。

2. 离线路径:LLM.generate() 其实是一个同步 engine loop

离线 API 的入口是 LLM.generate()。这层只做三件事:

  1. 确认模型 runner 是 generate
  2. 补默认 SamplingParams
  3. _run_completion()

_run_completion()offline_utils.py 里,先 _add_completion_requests(),再 _run_engine()。真正的循环在 _run_engine()

while llm_engine.has_unfinished_requests():
  step_outputs = llm_engine.step()
  收集 finished outputs
最后按 request_id 排序

这说明离线批处理不是“先把所有 prompt 拼成一个 batch 然后一次跑完”。它仍然复用同一套 engine step 机制,只是由 Python 同步循环不断调用 LLMEngine.step(),直到所有请求结束。

3. 在线路径:AsyncLLM.generate() 是 per-request queue 加后台 output handler

API server 主入口是 AsyncLLM.generate()。它的 docstring 已经把流程写得很清楚:创建 request 对应的 stream,处理输入,把 request 加入 detokenizer,再加到独立进程里的 EngineCore。

关键点是:streaming 输出不是 model runner 直接 yield 文本。

AsyncLLM.add_request() 会先通过 InputProcessor 把 prompt 变成 EngineCoreRequest,然后启动 _run_output_handler(),为请求创建 RequestOutputCollector。内部 _add_request() 做两件事:

OutputProcessor.add_request(request, queue)
await engine_core.add_request_async(request)

后台任务 _run_output_handler() 持续:

await engine_core.get_output_async()
OutputProcessor.process_outputs(...)
必要时 abort stop-string finished requests
记录统计

然后 generate() 自己只是从队列里 get(),把 RequestOutput yield 给调用方。这个解耦很重要:在线服务的前端 async generator、后台 EngineCore 输出拉取、OutputProcessor detokenize,是三段不同职责。

4. LLMEngine 是输入输出壳,核心执行交给 EngineCoreClient

LLMEngine.__init__ 直接揭示了 V1 的分层:

renderer
InputProcessor: EngineInput -> EngineCoreRequest
OutputProcessor: EngineCoreOutputs -> RequestOutput
EngineCoreClient: EngineCoreRequests -> EngineCoreOutputs

LLMEngine.add_request() 的流程是:

  1. 如果传入的不是 EngineCoreRequest,调用 InputProcessor.process_inputs()
  2. OutputProcessor.add_request() 先在本进程登记请求状态。
  3. engine_core.add_request() 把请求交给 EngineCore。
  4. 如果 SamplingParams.n > 1,用 ParentRequest fan out 成多个 child requests。

LLMEngine.step() 则是输出方向:

engine_core.get_output()
OutputProcessor.process_outputs(...)
engine_core.abort_requests(stop string 触发的请求)
记录 stats
返回 RequestOutput

所以 LLMEngine 更像前后处理壳,不直接调 GPU forward。

5. InputProcessorOutputProcessor 分别吃掉了两端复杂度

InputProcessor.process_inputs() 不只是 tokenization。它还会:

  1. 校验 SamplingParams / PoolingParams、LoRA、data parallel rank。
  2. 兼容 raw prompt 和已经 render 好的 EngineInput
  3. 拆 encoder/decoder inputs。
  4. max_tokens 补默认值。
  5. 更新 generation config 和 tokenizer 相关参数。
  6. 整理 multimodal features。
  7. 构造 EngineCoreRequest

输出侧的 OutputProcessor.process_outputs() 也不是简单把 token id 转文字。它在一个 batch loop 里集中做:

  1. 统计更新。
  2. detokenize。
  3. stop string 检查。
  4. sample logprobs / prompt logprobs。
  5. 构造 RequestOutput
  6. 如果是 AsyncLLM,把输出放进 per-request queue。
  7. 如果是 LLMEngine,同步返回 request_outputs

这也解释了为什么 stop string 可能触发上层再调用 engine_core.abort_requests():EngineCore 按 token 层面知道 stop token,但文本层面的 stop string 要等 detokenize 后才能确认。

6. EngineCoreClient 决定 EngineCore 在哪里跑

EngineCoreClient.make_client() 按两个维度选实现:

multiprocess + asyncio -> AsyncMPClient / DPAsyncMPClient / DPLBAsyncMPClient
multiprocess + sync    -> SyncMPClient
in-process             -> InprocClient

make_async_mp_client() 还会根据 data parallel 配置选择 DP 客户端。in-process 的 InprocClient.get_output() 很直观:调用 engine_core.step_fn(),再 post_step()

如果是多进程,EngineCore 进程里的 run_busy_loop() 也是两步循环:

_process_input_queue()
_process_engine_step()

这说明 EngineCore 可以和 API 前端拆进程,用 ZMQ/队列通信,但核心 step 语义不变。

7. EngineCore 的一步:schedule、execute、sample、update

EngineCore.add_request() 主要把 Request 放进 scheduler.add_request()。真正的一步在 EngineCore.step()

if scheduler 没有请求: return
scheduler_output = scheduler.schedule(...)
future = model_executor.execute_model(scheduler_output, non_block=True)
grammar_output = scheduler.get_grammar_bitmask(scheduler_output)
model_output = future.result()
if model_output is None:
  model_output = model_executor.sample_tokens(grammar_output)
处理 aborts
engine_core_outputs = scheduler.update_from_output(...)

这里有两个关键设计:

  1. Scheduler 先产出 SchedulerOutput,执行层不自己决定请求顺序。
  2. MRV2 的 forward 和 sampling 可以拆开。GPUModelRunner.execute_model() 可能只保存 hidden states,返回 None,随后 sample_tokens() 再做 logits、grammar mask、采样和 spec decode。

8. Scheduler 的核心思想:没有硬分离 prefill/decode phase

Scheduler.schedule() 顶部注释是理解 vLLM V1 的钥匙:

没有固定 decoding phase 或 prefill phase。
每个请求都有 num_computed_tokens 和 num_tokens_with_spec。
每一步调度器分配 token,让 computed 追上 tokens_with_spec。
这能覆盖 chunked prefill、prefix caching、speculative decoding。

这句话值得反复看。传统直觉会把请求分成“prefill 完了再 decode”。但 V1 Scheduler 选择的是更统一的 token debt 模型:

这个请求理论上已经有多少 token 需要被模型处理
它实际已经处理到哪里
本轮还欠多少
本轮预算和 KV cache 允许还多少

调度的实际顺序大致是:

  1. 先调度 running requests,消耗 token_budget
  2. 没有 preemption 且未暂停时,再调度 waiting requests。
  3. waiting 侧会检查 max running requests、LoRA 限制、远程 KV 状态、prefix cache 命中、encoder budget。
  4. 每个候选请求都要通过 KV slot 分配。
  5. 最后构造 SchedulerOutput,里面包括新请求、cached requests、每请求 token 数、spec decode tokens、encoder inputs、common prefix blocks、KV connector metadata。

源码对应:

9. KV cache 分配是调度能不能成的硬约束

KVCacheManager.allocate_slots() 是一段很长的函数,因为它处理的是“这个请求本轮真的能不能上车”。

它的 docstring 把布局分成几段:

computed prefix
new prefix-cache hit
external computed tokens
new tokens to compute
lookahead tokens

这里的 lookahead 就和 speculative decoding 相关,external computed tokens 和 KV connector / disaggregated prefill 相关。函数实际做的事包括:

  1. 如果 full sequence 必须 fit,先检查整段序列能不能放下。
  2. 释放 sliding window 等场景下不再参与 attention 的 blocks。
  3. 计算还需要多少新 blocks。
  4. 扣掉 reserved blocks 和 watermark,避免把后续请求逼死。
  5. 把 prefix-cache hit 或外部 KV blocks 接到请求已有 blocks 上。
  6. 为本轮要算的新 token 和 lookahead token 分配 blocks。
  7. 如果启用 caching,把可提交 token 的 blocks 加入 cache。

这就是为什么 vLLM 的吞吐不是只由 batch size 决定。真正限制往往是三件事一起卡:

token_budget
max running requests
free KV blocks

10. Executor / Worker / GPUModelRunner:执行层一层层落到 GPU

EngineCore.step() 调的是 model_executor.execute_model()。抽象 Executor 的 execute_model() 本质是:

collective_rpc("execute_model", args=(scheduler_output,))

单进程 executor UniProcExecutor.execute_model() 返回 future,多进程 executor MultiprocExecutor.execute_model() 会指定 output_rank、timeout 和 KV output aggregator。

Worker 侧的 Worker.execute_model() 主要处理 pipeline parallel 的收发和 profile context,然后调用:

self.model_runner.execute_model(scheduler_output, intermediate_tensors)

如果当前不是最后一个 pipeline rank,它可能返回 IntermediateTensors 并异步发给下一个 rank;最后一个 rank 才会产生 hidden states 供采样。

11. GPUModelRunner 初始化:模型、sampler、KV cache、CUDA graph 都在这里接上

GPUModelRunner.__init__ 初始化了很多长期状态:

  1. model_configcache_configparallel_configscheduler_config
  2. pipeline/data/decode-context parallel 的 rank 信息。
  3. multimodal encoder cache。
  4. speculative decoding speculator。
  5. RequestStateInputBuffers
  6. sampler、rejection sampler、prompt logprobs worker、structured outputs worker 的占位。
  7. LoRA state。
  8. KV connector。

GPUModelRunner.load_model() 会通过 get_model_loader() 加载模型,按需包 LoRA,初始化 model_stateSamplerRejectionSamplerPromptLogprobsWorkerStructuredOutputsWorker

GPUModelRunner.initialize_kv_cache() 则会:

  1. 根据 KV cache spec 算 block table 上限。
  2. 初始化 attention backend。
  3. 创建 BlockTables
  4. 解析 CUDA graph mode。
  5. 初始化实际 KV cache tensors。
  6. 创建 KV connector。

这说明 ModelRunner 不是“模型 forward wrapper”。它是模型、请求状态、KV cache、attention metadata、CUDA graph、sampler、LoRA、spec decode 的汇合点。

12. GPUModelRunner 的一次 forward 和一次 sampling

GPUModelRunner.execute_model() 这段很长,但主干可以拆成:

  1. 更新 request state:finish/free/add/update requests。
  2. 如果本轮没有 token,走 kv_connector.no_forward()
  3. 根据 batch 描述和 DP rank 选择 CUDA graph / eager。
  4. prepare_inputs()prepare_attn(),构造 block tables 和 slot mappings。
  5. 准备 multimodal embeddings、LoRA inputs、attention metadata。
  6. 进入 set_forward_context(...)
  7. kv_connector.pre_forward(...)
  8. 用 full graph、piecewise graph 或 eager self.model(**model_inputs) 跑 forward。
  9. 如果是最后 PP rank,保存 hidden states 到 execute_model_state
  10. 返回 None,等待 sample_tokens()

采样在 GPUModelRunner.sample_tokens()。它会取出上一步保存的 execute_model_state,最后一个 PP rank 调 sample()

hidden_states[input_batch.logits_indices]
model.compute_logits(...)
可选 grammar bitmask
sampler 或 rejection_sampler

然后它会计算 prompt logprobs,创建 AsyncOutput 让输出拷贝和后续工作重叠,更新 sampled token 状态,必要时让 speculator propose draft tokens,最后执行 kv_connector.post_forward(...)

这就是 MRV2 路径一个非常关键的改变:forward、采样、异步输出拷贝、spec decode 后处理,不再揉成一个黑盒函数。

13. 输出不是终点,还要回 Scheduler 更新状态

模型 runner 的输出会回到 Scheduler.update_from_output()。这一步会:

  1. 读取 sampled token ids、logprobs、prompt logprobs、pooler output、KV connector output。
  2. 处理 KV load failure。
  3. 处理 speculative decoding 的 accepted/rejected tokens,并回调 request 的 num_computed_tokens
  4. 更新 request 输出 token,检查 stop。
  5. 推进 structured output grammar。
  6. 对 finished request 释放 KV blocks。
  7. 构造 EngineCoreOutput
  8. 汇总成每个 client 的 EngineCoreOutputs
  9. 发布 KV cache events 和 scheduler stats。

这个回环说明 vLLM 的模型执行不是纯函数:

SchedulerOutput -> ModelRunnerOutput

更准确是一个状态机:

Scheduler state + KV state + Request state
-> schedule
-> model runner
-> update scheduler/request/KV state
-> next step

14. 读源码后的架构判断

我会把 vLLM V1 主链路理解成四层:

Frontend shell:
  LLM / AsyncLLM / LLMEngine
  负责 API、输入输出、streaming queue、stats

Engine core:
  EngineCore / Scheduler / KVCacheManager
  负责请求状态、token budget、KV blocks、preemption、prefix/spec/grammar

Execution bridge:
  EngineCoreClient / Executor / Worker
  负责进程边界、collective_rpc、TP/PP/DP worker 调用

GPU runtime:
  GPUModelRunner / ModelState / attention backend / sampler
  负责 input batch、attention metadata、CUDA graph、model forward、sampling

这也是为什么 vLLM 很难用一句“它就是 PagedAttention”概括。PagedAttention 是 KV cache 管理的基础思想之一,但在 v0.25.0 的 V1/MRV2 主路径里,性能来自一整套协作:

  1. Scheduler 用统一 token debt 模型混排 prefill/decode/spec。
  2. KVCacheManager 用 blocks 和 prefix cache 决定 admission。
  3. EngineCore 和 Executor 隔离前端、调度和 worker。
  4. GPUModelRunner 把 attention metadata、CUDA graph、sampler、speculator、LoRA 接到同一个 runtime。
  5. OutputProcessor 把 token 级输出还原成用户可见的 streaming 文本。

15. 这篇没有覆盖什么

这篇只沿 vLLM v0.25.0 的 V1 dense generate 主路径读。没有展开:

  1. 每个 attention backend 的 kernel 细节。
  2. ROCm、XPU、CPU worker 的差异。
  3. Rust frontend 的 HTTP/gRPC 实现。
  4. MoE expert parallel 的 all-to-all 细节。
  5. Disaggregated prefill / KV offloading connector 的部署细节。
  6. 每个模型类的 forward() 实现。

这些都可以继续拆成后续文章,但不应该混进这篇主链路里,否则读者会失去骨架。

16. 自检问题

读完这条链路,至少应该能回答:

  1. LLM.generate() 为什么不是直接调用模型 forward?
  2. AsyncLLM.generate() 的 streaming 输出从哪里来?
  3. Scheduler 为什么说没有硬分离 prefill/decode phase?
  4. KV cache block 分配为什么会影响请求能不能被调度?
  5. 为什么 EngineCore.step()execute_model() 返回 None 后还要调用 sample_tokens()
  6. 模型输出为什么要先回 Scheduler,再交给 OutputProcessor?

如果这六个问题能顺着源码答出来,vLLM 的主链路就算真正入门了。