ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

深入解析Decode-Only Transformer:大模型架构为何统一选择它?

深入解析Decode-Only Transformer:大模型架构为何统一选择它?

这两年做大模型相关的项目,被问得最多的问题之一就是:为什么GPT、Llama、Mistral这些模型长得越来越像,而早年NLP里常见的Encoder-Decoder框架反而很少出现在生成式大模型底座里?答案其实不复杂:因为大家最终都倒向了Decode-Only Transformer架构。

简单说,Decode-Only架构就是只有解码器、没有编码器的Transformer变体,模型只做一件事:根据已经看到的所有内容,预测下一个Token。听起来好像比机器翻译时代的编码器-解码器结构更简单,但它恰恰是当前ChatGPT类产品、开源大模型、以及各类生成式应用的基础底座。这篇文章我会从架构动机、因果掩码、位置编码、训练范式、推理优化和踩坑记录几个角度,把我自己的工程经验完整分享出来,适合刚入门Transformer、想自己动手搭小型语言模型,或者在做推理加速时遇到瓶颈的读者。

1. 为什么大模型最后都收敛到了Decode-Only这一条路上

1.1 一个被名字误导的模块

很多人第一次接触Transformer,看的是《Attention Is All You Need》那篇论文,里面最显眼的是编码器和解码器两个堆叠结构。后来看到GPT只用Decoder,就会下意识觉得“Decoder应该是用来输出的半成品”,实际上这个理解完全跑偏了。

Decode-Only里的“解码器”已经不是原来那个依赖Cross-Attention、需要Encoder提供KV的Decoder。它把原本Encoder负责的“理解输入”职责也一并承担了,模型内部没有额外的编码器模块,只有单向的因果自注意力、前馈网络、层归一化和残差连接。换句话说,它是一个“自己读序列、自己生成序列”的完整体,而不是某条流水线的后半段。

我刚开始自己写Transformer时也犯过这个混淆,把Cross-Attention硬塞进一个小型GPT模型里,结果训练速度慢了一倍,生成质量也没有提升。后来才意识到:在纯生成场景下,所有信息本来就已经被统一编码进同一串Token序列里了,再单独设置一个Encoder去“处理源端”,反而破坏了信息流动的简洁性。

1.2 从任务适配到通用生成:架构收敛的必然

为什么大模型几乎都倾向于Decode-Only,而不是继续沿用机器翻译时代那套Encoder-Decoder?我个人的理解是:通用性。

Encoder-Decoder的优势在于序列到序列的强对齐,比如翻译时源语言和目标语言结构差异很大,需要专门的编码过程去“理解”源句。但这种结构天然就把任务限制成了“一种输入、一种输出”。而大语言模型的目标是成为通用任务求解器,无论你是让它续写代码、回答问题、做摘要、还是角色扮演,本质上都可以被表达成“给定一段文本,继续生成后文”。Decode-Only正好是最简洁的统一表达方式,不需要为每种任务单独设计输入和输出接口。

另外,工程上Decode-Only也更好做。模型结构单一,没有Encoder和Decoder两套参数体系,预训练、微调、推理的代码路径都能保持同一套实现。我做过多任务微调实验,Encoder-Decoder模型常常需要额外处理Encoder端的memory分配,而Decode-Only模型只需要把任务描述和示例拼进Prompt,训练和推理完全一致,非常干净。

1.3 自回归生成:一句话解释它的运作方式

Decode-Only模型使用自回归方式生成内容。所谓自回归,就是每次只预测下一个Token,然后把这个Token接回输入序列末尾,再用更新后的序列预测再下一个Token。

举个例子:模型输入是“今天天气”,它会根据这4个Token计算概率分布,挑出概率最高的“很”作为预测结果;接着输入变成“今天天气很”,再去预测“好”;如此循环,直到生成结束符或达到最大长度。整个过程是串行的,这也是Decode-Only推理时速度受限制的根本原因。

这种设计的好处是训练过程可以完全并行。因为在训练时,我们已经知道了完整的文本,可以把所有位置的预测同时算出来,不需要像推理那样一个个向后滚。这一点是理解Transformer训练效率的关键,也是后续谈因果掩码的基础。

2. 因果掩码与位置编码:Decode-Only的两个地基

2.1 因果掩码到底“遮”了什么

Decode-Only最核心的机制是因果自注意力(Causal Self-Attention),也就是每个位置只能看到当前位置及之前的内容,不能偷看未来的Token。实现方式是在注意力分数矩阵上叠加一个上三角掩码矩阵。

假设序列长度为4,注意力分数矩阵是一个4x4的矩阵,第i行第j列表示位置i对位置j的注意力权重。训练时如果不做任何处理,位置2就能看到位置3和位置4的信息,这就等于“作弊”了。加上掩码后,矩阵右上部分全部被替换成负无穷大,经过Softmax后这些位置的注意力权重会变成0。

为什么一定要这样?因为做预测时位置0到位置i-1是已知的,位置i及之后是未知的。如果训练时允许模型看到未来,那么模型学到的是一个“作弊函数”,而不是真正理解语言规律,推理时就不可能复现训练时的表现。

我自己实现这个掩码时踩过一个坑:直接在注意力分数上套布尔掩码,然后做Softmax时负无穷的位置因为浮点精度问题偶尔会留下很小的非零残差。后来老老实实用一个上三角矩阵,从1到0做乘法而不是加法,再配合float32的稳定处理,效果才稳定下来。

从实现视角来看,训练阶段,因果掩码主要目的是并行计算所有位置。它的代价是多消耗一部分显存,用于存储上三角掩码或对应的布尔矩阵,序列越长,这部分浪费越大。推理阶段则完全不同,因我们已经知道总长度,只需要维护一个不断增长的KV缓存,掩码部分只需要处理新增的那个位置,所以每次解码的注意力计算是轻量级的。

2.2 位置编码:没有它,Token就是一堆乱序的集合

Transformer没有循环结构,它本身不感知Token出现顺序。如果去掉位置编码,模型看到的“我爱学习”和“学习爱我”完全一样,因为词袋顺序被洗掉了。所以位置编码是Decode-Only架构里另一块地基。

当前最主流的位置编码方式是RoPE(旋转位置编码),Llama系列、Mistral、Qwen这些大模型基本都用它。RoPE的思路很巧妙:把位置信息编码到查询向量和键向量的夹角里,两个Token距离越远,向量夹角越大,注意力分数就会自然衰减。它不像早期Transformer的绝对位置编码那样直接把一个位置向量加到Token向量上,而是通过旋转隐空间中的向量来实现。

我测试过几种位置编码,直观感受是:绝对位置编码实现最简单,但外推性差;ALiBi主打长度外推,但有些任务上精度略降;RoPE综合表现最好,尤其是配合NTK缩放后可以支持更长的上下文。下面是我在实际对比时用过的简单对照表。

位置编码方式外推能力训练稳定性实现复杂度主流应用
绝对位置编码差较高低早期BERT、原始Transformer
ALiBi较强较高低部分长短文本模型
RoPE中,可配合缩放较高中GPT系、Llama系、Qwen、DeepSeek

2.3 长度外推:RoPE不是万能的

很多人以为用了RoPE就可以无限长文本,这是一个大误区。RoPE本身只是在训练长度范围内表现良好,一旦推理长度超过训练长度,注意力分数会变得异常,生成质量迅速下降。

我记得有一次用一个4K长度训练的模型去生成16K的文档,前4K内容正常,过了训练边界之后就开始出现重复片段和逻辑断裂。后来排查发现,问题出在RoPE的旋转频率上:训练时模型没有见过那么大的位置索引,旋转角度变化得太快,导致相对位置关系紊乱。

解决方案有几种:一是做位置插值,把超出范围的位置索引压缩到训练范围内;二是用NTK-aware缩放,调整旋转频率的基数;三是在微调阶段引入更长的序列。这些方案各有利弊,位置插值简单但会损失细节,NTK缩放需要调参,直接延长训练序列成本最高但效果最自然。实际项目中我常用NTK搭配短序列微调,修复速度快,效果也最接近预期。

3. 训练阶段的真实面貌:补全任务、损失掩码与数据配比

3.1 Decode-Only的训练目标就是“补全”

Decode-Only模型的预训练目标只有一个:给定左边所有Token,预测右边下一个Token。这听起来简单,但它的学习能力非常强,因为语言本身就是一种高度结构化的信号,模型通过海量文本的“完形填空式”学习,能逐步掌握语法、事实、逻辑、甚至代码运行规则。

训练时采用的损失函数是交叉熵损失。假设模型在位置t的真实Token是词表里第v个词,模型预测的概率分布是p,那么损失就是 -log p[v]。整个序列的损失是所有位置的损失平均值。

值得强调的是,这里说的“所有位置”是训练数据中的每个Token,并不只计算句子结尾或某些特殊位置。这意味着一个长度为2048的样本,一次前向计算就能产生2047个预测点,监督信号密度非常高。这也是Decode-Only训练效率高的原因之一。

3.2 损失掩码:不是每个Token都值得训练

在预训练阶段,所有Token一视同仁。但进入指令微调阶段,情况就变了。普通做法是把Prompt和Answer拼接成一段文本,如果还是对所有Token算损失,模型很可能在训练时过度优化Prompt的续写能力,忽略了真正需要学习的答案部分。

正确做法是给损失函数加一个掩码:只计算Answer部分的Token损失,Prompt部分的损失置为0。这么做能让模型把有限的参数容量集中在真正有用的信息上,收敛更快,表现也更稳定。

我之前做SFT时漏掉损失掩码,结果模型学会了“复述问题”,对答案本身却没学会准确生成。加了掩码之后,效果立刻改善。对于代码类的微调数据,有时候我还习惯把注释部分也做掩码,这样模型会把更多容量用去学代码逻辑本身,训练出来的能力更精简。

3.3 训练工程要点:数据打包、学习率与显存取舍

预训练阶段最常碰到的一个问题是:句子有长有短,如果按句子为单位padding到固定长度,短样本会浪费大量计算。所以现在主流做法是序列打包:把多条短文本首尾相接,塞进固定长度的样本里,样本之间用特殊分隔符隔开。这个过程要注意别让一条样本跨越两条不同文档的边界,否则模型会学到“文档之间无缝衔接”的错误模式。

我在训练小型模型时通常用这样的配置:序列长度2048、批量大小32、学习率峰值3e-4、前1000步做warmup,使用余弦衰减。如果显存不够,先考虑梯度累积,再考虑梯度检查点。梯度累积只是把优化器更新分成几次,不节省总计算量,但能解决显存不足的问题;梯度检查点则是用额外前向计算换显存,具体取舍要看你是计算密集还是显存紧张。

数据配比也会明显影响模型能力。如果训练数据里90%是代码,模型代码能力会很强,但通用对话能力会退化。我一般按7:2:1的比例混合通用文本、代码和数学数据,再用少量高质量指令数据做最后阶段的微调。这个比例不是公式,需要根据下游任务不断调整。

4. 推理优化的实战:KV Cache、Prefill/Decode 与显存账本

4.1 推理周期两头分:Prefill 与 Decode

Decode-Only模型推理时有一个明显的“两头”结构。用户输入Prompt之后,模型先对所有输入Token并行计算一遍自注意力,得到每个位置对应的KV向量,这个过程叫Prefill。它计算密集,GPU算力利用率很高。之后模型进入逐Token生成的Decode阶段,每次只计算最后一个位置,期间只更新KV Cache,前文所有历史KV都直接从缓存里读,不需要重复计算。

很多人不理解KV Cache为什么能加速,核心原因就是它省掉了重复计算。没有缓存的话,生成第100个Token时,需要重新把所有前99个Token的K、V向量计算一遍,复杂度从O(n^2)退化得更严重,显存也会成倍增长。有了缓存,每次解码只需要计算第100个Token本身的K、V并追加到缓存里,时间复杂度降为O(n)。

4.2 KV Cache的显存怎么估算

KV Cache不是免费的,它占用的显存可以精确计算。对于一个Transformer模型,KV Cache大小公式是:

KV Cache大小 = 2 × 层数 × 注意力头维度 × 序列长度 × 批量大小 × 数据类型大小

这里的“2”是因为每层都要分别存K和V。如果你用的是多头注意力,那么注意力头维度等于单个头的维度;如果是多头查询分组注意力(GQA),K和V的维度会更小,这也是Mistral、Llama 2/3这些大模型引入GQA的原因。

举个例子:一个12层、隐藏层维度768、序列长度2048、批量大小32的模型,KV Cache大约为:

2 × 12 × 768 × 2048 × 32 × 2字节 ≈ 2.4GB

这只是一种估算方式,实际还要加上模型参数、激活值、优化器状态等。做长文本推理时,KV Cache会成为显存开销的大头。所以我通常建议先把KV Cache的占用算清,再去决定最大并发batch是多少。如果盲目往上堆并发,很容易出现显存溢出。

4.3 并行策略与流式输出的取舍

在推理场景,Decode阶段单纯串行,但可以通过批量处理多个请求来利用GPU的并行能力。批量的意思是一次解码多个用户的多个序列,GPU把同一时刻所有请求的下一步Token一起算出来。批量越大,计算单元的利用率越高,但每个请求的Latency也会略有增加。这里需要根据业务需求的实时性来权衡:对话类应用通常倾向于小批量、低延迟;离线批量生成任务则适合大批量、高吞吐。

流式输出也是Decode-Only推理的一个独特优势。因为生成是逐Token进行的,模型每算出一个Token就可以立刻吐给前端,用户看到的是一个字一个字蹦出来的效果,实际体验比等整段生成完再显示要自然得多。我在做一个对话机器人项目时,首Token延迟从3秒压到不到0.5秒,用户反馈满意度提升非常明显,这就是Prefill优化和流式输出叠加的效果。

5. 从零搭一个1.5亿参数Decode-Only模型:过程复盘

5.1 模型配置的选择逻辑

纸上谈兵再多,不如直接动手跑一个。我最近用一个小型Decode-Only模型做实验,配置大概是这样的:12层Transformer,隐藏层维度768,8个注意力头,每头维度64,词表大小大约1.5万,上下文长度1024。粗略算下来参数在1.4到1.6亿之间。

为什么选这个规模?因为单卡训练速度快,可以反复试错,也方便调试各种工程细节。如果一开始就上7B甚至13B模型,一个bug可能要浪费几天的算力。先把小型模型跑通,理解每个环节的行为,再迁移到大模型,这是最稳妥的路线。

这个规模下,我还专门把注意力改为GQA,K和V的头数降到4个。虽然模型变大不多,但KV Cache的显存占用直接少了一半,推理速度也有可感知的提升。

5.2 训练脚本与关键实现

核心训练循环并不复杂,重点在于掩码和位置编码的处理。这里我贴一段简化后的PyTorch风格实现,方便你理解主干逻辑:

import torch import torch.nn as nn import torch.nn.functional as F class CausalSelfAttention(nn.Module): def __init__(self, d_model, n_heads): super().__init__() 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.wo = nn.Linear(d_model, d_model) def forward(self, x, kv_cache=None): bsz, seq_len, d_model = x.shape q = self.wq(x).view(bsz, seq_len, self.n_heads, self.head_dim).transpose(1, 2) k = self.wk(x).view(bsz, seq_len, self.n_heads, self.head_dim).transpose(1, 2) v = self.wv(x).view(bsz, seq_len, self.n_heads, self.head_dim).transpose(1, 2) if kv_cache is not None: k = torch.cat([kv_cache[0], k], dim=2) v = torch.cat([kv_cache[1], v], dim=2) kv_cache = (k, v) scores = q @ k.transpose(-2, -1) / (self.head_dim ** 0.5) # 因果掩码:右上三角置为负无穷 mask = torch.triu(torch.ones(seq_len, seq_len, device=x.device), diagonal=1).bool() scores = scores.masked_fill(mask, float("-inf")) attn = F.softmax(scores, dim=-1) out = attn @ v out = out.transpose(1, 2).contiguous().view(bsz, seq_len, d_model) return self.wo(out), kv_cache

实际训练时,我会把RoPE函数放在注意力之前,对q和k进行旋转。RoPE实现有现成库,也可以手写,核心思路是按位置计算角度并构造旋转矩阵,然后对向量做旋转。

5.3 观测损失与中间现象

训练过程中,我发现损失曲线有几个典型阶段:刚开始几千步,损失下降非常快,这是因为模型在学词法和基础共现规律;随后下降变缓,进入语法结构和常识学习阶段;等到损失趋于平台期,再继续训练收益就变得很有限。这时候更需要关注的是验证集困惑度,而不是单看训练损失。

还有一个值得注意的现象:在小规模数据上训练时,模型很早就出现过拟合,表现为训练损失持续下降,验证集损失却不降甚至上升。解决办法包括加大Dropout、增加数据量、降低模型容量、以及加强权重衰减。我一般会把Dropout设到0.1左右,并且用早停机制,防止浪费时间在小数据上调不出好效果。

6. 解Bug记录:位置编码失效、重复生成与显存抖动

6.1 位置编码在长文本上的隐性失效

前文已经提过一次长度外推问题,但这块在实际项目中非常容易踩坑,值得单独展开。有一次我在一个文档摘要项目里,把推理时的最大生成长度从2048调到了4096,结果生成内容从第3000个Token开始明显崩坏。好看的是崩溃的并不是语法错误,而是语义上的自我重复和话题漂移。

排查过程我先检查了KV Cache是否有越界问题,确认无误后开始怀疑位置编码。把RoPE的旋转角度打印出来,发现超过训练长度后位置索引对应的旋转角度增长过快,相邻Token的相对角度差变得异常大,模型把这种异常理解成一种不该出现的信息,注意力分布也就跟着乱了。

修复方法是采用NTK-aware缩放:扩大RoPE的base值,让旋转频率变得更平缓。调参时我把base从10000调大到600000,再用一段长文本做少量微调,效果明显改善。这个过程说明,位置编码不是“加了就完事”,它与训练长度、缩放策略、微调数据共同决定模型能支持的有效上下文。

6.2 生成阶段的重复退化:贪心解码不是好选择

Decode-Only模型在自动回归生成时,如果直接每次都取概率最大的Token,很容易陷入重复循环。比如模型反复生成“这家咖啡店很好很好很好很好”这样的内容,乍一看通顺,其实已经完全退化。

原因在于语言模型建模的是条件概率,当生成的序列已经很长时,某些高频词组的条件概率会被不断放大,导致模型在局部最优里打转。解决手段主要有三类:温度采样、Top-k/Top-p采样、以及重复惩罚。

策略作用注意事项
温度参数调整概率分布平滑度温度过高输出天马行空,过低趋于贪心
Top-p采样只从累计概率超过阈值的Token中采样能保留多样性,但不能完全解决重复
重复惩罚对已出现Token的概率打折扣惩罚过重会伤害自然表达

我实际调参时,一般先用温度为0.7到0.9、Top-p为0.9的组合,如果出现重复再叠加一个保守的重复惩罚系数。需要提醒的是,不同任务适合的采样参数差异很大,代码生成倾向于保守,创意写作则需要更低的温度之外,适度增加随机性。模型训练数据多样性越差,退化问题越严重。

6.3 显存OOM与解码速度的平衡

Decode-Only模型训练和推理中最常见的崩溃就是CUDA Out of Memory。除了把批量调小,还有一些很难查的小坑。

第一,梯度检查点开启后,如果同时再做动态形状缩放,可能导致中间激活的形状不匹配,报错信息又隐晦,原因是某些库版本对检查点函数限制比较多。第二,混合精度训练时,Loss容易出现NaN,尤其是FP16下计算长序列时,K和V向量会因为数值溢出出现问题。我的处理方式是使用BF16,它的指数范围更大,对大模型训练更友好。

推理阶段的显存优化,除了GQA和KV Cache外,我还用过量化方案,把权重从FP16量化到INT8或INT4。效果很显著,比如推理显存占用可以下降到原来的四分之一,但精度损失有限。如果做大规模并发服务,量化几乎是必须走的路,不过量化后的模型要重新跑一遍评测,确认下游任务不掉点。

还有一个容易被忽略的瓶颈:Prefill阶段虽然算力利用率高,但如果Prompt特别长,中间激活值也会很大。长Prompt场景下我会优先考虑FlashAttention系列,它既降低显存占用又提升计算速度。引入FlashAttention后,我此前好几个卡在OOM边缘的项目都顺利跑起来了。

回忆起这些调试过程,最大的体会是:Decode-Only架构的代码骨架非常简洁,真正费时间的是在不同运行阶段维护好数值稳定性、显存边界和采样策略的平衡。只要你亲手把一个小模型从零训练起来,再把它推到推理服务里,对架构的理解会立刻上一个台阶。遇到位置编码失效、重复生成或者显存溢出时,不要急着换大模型,先按文章里的排查链路走一遍,很多时候问题比想象中简单。希望这篇记录能帮你少走几步弯路,把时间省下来去解决更核心的业务问题。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进