资讯详情

资讯详情

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 能看到 i1 甚至更后面的 token那预测下一个词就变成了一眼扫过去抄答案学不到真正的语言规律。2.3 多头自注意力矩阵运算里的信息混合多头自注意力是 Decode-Only 的最核心组件。每个输入 token 经过三个线性变换得到 Query、Key、ValueQuery 和 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 看半天实际上在参数量上FFNFeed-Forward Network才是大头。一个典型的 Transformer Block 里FFN 的两个线性层占掉约 2/3 参数量注意力里的 QKV 投影和输出投影加起来只占 1/3 左右。所以在做模型压缩或者推理优化时优先看 FFN 往往更划算。Llama 系列把 FFN 的激活函数改成 SwiGLU 后参数分布的规律还是没变。MoE 的改造方向也只是把 FFN 换成多个专家网络注意力部分还是标准的 Decode-Only 形态。3. 落地到代码手写一个极简 Decode-Only Transformer3.1 最小模块划分为了把架构看清楚我用 PyTorch 写了一个极简实现只保留主干嵌入层、位置编码、若干个 Block每个 Block 包含一个因果自注意力层和一个 FFN。这个版本不做 KV Cache不做 RoPE先把核心逻辑跑通后面再看怎么优化。模型的关键参数需要自己定d_model 是隐藏层维度n_heads 是注意力头数num_layers 是 Block 数量。为了训练一个小规模字符级模型可以设 d_model128n_heads4num_layers4参数量大概几十万单张消费级显卡就能跑。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再过注意力残差连接再 LayerNormFFN残差连接。Pre-LN 比 Post-LN 更稳因为残差路径始终保留原始信息深层堆叠时不容易梯度消失。class FeedForward(nn.Module): def __init__(self, d_model, hidden_mult4): 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, devicex.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 Cache4.1 位置编码为什么可学习位置编码退居二线上面手写的模型使用了可学习位置编码但如果你去看 Llama、Qwen、Mistral 的实现会发现它们都不用这种方案而是用 RoPE旋转位置编码。原因很直接可学习位置编码是绝对位置编码它天然认为第 100 个位置和第 101 个位置是绝对坐标这会导致序列长度超过训练长度时位置嵌入向量完全没见过模型表现直接崩溃。RoPE 是相对位置编码通过把 Query 和 Key 按位置做旋转让注意力分数天然依赖相对距离。这样训练长度是 4096推理时也可以尝试外推到更长序列。RoPE 的实现会稍微绕一点核心是把向量按二维子空间交替旋转公式写出来像傅里叶变换[ \theta_i base^{-2i/d} ]其中 base 通常取 10000i 是维度下标。对第 m 个位置的 token它的每个子空间都旋转 m·θ_i。因为旋转操作是线性的代码实现时可以直接使用复数乘法或者预计算 cos/sin 矩阵。我建议新手不要硬记公式先去看一个成熟库的实现理解了为什么旋转后点积只与相对位置有关再回来看公式。4.2 归一化与激活函数RMSNorm 和 SwiGLU现代 Decode-Only 模型里LayerNorm 已经被 RMSNorm 取代。原因不是简单的地理原因而是 RMSNorm 去掉了 LayerNorm 中的均值中心化操作只保留均方根归一化减少了计算量同时实验发现对大模型影响很小。激活函数方面GPT-2 时代用 GELULlama 之后普遍转向 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 大小用 PagedAttention6. 架构选型上的一点个人体会踩过各种架构的坑之后我的看法是Decode-Only 的流行不是偶然它赢在目标一致。编码器-解码器的问题在于两端目标不同编码器学理解、解码器学生成中间的桥接层很难优化Encoder-Only 则把生成能力让渡出去了。Decode-Only 把所有任务统一成续写模型不需要关心任务类型只需要学会在任意前缀下预测下一个 token这个目标简单、可扩展、和数据天然同构。我在实际复现中发现真正难的不是跑通 Decode-Only而是理解它为什么每一步都要这样做。比如因果掩码的那一行代码如果改成双向注意力loss 能立刻降得很低但生成能力会彻底消失。再比如 KV Cache 那一串缓存矩阵如果理解不了它的形状变化写出来的代码要么爆显存、要么慢到怀疑人生。架构的本质往往藏在这些看起来不起眼的细节里这也是我建议每个想深入大模型的人都要亲手写一遍最小 Decode-Only 的原因。如果你已经跑通了最小实现下一步我建议用 torch.compile 或者 Flash Attention 把注意力计算加速再试试 RoPE 和 RMSNorm 替换原始组件观察训练曲线的变化。真正吃透这个架构最好的方式不是再读十篇解析而是亲手改参数、调代码、看 loss 曲线说话。
觉得有用,分享给同行:

为您的企业打造数字门面

稳重轻奢商务风格,端正雅致视觉,长效耐看不易过时。

立即咨询 →