Decoder-Only架构深度解析:从原理到代码实现的大模型基础指南

发布时间:2026/11/3 16:44:16
Decoder-Only架构深度解析:从原理到代码实现的大模型基础指南

在 GPT、Llama、Qwen 这一批模型刷榜的时代,"Transformer" 这个词已经快被说烂了,但你要是打开这些模型的实际结构图,会发现它们几乎都是同一个形态:Decoder-Only。很多人最开始接触 Transformer 是从经典的 Encoder-Decoder 图开始的,结果一看现代大模型里只有一大堆 Decoder Block 堆叠,直接懵掉。这其实是近五年 NLP 架构演变里最值得搞清楚的一个关键变化——Decode-Only 架构不是把原来那套机器翻译框架删了一半,而是整个训练与推理范式都换了。

这篇文章就围绕 Decode-Only Transformer 架构,把三件事讲透:它和传统 Encoder-Decoder、Encoder-Only 的本质区别是什么,内部的核心机制(因果注意力、自回归、KV Cache)是怎么落地的,以及如果你想自己复现一个能跑的极小版本,应该怎么做、会遇到哪些坑。无论你是刚接触大模型的小白,还是已经在微调业务模型的开发者,这篇文章都可以当一份架构速查笔记用。

1. 先从家族谱系说起:它为什么能胜出

1.1 Encoder-Decoder 时代:翻译任务塑造的经典结构

2017 年《Attention Is All You Need》提出的原始 Transformer 是 Encoder-Decoder 结构。编码器部分用双向注意力读完整句输入,解码器部分用带掩码的自注意力逐词生成输出。在当时,这种结构完美契合机器翻译:源语言需要被完整理解,目标语言需要按顺序生成。所以很多早期 NLP 任务,比如摘要、翻译、问答,都默认走 Encoder-Decoder 路线,T5 是这条路线的巅峰代表。

但这种结构有一个隐性代价:编码器输出的向量表示与解码器的生成目标之间,隔了一层"语义对接"。为了让它们互相配合,你需要额外的训练信号把编码器的表征拉向解码器需要的形态。换句话说,模型里存在两组分工不同的参数,训练目标也常常是分开优化,这在大规模预训练阶段会拖慢收敛。

1.2 Encoder-Only 的 BERT 路线:理解强,生成弱

BERT 那波选择了只用编码器,核心思想是做双向的语言理解,比如完形填空、句对关系判断。效果确实好,尤其在下游理解任务上碾压了当时的很多模型。但 Encoder-Only 天生不适合生成,因为你不能简单地让它自己一个 token 一个 token 地写下去。这类模型更适合做表示学习,对文本生成任务需要再接额外的解码头或者用非自回归的方式处理,非常别扭。

到后来大家发现,预训练模型如果只能做理解、不能做生成,那这种能力边界就很尴尬。因为真实世界里的大量任务,包括对话、写作、代码生成、信息抽取,本质上都需要"续写"一个序列,而不仅仅是判断两个句子的关系。理解能力当然是基础,但只有理解没有生成,离真正的通用助手还差得很远。

1.3 Decode-Only 的翻身逻辑:所有任务都是"接着写"

Decode-Only 的思路简单到有点暴力:把所有任务都统一成"给定前文,预测下一个 token"。翻译是续写,问答是续写,摘要也是续写,完形填空甚至都可以变成续写。你不需要设计复杂的任务头,也不需要区分编码器与解码器的分工,整个模型就是一堆 Decoder Block 堆叠,输入一段 token 序列,输出下一个 token 的概率分布。

这种设计的最大红利是训练目标与推理目标完全一致。训练时用 teacher forcing(给定真实前文预测下一个词),推理时也做同样的事,只不过把预测出来的 token 拼回输入继续预测。少了一层"语义对接",模型可以把所有容量都花在语言建模上。GPT-1 证明了这条路可行,GPT-3 把规模推到极致后,大家发现 Decode-Only 不仅生成能力强,理解能力也没落下——因为理解本质上也依赖对上下文的预测能力。

下面这张表是我自己经常用来给别人讲三种架构区别的速查表:

维度Encoder-DecoderEncoder-OnlyDecode-Only
代表模型T5、BARTBERT、RoBERTaGPT、Llama、Qwen
注意力方式编码器双向 + 解码器因果双向因果
擅长任务翻译、摘要分类、匹配生成、对话、通用任务
训练目标不同目标拼接掩码语言建模自回归语言建模
生成能力有但复杂弱、需拼接天然自回归
推理效率两段式,复杂一次前向预填充 + 逐 token 解码

Decode-Only 能胜出,本质上是"用单一目标逼近通用能力"这个哲学赢了。

2. 拆开骨架看细节:自回归与因果掩码

2.1 自回归:一次生成一个 token 的机制

Decode-Only 模型生成文本时,走的是一条自回归链路。给定已有 token 序列,模型前向传播一次,算出一个概率分布,采样一个 token 拼到末尾,然后再把完整的序列喂给模型,继续预测下一个。虽然实际工程里会用 KV Cache 加速,但概念上就是"每生成一个词,都要重新看一眼整个上文"。

这种做法的好处是生成结果天然符合从左到右的语言习惯,坏处是慢——每一步都要做一次全序列的前向传播。你写一句 100 字的回复,就要执行 100 次解码步骤,虽然每次只计算最新的 token 对应的那一路输出,但要复用前 99 个 token 的中间结果,就必须引入 KV Cache(后面会详细讲)。

我刚接触这个架构时最不习惯的地方是:训练时明明可以看到完整的句子,为什么推理时非要一步一步挤牙膏?其实这正是 Decode-Only 的设计意图——训练时用完整句子做 teacher forcing,强迫模型学会"在任意前缀条件下预测下一个词",等推理阶段,这个能力就顺滑迁移到逐 token 生成上了。

2.2 因果掩码:让未来信息彻底隔离

要支持自回归,Decode-Only 的注意力层必须使用因果掩码。标准注意力会让第 i 个 token 看到序列里所有 token,但在 Decode-Only 里,第 i 个 token 只能看到第 1 到第 i 个 token。实现方式很朴素:构建一个下三角矩阵,对右上角的部分填充负无穷,在 softmax 之前把这些位置的注意力分数压成零。

import torch max_len = 8 causal_mask = torch.tril(torch.ones(max_len, max_len)).bool() print(causal_mask)

这一步看似简单,但它决定了 Decode-Only 的语义边界:任何位置都看不到未来 token。这保证了模型在训练时不会"作弊"——如果第 i 个 token 能看到 i+1 甚至更后面的 token,那预测下一个词就变成了一眼扫过去抄答案,学不到真正的语言规律。

2.3 多头自注意力:矩阵运算里的信息混合

多头自注意力是 Decode-Only 的最核心组件。每个输入 token 经过三个线性变换得到 Query、Key、Value,Query 和 Key 做点积得到注意力分数,再缩放后 softmax,用分数加权 Value。这个过程在多个注意力头上并行做,每个头学不同的语义关系,比如有的头关注语法依赖,有的头关注指代关系,有的头关注远程的实体一致性。

矩阵视角下,整个操作就是:先把输入 x 乘上 Wq、Wk、Wv,得到 Q、K、V;然后计算 QK^T,除以 sqrt(d_k) 做缩放;再加上因果掩码,softmax 后乘 V;最后经过输出投影。

缩放这一步很容易被忽略,但它很重要。如果不除以 sqrt(d_k),当维度变大时 QK^T 的方差会变大,softmax 会进入梯度饱和区,训练很容易崩。所以 d_k 越大,缩放因子也要跟上,这是一个纯粹的数学稳定性问题。

2.4 参数量意识:不是每一层都那么费参数

不少人在看 Decode-Only 结构图时会盯着 Multi-Head Attention 看半天,实际上在参数量上,FFN(Feed-Forward Network)才是大头。一个典型的 Transformer Block 里,FFN 的两个线性层占掉约 2/3 参数量,注意力里的 QKV 投影和输出投影加起来只占 1/3 左右。

所以在做模型压缩或者推理优化时,优先看 FFN 往往更划算。Llama 系列把 FFN 的激活函数改成 SwiGLU 后,参数分布的规律还是没变。MoE 的改造方向也只是把 FFN 换成多个专家网络,注意力部分还是标准的 Decode-Only 形态。

3. 落地到代码:手写一个极简 Decode-Only Transformer

3.1 最小模块划分

为了把架构看清楚,我用 PyTorch 写了一个极简实现,只保留主干:嵌入层、位置编码、若干个 Block,每个 Block 包含一个因果自注意力层和一个 FFN。这个版本不做 KV Cache,不做 RoPE,先把核心逻辑跑通,后面再看怎么优化。

模型的关键参数需要自己定:d_model 是隐藏层维度,n_heads 是注意力头数,num_layers 是 Block 数量。为了训练一个小规模字符级模型,可以设 d_model=128,n_heads=4,num_layers=4,参数量大概几十万,单张消费级显卡就能跑。

3.2 因果自注意力实现

核心代码如下,我会写注释方便你复现:

import torch import torch.nn as nn import math class CausalSelfAttention(nn.Module): def __init__(self, d_model, n_heads, max_len): super().__init__() assert d_model % n_heads == 0 self.d_model = d_model self.n_heads = n_heads self.head_dim = d_model // n_heads self.wq = nn.Linear(d_model, d_model) self.wk = nn.Linear(d_model, d_model) self.wv = nn.Linear(d_model, d_model) self.out_proj = nn.Linear(d_model, d_model) # 因果掩码:下三角为 True,右上角为 False self.register_buffer( "causal_mask", torch.tril(torch.ones(max_len, max_len)).bool() ) def forward(self, x): B, T, C = x.shape # batch, 序列长度, 隐藏维度 q = self.wq(x).view(B, T, self.n_heads, self.head_dim).transpose(1, 2) k = self.wk(x).view(B, T, self.n_heads, self.head_dim).transpose(1, 2) v = self.wv(x).view(B, T, self.n_heads, self.head_dim).transpose(1, 2) attn = q @ k.transpose(-2, -1) / math.sqrt(self.head_dim) mask = self.causal_mask[:T, :T] attn = attn.masked_fill(~mask, float("-inf")) attn = torch.softmax(attn, dim=-1) y = attn @ v y = y.transpose(1, 2).contiguous().view(B, T, C) return self.out_proj(y)

这里面有一个我踩过的坑:如果直接用triu的相反情况来构造掩码,很容易搞混行列方向。建议专门写一个测试用例,输入一个随机张量,检查输出位置 i 是否只依赖位置 <= i 的输入。这种边界错误很难通过 loss 曲线发现,因为小模型照样能过拟合训练集,只是推理时会有"穿越感"。

3.3 Block 与主模型搭建

有了注意力层,Block 就很简单了。标准 Decode-Only 使用 Pre-LN 结构:先做 LayerNorm,再过注意力,残差连接;再 LayerNorm,FFN,残差连接。Pre-LN 比 Post-LN 更稳,因为残差路径始终保留原始信息,深层堆叠时不容易梯度消失。

class FeedForward(nn.Module): def __init__(self, d_model, hidden_mult=4): super().__init__() hidden = d_model * hidden_mult self.fc1 = nn.Linear(d_model, hidden) self.fc2 = nn.Linear(hidden, d_model) def forward(self, x): return self.fc2(torch.relu(self.fc1(x))) class DecoderBlock(nn.Module): def __init__(self, d_model, n_heads, max_len): super().__init__() self.ln1 = nn.LayerNorm(d_model) self.attn = CausalSelfAttention(d_model, n_heads, max_len) self.ln2 = nn.LayerNorm(d_model) self.ffn = FeedForward(d_model) def forward(self, x): x = x + self.attn(self.ln1(x)) x = x + self.ffn(self.ln2(x)) return x class DecodeOnlyTransformer(nn.Module): def __init__(self, vocab_size, d_model, n_heads, num_layers, max_len): super().__init__() self.token_embed = nn.Embedding(vocab_size, d_model) self.pos_embed = nn.Embedding(max_len, d_model) self.blocks = nn.ModuleList([ DecoderBlock(d_model, n_heads, max_len) for _ in range(num_layers) ]) self.ln_f = nn.LayerNorm(d_model) self.head = nn.Linear(d_model, vocab_size) def forward(self, x): B, T = x.shape positions = torch.arange(T, device=x.device).unsqueeze(0) h = self.token_embed(x) + self.pos_embed(positions) for block in self.blocks: h = block(h) logits = self.head(self.ln_f(h)) return logits

这个版本的嵌入层用了可学习的位置编码,适合短序列小规模实验。如果你追求更接近 Llama/Qwen 的现代做法,要把pos_embed换成 RoPE,后面第四节详细讲。

3.4 训练一个小模型:字符级生成

训练时用交叉熵损失,只需要把 logits 的最后一维映射到词汇表,然后和真实的下一个 token 做分类。关键是构造训练样本时要用滑动窗口切序列,比如把一段文本切成多个(input_seq, target_seq)对,target 整体是 input 右移一位。

def train_step(model, batch, optimizer): input_ids, target_ids = batch logits = model(input_ids) # (B, T, vocab) loss = nn.functional.cross_entropy( logits.reshape(-1, logits.size(-1)), target_ids.reshape(-1) ) optimizer.zero_grad() loss.backward() optimizer.step() return loss.item()

一个小建议:数据预处理阶段就可以做 token-level 的 chunking,不要放在训练循环里做,否则 CPU 会拖累 GPU。我早期犯过这个错误,每个 step 都重新做切分,训练速度慢到怀疑人生。

推理时要自己实现"贪心解码"或者"温度采样"。贪心解码就是每次都取 argmax,温度采样则在 softmax 之前除以 temperature,调节分布的平滑程度。实际生成时,通常还会做 top-k 和 top-p 截断来降低采样风险。

4. 当代 LLM 的工程细节:从 RoPE 到 KV Cache

4.1 位置编码:为什么可学习位置编码退居二线

上面手写的模型使用了可学习位置编码,但如果你去看 Llama、Qwen、Mistral 的实现,会发现它们都不用这种方案,而是用 RoPE(旋转位置编码)。原因很直接:可学习位置编码是绝对位置编码,它天然认为第 100 个位置和第 101 个位置是绝对坐标,这会导致序列长度超过训练长度时,位置嵌入向量完全没见过,模型表现直接崩溃。RoPE 是相对位置编码,通过把 Query 和 Key 按位置做旋转,让注意力分数天然依赖相对距离。这样训练长度是 4096,推理时也可以尝试外推到更长序列。

RoPE 的实现会稍微绕一点,核心是把向量按二维子空间交替旋转,公式写出来像傅里叶变换:

[ \theta_i = base^{-2i/d} ]

其中 base 通常取 10000,i 是维度下标。对第 m 个位置的 token,它的每个子空间都旋转 m·θ_i。因为旋转操作是线性的,代码实现时可以直接使用复数乘法或者预计算 cos/sin 矩阵。我建议新手不要硬记公式,先去看一个成熟库的实现,理解了"为什么旋转后点积只与相对位置有关"再回来看公式。

4.2 归一化与激活函数:RMSNorm 和 SwiGLU

现代 Decode-Only 模型里,LayerNorm 已经被 RMSNorm 取代。原因不是简单的地理原因,而是 RMSNorm 去掉了 LayerNorm 中的均值中心化操作,只保留均方根归一化,减少了计算量,同时实验发现对大模型影响很小。

激活函数方面,GPT-2 时代用 GELU,Llama 之后普遍转向 SwiGLU。SwiGLU 是 gated linear unit,把原来的一层线性变换拆成三条路径,其中一条用于门控。效果是非线性更强,训练更稳,代价是多了一个线性层,参数和计算量稍微增加。所以做 FFN 时,hidden_dim 通常会按比例压缩,比如 Llama 的 hidden_dim 大约是 2/3×4×d_model,而不是简单的 4×d_model。

4.3 KV Cache:用显存换速度的经典方案

没有 KV Cache 的 Decode-Only 在推理时非常吃亏,因为每生成一个 token,都要从头计算之前所有 token 的 Key 和 Value。实际上这些中间结果大部分是可以复用的——第 i 步生成完后,前 i 个 token 的 K、V 已经算过了。KV Cache 的思想就是把这些 K、V 缓存下来,每一步只计算新 token 的 Query、Key、Value,然后新的 Key、Value 拼接到缓存里,再与当前的 Query 做注意力。

这带来的收益极大:生成阶段的计算量从"每步都算全序列"降为"每步只算新增部分"。但代价是显存占用随着序列长度线性增长,因为缓存大小是 batch×num_heads×seq_len×head_dim×2。对长上下文或者多路并发来说,KV Cache 是显存开销的主要来源之一。PagedAttention 的思路就是像操作系统管理内存一样管理这些缓存块,不连续时也能分配,这就是 vLLM 提速的底层逻辑。

一个工程提示:做推理服务时,可以从cache_size = 2 * num_layers * num_heads * seq_len * head_dim * batch这个公式先粗估显存需求。如果你部署 70B 模型且开了 8K 上下文,KV Cache 轻松占掉十几 GB 甚至更多,选显卡时必须把这部分算进去,不能只盯着权重文件大小。

4.4 MoE 和 Agent:同一套骨架的扩展

MoE(混合专家)模型看起来结构很复杂,但它的改造点就在 Block 里的 FFN。把原来的一整个 FFN 换成多个"专家"网络,再增加一个路由网络选择本轮激活哪些专家,注意力部分完全不动。所以你仍然可以说 Qwen-MoE、DeepSeek-MoE 这些模型本质上是 Decode-Only 架构,只是"中间那一层变成了好几个小 FFN 的调度池"。

这套思路的好处是,在总参数量很大的情况下,每个 token 实际只经过少数几个专家,推理 FLOPs 就能压下来。这也解释了为什么 MoE 模型能堆到几百 B 参数,单卡却还能勉强推理——运行时计算量不是全部专家都参与,而是每 token 只激活 top-k 个。

Agent 架构也很有意思。很多人以为 Agent 是独立于模型的体系,其实对模型本身来说,Agent 的所有能力都还是建立在 Decode-Only 的续写上。工具调用被写成特殊 token 序列,思维链是一段自然语言续写,多步规划也不过是多个续写回合的组合。核心模型只负责"预测下一个 token",而下一段规划、下一个工具调用、下一步判断,全部被编码成了需要预测的 token。这就是为什么 Decode-Only 能成为 Agent 领域的事实标准结构。

4.5 多模态融合:以 Qwen2.5-VL 与 Qwen3-VL 为例

多模态大模型是 Decode-Only 架构最典型的延伸场景,Qwen2.5-VL 和 Qwen3-VL 这两代就是很好的案例。它们保留了纯解码器语言模型作为主干,图像输入先用视觉编码器提取特征,再通过投影层映射到语言模型的 token 空间,后续所有能力还是靠 Decode-Only 的自回归生成。视频理解则可以看作"多帧图像特征序列"拼接到文本序列里。

这种设计带来一个特殊的训练细节:图像位置编码与文本位置编码必须协同。因为图像 token 可能是网格排列,直接按线性位置编码会丢失二维空间关系。Qwen 系列通过加入特殊的 token 边界符和二维位置嵌入来处理,核心模型仍然是因果注意力,只在输入构造上做文章。所以搭建 Decode-Only 多模态模型时,最值得花时间的不是模型本体,而是视觉特征如何切分、如何插入文本序列、位置编码如何对齐——这些反而决定了多模态能力的上限。

5. 常见问题与避坑指南

5.1 训练与推理的语义漂移:Exposure Bias

训练时用 teacher forcing 喂真实前文,推理时用自己的预测结果作为上文,这两者之间存在分布差异。如果模型在训练中没学会"遇到自己的错误时如何纠正",推理时一旦出错,错误会沿着序列一路放大,生成结果越来越差。这被称为 exposure bias。

缓解手段有几个,最实用的是在训练里加入 scheduled sampling,按一定概率把前文替换成模型自己的预测;也可以增加数据增强,比如随机遮蔽部分前缀,让模型学会不依赖精确前缀。不过对大模型预训练来说,这种方式会降低收敛效率,所以更多依靠模型容量和数据规模来自然克服。做小模型实验时,我建议直接观察"泛化 Start 失败"现象,检查是不是测试集和训练集分布差距过大,未必都要归因到 exposure bias。

5.2 KV Cache 显存爆掉

这是推理服务最常见的坑。特征很明显:刚开始推理一切正常,上下文一长就 OOM。排查路径很简单,先看缓存公式,再确认是否启用了 PagedAttention,如果用的是 Hugging Face 的 naive attention,建议尽早换成 vLLM 或者 flash-attention 等支持缓存管理的方案。

如果你自己实现了 KV Cache,一定要记得缓存张量用的是 FP16 而不是 FP32,否则显存占用会翻倍。另外,不需要缓存所有层吗?不对,每层都有独立的 K、V,缓存需要按层分别保存。很多初学者只缓存最后一层,结果每一步都在重新算前面的层,速度完全没提升。

5.3 小模型不收敛的排查清单

我复现 Decode-Only 小模型时遇到过几次不收敛。最常见的几个原因依次是:学习率过高(建议 1e-4 或更低配 warmup)、没有做梯度裁剪(clip_grad_norm 设为 1.0)、因果掩码维度写错导致信息泄露、batch size 太小导致 loss 抖动剧烈。

一定要用一个极小的训练集先做 sanity check。把训练集固定为一句话,过拟合到 0 loss,说明模型结构和数据管线没问题;如果连单句都过拟合不了,问题大概率在注意力掩码或者梯度计算上。这个排查手段在老练的工程师手里几乎是标准操作。

5.4 对比实验的公平性

很多论文在对比 Decode-Only 和 Encoder-Decoder 时,容易忽略计算量差异、训练步数差异、数据量差异。做自己的对比实验时,建议记录三个基准:参数量、FLOPs、训练 token 数。否则你会发现某模型效果差,不是架构不行,而是它根本没吃够训练量。这算是文本生成领域最容易踩的隐藏坑。

下面整理了一份速查表:

问题典型现象推荐排查方向
训练不收敛loss 抖动大学习率、梯度裁剪、掩码维度
生成内容重复一直绕同一句调整温度、加入 top-p 截断
长文本效果差超长度后崩坏位置编码用 RoPE,检查外推策略
推理速度慢每 token 巨慢开启 KV Cache,换 vLLM
显存 OOM上下文长后爆显存检查 KV Cache 大小,用 PagedAttention

6. 架构选型上的一点个人体会

踩过各种架构的坑之后,我的看法是:Decode-Only 的流行不是偶然,它赢在"目标一致"。编码器-解码器的问题在于两端目标不同,编码器学理解、解码器学生成,中间的桥接层很难优化;Encoder-Only 则把生成能力让渡出去了。Decode-Only 把所有任务统一成续写,模型不需要关心任务类型,只需要学会在任意前缀下预测下一个 token,这个目标简单、可扩展、和数据天然同构。

我在实际复现中发现,真正难的不是跑通 Decode-Only,而是理解它为什么每一步都要这样做。比如因果掩码的那一行代码,如果改成双向注意力,loss 能立刻降得很低,但生成能力会彻底消失。再比如 KV Cache 那一串缓存矩阵,如果理解不了它的形状变化,写出来的代码要么爆显存、要么慢到怀疑人生。架构的本质往往藏在这些"看起来不起眼"的细节里,这也是我建议每个想深入大模型的人,都要亲手写一遍最小 Decode-Only 的原因。

如果你已经跑通了最小实现,下一步我建议用 torch.compile 或者 Flash Attention 把注意力计算加速,再试试 RoPE 和 RMSNorm 替换原始组件,观察训练曲线的变化。真正吃透这个架构,最好的方式不是再读十篇解析,而是亲手改参数、调代码、看 loss 曲线说话。