Qwen3.8-27B 的 MTP 在推理时干什么:一个拼接、一次降维、一层全注意力
Qwen3.8-27B 的 model card 提到了多步训练,但 config.json 里跟 MTP 有关的信息少得可怜——配置里只有一层,除此之外什么都看不出来。
一层能干什么?它接在哪里?输入是什么?这些问题在模型仓库里找不到答案,因为答案不在配置里,在推理框架的实现代码里。
这篇读 vLLM 的那份实现,把 MTP 的结构讲清楚。
这篇的依据
来源是 vLLM 主干仓库的 vllm/model_executor/models/qwen3_5_mtp.py(323 行),采集时间 2026-08-24。
我们没有安装 vLLM、没有加载过权重、没有开启过投机解码、没有做过任何性能测量。 下面讲的是代码里写了什么结构,不是运行时表现。任何关于「MTP 能带来多少加速」的说法本文都不会有——那需要实测,我们没测。
先确认一件事:MTP 是独立模型
前面写过,主模型加载权重时会把 mtp. 前缀的权重直接丢弃(见 64 层在 vLLM 里怎么分流)。vLLM 的注册表里,Qwen3_5MTP 也是单独一条,指向单独的模块文件。
这不是随意的工程划分,而是反映了 MTP 的真实身份:它不是主模型的一部分,是挂在主模型旁边的一个附加组件。 主模型不需要它也能跑,它离开主模型则毫无意义——因为它的输入之一就是主模型的输出。
它接在第几层
构造函数开头两行定义了位置:
self.mtp_start_layer_idx = config.num_hidden_layers
self.num_mtp_layers = getattr(config, "mtp_num_hidden_layers", 1)
第一行说:MTP 层的编号从主模型的层数开始。Qwen3.8-27B 有 64 层,所以 MTP 层的下标从 64 起算——它接在整个主干之后。
第二行说:MTP 有几层,取配置里的 mtp_num_hidden_layers,取不到就默认 1。这正好对上配置文件里那个「只有一层」的观察:字段没写,走默认值。
结构一:它有自己的词嵌入
self.embed_tokens = VocabParallelEmbedding(
self.vocab_size,
config.hidden_size,
)
MTP 自带一份词嵌入表,不是复用主模型的。词表大小和隐藏维度都跟主模型一致(248,320 词表、5120 隐藏维度)。
这解释了为什么带 MTP 和不带 MTP 的权重文件是两份不同的内容——多出来的不只是一层解码层,还有一整张嵌入表。
结构二:核心是一个拼接加降维
真正体现 MTP 思路的是这个线性层:
self.fc = ColumnParallelLinear(
self.config.hidden_size * 2,
self.config.hidden_size,
...
)
输入维度是 hidden_size * 2,输出是 hidden_size。 输入的宽度正好是隐藏维度的两倍,说明它吃的是两份东西拼起来的向量,吐出来的是一份。
哪两份?看 forward 就清楚了:
inputs_embeds = self.pre_fc_norm_embedding(inputs_embeds)
hidden_states = self.pre_fc_norm_hidden(hidden_states)
hidden_states = torch.cat([inputs_embeds, hidden_states], dim=-1)
hidden_states = self.fc(hidden_states)
四步,读起来像一段说明文:
第一步,把当前 token 的嵌入向量做一次归一化。
第二步,把传进来的 hidden_states 也做一次归一化——注意这个 hidden_states 是从外面传进来的参数,它来自主模型跑完之后的输出。
第三步,把这两份向量在最后一维上拼接,宽度变成两倍。
第四步,用 fc 把它压回原来的宽度。
这就是 MTP 的核心机制:把「主模型看完前文之后的理解」和「当前这个 token 是什么」两份信息揉在一起,压成一份新的表示,然后拿这份表示去预测下一个 token。
两个归一化层是分开的(pre_fc_norm_embedding 和 pre_fc_norm_hidden),各自归一化各自那一份再拼——而不是拼完一起归一化。这个细节说明设计者认为这两份向量的数值分布不一样,需要分别处理。
结构三:那一层永远是全注意力
拼接降维之后,结果要过一层解码层。这一层是这么造的:
Qwen3_5DecoderLayer(
vllm_config,
layer_type="full_attention",
prefix=f"{prefix}.layers.{idx}",
)
layer_type 是硬编码的字符串 "full_attention"。
这一点值得停下来。主模型的 64 层里,层类型是从 config.layer_types 数组里按下标取的,48 层线性、16 层全注意力(见 64 层怎么排)。但 MTP 这一层不查那个数组,直接写死成全注意力。
代码里没解释原因。能确定的是:MTP 层不参与主模型那套混合注意力的排布规则,它就是一层标准的全注意力解码层。
多层时怎么轮转
虽然这个模型默认只有一层,代码是按多层写的:
current_step_idx = spec_step_idx % self.num_mtp_layers
mtp_layer = self.layers[current_step_idx]
spec_step_idx 是投机解码的步数索引。取模之后去选对应的层——如果有 N 层 MTP,投机的第 k 步就用第 k mod N 层。
对 Qwen3.8-27B 来说 num_mtp_layers 是 1,取模的结果恒为 0,永远用同一层。但这段代码告诉我们:MTP 的设计是为投机解码服务的,而且支持每一步用不同的层。
投机解码的基本思路是先用便宜的方式猜几个 token,再让主模型一次性验证。MTP 在这里扮演的就是「便宜地猜」的角色——它只有一层,成本远低于跑完 64 层主干。
至于实际能不能加速、加速多少,取决于猜中率和具体实现,本文不做任何估计。
两处量化的特殊处理
代码里还有两段专门对付量化的逻辑,挺能说明工程现实的。
第一处针对 NVFP4 格式。 注释写得很直白:mtp.fc 在 NVFP4 的 checkpoint 里是以 BF16 存储的,但它没有被列进量化配置的排除名单里。所以 vLLM 主动把这一层的量化配置置空,强制它保持不量化。注释还附了两个上游 PR 的链接——一个在 vLLM,一个在模型优化工具那边。
第二处针对 GPTQ。 某些量化 checkpoint 会用 -: 开头的模式串来标记「这些模块不要量化」。代码会去检查这份动态配置里有没有含 mtp 的排除项,有的话就在构造 MTP 层期间临时把量化配置关掉,造完再恢复回去。
这两段都是同一类问题:量化工具和推理框架对「MTP 这部分该不该量化」的理解不一致,框架侧得做兼容。
对使用者的实际意义是:如果你在用量化版本的 MTP 并且遇到了奇怪的问题,先看看框架版本够不够新——这类兼容处理是逐步补上去的。这也呼应了 Ollama 上那些 带 mtp 的量化标签 为什么要单独分出来。
小结
把 MTP 的结构收一收:
- 它是独立注册的模型,主模型加载时会丢弃
mtp.权重 - 层号从主模型的 64 层之后开始,层数默认 1
- 自带一份词嵌入表,不复用主模型的
- 核心是把当前 token 的嵌入和主模型的隐状态各自归一化后拼接,再用 fc 压回原宽度
- 那一层解码层的类型被硬编码为全注意力,不参与主模型的混合注意力排布
- 支持多层轮转,按投机解码的步数取模选层
- 量化时
fc有两处特殊处理,因为量化工具和框架对它的处理约定不一致
一句话概括这个结构:MTP 是一个知道「主模型刚才想到哪儿了」的轻量预测头,用一层的代价去猜下一个 token,猜完交给主模型验证。
理解了这个结构,也就理解了为什么 Ollama 上要把带 MTP 和不带 MTP 的版本分成两组标签:多出来的不是一个开关,而是一整套额外的参数——一张词嵌入表、一个降维矩阵、三个归一化层、一层完整的全注意力解码层。它们是实打实占空间的,只是相对 64 层主干来说占比不大,所以标注体积看起来一样。
如果你要对照 MTP 的影响,记得从标签层面就把两个版本区分开,别指望在同一份权重里用参数切换。
更多拆解在 Qwen3.8-27B 专题。