资讯详情

Decoder-only Transformer:为什么AI圈都押注“只剩解码器”的架构

📅 2026/11/3 7:42:46 | 华诺云谱 👁 阅读
Decoder-only Transformer:为什么AI圈都押注“只剩解码器”的架构
Decode-Only Transformer为什么整个AI圈最后都押注在只剩解码器的架构上2020年我刚接触Transformer时周围人讨论最多的是BERT面试官最爱追问CLS向量到底该怎么用。风向变得比想象中快如今GPT系列、LLaMA、Qwen这些站在前沿的模型清一色是Decoder-only架构——也就是题目里说的Decode-Only Transformer。我最初对这个转向是有些抵触的毕竟教科书上把编码器-解码器称为标准结构一个连编码器都不要的模型凭什么通吃所有任务后来自己动手跑模型、调参、部署推理服务才把这笔账慢慢算明白。这篇文章想把整套逻辑完整梳理一遍Decode-Only为什么能赢过另外两支血脉它的因果注意力内部到底在算什么KV Cache怎么让推理提速几十倍以及真到自己落地时显存、参数量、各种坑分别该怎么算、怎么避。适合已经对Transformer有基础概念、想往GPT类模型内部再走一步的读者也适合那些准备拿这套架构做实际项目的人。1. 为什么只剩解码器反而赢了三支血脉的路线之争1.1 三种架构的血缘与分工差异Transformer模型按结构可以粗略分成三支Encoder-Decoder、Encoder-Only 和 Decoder-Only。它们不是随便剪出来的而是按照不同的任务目标演化的。Encoder-Decoder是原版论文的形态左侧编码器吃完整输入右侧解码器自回归吐输出。BART、T5都属于这类天然适合翻译、摘要这种一种序列变另一种序列的任务。Encoder-Only把编码器单独拎出来用代表作是BERT它通过掩码语言模型的方式预训练擅长做分类、抽取、语义相似度这类理解型任务。Decoder-Only则只保留解码器代表作是GPT系列它的训练目标极度简单给定上文预测下一个token。我之前画过一张对比表基本可以概括三者的差异架构类型注意力可见范围代表模型强项任务预训练目标Encoder-Decoder编码器双向 / 解码器因果BART、T5翻译、摘要、生成去噪自编码 自回归Encoder-Only双向BERT、RoBERTa分类、抽取、匹配掩码语言模型Decoder-Only因果单向GPT、LLaMA、Qwen生成、对话、推理自回归语言模型仅从这张表看三者分工明确Decoder-Only似乎只是负责生成的专用组件没什么特别。但过去几年发生的事是Decode-Only不仅把自己的主场拿下了还把理解、分类、甚至多模态任务也一并收拾干净。理解能力表面积反而变小了内部却变深了。1.2 自回归的本质把理解改写为预测Decoder-Only真正颠覆性的地方在于它重新定义了什么叫理解。传统观点认为理解一道阅读理解题需要把文章和问题全面编码然后输出类别或片段。BERT路线就是这么干的它给每个token一个双向的上下文表示最后用特殊标记的向量去做分类。这套思路在GLUE时代确实统治过一阵。但Decode-Only走的是另一条路不显式建模理解这个中间状态而是直接建模在给定所有上下文的情况下下一个token是什么的条件概率。你说它没理解不对如果它能在看完一份病历后准确写出诊断结论能在看完整段代码后接上正确的下一行那它对上下文的理解已经被压缩进参数里了只是没有以向量分类头的形式展示给你看。这种设计的精妙之处在于几乎所有任务都能被重新组织成预测任务。分类可以变成这段评论的情感是正面负面抽取可以变成文中提到的人名是张三、李四生成更不用说了骨架就是自回归。训练目标始终只有一个——下一个token预测。所以Decode-Only天然具备一个模型到处可调的优势。1.3 行业从BERT转向GPT的深层原因算力效率行业集体押注Decode-Only除了任务统一性之外还有一个更现实的原因训练效率。Encoder-Only的BERT类模型虽然能并行计算所有token但它的掩码语言模型目标有个尴尬之处——15%的掩码token里只有部分参与损失计算大量计算其实花在了重建被遮住的词这一人为任务上。而Decode-Only的每个token都参与预测下一个token的损失训练信号密度更高。更重要的是只要预训练架构选定了部署阶段的取舍就逃不掉了。生成任务天然是自回归的你让Encoder-Decoder来做生成解码器部分照样是因果注意力那前面那个编码器就变成了生成时的负担——每次都要把完整输入重新编码一遍。Decode-Only则干脆利落输入和输出共用一套参数KV Cache一套到底推理时内存增长几乎是线性的。我在后面第4节会详细算这笔账。2. 因果注意力的内部世界Decode-Only的基石2.1 掩码矩阵的构造上三角里藏着的秩序因果注意力是Decode-Only和另外两支架构最本质的差别。理解它需要回到注意力公式[ \text{Attention}(Q, K, V) \text{softmax}\left(\frac{QK^T}{\sqrt{d_k}}\right)V ]这个公式本身是双向的——第i个token可以和序列里任意一个token做内积。要想让它变成只能看自己和前面最直接的做法就是在softmax之前把注意力分数矩阵的上三角部分改成负无穷import torch def build_causal_mask(seq_len): # 上三角置为1其余为0 mask torch.triu(torch.ones(seq_len, seq_len, dtypetorch.bool), diagonal1) return mask # 示例seq_len4时mask长这样 # [[False, True, True, True], # [False, False, True, True], # [False, False, False, True], # [False, False, False, False]]构造出掩码后加到注意力分数上再执行softmax。由于负无穷经过softmax后趋近于0那些未来位置的注意力权重会被完全压制。这就是因果注意力的全部秘密。我第一次手撕这段代码时觉得这也太简单了后来才意识到简单背后是数学上优雅的强制时间有序——它把序列建模中不能偷看答案的约束硬编码进了计算图。训练阶段torch的FlashAttention等实现会把掩码和注意力融合起来优化省得先算完整矩阵再塞负无穷浪费显存。但原理层面你手动构造mask永远不会错。2.2 并行训练的假象与串行推理的现实Decode-Only有一个让人困惑的点训练时可以一次性把一整段文本喂进去所有位置的输出同时算出来看起来完全是并行的但推理时却必须一个token一个token地蹦出来。为什么会有这种错位因为训练时每个位置的正确答案是已知的。你预测第i个token时用的上文是语料里真实的token而不是模型自己生成的。这种范式叫教师强制。既然正确答案都在手里模型当然可以同时学习所有位置的预测互不干扰。推理时就完全不一样了。模型输出端只有一个位置有初始输入通常是bos标记要拿到第i个token的概率分布必须先把前面第0到第i-1个token全部计算一遍。第i1个token又要重新从第0开始。如果每次都全量重算复杂度是O(T^2)——这就是我下一节要讲KV Cache的动机所在。很多新手会问训练和推理行为不一致模型不会懵吗答案是会这就是为什么大模型训练后期要用序列级损失或引入特殊的训练技巧来弥合差距。不过在标准实践中大家默认接受这个不一致然后用工程手段去补。2.3 位置信息怎么算从绝对位置到旋转位置编码Decode-Only还有一个必须解决的问题注意力本身是不认识顺序的输入我打你和你打我在无位置信息时算出的向量一模一样。因此必须把位置注入进去。早期GPT用的是可学习绝对位置编码给每个位置配一个可训练向量直接加到token embedding上。这个方法简单但有两个短板一是没见过的序列长度就没有对应的位置向量需要外推或截断二是位置信息是加到语义里的模型很难把某个位置的token是什么和这个位置在哪解耦。后来RoPE旋转位置编码成了主流LLaMA、Qwen、DeepSeek等都用它。RoPE的思路是把token的Q和K向量按维度两两分组每组看作复数平面里的一个点然后根据位置差做一个旋转变换。旋转后的Q和K做内积时自然携带了相对位置的信息——两个token距离越远旋转角度差越大内积里多出来的相位差就可以表示相对位置。写代码时需要手动给query和query注入旋转矩阵import torch import torch.nn as nn import math def precompute_rope_freqs(dim, max_seq_len, base10000.0): freqs 1.0 / (base ** (torch.arange(0, dim, 2).float() / dim)) t torch.arange(max_seq_len).float() angles torch.outer(t, freqs) # [max_seq_len, dim/2] return torch.stack([angles.cos(), angles.sin()], dim-1) # [max_seq_len, dim/2, 2] def apply_rope(x, pos, rope_cache): # x: [batch, num_heads, seq, head_dim] # 将x两两一组与旋转角度相乘 x x.view(*x.shape[:-1], -1, 2).float() cos rope_cache[pos][..., 0].unsqueeze(0).unsqueeze(0) sin rope_cache[pos][..., 1].unsqueeze(0).unsqueeze(0) x1 x[..., 0] x2 x[..., 1] rotated torch.stack([x1 * cos - x2 * sin, x1 * sin x2 * cos], dim-1) return rotated.flatten(-2).to(x.dtype)RoPE如今在Decode-Only里几乎成了默认配置不少中文资料讲位置信息怎么计算也都在围绕RoPE展开。它对长序列更加友好而且天然支持相对位置理解这对预测下一个token这种顺序性极强的任务非常重要。3. 手写一个能跑的最小Decode-Only Transformer理论知识说了一堆不如直接动手写一个玩具规模的模型。我在transformer手写这个方向上也踩过不少坑这里按我实际能跑通的路径给你拆一遍。目标不是复刻LLaMA而是让你看到每个模块的真实形状跑通一个从字符序列到loss 下降的完整闭环。3.1 基础组件RMSNorm与SwiGLU现代Decode-Only通常不再用LayerNorm而是用RMSNorm。区别在于RMSNorm去掉了均值的计算只做按均方根归一化。实现很简单import torch import torch.nn as nn class RMSNorm(nn.Module): def __init__(self, dim, eps1e-6): super().__init__() self.weight nn.Parameter(torch.ones(dim)) self.eps eps def forward(self, x): rms torch.sqrt(x.pow(2).mean(-1, keepdimTrue) self.eps) return x / rms * self.weight为什么用RMSNorm而不是LayerNorm一个原因是计算量小一截另一个原因是在Decode-Only这种大量堆层数的场景里去均值未必带来收益反而省掉中心化步骤能减少数值不稳定的来源。很多大模型实验发现RMSNorm在深网络上一样稳速度还快。FFN部分主流选择是SwiGLU激活。它本质是门控线性单元的变体把原来的单层线性ReLU换成了三个权重矩阵class SwiGLU(nn.Module): def __init__(self, dim, hidden_dim): super().__init__() self.w1 nn.Linear(dim, hidden_dim, biasFalse) self.w2 nn.Linear(dim, hidden_dim, biasFalse) self.w3 nn.Linear(hidden_dim, dim, biasFalse) def forward(self, x): # 先经过w1走激活再和w2的门控相乘最后w3降维 gate torch.nn.functional.silu(self.w1(x)) hidden self.w2(x) return self.w3(gate * hidden)SwiGLU比普通ReLU FFN更强的经验证据来自很多开源模型的消融实验。直观理解是门控机制能让网络对每个token的信息流入量做精细化控制相当于多了一个可学习的开关。3.2 单层Block注意力与FFN的完整搭配有了RMSNorm和SwiGLU可以开始堆Block。每个Block内部顺序是先对输入做RMSNorm再进多头因果注意力残差连接再做RMSNorm进SwiGLU FFN再残差连接。class DecoderBlock(nn.Module): def __init__(self, dim, num_heads, hidden_dim): super().__init__() self.norm1 RMSNorm(dim) self.attn CausalSelfAttention(dim, num_heads) self.norm2 RMSNorm(dim) self.ffn SwiGLU(dim, hidden_dim) def forward(self, x, mask, rope_cache, positions): x x self.attn(self.norm1(x), mask, rope_cache, positions) x x self.ffn(self.norm2(x)) return x残差连接在浅层模型里看不出什么但堆到十几层后你会发现没有残差根本训不动。它让梯度可以直接从输出层回传到任意一层不必穿过所有非线性模块。CausalSelfAttention内部也不复杂核心步骤如下输入先过三个Linear得到Q、K、V在Q和K上注入RoPE位置信息用flash attention或手动方式计算带掩码的注意力分数最后过输出投影。证明一下因果掩码的作用假设输入长度是5QK^T形状是[5,5]我们把上三角置为负无穷后再做softmax第4个token只能和自己以及前面3个token交互彻底看不到未来。3.3 训练一个小模型从代码到loss变化把多个Block串起来前面接token embedding后面接LM head一个玩具Decode-Only就成型了class TinyDecoder(nn.Module): def __init__(self, vocab_size, dim128, num_heads4, num_layers4, max_seq_len256): super().__init__() self.embed nn.Embedding(vocab_size, dim) self.blocks nn.ModuleList([ DecoderBlock(dim, num_heads, dim * 4) for _ in range(num_layers) ]) self.norm_final RMSNorm(dim) self.lm_head nn.Linear(dim, vocab_size, biasFalse) self.lm_head.weight self.embed.weight # 参数共享 self.max_seq_len max_seq_len self.rope_cache precompute_rope_freqs(dim // num_heads, max_seq_len) def forward(self, idx): B, T idx.shape x self.embed(idx) positions torch.arange(T, deviceidx.device) mask build_causal_mask(T).to(idx.device) rope_cache self.rope_cache.to(idx.device) for block in self.blocks: x block(x, mask, rope_cache, positions) logits self.lm_head(self.norm_final(x)) return logits训练时用交叉熵损失标签就是输入右移一位后的结果。下面这段代码可以拿一份英语文本直接跑。我自己试过在一个3MB的莎士比亚语料上训练大约100步后就能看到loss从4.5左右降到2.8模型开始能拼出像样的单词。def train(): model TinyDecoder(vocab_size100) optimizer torch.optim.AdamW(model.parameters(), lr3e-4) loss_fn nn.CrossEntropyLoss() for step in range(500): x, y get_batch() # x: [B, T], y: [B, T]y是x右移一位 logits model(x) # [B, T, vocab] loss loss_fn(logits.view(-1, logits.size(-1)), y.view(-1)) optimizer.zero_grad() loss.backward() optimizer.step()这里有个容易被忽略的细节embedding和lm_head参数共享能省大量显存尤其是vocab_size很大时。小模型里效果也好因为共享后模型被迫让表示空间同时兼顾输入语义和输出预测起到隐式正则的效果。3.4 生成阶段的采样逻辑训练完这个玩具模型就可以让它生成文本了。初始化输入放一个起始token然后反复做前向传播每次从最后一个位置的logits里采样下一个token把它拼到序列末尾继续下一轮。这就是自回归生成的雏形。def generate(model, prompt, max_new_tokens50, temperature0.8, top_k40): idx prompt for _ in range(max_new_tokens): logits model(idx)[0, -1, :] # 只取最后一个位置 logits logits / temperature # top-k 截断 topk_vals, topk_idx torch.topk(logits, top_k) logits torch.full_like(logits, float(-inf)) logits[topk_idx] topk_vals probs torch.softmax(logits, dim-1) next_idx torch.multinomial(probs, 1) idx torch.cat([idx, next_idx], dim-1) return idx这是理解推理阶段一切的起点。你在这个demo里看到的每生成一个token都要喂入全部历史正是第4节KV Cache要解决的核心矛盾。4. KV Cache推理阶段的显存换时间账本4.1 朴素自回归的浪费全在重复计算如果一个7B模型在生成长度为1000的序列时每一步都把完整历史重新forward一遍总计算量和序列长度的平方成正比。原因在于第i步的隐藏状态是逐步依赖的前几百步算出的隐藏状态实际上在第1000步又要原封不动再算一回。注意力部分的浪费最直观第i步生成时对第j个历史位置j i计算K_j和V_j第i1步又会对同样位置重新算一遍完全相同的K_j和V_j。我实际测试过不缓存KV的情况下生成一个50token的短句也还能忍一旦生成几百上千token每步的延迟会线性恶化因为K和V矩阵会越来越宽。解决思路非常朴素反正历史位置的K、V不会变那就把它们存在内存里下一步直接拼接使用只计算当前token的Q、K、V。4.2 缓存实现预分配还是动态拼接KV Cache的简易实现是动态拼接def forward_with_kv_cache(self, x, past_kvNone, position_idsNone): B, T, _ x.shape q, k, v self.q_proj(x), self.k_proj(x), self.v_proj(x) if past_kv is not None: past_k, past_v past_kv k torch.cat([past_k, k], dim2) # 沿序列维度拼接 v torch.cat([past_v, v], dim2) # 这里再注入RoPE时要注意 # 对新增token(q、k) 使用当前位置的旋转角 # 对拼接后的完整k 只需旋转新增部分历史部分已经编码过位置实际工程里纯拼接带来大量内存拷贝所以像vLLM、TensorRT-LLM这类推理框架会把KV缓存预先分配成固定大小的显存块用位置索引去填充避免反复重新分配。还有一个关键细节RoPE的旋转注入必须只对新token做。如果你把历史K取出来再整体旋转一遍就等于是把已经旋转过的向量再旋转一次位置信息直接串台。4.3 KV Cache的显存代价KV Cache把计算复杂度降到了O(T)但显存开销上升得非常惊人。我常跟同事算这笔账公式如下[ \text{KV Cache显存} \text{batch} \times \text{seq_len} \times \text{layers} \times 2 \times \text{num_heads} \times \text{head_dim} \times \text{bytes_per_elem} ]以LLaMA-7B举例32层、32个注意力头、head_dim128、FP16存储。假设推理时batch32上下文长度2048(32 \times 2048 \times 32 \times 2 \times 32 \times 128 \times 2 \approx 34.4\text{GB})34GB只是一个batch 32、2K上下文的KV Cache。这时候你才会理解为什么开源社区要疯狂研究GQA和MQA——因为在服务长上下文时KV Cache可能比模型权重还贵甚至贵好几倍。4.4 GQA与MQA压缩KV的工程实践标准的MHA多头注意力是每个头都有独立的K、V。GQA分组查询注意力把多个query头共享一组K、VMQA多查询注意力更极端所有query头共享一组K、V。以LLaMA-2 7B为例如果从MHA改成8组GQAKV Cache的显存直接降到原来的1/4左右。代价是理论上模型的表达能力略有下降但实测中GQA在绝大部分任务上几乎不掉点这也是为什么近两年新发布的模型大部分都标配GQA。我在自己项目里尝试过这样的分组实现class GQA(nn.Module): def __init__(self, dim, num_heads, num_kv_heads): super().__init__() assert num_heads % num_kv_heads 0 self.num_heads num_heads self.num_kv_heads num_kv_heads self.group_size num_heads // num_kv_heads self.head_dim dim // num_heads self.q_proj nn.Linear(dim, num_heads * self.head_dim, biasFalse) self.k_proj nn.Linear(dim, num_kv_heads * self.head_dim, biasFalse) self.v_proj nn.Linear(dim, num_kv_heads * self.head_dim, biasFalse)实际部署时GQA的K、V缓存量比MHA小很多服务端的并发能力会成倍提高。以我的经验如果你要部署一个7B模型做高并发对话服务直接上GQA或MQA是性价比最高的优化手段因为它的收益不靠任何花哨算法只靠砍掉重复KV。5. 现实项目里的选型、算账与踩坑5.1 显存估算动手之前先算这笔账做Decode-Only项目最常见的问题不是算法跑不通而是显存爆了。训练、微调、推理三阶段的显存占用逻辑完全不同需要分开估算。训练阶段模型权重、梯度、优化器状态、激活值四者都要占显存。以AdamW优化器为例一个7B模型用FP16训练权重占14GB梯度占14GB优化器状态占42GB因为AdamW会存一阶动量m和二阶动量v都是FP32激活值另算。这就意味着如果你只有一张24GB的显卡想全量训练7B模型纯属做梦。LoRA这类参数高效微调技术之所以火爆核心原因就是它把优化器状态和梯度从训练开销里砍掉了绝大部分。推理阶段则是权重、KV Cache、激活值三者的博弈。7B模型FP16权重14GB加上KV Cache和激活值一张A10080GB能塞下batch 32、4K上下文的推理服务这已经是比较理想的状态了。5.2 训练稳定的经典坑warmup与loss尖刺Decode-Only模型在训练过程中最让我头疼的问题之一是loss尖刺。长时间训练loss平稳下降突然某一batch出现巨大损失值然后整个训练节奏都被打乱。我的经验里loss尖刺最常见的两个来源一个是数据里出现了异常长或异常重复的样本导致梯度过大另一个是学习率过高随着模型规模增大最优学习率反而需要下降而且AdamW的beta2对长上下文场景往往需要从0.999调到0.95或0.98。实操建议是把warmup步数从1000提到5000以上尤其是在2B以上规模的模型训练中用3%左右的全局步数做warmup是比较稳的选择。同时设置梯度裁剪clip值设为1.0能直接把尖刺问题压掉一大半。还有一个很容易被忽略的细节positional embedding或RoPE的base参数如果直接沿用10000而训练时上下文长度比推理时短很多模型会在长文本上出现性能跳水。LLaMA把RoPE base从10000调到500000才支持32K上下文核心思路就是让高频旋转变得更慢给外推留出余地。5.3 推理时最容易忽视的三个参数模型服务上线后问题往往不在模型本身而在于解码策略的配置。我在多个项目里总结出三个最容易被忽视的推理参数。第一个是temperature。很多新手以为越接近0越聪明结果发现模型只会复读最高频的词。推荐值一般在0.6到0.9之间要看业务对随机性的容忍度。严格打分场景可以用0内容创作场景0.8左右体验最好。第二个是max_new_tokens。如果你给模型一个很大的上限它可以在回答结束后开始编造内容这在开放式生成中尤其明显。实践中建议给一个合理的截断上限同时设置停止符检测别让模型一口气把上下文窗口填满。第三个是EOS token的处理。你在自回归生成时必须显式判断是否需要停止。有些微调模型对EOS的敏感度不高生成完回答后还会继续输出这时候你可以在解码器里加入连续N个相同token时断开的自定义逻辑而不是死等EOS。5.4 Decode-Only之外它如何沿用到了视觉和多模态最后说一个方向性问题。很多人以为Decode-Only只关心文本但Vision TransformerViT和Swin Transformer的火热以及transformer图像分类这类话题的出现本质上是同一套架构在不同模态上的复制。图像分类任务中通常的做法是把图片切成patch序列用Encoder-Only或Decode-Only的底层模块提取特征。而图像生成任务如自回归图像生成则直接对齐到了Decode-Only把像素或patch当作token按顺序预测。甚至医学图像分割领域里出现的MissFormer这类工作也正是把Transformer在局部和全局特征提取上的能力做了重新组合。这说明一个趋势模型架构正在收敛。不管输入是文本、图片还是序列数据预训练目标越来越统一为预测下一个元素而载体大概率就是Decode-Only。这让我想起早年看《The Illustrated Transformer》时的感觉——作者用大量图示解析注意力依然是在回答信息如何流动的问题。Decode-Only把这个答案收敛得越来越简洁只留一条自回归的流让所有任务都向它对齐。我在多个项目中的体会是架构选型不是追赶时髦而是要从数据形态、任务目标、推理成本三个维度去倒推。如果你要做一个生成式产品哪怕业务里还夹着分类和抽取优先考虑Decode-Only也是对的因为在统一架构里微调多个任务的成本远低于维护三套模型。最后分享一个小技巧如果你在现有开源模型上做微调训练和推理的上下文长度最好保持一致即便你的训练数据里大部分样本都比较短也要把训练时的序列长度pad到推理长度附近。很多微调模型在长文本场景下表现崩盘不是因为模型本身不行而是因为训练时的位置信号分布和推理时不一致。这个坑我至少碰了三次每次都是重新审视数据填充策略才解决的。
📝

华诺云谱内容团队

资深建站顾问 · 行业研究员

10年+企业数字化服务经验,专注智能建站、SEO优化与品牌营销,持续输出建站技巧、行业洞察与营销干货,已帮助5000+企业实现数字化增长。

你可能需要的服务

订阅华诺云谱资讯周报

每周一封,精选建站技巧、SEO与营销干货,直达邮箱。已有 8,000+ 企业主订阅,助你少走弯路。

↑