1. Transformer架构的起点:为什么是自注意力
1.1 RNN/LSTM时代的瓶颈
Transformer架构这几年的演进速度,在深度学习历史上都是少见的。从2017年《Attention Is All You Need》提出至今,transformer几乎重构了自然语言处理、计算机视觉甚至语音领域的技术版图。很多人问过我,transformer核心算法到底强在哪,又是怎么一路演进到现在的GPT、Swin Transformer这类具体形态的。这篇笔记我想从核心算法演进的视角,把自注意力机制、位置编码、稀疏化改造这些关键节点串起来讲一遍,适合刚接触transformer的初学者,也适合已经跑过模型但没系统梳理过演进脉络的工程师。
在聊transformer之前,得先说说它革掉的命是什么。早期处理序列数据,大家默认用RNN或者LSTM。RNN的思路很直白:按时间步一个一个处理输入,把上一个时间步的隐藏状态传到下一个时间步。这在语言建模、机器翻译这类任务上确实能跑,但问题也很明显——第t个词要依赖第t-1个词的隐藏状态,这个隐藏状态又依赖第t-2个词,信息必须沿着时间步一步步传。一旦序列长到几百甚至上千步,早期信息要么在传递过程中被遗忘,要么被后面不断叠加的噪声淹没。LSTM用门控机制缓解了一部分问题,但本质上还是没有跳出"顺序计算"这个框架。
我当年在GPU上训练一个基于LSTM的机器翻译模型,序列长度压到50个词以内还算能接受,一旦超过100,训练时间让人想砸电脑。因为RNN/LSTM的每一步计算都强依赖上一步,没法并行。你给了它16张卡,它也快不了多少,因为计算路径是串行的。这是设计上的硬约束,不是靠堆算力能解决的。
还有一个隐性问题是长距离依赖。LSTM虽然理论上能记住几百步前的信息,但实际上梯度在经过这么多步的反向传播后,依然容易爆炸或者消失。梯度裁剪能救一部分,但信息丢失的问题始终存在。所以当transformer提出"直接让任意两个位置之间建立联系"的自注意力机制时,整个序列建模的思路被彻底翻了个个儿。
1.2 自注意力机制的破局逻辑
自注意力(Self-Attention)的核心逻辑,用大白话说就是:序列里每个词,都直接跟序列里所有其他词计算相似度,然后根据相似度把其他词的信息加权汇总。这样做的好处有两个。
第一个好处是路径长度变成1。不管两个词在序列里隔了多远,attention都可以一步到位地建立依赖。第二个词和第九十九个词之间的信息传递,不经过中间97个词的"接力",而是直接算一个相似度分数。这从机制上解决了长距离依赖问题。
第二个好处是所有位置的计算可以并行。self-attention的输入是整个序列的矩阵,输出也是整个序列的矩阵,计算过程中没有时间步之间的顺序依赖。GPU矩阵运算的效率直接被拉满,这是transformer能吃到算力红利的关键。
我经常用一句话跟朋友解释这个转变:RNN是在一条单行道上排队往前走,每一步都要等前一个人;Transformer是直接把所有人拉到广场上,两两之间都能直接喊话。广场模式的信息流通效率,天然比单行道高几个量级。
当然,自注意力也不是没有代价。它把序列中任意两个位置都建立连接,计算复杂度是O(n²)的,n是序列长度。这个O(n²)后来成了整个transformer演进史里最核心的矛盾点,后面几乎所有算法改进,都在跟这个n²较劲。理解了这个背景,你就能看懂为什么后来会出现稀疏注意力、线性注意力、局部窗口注意力这些五花八门的变体。
2. 核心算法拆解:Transformer的引擎室
2.1 自注意力的计算过程
要理解transformer的演进,必须先吃透自注意力这一步。它的计算过程分三步,每一步都有明确的数学含义。
假设输入序列有n个token,每个token的向量维度是d。先把整个序列的向量拼成一个矩阵X,形状是(n, d)。然后用三个可学习的权重矩阵分别做线性变换,得到Query(查询)、Key(键)、Value(值):
Q = X @ W_Q # 形状 (n, d_q) K = X @ W_K # 形状 (n, d_q) V = X @ W_V # 形状 (n, d_v)注意这里Q和K的维度是d_q,V的维度是d_v,d_q和d_v不一定相等。Query代表"我想找什么信息",Key代表"我有什么信息",Value代表"我实际给出的信息内容"。用生活里的例子类比:你在搜索引擎里输入关键词(Query),搜索引擎把网页的标题和标签(Key)做匹配,匹配上了就把网页正文(Value)返回给你。
接下来是算注意力分数。计算Q和K的点积,得到一个(n, n)的相似度矩阵:
scores = Q @ K.T / sqrt(d_q)这里除以sqrt(d_q)是一个非常关键的细节。如果不除,当d_q很大时,点积结果的方差会随维度增大而变大,数值动不动就跑到几十上百,softmax函数的输入一旦进入饱和区,梯度就会变得极小,模型训练会非常不稳定。除以sqrt(d_q)的目的是把方差拉回到1左右的量级,让softmax的输入落在梯度敏感的区域。
我见过不少新手在这个缩放因子上面翻车。去掉sqrt(d_q)之后模型不是不能训,而是训练曲线变得异常敏感,学习率稍微调大一点loss就飞了。这个缩放因子不是经验上的锦上添花,而是数学上的必需品。
最后是softmax归一化,然后和V加权求和:
weights = softmax(scores, dim=-1) output = weights @ Vsoftmax保证每个query位置上所有key的权重之和为1,让模型对数值尺度不敏感。而output矩阵的第i行,就是第i个token聚合了整个序列信息之后的新表示。
整个过程的pytorch实现非常直接:
import torch import torch.nn.functional as F def self_attention(x, W_Q, W_K, W_V): # x: (batch, seq_len, d_model) Q = x @ W_Q # (batch, seq_len, d_q) K = x @ W_K # (batch, seq_len, d_q) V = x @ W_V # (batch, seq_len, d_v) scores = Q @ K.transpose(-2, -1) / math.sqrt(Q.size(-1)) weights = F.softmax(scores, dim=-1) output = weights @ V # (batch, seq_len, d_v) return output实际工程里一般会用torch.nn.MultiheadAttention或者nn.Linear封装好的模块,但自己手写一遍这个流程,对理解后续所有变体都大有帮助。我建议每个想深入transformer的人都用纯矩阵运算手写一次attention,别急着调库。
2.2 多头注意力到底在做什么
原始transformer论文里用的不是单个attention,而是多头注意力(Multi-Head Attention)。所谓多头,就是把d_model维度的表示切成h个头,每个头分到d_model/h的维度,各自独立做一次上面的自注意力计算,然后把所有头的输出拼回去。
为什么要多此一举分成多个头?因为单次attention只能算一种关系模式。比如一句话里,"苹果"可能同时和"水果"有语义关系,和"公司"有品牌关系,和"红色"有视觉属性关系。如果你只用一个注意力头,那它只能做一个加权平均,把所有这些关系揉在一起,效果往往互相干扰。拆成多个头之后,每个头可以各自专注一种关系模式,有的头学语法依赖,有的头学指代关系,有的头学局部共现。
多头注意力的计算可以写成:
MultiHead(Q, K, V) = Concat(head_1, ..., head_h) @ W_O其中head_i = Attention(Q W_Q^i, K W_K^i, V W_V^i)。
有个实操细节值得注意:每个头的维度d_k = d_model/h,当h增大时,每个头的表达能力会变弱。工程上最常用的设定是d_model=512,h=8,每个头64维。这是原论文的经验值,事实证明确实够用。如果你调大d_model到1024甚至更大,h可以对应增加,但要记得保持每个头的维度在64左右附近,效果通常比较稳。
多头注意力还有一个隐藏好处:它为模型的并行计算提供了天然的"分而治之"结构。多个头之间互不依赖,可以完全并行计算,对GPU利用率非常友好。
2.3 位置编码:给序列加上"坐标"
自注意力本身对位置是"盲"的。如果把序列里的token顺序打乱,注意力的计算结果完全不变,因为所有token两两之间算相似度,根本不关心谁在谁前面。这在建模语言时是致命的,因为"猫追老鼠"和"老鼠追猫"的token完全一样,只是顺序不同,语义却天差地别。
所以transformer必须显式地把位置信息注入进去。原始论文用的是固定频率的正弦余弦编码:
PE(pos, 2i) = sin(pos / 10000^(2i/d_model)) PE(pos, 2i+1) = cos(pos / 10000^(2i/d_model))这个设计的巧妙之处在于,位置编码不需要学习,直接用公式生成即可;而且它天然包含了相对位置的关系信息,因为PE(pos+k)可以近似表示成PE(pos)的某种线性组合。当然,后来实践发现直接用可学习的positional embedding(把位置编码当参数训练)在很多场景下效果更好,尤其是数据量充足时。于是从BERT开始,业界更倾向于用可学习位置编码。
不过这还不够。可学习位置编码是绝对位置编码,也就是每个位置学一个固定的向量,但它不能显式建模两个位置之间的"相对距离远近"。后来RoPE(Rotary Position Embedding)给出了一个更好的方案:在计算Q和K的时候,通过旋转矩阵把相对位置信息注入进去,让attention分数天然携带"位置越近,越被偏好"的归纳偏置。RoPE现在已经是GPT系列和很多开源大模型的标准配置。
再后来的一些工作甚至尝试把位置编码直接去掉,或者用其他方式隐式建模位置信息,比如ALiBi(Attention with Linear Biases)在注意力分数上加一个和相对距离成线性关系的偏置。这种"不给位置建模,直接在attention里加偏置"的做法,在实际效果上还挺好,尤其是对长度外推(在更长序列上做推理)有不错的支持。
看完这个演进过程你就明白,位置编码不是死板的组件,它是一套在"如何让模型感知顺序"这个问题上不断迭代的算法思路。
3. 算法演进的几条主线
3.1 计算效率演进:从稀疏注意力到线性注意力
前面提到过,自注意力的O(n²)复杂度是核心痛点。当年在机器翻译上处理50个词的句子毫无压力,但当人们尝试用transformer处理长文档、音频序列、高分辨率图像时,n一变大,显存和时间都扛不住了。处理一个4096长度的序列,注意力矩阵就有1600万个数,光是存这个矩阵就已经很肉疼。
针对这个问题的演进,大体分成三条路线。
第一条是稀疏注意力。代表工作是Longformer和BigBird。思路很简单:不让每个token看到所有token,而是只让它在局部窗口内做注意力,再配合少量全局token(比如CLS token或者随机选几个锚点)做长距离信息传播。这种方式把复杂度从O(n²)降到O(n√n)甚至O(n)。以Longformer为例,它的注意力模式是"滑动窗口+全局token",每个token除了看自己周围的k个邻居,还能通过全局token间接获得全局信息。效果上,它在长文本任务上可以替代BERT,但显存占用只有几分之一。
第二条是稀疏化加哈希。Reformer提出了LSH(局部敏感哈希)注意力:先把token分桶,然后只在同一个桶内部的token之间计算注意力。这个想法非常聪明,它利用"注意力分数大的token往往在表征空间里离得近"这个经验观察,用低成本的近似方法跳过那些肯定会被softmax压掉的低分位置。代价是带来一定的近似误差,但换来的是长序列下内存的显著下降。
第三条是线性注意力。代表工作是Linformer和Performer。它们通过低秩或者核函数近似,把注意力矩阵分解成两个小矩阵的乘积,使得复杂度降为O(n)。比如Linformer的做法是假设注意力矩阵是低秩的,用两个可学习的投影矩阵把K和V压缩到固定维度k(远小于n),这样注意力矩阵变成(n, k)和(k, n)的乘积。Performer则用了随机特征映射(random feature map),让softmax的核函数可以被近似地分解成两个特征向量的内积。
这三条路线各有优劣。稀疏注意力实现简单、效果可控,但复杂度还是带着一个n;线性注意力理论上最优,但实际跑起来常数项很大,而且对训练稳定性要求更高。工程上直到今天,最主流的做法反而不是这些花哨的近似方法,而是FlashAttention——它没有改变注意力算法本身,纯粹从IO优化的角度,把QK^T这个大矩阵的读写降到最低。FlashAttention做到的是"算法不变、复杂度不变,但常数项小得惊人",这让很多人开始重新审视"你到底是算法慢,还是IO慢"。
这给我一个很深的体会:算法演进的终极目标不是追求理论复杂度的绝对最低,而是在真实硬件上实现"能落地的快"。
3.2 视觉领域的适配:ViT与Swin Transformer
Transformer最初是为文字设计的,但它的建模能力天然适合视觉任务。2020年ViT(Vision Transformer)的出现是一个标志性事件。ViT的做法出奇地简单:把一张224x224的图像切成16x16的patch,每个patch通过线性映射变成一个token,然后把这些patch token直接丢进标准的transformer encoder里跑。
这里遇到一个核心问题:图像的分辨率如果稍微放大一点,patch数量就会暴涨。224x224对应196个patch,这个规模还好说;一旦到1024x1024,patch数量就变成4096个,计算量直接爆炸。视觉transformer的演进,很大程度上就是围绕"如何降低patch数量或者限制注意力范围"展开的。
Swin Transformer的解法非常有代表性。它把注意力限制在局部窗口内(比如7x7的窗口),每个窗口内部做自注意力,窗口之间再通过shifted window机制进行信息交换。这种做法同时吸收了CNN的局部归纳偏置和transformer的全局建模能力,视觉幻觉少了很多。Swin Transformer在ImageNet上取得了当时的最优成绩,也成了后来很多视觉大模型的主干网络。
从ViT到Swin的演进反映了一个更普适的道理:直接把NLP的transformer原样搬到新领域通常不是最优解,针对数据特性做算法适配才是正确姿势。视觉数据和文本数据的核心差异在于局部性极强——一个像素通常和它周边的像素关系最密切,和远处另一个角落的像素几乎无关。所以滑动窗口式注意力比全局注意力更适合图像。
类似的适配思路也出现在语音和多模态领域。处理长音频时常见的做法是时间维度上做下采样,或者用卷积做局部建模,再用transformer做全局整合。多模态transformer(比如各种融合模型)则专门设计跨模态的注意力机制,让文本token可以attend到图像区域,也允许图像token attend到文本,这背后的设计哲学是一样的:根据任务特性决定让哪些位置之间建立直达连接。
3.3 参数规模演进:MoE与稀疏激活
注意力机制的演进解决了计算效率问题,而模型能力的进一步提升则靠增大参数量。但参数也不能无脑加。一个大模型动辄几百上千亿参数,如果每次推理都要激活所有参数,显存和算力都撑不住。于是Mix-of-Experts(MoE)这种稀疏激活架构走上了台前。
MoE的思路最早可以追溯到1991年的自适应专家混合模型,但真正把它和transformer结合并做大,是GShard和Switch Transformer这些工作。它的基本结构是把FFN层替换成多个并行的专家FFN,每个token不是被所有专家处理,而是通过一个路由器(router)只选择最相关的top-k个专家。比如Switch Transformer只选top-1专家,也就是每个token只经过一个专家的FFN。这样虽然模型总参数量很大,但每个token的计算量只对应一个专家,推理成本不会线性增长。
MoE给transformer带来的核心变化是让"容量"和"计算量"解耦。你可以有一个万亿参数模型,却只有几十亿参数的推理开销,本质上是在利用模型的稀疏性。这个思路在今天的GPT-4、开源模型以及各种大模型服务里几乎成了标配。
不过MoE也不是白捡的便宜。工程上最大的痛点是通信开销——token被分到不同专家,如果一个batch里的token分布不均衡,有些专家被塞满了,有些专家闲得慌,训练效率会大打折扣。所以算法演进里增加了负载均衡的auxiliary loss,强迫路由器把token均匀地分配给各专家。另一条路线是用更精细的路由策略,比如专家并行、分组路由,尽量降低跨设备的通信量。
我实际跑过Switch Transformer的开源实现,最直观的感受是:模型收敛的速度确实快,但训练时的显存峰值往往比同等稠密模型高不少,因为虽然每个token只走一个专家,但优化器状态、梯度、专家参数依然都是全量保存的。除非你真的把参数规模推到几十亿以上,否则在小规模实验里MoE的收益可能并不明显。
3.4 架构范式演进:Encoder-Decoder到Decoder-only
Transformer架构演进里还有一条容易被忽视的主线:整体范式从Encoder-Decoder转向Decoder-only。原始transformer是一个典型的encoder-decoder结构,编码器负责理解输入,解码器负责生成输出。BERT只用encoder做理解任务,GPT用decoder做生成任务。很长一段时间里,这两条路线是并行的。
但GPT系列的成功让decoder-only范式后来居上。背后有一个很实在的技术原因:纯decoder结构在自回归生成时只需要维护一组KV cache(Key和Value的缓存),每生成一个token只需要计算新增token的attention,前面的历史直接复用即可。而encoder-decoder结构在推理时要先跑一遍完整encoder,再进入decoder生成,流程更重。
另一个原因是decoder-only模型天然统一了理解和生成任务。你问"1+1等于几",模型可以直接把这句话当成prompt,然后续写"等于2"。理解任务和生成任务本质上都变成了"给定前缀预测下一个token"。这种统一让预训练的损失函数变得极其简单——就是next token prediction,却可以在足够数据和足够参数下涌现出指令跟随、推理等能力。
当然,encoder-decoder也没有完全被抛弃。表格问答、摘要抽取这类"输入非常长,输出相对短"的任务,encoder-decoder依然有优势,因为encoder可以一次性对输入做双向建模,而decoder只专注于生成。T5、Flan-T5这些模型在特定任务上依然比同规模的decoder-only模型能打。所以范式演进不是旧范式的彻底消亡,而是新的范式在很多通用任务上更占优。
4. 实操复现与调参心得
4.1 手写一个轻量Transformer的关键点
看懂了算法,动手写一个能训练的transformer才是检验理解是否到位的最好方式。我建议不要一上来就调torch.nn.Transformer,而是自己搭一个最小的encoder层。核心组件只有四个:多头注意力、FFN、LayerNorm、残差连接。
这几个组件在实现顺序上有讲究。很多新手容易忽略的一点是"原始transformer用的是Post-Norm还是Pre-Norm"。原始论文用的是Post-Norm,也就是"attention -> add -> norm",但这种结构在深层网络里训练不稳定。实际操作中,我更推荐Pre-Norm结构,顺序是"norm -> attention -> add"。原因是Pre-Norm把LayerNorm放在残差分支的前面,可以让残差连接处的梯度更干净,深层网络训练更稳。
一个最小可用的transformer encoder层大概长这样:
import torch import torch.nn as nn class TransformerEncoderLayer(nn.Module): def __init__(self, d_model, n_heads, d_ff, dropout=0.1): super().__init__() self.self_attn = nn.MultiheadAttention(d_model, n_heads, dropout=dropout, batch_first=True) self.ffn = nn.Sequential( nn.Linear(d_model, d_ff), nn.GELU(), nn.Dropout(dropout), nn.Linear(d_ff, d_model), nn.Dropout(dropout) ) self.norm1 = nn.LayerNorm(d_model) self.norm2 = nn.LayerNorm(d_model) def forward(self, x): # Pre-Norm 结构 x = x + self.self_attn(self.norm1(x), self.norm1(x), self.norm1(x))[0] x = x + self.ffn(self.norm2(x)) return x这里有几个值得注意的细节。第一,nn.MultiheadAttention的Q、K、V输入在Pre-Norm结构里应该是同一个归一化后的向量,我写成self.norm1(x)传了三次,这没问题,因为它们是同一个引用。第二,FFN的激活函数早期常用ReLU,但GELU在transformer里基本成为默认选择,收敛曲线更平滑。第三,dropout的默认值0.1是原论文的经验,但你如果数据量很小,建议降到0.05甚至0.01。
把这一层叠加到6到12层,加上词嵌入和位置编码,就是一个可以跑语言模型训练的mini transformer了。我当年第一次自己搭的时候,最容易被卡住的地方是维度不匹配。建议每一步都打印一下shape,尤其是多头注意力的输出维度经过投影之后是否还是d_model。
4.2 训练稳定性的细节
训练transformer最折磨人的问题就是loss不降或者突然暴涨。我自己的经验里,有几个细节对训练稳定性起决定性作用。
第一是学习率预热。Transformer对学习率非常敏感,如果一开始就用较大的学习率,很容易在前几百步就把模型参数推到一个坏的局部区域,后面怎么调都救不回来。标准做法是warmup:在前w步里让学习率从0线性增长到峰值,比如w=4000,然后再按倒数平方根方式衰减。这个策略在原始论文里就有明确的公式,无数后续工作沿用了这个设定。用AdamW优化器时,我习惯设置betas=(0.9, 0.95),这会比默认的0.999更激进一些,但收敛速度更快。
第二是梯度裁剪。虽然transformer比RNN在长程梯度方面强很多,但训练初期仍然可能出现梯度范数突然变大的情况。我一般会把梯度范数裁剪到1.0到5.0之间,这个经验值在不同任务里都算安全。
第三是混合精度训练。现在基本都是用fp16或者bf16来训大模型。fp16有个坑是精度溢出,因为fp16能表示的最大值只有65504,损失函数稍微大一点就容易变成inf。解决方法是loss scaling,也就是在反向传播前把loss乘一个大系数,更新梯度前再除回来。而bf16的动态范围跟fp32一致,几乎没有溢出问题,如果你的GPU支持bf16,我强烈建议优先使用。
还有一个容易被忽视的点是LayerNorm的epsilon值。默认值1e-5在大多数情况下没问题,但在低精度训练时,我遇到过因为epsilon太小导致归一化出现NaN的情况,把它调到1e-6到1e-5之间的一个值,问题往往就消失了。
4.3 显存优化技巧
transformer的显存占用大头集中在注意力矩阵和激活值上。训练一个稍大的模型,16G的显卡很快就爆了。这里我分享几个实测有效的技巧。
第一招是gradient checkpointing。思路非常简单:前向传播时丢掉中间激活值,反向传播时重新计算一次前向。代价是训练时间大约多花30%,但显存占用能降到原来的三分之一以下。这个技巧在很多框架里只需要一行设置,我一般是在大型实验里默认开启。
第二招是FlashAttention。前面提到过它是IO优化的注意力实现,不改变算法却能大幅减少显存。PyTorch 2.0及以上版本里,torch.nn.functional.scaled_dot_product_attention已经原生支持FlashAttention内核,只要把attention计算换成这个函数,显存就能肉眼可见地降下来。
第三招是attention矩阵的读取优化。常规实现里,scores = Q @ K.T会生成一个(batch, heads, seq_len, seq_len)的矩阵,这个矩阵在反向传播时也要保存。如果序列长度是4096,batch是8,heads是16,光是这个矩阵就有约8.6亿个数,fp16下一个batch就要占1.7G。FlashAttention的核心价值就是不再实例化这个完整矩阵。如果你的环境不支持FlashAttention,另一个折中方案是分块计算attention,手动控制每个小块的内存使用。
显存问题还有一个经常被忽略的层面:KV cache。推理阶段每生成一个token,都要缓存之前所有token的Key和Value向量,随着生成长度增长,KV cache线性膨胀。对大模型来说,KV cache最终往往比模型本身还占显存。优化办法包括使用GQA(Grouped Query Attention)或者MQA(Multi-Query Attention),让多个注意力头共享一组Key和Value,大幅压缩缓存量。这属于推理侧的核心算法演进方向。
5. 常见问题与排查技巧
5.1 训练不收敛
训练transformer最常见的噩梦就是loss纹丝不动。我自己遇到这种情况时,排查顺序基本固定。
先看数据。分词器如果没做好,或者序列padding方式不对,模型可能根本没读到有效信息。我把tokenizer吐出来的序列打印出来看过,发现过一个很尴尬的情况:因为padding token的id设置错误,导致整个序列被当成了一堆特殊的空token,模型当然学不到任何东西。
再看模型结构。检查注意力层是不是不小心把mask加反了,把应该attend的位置全部mask掉了。这种bug在代码里非常隐蔽,因为你不会直接看到错误,只会发现loss永远不降。我排查这个问题的办法是把mask矩阵单独打印出来,人工看一眼有没有常识性错误。
然后是优化器参数。学习率如果设成1e-2这种值,transformer几乎必炸。我的建议是先用1e-4这个保守值跑500步,看loss有没有下降趋势。没有的话再调小到5e-5,而不是反过来往上调。
5.2 注意力分数异常
有时候loss在降,但你心里没底,因为注意力分布完全不合理。我把所有注意力头的分数heatmap画出来看过,有几种异常模式值得记录。
第一种是全均匀分布。所有token对所有token的注意力权重都接近1/n,说明模型根本没有学到任何有意义的依赖关系。这种通常发生在模型容量不足、训练步数太少,或者你用了过大的dropout把有用的信号都drop掉的情况下。
第二种是注意力集中在少数几个token上。如果集中在CLS或者SEP这类特殊token上,这其实是模型在偷懒——它知道这些特殊token的表示往往携带了全局汇总信息,所以干脆把所有信息都从那里取,不在其他token上做细粒度区分。这不是致命的,但说明模型可能有捷径可走,真正需要的细粒度关系没学到。缓解办法是检查是不是特殊token的embedding初始化有问题,或者是否需要调整注意力层的dropout。
第三种是注意力分数出现NaN。这个几乎总是和混合精度训练有关,fp16动态范围不够。解决办法是把attention的softmax换成更稳定的实现,或者改用bf16。我遇到过LayerNorm输出有极小值导致softmax溢出,追到最后发现是输入数据里混入了NaN,清洗数据之后就正常了。
5.3 推理速度过慢
推理速度慢的瓶颈跟训练时不一样。训练是算力密集,推理对长序列生成来说反而是访存密集。
第一个常见问题是重复计算。如果代码里没有正确使用KV cache,每生成一个token都会把前面所有token的attention重新计算一遍。这个在算法上是完全没必要的。使用KV cache后,生成第n个token时只需要计算第n个token的Q,然后跟缓存的K、V矩阵做注意力,速度能有数量级的提升。
第二个问题是batch size设得太大。推理时GPU的算力利用率往往不满,如果batch size太大,显存被KV cache挤爆,模型会被迫做额外的显存交换,速度反而下降。我通常会让batch size从1开始往上试,找到"显存刚好够用"的临界点。
第三个问题是模型压缩手段没跟上。剪枝、量化这些技术中,PTQ(训练后量化)是对推理最友好的优化方式。把权重从fp16量化到int8,显存占用直接减半,速度也明显提升。如果精度受损,可以考虑一些重建校准方法,比如GPTQ或者AWQ。我不是说这些方法效果一定好,它们肯定要做精度评测,但实测下来大多数场景从fp16到int8的损失是可以接受的。
5.4 长序列训练时的内存崩溃
我最后再说一个长序列场景里很多人踩过的坑:OOM不一定是显存不够,也有可能是注意力矩阵的中间变量太大。你把序列长度从512改成2048,看起来只涨了4倍,但注意力矩阵是n²的,显存占用涨了16倍。这个增长速度是非常吓人的。
如果你必须处理长序列,优先考虑几种方案:换用FlashAttention内核、开启gradient checkpointing、使用序列切分策略(比如把长序列切成几个块,分块做attention再聚合)。不要试图通过减小batch size来硬撑,因为序列本身很长时,即使batch size为1,注意力矩阵照样会炸。我在训练2048长度序列时,batch size只能设为1,但靠FlashAttention和checkpointing,照样能训得很稳。
有一段时间我被人问到"为什么我的transformer在长文本摘要任务上反应这么慢",排查下来发现就是KV cache没做。这提醒我,算法演进里的每一个优化点,落到代码里可能就是少写一行缓存的问题。原理要懂,工程实现也要跟得上。
6. 从算法到落地的一些个人体会
写到这里,我回头看transformer架构的核心算法演进,感觉它其实是两个维度的交替推进。一个维度是表达能力,从单头attention到多头attention,从绝对位置编码到RoPE,从dense参数到MoE稀疏激活,本质都是让模型能刻画更复杂的关系;另一个维度是运行效率,从密集attention到稀疏attention、线性attention、FlashAttention,从没有KV cache到各种cache优化、量化压缩,本质都是让模型在现有硬件上跑得更快更省。
这两个维度有时候相互制约,有时候相互成就。很多改进看上去是在优化效率,但效率提升后模型可以训练更长的序列、更大的batch,反过来又提升了最终效果。比如FlashAttention让大家能吃下更长上下文,RoPE让位置编码更好地支持外推,这两者叠加才有了今天动辄几十万token上下文长度的应用场景。
我个人在实际操作中最大的体会是:不要盲目追新。每个新的注意力变体、新的架构范式,都有它的适用场景和代价。Swin的窗口注意力在视觉上很好用,但如果你做的是文本generation,它的收益就很有限。MoE省了推理算力,但引入了通信和调度复杂度,小规模实验里往往是负优化。我在实际项目中做技术选型时,会先问自己三个问题:输入序列到底多长?任务本身是更依赖全局关系还是局部关系?可用显存和推理时延的预算是多少?把这三个问题的答案想清楚,很多架构选择其实是水到渠成的。
还有一点想特别提醒:理解transformer演进最好的方式是动手实现。你不用从零写出GPT-4的完整代码,但把一个最小版encoder-decoder跑通,再把FlashAttention或者RoPE集成进去,你的理解深度会远超只读十篇论文的水平。我见过太多人把架构图画得头头是道,一问到具体维度对应关系就含糊了。数学公式和代码之间翻译的能力,才是真正属于工程师的护城河。
Transformer的演进还远没有停。分布式并行训练、超长上下文建模、端侧模型压缩,这些方向上每天都有新工作出来。但不管外面的模型变得多复杂,回到最基础的自注意力机制和那几条演进主线,你会发现万变不离其宗。先把几个核心组件和它们的计算关系彻底吃透,后续所有的进阶内容都会变得顺理成章。