如果你从2017年《Attention Is All You Need》刚发布那阵子就在看Transformer,应该会有个很直观的感受:这七八年里真正留在牌桌上的,不是某个花哨的魔改变体,而是一条非常清晰的演进主线。从最初的编码器-解码器框架,到BERT带火的Encoder-only路线,再到GPT带火的Decoder-only路线,以及后面FlashAttention、RoPE、MoE这些关键算法,每一步都是围绕三件事做文章:算得更快、训得更稳、长得更大。这篇文章就沿着这条核心线索,把Transformer架构里的关键算法演进从头到尾梳理一遍,全程用我在实际项目里验证过的经验和理解来写。不管你是刚开始啃Transformer源码的初学者,还是已经在训练大模型但总觉得某些细节没彻底吃透的工程师,这篇都能给你一些有用的参考。
1. 从骨架开始:Transformer架构为什么是今天这个样子
1.1 基本Block里那几件套是怎么组合起来的
先不聊那些高大上的变体,回到最原始的Transformer Block。一个block内部就是四件套:多头注意力(Multi-Head Attention)、前馈网络(Feed-Forward Network)、残差连接(Residual Connection)、层归一化(LayerNorm)。排列方式看起来简单,但这里面每一步都是有讲究的。
注意力层负责让token之间互相交换信息,前馈网络负责对每个token独立做非线性变换,残差连接保证梯度在深层传播时不容易消失,LayerNorm则把每层的激活值拉回一个稳定分布。很多初学者会忽略一个细节:前馈网络为什么一定要放在注意力后面?我的理解是,注意力层本质上是一个“加权平均”操作,它把别的token的信息揉进来,但这会让输出的表示趋于平滑,需要FFN这种逐位置的强非线性变换把信息重新“拉开”。先聚合再变换,这个交替节奏是Transformer设计里非常核心的思想。
原生Transformer里,注意力层和FFN后面都接了一个LayerNorm,顺序是“norm先、残差后”,也就是Post-LN结构。这个结构在浅层模型上表现稳定,但一旦层数加深,训练会变得异常敏感,后面第5部分我会单独讲Pre-LN是怎么把这个坑填上的。
1.2 编码器-解码器结构留下的基因
原版Transformer是给机器翻译设计的,所以天然是编码器-解码器结构:Encoder部分用双向注意力理解整个输入句子,Decoder部分用带掩码的注意力自回归地生成目标句子。这种结构直接影响了后来所有Transformer的形态分化。
不过从我的实际使用体验来说,编码器-解码器结构有一个很麻烦的问题:推理时Encoder要处理一遍完整输入,Decoder要逐步生成,两个模块的并行策略和显存占用都不一样,工程上要额外照顾。这也是为什么后来GPT系列坚持用Decoder-only,结构统一了,预训练和部署都简便很多。但T5、BART这类模型在翻译、摘要、风格改写这类“输入输出都是完整句子”的任务上依然能打,说明编码器-解码器这个基因并没有过时,它只是从“默认选项”变成了“按任务选择”。
1.3 残差与归一化的位置之争
残差连接和LayerNorm的位置关系,是架构演进里一条很重要的暗线。Post-LN是原始的“Attention/LN/残差”,Pre-LN则把LayerNorm提到注意力层前面,变成“LN/Attention/残差”的顺序。
我复现GPT-2时用过Post-LN,层数一到24层左右就明显感觉训练不稳定,loss曲线经常会突然抽风,稍微动一下学习率就整锅端。换成Pre-LN之后,同样的参数配置下训练顺畅得多,深层梯度也稳了。Pre-LN之所以稳,是因为每一层输入都被重新归一化,激活值方差不会层层放大。但Pre-LN也有代价,它相当于把“标准化”这个操作提前,模型表达能力理论上会损失一些,浅层注意力也容易退化。后来很多工作研究DeepNorm、QK-Norm,本质上都是想鱼和熊掌兼得,这一块我们放到第5章细说。
2. 注意力机制:核心算法的第一次大迭代
2.1 从self-attention公式看清真正的计算逻辑
自注意力(Self-Attention)是Transformer最核心的计算单元。给定输入序列X,先通过三个可学习权重矩阵把每个token映射成Query、Key、Value三个向量。注意力分数的计算是:
Attention(Q,K,V) = softmax(QK^T / sqrt(d_k))V
很多文章把这个公式一笔带过,但里面有几个细节值得深抠。为什么要除以sqrt(d_k)?因为当向量维度变高时,Q和K点积的方差会跟着变大,如果不缩放,softmax很容易进入饱和区,最大那个分数被压到接近1,其他分数趋近于0,梯度基本传不动。假设Q、K每个元素都是均值为0、方差为1的随机变量,d_k维点积的方差就是d_k,所以除以sqrt(d_k)恰好把方差拉回1。
实际写代码时,很多人会直接把Q和K乘起来然后除以head_dim的平方根,但很少有意识去检查分数方差是否在合理范围。我在调试一个训练不收敛的小模型时,发现罪魁祸首就是某个注意力头在初始化阶段logits均值偏高,softmax后输出几乎成了one-hot分布,前面怎么调学习率都没用。从那以后我习惯在初始化时加上一层QK归一化,或者至少打印一下初始logits的均值和方差。
2.2 多头注意力的参数配置
多头注意力把一个d_model维的注意力拆成h个独立的头,每个头在d_head = d_model/h维的子空间里做注意力,最后拼接起来再过一层线性映射。原版Transformer配置是d_model=512,h=8,所以head_dim=64。
这个64不是随便定的,它几乎是Transformer世界里最稳固的超参数。BERT、GPT、LLaMA虽然每层宽度不同,但head_dim基本都保持在64附近。为什么?经验证据是,当head_dim过小时,每个头的表达能力太弱,学不到足够丰富的注意力模式;当head_dim过大时,单头内部的softmax竞争太激烈,头与头的多样性反而下降。64是经过大规模实验验证过得去的平衡点。
2.3 从注意力掩码到因果控制:mask带来的架构分化
注意力机制里还有一个很容易被忽略的核心设计:掩码(mask)。Encoder用的是全双向注意力,每个token能看到整个序列;Decoder用的是因果掩码,也叫下三角掩码,每个token只能看到自己和之前的位置。
这个掩码的存在直接决定了模型的任务形态。双向注意力更适合“理解整段话”的任务,比如文本分类、实体抽取、语义相似度;因果注意力则天然适配“逐个生成”的任务,比如对话、续写、翻译。BERT和GPT的路线之争,本质上就是掩码的路线之争。后来有人在双向模型上也能做生成,但那是通过特殊的掩码策略,不是默认状态。
2.4 KV Cache:解码器推理时代才需要的缓存
训练完Transformer做推理时,Decoder需要step by step地生成。每一步生成当前token,都要拿当前Q去和所有历史token的K、V做注意力。如果每次重新算一遍历史K、V,那推理复杂度是O(n^2),越往后越慢。
KV Cache的做法很简单:把每一步算出来的K、V缓存起来,后续生成时直接复用,复杂度降到O(n)。这个优化几乎是所有生成型Transformer的基础工程组件。但KV Cache也带来了显存压力,因为每个token的K、V都要存一份,长上下文场景下缓存可能比模型本身还大。正因如此,后面才演化出MQA、GQA、MLA这些针对KV Cache做压缩的算法,我放在下一章细讲。
3. 效率驱动的算法演进:稀疏、线性与硬件感知的注意力
3.1 二次复杂度问题到底有多痛
原生注意力需要计算完整的注意力矩阵,尺寸是序列长度n×n,时间和内存复杂度都是O(n^2)。n=512时还好,n=2048就开始吃力,n=10000以上基本就是灾难。
我在处理长文档时试过直接把BERT搬上去,输入序列拉到4096,显存直接爆掉。这不是优化技巧能解决的问题,是算法层面的复杂度瓶颈。所以长文本场景下,必须对注意力机制本身动刀。
3.2 Longformer与BigBird:稀疏注意力的两种代表
稀疏注意力的思路是,不要所有token都两两交互,只保留一部分关键交互。Longformer用的是滑动窗口+全局token的组合:窗口内保留局部注意力,再额外指定一些全局token(比如CLS)能和所有位置交互。BigBird更进一步,在窗口和全局的基础上加入随机连接,理论上保证了信息能在长距离之间流动。
这类方法在长文本分类、抽取任务上效果不错,训练和推理都能跑得动。但它的缺点是工程实现复杂,因为每个token的注意力范围不同,没法直接用标准矩阵乘,要用块稀疏运算来模拟。如果只是做研究验证还可以,真正上线时维护成本不低,这也是后来FlashAttention这种硬件感知方案更流行的原因。
3.3 线性注意力:理论上很香,实际落地有坑
线性注意力走的是另一条路:把softmax展开成核函数形式,利用矩阵乘法的结合律,把QK^T先和V交换顺序,变成(QK^T)V = Q(K^TV),这样复杂度从O(n^2)降到O(n)。
Performer、Linear Transformer这类方法在数学上很优雅,但实际部署时我发现一个问题:核近似会损失一部分注意力表达能力,尤其是在需要精确局部关系建模的任务上,效果比原版注意力有明显差距。而且很多优化后的矩阵乘实现其实并没有把理论优势完全兑现,跑出来的加速比没有想象中高。
3.4 FlashAttention:IO感知才是真正改变游戏规则的思路
FlashAttention没有改变注意力数学本身,而是重新思考了怎么利用现代GPU的存储层次。它的核心洞察是:softmax归一化可以在分块计算时增量更新,不必把完整的注意力矩阵写回显存。传统实现中,注意力矩阵是显存层面的大矩阵,读写开销远大于计算开销。FlashAttention把计算切成小块,让每个小块在SRAM里完成全部中间计算,再增量更新输出,极大减少显存读写。
我在实测中发现,FlashAttention不仅省显存,速度也有很明显的提升,特别是在长序列场景,几乎是把O(n^2)的墙推倒了一半。PyTorch 2.0之后原生支持了FlashAttention的调用,这个算法已经成了大规模Transformer训练的事实标配。它的意义不只是优化实现,而是证明了“算法演进”不只发生在公式层面,硬件感知的调度策略同样重要。
3.5 MQA、GQA、MLA:把KV Cache压缩到极致
随着上下文越来越长,KV Cache的显存占用成了推理瓶颈。多头注意力MHA为每个头都存一份独立的KV,这很奢侈。Multi-Query Attention(MQA)做了个极端操作:所有头共享一份K和V,只有Q还是多头独立。这样KV Cache的显存占用直接降到原来的1/h,推理速度明显上升,但代价是模型表达能力下降,训练效果会打折扣。
Grouped Query Attention(GQA)是折中方案:把所有头分成若干组,每组共享一份KV,LLaMA 2和LLaMA 3都用这个方案。我在实际部署GQA模型时发现,分组数取8左右是个甜点,推理速度和效果平衡得不错。再到DeepSeek的MLA(Multi-head Latent Attention),进一步把KV压缩到低维潜空间,用很小的缓存保留足够信息,属于同一个演进方向的极致版本。
4. 位置编码的演进:从三角函数到旋转位置编码
4.1 为什么位置信息不能丢
Transformer是置换等变的——如果把输入token顺序打乱,注意力计算的结果也只会跟着顺序打乱,模型本身根本不知道“小明打了小狗”和“小狗打了小明”有什么区别。为了让模型感知位置,必须在输入里显式加入位置信息。
我遇到过那种看起来写得很对的Transformer模型,但在句子顺序敏感的任务上效果一塌糊涂,排查半天发现就是因为位置编码没加上。位置信息不是可选项,它决定了模型对语言顺序的基本敏感性。
4.2 绝对位置编码与可学习位置编码
原始Transformer用的是三角函数形式的绝对位置编码,不同维度对应不同频率的正余弦周期。设计初衷是希望模型能利用三角函数的性质学到相对位置关系。但在足够大的训练数据下,可学习位置编码效果更好,BERT用的就是这种:把位置信息当成一个词表大小的embedding矩阵,训练过程中自动更新。
可学习位置编码的问题是外推能力差。训练时最大长度512,推理时给到600就失效了,因为600位置的embedding根本没被训练过。这也是后面位置编码演进要解决的核心矛盾:既要感知位置,又要能处理训练时没见过的新长度。
4.3 相对位置编码的突围
相对位置编码思路变了:不再问“token i在哪里”,而是问“token j相对于token i偏移了多少”。Transformer-XL把位置差信息加进attention score计算里,T5则直接用可学习的相对位置偏置bias加到logits上。这种做法在长文本上的外推稳定性比绝对位置编码好很多。
DeBERTa更进一步,把内容信息也纳入注意力计算——不只给Q/K加相对位置偏置,而是把位置偏置和内容向量分开算再融合。这类设计在NLP任务上确实有效,但在极端长度外推上还是不够彻底。
4.4 RoPE与ALiBi:现在大模型普遍选择的两条路
RoPE(旋转位置编码)是目前最流行的方案。它的思路很巧妙:把位置信息编码成旋转矩阵,作用于Q和K向量上,这样Q和K的内积自然包含相对位置信息,而且位置差越大,旋转角度相差越大。关键是它天然支持长度外推,配合插值微调可以处理远超训练长度的上下文。
ALiBi则更简单粗暴:直接给每个注意力分数减去一个和token相对距离成正比的线性偏置。距离越远,偏置越大,注意力分数越低。这个方法几乎不需要额外参数,训练时收敛很快,长度外推效果也很好。我在几个中等规模模型上对比过RoPE和ALiBi,单看短文本任务两者差不太多,但长文本外推时ALiBi的稳定性让我印象很深。
5. 训练稳定性的隐线:归一化、激活函数与初始化
5.1 Post-LN与Pre-LN:稳定与表达的取舍
前面提过Post-LN和Pre-LN的区别,这里展开讲训练层面。Post-LN是“残差直连、归一化在最后”,输出方差会随着层数累积,深层容易出现梯度爆炸,训练时对学习率非常敏感,必须配合warmup逐步拉高学习率。
Pre-LN把归一化挪到每层输入之前,激活值方差被压住,梯度路径干净,训练稳定性大幅提升。代价是浅层的信息容易在多层归一化后被“洗”掉一部分,有些研究观察到Pre-LN模型的底层表示质量会下降。GPT-3、LLaMA这些大规模模型几乎都用Pre-LN,不是因为他们不在乎表达能力,而是当模型大到几十层时,能稳定训练比什么都重要。
5.2 激活函数从ReLU到GELU再到SwiGLU的进化
FFN层里的激活函数,从ReLU到GELU再到SwiGLU,也是一条有意思的演进线。ReLU简单高效但有“死亡神经元”问题,负区间梯度直接归零。GELU用高斯累积分布做平滑,负区间仍然有梯度,BERT时代开始大面积使用。
SwiGLU是LLaMA系列采用的方案,形式上结合了Swish激活和GLU门控机制,输出前会先做一个门控投影,相当于给前馈网络动态加了一道信息筛选。我实际体会是,SwiGLU训练收敛确实比ReLU更稳,但代价是FFN从两个权重矩阵变成三个,参数量增加。为了控制总参数,LLaMA把FFN的中间维度从常规的4倍模型宽度压缩到大约2.7倍,算下来总参数量基本持平。
5.3 初始化、DeepNorm与QK-Norm:让超深模型训得动
当模型深度突破几十层甚至上百层时,即使换Pre-LN,残差路径上的激活值累积也可能导致信号失真。GPT-2时代有个小技巧:对残差分支最后的线性层按1/sqrt(N)缩放初始化,N是层数。这个细节不深究的话很难注意到,但对深层训练的稳定性很重要。
DeepNorm是微软在DeepNet里提出的方案,把残差分支的缩放系数改成跟层数直接相关,配套修改初始化策略,能让1000层Transformer训练而不发散。QK-Norm则是在把Query和Key各自做一次LayerNorm,控制注意力分数方差。Chinchilla、LLaMA 2这些模型内部都用到了类似操作,本质还是“缩放+广播”那套稳定逻辑。
5.4 混合精度训练与loss scale
实际训练Transformer时还有一个数值层面的坑:FP16混合精度。Half精度下梯度很容易下溢,loss比例系数(loss scale)调不好就导致loss变成NaN。我踩过一次很典型的坑:小数据集上batch小,梯度范数整体偏小,loss scale自动衰减后模型干脆不更新了,看起来像loss plateaus,实际是精度死掉了。现在BF16在大模型训练里是更好的选择,动态范围跟FP32接近,不用太担心下溢问题。
6. 模型形态的分化:Encoder-only、Decoder-only与MoE
6.1 三种形态怎么选
顺着注意力掩码的路线往下走,Transformer分化出三种主流形态:Encoder-only、Decoder-only、Encoder-Decoder。
Encoder-only的代表是BERT,双向注意力擅长理解语义,适合分类、抽取、句子相似度这类任务。Decoder-only的代表是GPT,因果注意力擅长生成,适合对话、续写、摘要。Encoder-Decoder的代表是T5,适合“输入一段,输出一段”的强序列转换任务,比如翻译、降级改写。
我的选择经验很简单:需要“读得懂”的任务先用Encoder-only,需要“写得顺”的任务先用Decoder-only,需要严格按输入输出对应转换的用Encoder-Decoder。现在的趋势是Decoder-only模型配合prompt也能覆盖很多传统理解任务,但我始终觉得Encoder-only在标注数据有限时更容易训出稳定的理解能力。
6.2 为什么Decoder-only后来居上
GPT系列把Decoder-only做成了主流,深层逻辑不只是生成能力强,还在于它的训练目标极其简单:给定前面的token预测下一个token,这个自监督目标不需要任何人工标注,数据就是海量文本本身。模型规模和数据量同时增长时,这套范式展现出了惊人的扩展能力,而且还附带了很多“涌现”能力,比如少样本学习、思维链推理。
相比之下BERT的MLM目标需要mask一些token,训练效率和生成任务的适配性都弱一些。所以今天我们看到的大语言模型几乎全是Decoder-only架构,这是规模竞赛下自然筛选的结果。
6.3 MoE:稀疏激活带来的规模演进
模型的再扩大遇到了算力瓶颈:每增加一倍参数量,训练成本也大体翻倍。MoE(混合专家)提供了一条更聪明的扩容路径:把FFN层替换成多个并行的专家网络,每个token只激活其中少数几个专家,参数量上去了,但每次前向传播的实际计算量没有同比例上涨。
Switch Transformer提出了简化的Top-1路由,每个token只选一个专家;后续工作普遍用Top-2或Top-K,效果更稳。MoE的麻烦在于负载均衡和通信开销:如果所有token都涌向同一个专家,那个专家会成为瓶颈,所以训练时要加负载均衡loss;另外专家如果放在不同GPU上,token路由会产生大量通信。
我在实际实验里发现,MoE的推理工程化比训练还要烦,专家并行和KV Cache分配容易互相打架。但现在很多70B级别的大模型都用了MoE结构,DeepSeek在MoE上还加了细粒度专家和共享专家,把路由和计算效率推到了新的level。这算是Transformer架构演进里最近最热闹的一条分支。
7. 实操心得:从手写Transformer到训练踩坑
7.1 手写一个最小Transformer需要关注的细节
理论讲再多,不如亲手写一遍。手写实现时我建议重点关注下面这几个位置,这些都是踩过坑才长记性的点:
- 多头注意力的reshape/Merge逻辑:把d_model拆成h个head时,要保证“先拆头、再算注意力、最后合并”,维度顺序错了会得到莫名其妙的结果
- 因果掩码的实现:上三角矩阵要配合
-inf填充,不能直接mask成0,否则softmax归一化时被mask的位置仍然有非零概率 - 残差连接的位置:Pre-LN和Post-LN顺序不要弄混
- 输出层维度:分类任务需要接pooler或取CLS embedding,生成任务需要把隐状态映射回词表大小
- 参数初始化:手写代码时不要依赖默认初始化,至少按正态分布标准差0.02试一轮
import torch import torch.nn as nn import torch.nn.functional as F class SelfAttention(nn.Module): def __init__(self, d_model, n_heads): 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.wo = nn.Linear(d_model, d_model) def forward(self, x, mask=None): batch, seq_len, _ = x.shape q = self.wq(x).view(batch, seq_len, self.n_heads, self.head_dim).transpose(1, 2) k = self.wk(x).view(batch, seq_len, self.n_heads, self.head_dim).transpose(1, 2) v = self.wv(x).view(batch, seq_len, self.n_heads, self.head_dim).transpose(1, 2) attn = q @ k.transpose(-2, -1) / (self.head_dim ** 0.5) if mask is not None: attn = attn.masked_fill(mask == 0, float("-inf")) attn = F.softmax(attn, dim=-1) out = attn @ v out = out.transpose(1, 2).contiguous().view(batch, seq_len, self.d_model) return self.wo(out)这段代码是标准的MHA实现,核心就是把QK^T的结果先缩放,再掩码,再softmax。很多教程直接写attention = softmax(QK^T),完全不提缩放,这种简化在练习里能跑,但工程上很容易让注意力分数太极端。
7.2 训练loss不降时怎么排查
我在训练Transformer时遇到过各种loss问题,这里整理几个最常见的排查点:
| 现象 | 常见原因 | 排查方向 |
|---|---|---|
| loss从一开始就不降 | 学习率太小或太大、warmup没生效 | 先固定step打印lr,尝试1e-4到1e-3之间扫几组 |
| loss下降后又冲到NaN | FP16下溢、梯度裁剪失效 | 检查loss scale,切BF16,加梯度裁剪 |
| loss很低但效果差 | 模型学到捷径、数据泄漏 | 检查数据预处理,看attention是否退化到只看局部 |
| 长序列训不动 | 注意力O(n^2)导致显存爆 | 换FlashAttention或稀疏注意力,开gradient checkpointing |
| 测试表现不稳定 | 随机种子没固定、dropout过高 | 固定seed,dropout降到0.1以下 |
如果loss一直在某个平台期震荡,我会先把序列长度缩短、batch调大一点,排除数据噪声问题,再考虑调整模型结构。
7.3 常见参数配置经验值
我复现过不少文章里的Transformer,积累了一套比较稳的参数经验,适合中小规模实验起步:
- head_dim保持在64附近
- FFN中间维度是d_model的2.7到4倍,具体看激活函数选型
- 层数先按6到12层起步,稳定后再加深
- Adam优化器,beta2设0.98,epsilon设1e-6或1e-8
- 学习率3e-4附近,warmup比例5%到10%,配合cosine衰减
- dropout在0.1以内,超过0.1很容易欠拟合
- gradient checkpointing在显存紧张时开,基本上张量并行之前都先靠它
这些经验值不是铁律,但作为初始配置很够用。我在实际项目中经常发现,模型效果上不去的第一个原因不是网络设计不够新,而是训练配置本身就带着一堆暗坑。
7.4 关于验证的小技巧:拿正弦数据做冒烟测试
想快速验证一个Transformer实现有没有大问题,可以用正弦函数预测任务做冒烟测试。比如给定sin函数过去一段时间点的取值,让模型预测未来的走势,这是一个非常小的序列预测任务,几步迭代就能看出模型是否具备基本序列学习能力。
这个测试成本很低,比直接扛一个真实NLP数据集快太多。它能帮你判断模型结构、位置编码、训练配置是否都搭对了。如果连平滑的正弦曲线都学不像,说明代码有低级错误,继续往复杂任务上搬只会浪费更多时间。网上很多人在找“transformer预测正弦数据”的教程,原因就是这个任务最适合当Transformer的hello world。
我自己在预训练小型Transformer的时候,最深的一个体会是:算法的每一步演进都不是孤立的。位置编码变化会影响训练稳定方案,训练稳定方案会影响激活函数选择,激活函数又会倒逼参数量配置调整,最终落点都在效率、效果和规模之间找平衡。把这根主线攥在手里,再看那些令人眼花缭乱的模型变体,就不会觉得是一堆知识碎片了。对我来说,“算法演进”这四个字最贴切的翻译,其实就是一代又一代工程师被同一个瓶颈逼着往前走的轨迹。