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 Scaling 和 Data 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()。这层只做三件事:
- 确认模型 runner 是
generate。 - 补默认
SamplingParams。 - 调
_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() 的流程是:
- 如果传入的不是
EngineCoreRequest,调用InputProcessor.process_inputs()。 OutputProcessor.add_request()先在本进程登记请求状态。engine_core.add_request()把请求交给 EngineCore。- 如果
SamplingParams.n > 1,用ParentRequestfan out 成多个 child requests。
LLMEngine.step() 则是输出方向:
engine_core.get_output()
OutputProcessor.process_outputs(...)
engine_core.abort_requests(stop string 触发的请求)
记录 stats
返回 RequestOutput
所以 LLMEngine 更像前后处理壳,不直接调 GPU forward。
5. InputProcessor 和 OutputProcessor 分别吃掉了两端复杂度
InputProcessor.process_inputs() 不只是 tokenization。它还会:
- 校验
SamplingParams/PoolingParams、LoRA、data parallel rank。 - 兼容 raw prompt 和已经 render 好的
EngineInput。 - 拆 encoder/decoder inputs。
- 给
max_tokens补默认值。 - 更新 generation config 和 tokenizer 相关参数。
- 整理 multimodal features。
- 构造
EngineCoreRequest。
输出侧的 OutputProcessor.process_outputs() 也不是简单把 token id 转文字。它在一个 batch loop 里集中做:
- 统计更新。
- detokenize。
- stop string 检查。
- sample logprobs / prompt logprobs。
- 构造
RequestOutput。 - 如果是 AsyncLLM,把输出放进 per-request queue。
- 如果是 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(...)
这里有两个关键设计:
- Scheduler 先产出
SchedulerOutput,执行层不自己决定请求顺序。 - 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 允许还多少
调度的实际顺序大致是:
- 先调度 running requests,消耗
token_budget。 - 没有 preemption 且未暂停时,再调度 waiting requests。
- waiting 侧会检查 max running requests、LoRA 限制、远程 KV 状态、prefix cache 命中、encoder budget。
- 每个候选请求都要通过 KV slot 分配。
- 最后构造
SchedulerOutput,里面包括新请求、cached requests、每请求 token 数、spec decode tokens、encoder inputs、common prefix blocks、KV connector metadata。
源码对应:
- running / waiting 调度入口:
Scheduler.schedule()lines 440-745 - 构造
SchedulerOutput:Scheduler.schedule()lines 1039-1133
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 相关。函数实际做的事包括:
- 如果 full sequence 必须 fit,先检查整段序列能不能放下。
- 释放 sliding window 等场景下不再参与 attention 的 blocks。
- 计算还需要多少新 blocks。
- 扣掉 reserved blocks 和 watermark,避免把后续请求逼死。
- 把 prefix-cache hit 或外部 KV blocks 接到请求已有 blocks 上。
- 为本轮要算的新 token 和 lookahead token 分配 blocks。
- 如果启用 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__ 初始化了很多长期状态:
model_config、cache_config、parallel_config、scheduler_config。- pipeline/data/decode-context parallel 的 rank 信息。
- multimodal encoder cache。
- speculative decoding speculator。
RequestState和InputBuffers。- sampler、rejection sampler、prompt logprobs worker、structured outputs worker 的占位。
- LoRA state。
- KV connector。
GPUModelRunner.load_model() 会通过 get_model_loader() 加载模型,按需包 LoRA,初始化 model_state、Sampler、RejectionSampler、PromptLogprobsWorker、StructuredOutputsWorker。
GPUModelRunner.initialize_kv_cache() 则会:
- 根据 KV cache spec 算 block table 上限。
- 初始化 attention backend。
- 创建
BlockTables。 - 解析 CUDA graph mode。
- 初始化实际 KV cache tensors。
- 创建 KV connector。
这说明 ModelRunner 不是“模型 forward wrapper”。它是模型、请求状态、KV cache、attention metadata、CUDA graph、sampler、LoRA、spec decode 的汇合点。
12. GPUModelRunner 的一次 forward 和一次 sampling
GPUModelRunner.execute_model() 这段很长,但主干可以拆成:
- 更新 request state:finish/free/add/update requests。
- 如果本轮没有 token,走
kv_connector.no_forward()。 - 根据 batch 描述和 DP rank 选择 CUDA graph / eager。
prepare_inputs(),prepare_attn(),构造 block tables 和 slot mappings。- 准备 multimodal embeddings、LoRA inputs、attention metadata。
- 进入
set_forward_context(...)。 - 调
kv_connector.pre_forward(...)。 - 用 full graph、piecewise graph 或 eager
self.model(**model_inputs)跑 forward。 - 如果是最后 PP rank,保存 hidden states 到
execute_model_state。 - 返回
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()。这一步会:
- 读取 sampled token ids、logprobs、prompt logprobs、pooler output、KV connector output。
- 处理 KV load failure。
- 处理 speculative decoding 的 accepted/rejected tokens,并回调 request 的
num_computed_tokens。 - 更新 request 输出 token,检查 stop。
- 推进 structured output grammar。
- 对 finished request 释放 KV blocks。
- 构造
EngineCoreOutput。 - 汇总成每个 client 的
EngineCoreOutputs。 - 发布 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 主路径里,性能来自一整套协作:
- Scheduler 用统一 token debt 模型混排 prefill/decode/spec。
- KVCacheManager 用 blocks 和 prefix cache 决定 admission。
- EngineCore 和 Executor 隔离前端、调度和 worker。
- GPUModelRunner 把 attention metadata、CUDA graph、sampler、speculator、LoRA 接到同一个 runtime。
- OutputProcessor 把 token 级输出还原成用户可见的 streaming 文本。
15. 这篇没有覆盖什么
这篇只沿 vLLM v0.25.0 的 V1 dense generate 主路径读。没有展开:
- 每个 attention backend 的 kernel 细节。
- ROCm、XPU、CPU worker 的差异。
- Rust frontend 的 HTTP/gRPC 实现。
- MoE expert parallel 的 all-to-all 细节。
- Disaggregated prefill / KV offloading connector 的部署细节。
- 每个模型类的
forward()实现。
这些都可以继续拆成后续文章,但不应该混进这篇主链路里,否则读者会失去骨架。
16. 自检问题
读完这条链路,至少应该能回答:
LLM.generate()为什么不是直接调用模型 forward?AsyncLLM.generate()的 streaming 输出从哪里来?- Scheduler 为什么说没有硬分离 prefill/decode phase?
- KV cache block 分配为什么会影响请求能不能被调度?
- 为什么
EngineCore.step()在execute_model()返回None后还要调用sample_tokens()? - 模型输出为什么要先回 Scheduler,再交给 OutputProcessor?
如果这六个问题能顺着源码答出来,vLLM 的主链路就算真正入门了。