vLLM 源码里的 MTP 一轮调用链
把上一课的抽象流程贴到最新 vLLM:上一轮 draft 如何进入 scheduler,本轮 target 如何验证,以及本轮结束后下一轮 draft 从哪里来。
本课所有路径和行号都对应 vLLM commit 3de4b2bf3c477513afff4a58680eb00d557bb53a(2026-07-22)。源码会继续变化,所以先看 commit,再看类名。
在 vLLM 的一次 iteration 中,哪句话更准确?
先画跨 iteration 的边界
固定一个请求:真实前缀是 我 喜欢。假设上一轮已经留下 draft [吃, 苹果, 。]。本课只追一个事实:这些 token 如何穿过 scheduler、GPU runner 和 EngineCore。
request.spec_token_ids 保存上一轮 draft。RejectionSampler 产出 accepted、correction 或 bonus。spec_token_ids。逐步跟源码走一遍
点击下一步时,只问“现在手里传的对象是什么”。每一步的源码链接都固定在本课版本。
SpeculativeConfig.__post_init__ 看到 method="mtp" 且没有单独 draft model 时,会把 target 的模型名复制给 self.model,并对齐 quantization。这说明 MTP draft 结构可以来自同一个 target checkpoint。
最新 vLLM 不是“所有 MTP 都走 V2”。VllmConfig.use_v2_model_runner 先根据架构、Triton 和不支持特性做判断;GPU worker 随后在 model_runner.py(V2)与 gpu_model_runner.py(V1)之间选择。第五课主读 V2,但这个 fallback 是读源码时必须保留的分支。
init_speculator 根据 speculative_config.method 创建 MTPSpeculator。这个类继承 AutoRegressiveSpeculator,并通过 load_eagle_model 复用 draft loader。类名叫 EAGLE loader,不代表算法目标变成 EAGLE;继续看调用者和输入输出。
scheduler 读取 request.spec_token_ids,把它放进 scheduled_spec_decode_tokens,然后清空 request 上的旧列表。于是 [吃, 苹果, 。] 成为本轮 GPU 输入的一部分,而不是本轮才临时猜出来的。
V2 runner 根据每个 request 的 draft 数量建立 num_draft_tokens_per_req、cu_num_logits 和 logits_indices。有 draft 时,target 要处理的不再只是每个 request 的一个普通 decode logits,还包括 draft 位置与 bonus 位置。
sample 先用 target model 计算 logits;检测到 draft 后,把这些 logits、InputBatch 和 speculator 保存的 draft logits 交给 RejectionSampler。它返回 num_sampled 与 num_rejected,也就把“接受了多少、首拒后回退多少”变成后续 proposal 的输入。
model_runner.py#L1106-L1138 · rejection_sampler.py#L103-L162
sample_tokens 先完成 sample 和 postprocess,再调用 self.speculator.propose(...)。proposal 会收到 target hidden states、num_sampled、num_rejected 等状态,生成新的 draft tokens,并交给 DraftTokensHandler。
model_runner.py#L1414-L1559 · autoregressive/speculator.py#L128-L274
同步调度路径中,EngineCore.post_step 取出 worker 的 draft token ids,调用 scheduler 的 update_draft_token_ids,最终写回 request.spec_token_ids。下一次 iteration 又从步骤 4 开始。
源码地图压缩成一条线
request.spec_token_ids
→ scheduler.scheduled_spec_decode_tokens
→ V2 InputBatch / logits_indices
→ target forward + RejectionSampler
→ num_sampled, num_rejected
→ speculator.propose(...)
→ DraftTokensHandler
→ EngineCore.post_step
→ request.spec_token_ids(下一轮)
这里最容易混淆的是时间:request.spec_token_ids 不是“当前 target 刚生成的 token”,而是上一轮 proposal 留给下一轮验证的候选。验证和 proposal 在逻辑上相邻,但跨过了 iteration 边界。
在这条调用链中,num_rejected 最直接传给谁?
打开 V2 sample_tokens,只做一个动作:沿着 num_rejected 向前追,直到 propose。然后再回到 本课速查页。下一课我们再拆 Triton rejection kernel;本课先把跨 iteration 的地图记牢。哪里看不懂,继续问我。