DeepSeek V4 源码剖析
第11章 MTP:多 Token 预测的训练放大器
不只预测下一个 token,把下下一个也预测出来——同样的数据能榨出更多监督信号。这是 V3 引入 MTP 的出发点。
V4 把 V3 引入的 MTP 保留了下来——一个永远只预测下一下个 token 的辅助 head,训练时增加监督信号,推理时变成投机解码。
11.1 引子:next-token 预测的训练效率瓶颈
LLM 训练的标准目标是 next-token prediction——给定 token[0:n],预测 token[n]。loss 是 cross-entropy。
这套训练有个隐性低效:每个 token 只产生 1 个监督信号。一段 4096 token 的 batch 提供 4096 个监督。如果能把"下一下个 token" (next-next) 也作为监督,监督密度直接翻倍——同样的训练数据能让模型学得更好。
但是简单地"加一个 next-next prediction head"会有问题:
- 模型表达力被分散到两个目标
- next-next 的 hidden 与 next 的 hidden 是同一个——head 学到的"下下一个 token" 可能干扰主 head 的"下一个 token"
MTP(Multi-Token Prediction) 的解法:给 next-next 配一个独立的 transformer block,让它在主模型 hidden 的基础上做"额外的一层处理",再预测 next-next。这种"独立 block + 共享 embedding" 的设计让监督信号翻倍,而主模型的 next-token 预测路径保持不变(V3 报告的消融实验显示,加 MTP 在多数评测上提升了主模型表现)。
V3 引入 MTP,V4 保留——num_nextn_predict_layers=1。本章拆 V4 中 MTP 的具体实现。
11.2 MTPBlock 类的源码
# inference/model.py:738
class MTPBlock(Block):
def __init__(self, layer_id: int, args: ModelArgs):
super().__init__(layer_id, args)
self.e_proj = Linear(args.dim, args.dim)
self.h_proj = Linear(args.dim, args.dim)
self.enorm = RMSNorm(args.dim, args.norm_eps)
self.hnorm = RMSNorm(args.dim, args.norm_eps)
self.norm = RMSNorm(args.dim, args.norm_eps)
self.hc_mult = hc_mult = args.hc_mult
hc_dim = hc_mult * args.dim
with set_dtype(torch.float32):
self.hc_head_fn = nn.Parameter(torch.empty(hc_mult, hc_dim))
self.hc_head_base = nn.Parameter(torch.empty(hc_mult))
self.hc_head_scale = nn.Parameter(torch.empty(1))
self.embed: ParallelEmbedding = None
self.head: ParallelHead = None
@torch.inference_mode()
def forward(self, x: torch.Tensor, start_pos: int, input_ids: torch.Tensor) -> torch.Tensor:
assert self.embed is not None and self.head is not None
e = self.embed(input_ids)
e = self.enorm(e)
x = self.hnorm(x)
x = self.e_proj(e).unsqueeze(2) + self.h_proj(x)
x = super().forward(x, start_pos, input_ids)
logits = self.head(x, self.hc_head_fn, self.hc_head_scale, self.hc_head_base, self.norm)
return logits
MTPBlock 继承自 Block——意味着它有完整的 attention + ffn + HC 处理。但它在 Block 之上加了几样东西:
新增组件 1:e_proj 与 h_proj
self.e_proj = Linear(args.dim, args.dim)
self.h_proj = Linear(args.dim, args.dim)
e_proj 处理 input embedding (e)、h_proj 处理主模型最后一层的 hidden (x)。两者在 forward 中相加:
x = self.e_proj(e).unsqueeze(2) + self.h_proj(x)
注意 e_proj(e).unsqueeze(2) 是 [B, S, 1, D]、h_proj(x) 是 [B, S, hc_mult, D]——unsqueeze + 广播让 e 被复制到 4 路。这相当于"input embedding 平均贡献到 4 路 hidden"。
新增组件 2:双 RMSNorm
self.enorm = RMSNorm(args.dim, args.norm_eps)
self.hnorm = RMSNorm(args.dim, args.norm_eps)
分别归一化 e 和 x,确保两者数值范围接近——避免某一方主导。
新增组件 3:独立的 hc_head 参数
self.hc_head_fn = nn.Parameter(torch.empty(hc_mult, hc_dim))
self.hc_head_base = nn.Parameter(torch.empty(hc_mult))
self.hc_head_scale = nn.Parameter(torch.empty(1))
MTPBlock 有自己的 hc_head 投影参数——把 4 路 hidden 混回 1 路,再过 lm_head 出 logits。
复用组件:embed 与 head
self.embed: ParallelEmbedding = None
self.head: ParallelHead = None
注意是 None——在 Transformer.__init__ 末尾被赋值:
self.mtp[-1].embed = self.embed
self.mtp[-1].head = self.head
MTPBlock 复用主模型的 embedding 和 lm_head——不重训这两个,直接共享。这让 MTP 不必再存一份词表大小的参数,额外参数只有一个 transformer block 加上 e_proj / h_proj(约 26 B,见 §11.8)。
11.3 MTP forward 的完整流程
把 MTPBlock.forward 一步步走一遍:
@torch.inference_mode()
def forward(self, x: torch.Tensor, start_pos: int, input_ids: torch.Tensor) -> torch.Tensor:
e = self.embed(input_ids) # 步骤 1: 重新算一遍 input embedding
e = self.enorm(e) # 步骤 2: 归一化 e
x = self.hnorm(x) # 步骤 3: 归一化主模型 hidden
x = self.e_proj(e).unsqueeze(2) + self.h_proj(x) # 步骤 4: 融合 e 和 x
x = super().forward(x, start_pos, input_ids) # 步骤 5: 跑一层 Block
logits = self.head(x, self.hc_head_fn, self.hc_head_scale, self.hc_head_base, self.norm) # 步骤 6: lm_head
return logits
输入:
x: [B, S, hc_mult, D]——主模型 61 层 Block 跑完之后的 hidden(4 路)input_ids: [B, S]——原始 token id
输出:
logits: [B, vocab_size]——预测下一下个 token
flowchart TB
subgraph Inputs["输入"]
InputIds["input_ids"]
HiddenX["x (主模型 61 层后)"]
end
subgraph MTPInside["MTPBlock 内部"]
E["embed(input_ids) → e"]
Enorm["enorm(e)"]
Hnorm["hnorm(x)"]
Eproj["e_proj(e).unsqueeze(2)"]
Hproj["h_proj(x)"]
Combine["x = + (融合)"]
Block["super().forward(...)<br/>(完整 Block: attn + ffn + HC)"]
Head["head(x, hc_head_*)"]
end
Logits["logits"]
InputIds --> E --> Enorm --> Eproj
HiddenX --> Hnorm --> Hproj
Eproj --> Combine
Hproj --> Combine
Combine --> Block --> Head --> Logits
注意 super().forward(x, start_pos, input_ids)——MTPBlock 调用父类(普通 Block)的 forward,跑一遍完整的 attention + ffn + HC。这意味着 MTPBlock 拥有完整的 transformer block 能力,不是简单的"再多一个 head"。
11.4 MTP 在训练时的角色
V4 的训练源码不公开,但 MTP 的训练目标可以从架构反推:
训练目标:给定主模型在位置 n 的 hidden state 与 token[n+1] 的 embedding,预测 token[n+2]。
具体地(下面 token[0..n] 表示含两端的前缀),主模型在位置 n 的预测是 P(token[n+1] | token[0..n]),MTP 的预测是 P(token[n+2] | token[0..n+1])——它在主模型预测的下一个 token 之上再多预测一个 token。
总 loss:
loss = main_loss + λ_mtp * mtp_loss
λ_mtp 是 MTP loss 的权重。DeepSeek-V3 报告里前 10T token 取 0.3、其余 4.8T token 取 0.1;V4 技术报告第 4.2.2 节给的是同一套取值——大部分训练取 0.3,学习率开始衰减时降到 0.1(Flash 与 Pro 相同),让 MTP 始终只提供"次要"监督、不主导训练。
MTP loss 的具体计算:
- 主模型 forward 完,得到每个 token 位置的 hidden state x[n]
- MTPBlock 接收 x[n] 与 input_ids[n+1]("下一个 token 的 embedding")
- MTPBlock forward 出 logits,与 token[n+2] 算 cross-entropy
注意第 2 步——MTP 用的 input_ids 是右移一位的。这是 next-next 预测的关键:MTP 看到主模型的 hidden + 下一个 token 的 embedding,预测下下一个 token。
这个设计的妙处:训练时 MTP 完全可微——主模型的梯度流过 MTP 路径回到主模型 backbone,让主模型学到"为 next-next 预测有用的 hidden representation"——间接提升主模型的表达力。
11.5 MTP 在推理时变成"投机解码"
V4 在推理时怎么用 MTP?答案是投机解码(Speculative Decoding)——
经典的投机解码用一个小型 draft 模型快速预测多个 token,然后用大模型验证。V4 的 MTP 让"draft"和"verify"用同一个模型——MTP 的输出就是"下一下个 token 的预测",主模型再 verify 一下:
1. 主模型 forward token[0:n],得到 logits[n] → 采样 token[n+1] = T1
2. MTP forward (hidden[n], T1) → 得到 logits → 采样 token[n+2] = T2
3. 主模型下一次 forward 把 T1、T2 一起喂进去(KV cache 增量加两个位置),一次拿到 logits[n+1] 与 logits[n+2];用 logits[n+1] 采样 token[n+2] = T2'
4. 比较 T2 vs T2':
- 如果一致:接受 T2,logits[n+2] 顺带给出 token[n+3]——这一次 forward 产出 2 个 token
- 如果不一致:拒绝 T2,改用 T2'——这一次 forward 仍产出 1 个 token,只是白算了 T2 那个位置
(这里为了好懂用"采样结果相等"来判定;采样解码下实际用的是投机解码的拒绝采样规则,它保证输出分布与不投机时完全一致。)
V4 的 MTP 与主模型一起训练、又直接吃主模型的 hidden state,与主模型分布高度一致——T2 与 T2' 一致的概率("接受率")很高。V3 技术报告(arXiv:2412.19437,Multi-Token Prediction Evaluation 一节)给的实测是:第二个 token 的接受率在各类生成话题上稳定落在 85%–90%,据此实现 1.8 倍 TPS。注意报告里 MTP 只预测接下来的 2 个 token(多出来的只有 1 个),所以「1.8 倍」已经接近这条路径的理论上限(每步最多产出 2 个 token),不是可以靠调参继续往上推的数字。
V4 继承 V3 的 MTP 设计(技术报告第 2.1 节:沿用 V3 的 MTP 策略、未做修改),预期接受率类似——但 1M 长上下文场景下的实测数据需要等 V4 GA 后社区报告。
11.6 MTP 投机解码的序列图
把 §11.5 描述的"MTP 投机解码 4 步"画成序列图:
sequenceDiagram
participant U as 用户
participant Main as 主模型 forward
participant MTP as MTPBlock forward
participant Sample as 采样器
U->>Main: input token[0:n]
Main->>Sample: logits[n]
Sample-->>Main: token[n+1] = T1
Main->>MTP: hidden[n], input_ids=[..., T1]
MTP->>Sample: logits (next-next)
Sample-->>MTP: token[n+2] = T2
Note over Main,MTP: 此时有 (T1, T2) 两个候选
Main->>Main: forward(KV cache + T1, T2)
Main->>Sample: logits[n+1], logits[n+2]
Sample-->>Main: token[n+2]' = T2'
alt T2 == T2'
Main-->>U: 接受 T1, T2 (logits[n+2] 再给出 token[n+3])
else T2 != T2'
Main-->>U: 接受 T1, 拒绝 T2, 用 T2' 替代
end
这里要小心一个常见的算错:num_nextn_predict_layers=1 意味着每步最多产出 2 个 token,所以 85% 的接受率对应"平均每次主模型 forward 产出 1.85 个 token",省下的是约 46% 的 forward,而不是"每 10 步省 8-9 次"。V3 报告给的 1.8 倍 TPS 正好落在这个上限附近。
11.7 MTP 与 EAGLE / Medusa 对比
投机解码领域最近几年有几个流行方案——把 MTP 与它们对比:
| 方案 | 草稿模型 | 训练复杂度 | 推理 overhead | 与目标分布的对齐度 |
|---|---|---|---|---|
| 单独小模型 draft | 独立训练的小 LLM | 高(多训一个模型) | 低 | 弱(两套训练数据) |
| MTP (V3/V4) | 共享主模型 + MTPBlock | 低(单模型一起训) | 低 | 强(同一份训练数据) |
| EAGLE | 在主模型 hidden 上加 head | 中 | 中 | 强(吃主模型 feature) |
| Medusa | 多个独立 head(next, next-next, ...) | 中 | 中 | 中 |
| Lookahead | n-gram 启发式 | 低 | 低 | 弱(不学分布) |
(各方案的实测接受率高度依赖模型、数据与采样参数,这里只给定性排序;唯一有一手出处的数字是 V3 报告给 MTP 的 85%–90%。)
V4 的 MTP 在三个维度上有优势:
- 训练成本极低:MTP 与主模型一起训,不需要额外训练阶段
- 接受率高:因为是同一份训练数据,分布对齐
- 推理 overhead 低:MTP 只有 1 个 transformer block,比独立 draft 模型快得多
EAGLE 这类"吃主模型 feature 的专用 draft head"在公开报告里往往能给出更高的接受率,但要单独训练与维护。V4 直接沿用 V3 的 MTP,是拿一点接受率上限换工程简洁性和训练成本——这是 V4 团队的权衡,不是"MTP 一定更强"。
11.8 MTP 的内存与计算开销
MTPBlock 在显存与计算上的代价:
显存:MTPBlock 是一个完整的 transformer block(FFN 同样是 384 专家的 MoE)。按 inference/config.json 在 meta 设备上实例化 MTPBlock 数参数:约 25.84 B(MoE 25.44 B、attention 0.30 B、e_proj 与 h_proj 合计 0.10 B),占 1.6T 总参的约 1.6%。它复用主模型的 embedding 与 lm_head(vocab_size × dim = 129280 × 7168 ≈ 0.93 B,两个加起来不到 2 B),所以这约 26 B 就是全部的"额外参数"。(镜像里的 model.safetensors.index.json 是 LFS 指针,拿不到真实张量清单,所以这里按源码里的形状数,而不是按权重文件数。)
计算:MTP 跑一次 forward 等于多跑一层 transformer 再加一次 lm_head。每 token 激活的参数约 0.87 B(attention + 6 个路由专家 + 共享专家 + e_proj / h_proj),加上 lm_head 的 0.93 B,合计约 1.8 B,约为 V4-Pro 每 token 激活量 49 B 的 3.7%。投机解码并不减少总 FLOPs——被接受的 token 照样要经过主模型验证,被拒的草稿还是白算;它省下的是主模型串行 forward 的次数,decode 阶段受访存带宽限制,步数少了墙钟时间就短。
KV cache:MTPBlock 内部的 attention 也有自己的 kv_cache buffer,但它的 compress_ratio 是 0,只有 window_size=128 个槽位,不随上下文长度增长(§11.10 细算)。
一句话对账:多付约 1.6% 的参数与每步多跑一层加一次 lm_head 的算力,换回 V3 报告里 85%–90% 的第二 token 接受率、约 1.8 倍 TPS——极划算。
11.9 动手实验:理解 MTP forward
import torch
import torch.nn as nn
class MockBlock(nn.Module):
"""占位 Block,模拟主 transformer block"""
def forward(self, x, start_pos, input_ids):
return x # 占位
class MiniMTPBlock(nn.Module):
def __init__(self, dim=128, hc_mult=4):
super().__init__()
self.dim = dim
self.hc_mult = hc_mult
self.e_proj = nn.Linear(dim, dim)
self.h_proj = nn.Linear(dim, dim)
self.enorm = nn.LayerNorm(dim)
self.hnorm = nn.LayerNorm(dim)
self.block = MockBlock()
self.norm = nn.LayerNorm(dim)
self.embed = nn.Embedding(1024, dim)
self.head = nn.Linear(dim, 1024)
def forward(self, x, input_ids):
# x: [B, S, hc, D]
# input_ids: [B, S]
e = self.embed(input_ids) # [B, S, D]
e = self.enorm(e)
x = self.hnorm(x) # [B, S, hc, D]
x = self.e_proj(e).unsqueeze(2) + self.h_proj(x) # broadcast
x = self.block(x, 0, input_ids)
x = x.mean(dim=2) # 简化:4 路 mean 成 1 路
logits = self.head(self.norm(x))
return logits
# 测试
mtp = MiniMTPBlock()
x = torch.randn(2, 10, 4, 128)
input_ids = torch.randint(0, 1024, (2, 10))
logits = mtp(x, input_ids)
print(logits.shape) # torch.Size([2, 10, 1024])
这个 mini 版对每个位置都出 logits;V4 源码的 ParallelHead.get_logits 只取最后一个位置(x[:, -1]),所以真实 MTPBlock.forward 输出的是 [B, vocab_size](见 §11.3)。
跑通这个 mini 版本后再回看 V4 源码。会发现"原来 MTPBlock 比主 Block 的复杂度只多了 e_proj / h_proj 两个 Linear + 双 RMSNorm + 独立的 hc_head"。
11.10 MTP 在 long-context 场景下的特殊性
V4 的主模型支持 1M context。MTP 在这种长上下文场景下的工作方式有几个特殊性,值得专门说一下。
特殊性一:MTP 的 KV cache 与主模型独立
MTPBlock 继承自 Block——它有自己的 attention,有自己的 KV cache。这意味着:在长 prompt 场景下,主模型和 MTP 的 KV cache 是两份独立的内存。
但 MTP 这一份小得可以忽略,原因藏在 compress_ratios 里:这个列表有 62 个元素(61 个主模型 Block + 1 个 MTPBlock),最后一个是 0——也就是 MTPBlock 走的是纯滑窗、没有压缩段。Attention.__init__(inference/model.py:473)里 kv_cache_size = window_size + (max_seq_len // compress_ratio if compress_ratio else 0),代入 compress_ratio=0 就只剩 window_size=128 个槽位:
128 × head_dim(512) × 2 bytes = 128 KB / 序列
对比主模型:61 层按各自的 compress_ratio(31 层 128、30 层 4)累加,按 max_seq_len=1048576 的部署设定(ModelArgs 默认只有 4096,inference/config.json 也没设这个字段)约 7.7 GiB,再加上 30 个 Indexer 各自的压缩 KV(max_seq_len/4 × index_head_dim(128))约 1.9 GiB,合计约 9.6 GiB / 序列。MTP 那 128 KB 在里面连零头都算不上。
真正容易被忽略的不是 MTP,而是 Indexer 那 1.9 GiB——做显存预算时别只算主 KV。
特殊性二:MTP 在 1M context 下的接受率可能下降
V3 报告给的 85%–90% 是在 V3 的上下文规模下测的,V4 在 1M context 下的接受率目前没有公开数据。本专栏的推测是会有所下降:MTP 自己的注意力只看 128 个 token(见特殊性三),远处的信息全靠主模型交过来的 hidden state,上下文越长,这份压缩后的信息越可能不够用。
具体下降幅度需要 V4 GA 后社区独立测试。工程上的应对:如果实测接受率低到不划算,可以关闭 MTP 投机解码,让主模型逐 token 跑。投机解码经过验证、不改变输出分布,所以这不是"稳定性"之争,而是"每步多付一层 MTP 的算力"与"省下的串行步数"之间的运行时权衡。
特殊性三:MTP 的 attention 里既没有 Compressor 也没有 Indexer
这一条容易想当然想反。MTPBlock 继承的是同一个 Attention 类,但它拿到的 compress_ratios[61] 是 0,于是 inference/model.py:466 的 if self.compress_ratio: 不成立——Compressor 和 Indexer 一个都不会被构造,稀疏选取那条路整个不存在。它跑的是 get_window_topk_idxs 给出的 128 个滑窗位置,外加 attn_sink,如此而已。
MTP 拿到的不是"V4 attention 的所有红利",而是"最便宜的那一档":KV 小到可以忽略(上面那 128 KB),但注意力半径也只有 128 个 token。它要看更远的东西,全靠主模型交过来的那份 hidden state——这正是 h_proj(x) 存在的意义。
特殊性四:MTP 在 prefill 阶段也要跑,但只多一层
MTP 有自己的 attention,decode 时要看前面位置的 KV,所以 prefill 时它也得把 prompt 过一遍、把自己那份 KV 填上。参考实现的自测代码就是先整段 mtp(h, 0, x) 再逐 token 调用(inference/model.py:826-827);vLLM 0.8.5 的 V0 投机解码在非投机步里同样会按 num_spec_prefill_steps(等于 n_predict)把 prefill 喂给 MTP 草稿模型(vllm-0.8.5/vllm/spec_decode/spec_decode_worker.py:202-204、vllm-0.8.5/vllm/spec_decode/spec_decode_worker.py:706-708)。
好在代价有限:MTP 只有一层,而且它的 attention 是纯 128 滑窗、没有 Compressor / Indexer,head 也只算最后一个位置的 logits。长 prompt 下 prefill 多出来的大致是"一层 Block"的量,TTFT 会略增,但远不到翻倍。
11.11 MTP 模块的训练数据流推演
为了让 MTP 训练机制更具象,把一个 batch 在训练中的数据流推演一遍:
给定 batch:32 个序列,每序列 4096 tokens。
步骤 1:主模型 forward
主模型逐层算完 61 个 Block(n_layers=61),得到每个位置的 hidden state:hidden: [32, 4096, 4, 7168](4 路 HC)。
步骤 2:主 head 算主 loss
主 head 接收所有位置的 hidden,输出 logits [32, 4096, vocab]。与 input_ids 右移一位后做 cross-entropy——得到主 loss。
步骤 3:MTP forward
MTPBlock 接收:
- 主模型最后一层的 hidden
[32, 4096, 4, 7168] - 右移一位的 input_ids(即"下一个 token 的 id")
MTP 内部融合 e_proj 与 h_proj,跑一层 Block,输出 logits [32, 4096, vocab]。
步骤 4:MTP loss
MTP logits 与"右移两位的 input_ids"做 cross-entropy——这是"预测下一下个 token"的目标。
步骤 5:合并 loss
total_loss = main_loss + 0.3 × mtp_loss # 学习率开始衰减后改为 0.1(技术报告第 4.2.2 节)
步骤 6:反向传播
梯度从 main_loss 流经主 head → 主模型 backbone → input embedding。 梯度也从 mtp_loss 流经 MTP head → MTPBlock → 主模型 backbone → input embedding。
注意:MTP 的梯度也回流到主模型 backbone——这是 MTP 训练让主模型表达力提升的核心机制。主模型 hidden state 不仅要为"预测下一个 token"服务,还要为"下一下个 token 预测"提供有用信号——这种"看更远"的目标隐式提升主模型的语义表征质量。
这个梯度共享是 MTP 与"完全独立的 draft 模型"的根本差别——独立 draft 不会反向影响主模型,但 MTP 会。
11.12 MTP 与 vLLM 投机解码引擎的串接
V4 的 MTP 在 vLLM 中怎么接,要先看 vLLM 已有的投机解码抽象——这是部署时需要专门理解的点。
vLLM 早期版本的投机解码(speculative decoding)支持"指定一个小型 draft 模型"——比如用 Llama-7B 作 draft、Llama-70B 做 target。这种模式下 vLLM 维护两个模型实例,每轮 decode 两个模型都要跑 forward。
但"draft 头长在目标模型体内"这件事,vLLM 早就为 V3 做过一遍了——0.8.5 里已经躺着一整套 DeepSeek MTP 实现,V4 要落地大概率是照着它扩,不是从零设计(这是本专栏的判断):
vllm-0.8.5/vllm/model_executor/models/deepseek_mtp.py:147的DeepSeekMTP,在vllm-0.8.5/vllm/model_executor/models/registry.py:219以DeepSeekMTPModel之名注册vllm-0.8.5/vllm/config.py:2271的SpeculativeConfig.hf_config_override把model_type == "deepseek_v3"就地改写成"deepseek_mtp",再把num_nextn_predict_layers搬成n_predict、把architectures换成["DeepSeekMTPModel"]。V4 的model_type是deepseek_v4,0.8.5 这条判断认不出来,得先扩上- 这条路径在 0.8.5 里只跑在 V0 的
SpecDecodeWorker上:V1 只放行ngram/eagle/eagle3,deepseek_mtp会回退 V0(见《vLLM 推理内核深度解析》专栏第 12 章 12.7.3 节)
也就是说 vLLM 用的办法是:仍然走 draft-model 那条路径,但让 draft model 指向同一份 checkpoint,靠 config 改写把 MTP 那几层单独拆出来加载。vLLM 恰恰是把 MTP 当作一个"独立 draft 模型"来处理的,只是权重来自目标模型自己的 checkpoint。
真正需要做工作的是下面几处结构差异(都能直接对着两边源码核):
- embedding 与 lm_head 到底共不共享。 V4 的
MTPBlock是把主模型的对象直接赋进来(inference/model.py:792-793的self.mtp[-1].embed = self.embed),而 vLLM 的 MTP 层自带一份embed_tokens(vllm-0.8.5/vllm/model_executor/models/deepseek_mtp.py:52)和一份shared_head.head(同文件vllm-0.8.5/vllm/model_executor/models/deepseek_mtp.py:33)。权重加载脚本必须知道这两块该指向哪里,否则要么多占 2 B 参数,要么落在未初始化(torch.empty)的权重上。V4 自己的inference/convert.py:105在转换时就直接跳过mtp.前缀下的 embedding 与head.weight,靠运行时共享主模型那两份。 - e / h 的融合形状。 vLLM 是一个
2D → D的eh_proj(cat([e, h]))(vllm-0.8.5/vllm/model_executor/models/deepseek_mtp.py:59),V4 是两个D → D的e_proj(e) + h_proj(x)(inference/model.py:763)。两者数学上等价——把eh_proj的权重沿输入维切两半就是e_proj和h_proj——但转换脚本要做这一刀。 - 另外 V4 的 MTPBlock 继承的是带 HC 的
Block,输入是[B, S, hc_mult, D]四路;vLLM 的DeepseekV2DecoderLayer是单路残差。这条差异不是 MTP 特有的,跟着第 10 章那套 HC 改造一起解决。
读者如果想自己实现"V4-MTP 投机解码",可以把本章与《vLLM 推理内核深度解析》专栏第 12 章对照着读——前者讲 MTP 算法,后者讲 vLLM 的投机解码引擎抽象。
11.13 MTP 在 fine-tune 时的 4 个常见误区
把 V4 fine-tune 到特定领域时,MTP 经常被忽略或误用。整理 4 个常见误区:
误区 1:fine-tune 时关掉 MTP loss
某些工程师把 fine-tune 看作"只优化主任务",把 MTP loss 的权重设为 0。结果:MTPBlock 的参数停止更新,逐渐与主模型分布偏离——接受率随之下滑,投机解码的收益被吃光(极端情况下每步都被拒,反而多付了一次 MTP forward)。
正确做法:fine-tune 时保留一个非零的 MTP loss 权重(预训练末段用的是 0.1;fine-tune 该取多少没有公开数据,需要自己调),让 MTPBlock 跟随主模型同步更新。
误区 2:fine-tune 时只 fine-tune MTP,不动主模型
反过来的误区:只 fine-tune MTPBlock,主模型冻结。结果:主模型本身没被领域数据改变,fine-tune 的目的落空;MTP 按领域数据的真实 token 训练,学到的是数据分布,而投机解码要对齐的是主模型的输出分布,两者不一致时接受率未必提升。
正确做法:MTP 必须与主模型一起 fine-tune——它们是耦合的,不能拆开训。
误区 3:fine-tune 数据没右移
MTP 训练时输入 input_ids 是右移一位的(详见 §11.4)。fine-tune 时如果数据 pipeline 没正确处理这个位移,MTP 实际预测的是"下一个 token" 而非"下一下个 token"——接受率会大幅下降。
正确做法:检查 fine-tune 数据 collator 是否正确生成 MTP 监督信号。
误区 4:fine-tune 完关闭 MTP 推理
部署 fine-tuned V4 时如果不启用 MTP 投机解码,就把 V3 报告里那约 1.8 倍的 TPS 整个丢掉了。某些工程师以为"我没专门训 MTP,应该关掉它"——但 MTPBlock 在 fine-tune 时已经被同步更新,可以照常用。
正确做法:fine-tune 不影响 MTP 推理可用性,部署时保持启用。
这 4 个误区都来自"对 MTP 是什么"的误解——把它当成"独立的小模型" 而非"主模型的副 head"。理解 MTP 与主模型的紧密耦合,就能避免这些坑。
11.14 MTP 与"长 think 链生成" 的特殊协同
V4 的 Think Max 模式会生成很长的 think 链——官方 README 的建议是"跑 Think Max 时上下文窗口至少留 384K",这是对窗口的建议,不是"think 链上限就是 384K"。MTP 在这种超长生成场景下有特殊作用——加速 thinking 的生成。
普通生成 vs thinking 生成:
普通生成:用户问"什么是 V4",模型给一段 200-500 token 回答。MTP 投机解码把 decode 步数压缩到约 1/1.8(按 V3 报告的口径),绝对时间省下的是几秒。
Thinking 生成:用户给数学题,模型生成 10K-100K token 的推理过程。同样的比例,绝对时间省下的是几十秒到几分钟——这才是 MTP 真正值钱的场景。
Thinking 生成对 MTP 接受率的影响:
Thinking 链对接受率的影响目前没有公开的实测数据:公式化的推导、代码片段比较好猜,探索性的推理分支则更难猜,两种效应哪个占上风要在自己的流量上测。
Think Max 模式的工程意义:
如果你的产品场景是数学 / 编程 / 复杂推理(thinking 密集),只要接受率没有明显下滑,MTP 的 ROI 就比聊天产品更高——同样的加速比作用在更长的输出上。生产部署时不宜为了简化而关掉 MTP。
MTP 与 thinking 长度:
README 建议 Think Max 至少留 384K 窗口。主模型那一侧要靠滑窗 + 压缩段的双段几何撑住这个长度;MTPBlock 这一侧因为 compress_ratios 末位是 0,走的是纯 128 滑窗(见 §11.10),长度再长它的 KV 也不涨。这部分设计已经包含在 MTPBlock 继承的 Block 结构里——不需要额外工程。
11.15 MTP 的"接受率波动"工程经验
MTP 投机解码的接受率不是恒定的——它随场景、prompt、生成阶段而波动。理解这种波动让你正确设 SLA。
接受率波动来源 1:prompt 类型
按"下一下个 token 有多好猜"做定性推断(不是实测排序),接受率大致是:结构化 / 模板化文本(代码、表格、常见问答)> 一般对话 > 创意生成(诗、故事)。V3 报告给的 85%–90% 是"在各类生成话题上稳定落在这个区间"的整体口径,本专栏不再给分场景的具体百分比——那些数字需要你在自己的流量上实测。
接受率波动来源 2:生成阶段
以下是定性推测,没有公开实测数据:
- 生成开头:接受率可能较低,因为分布还在收敛
- 中段:接受率可能最高,分布稳定
- 末尾(接近 EOS):接受率可能回落,因为模型在"决定何时停"
接受率波动来源 3:温度设置
温度越低、分布越尖,主模型与 MTP 越容易采到同一个 token,接受率越高;温度调高则两边分歧概率上升,接受率通常随之下降。注意 V4 官方对本地部署的采样建议是 temperature = 1.0, top_p = 1.0——也就是说默认配置并不在"接受率最高"的那一端。
接受率波动来源 4:长 context 影响
同 §11.10 特殊性二:长 context 下接受率可能下降,但 V4 没有公开分长度的实测,分界点和降幅都要自己测。
对 SLA 设计的指导:
不要按平均接受率设 SLA——按 P10(第 10 百分位)设。如果你的产品大量是创意生成这类难预测的场景,SLA 应该按"几乎不投机"的延迟兜底——避免用户在某些场景下感受到延迟突跳。这里的分位数必须来自你自己流量的实测,没有可以照抄的通用数字。
11.16 延伸阅读
- DeepSeek-V3 报告(arXiv:2412.19437):MTP 在 V3 中的最早期工业化
- Speculative Decoding 论文(arXiv:2211.17192):投机解码的源头
- EAGLE 论文(arXiv:2401.15077):基于 hidden 的高接受率方案
- Medusa 论文(arXiv:2401.10774):多 head 投机解码
- 《vLLM 推理内核深度解析》专栏第 12 章:投机解码在 vLLM 中的工程实现
11.17 本章小结
- V4 保留 V3 的 MTP 设计——
num_nextn_predict_layers=1 - MTPBlock 继承自 Block,加了 e_proj / h_proj / 双 RMSNorm / 独立 hc_head
- 复用主模型的 embedding 和 lm_head——额外参数只有一个 Block 加 e_proj / h_proj(约 26 B,约 1.6% 总参)
- 训练时:MTP 提供 next-next 监督,让训练信号翻倍
- 推理时:MTP 变成投机解码的 draft 头——V3 报告实测第二 token 接受率 85%–90%、约 1.8 倍 TPS
- 与 EAGLE / Medusa / Lookahead 对比,V4 MTP 在训练成本 + 推理 overhead 上有优势
第 12 章我们离开模型结构,进入 V4 的精度链路:FP4 e2m1 + FP8 e4m3 + ue8m0 scale 的混合精度几何。