1. 从RNN的瓶颈说起:为什么非得是Attention
先聊一个我早年间特别有感触的场景。2017年之前,做序列建模基本绕不开RNN、LSTM、GRU这三件套。那时候大家最常干的事,就是绞尽脑汁设计各种门控机制、堆叠双向层、加各种trick,只为了让模型能把长距离的依赖关系记得再久一点。但无论怎么调,RNN家族有两个天生的硬伤始终绕不过去:顺序计算导致的低并行性,以及长距离信息衰减。
当时我为了训一个稍微像样点的文本生成模型,在GPU上跑一个两层的LSTM,一个batch要等很久。不是模型不够聪明,而是整个计算过程本质上是一个token接一个token串行下去的。每个时间步的隐状态都依赖前一个时间步的输出,GPU再快,也没法把这条链拆开同时算。这就好比一条流水线上只有一个工人,每个零件都得他一个人挨个过手,机器再多都白搭。
然后2017年那篇《Attention Is All You Need》出来了。说实话,第一次看到这个标题我是有点不屑的,心想什么玩意儿就All You Need了。直到我把论文里的图反反复复看了几遍,自己动手推了一遍公式,才意识到这玩意儿确实捅破了天。它把整个序列建模的思路从“逐步递归”彻底改成了“全局关联”,所有位置同时参与计算,并行度直接拉满。
这里有个值得先厘清的点:很多人以为Transformer牛在“效果好”,其实它牛在建模方式的根本性转变。以前的模型是靠“记忆”来理解序列的,一个个往后传;Transformer是靠“查关系”来理解序列的,每个词直接和所有词计算关联强度。这种方式不再依赖信息在时间轴上一点一点传递,所以也就没有了所谓的“长距离遗忘”问题。理解了这个动机,你才能真正看懂后面所有的架构设计——为什么有Self-Attention,为什么有多头,为什么有位置编码,全都是围绕“如何高效、稳定地建模全局关系”这件事展开的。
所以这篇我打算不按论文顺序平铺直叙,而是从“一个样本在Transformer里到底是怎么流动的”这个角度拆开讲,中间把张量形状、注意力计算、工程细节、训练技巧全部穿插进去。适合三类人看:刚入门想搞懂Transformer到底在干嘛的新手、已经会调包但想知道内部细节的开发、以及准备面试前想系统梳理一遍的求职者。
2. 宏观架构拆解:一个样本在Transformer里是怎么流动的
2.1 Encoder-Decoder骨架与数据流
Transformer的经典架构是Encoder-Decoder结构。用最通俗的话描述:Encoder负责把输入序列“读”成一堆向量,Decoder负责根据这堆向量“写”出输出序列。
拿机器翻译举例。假设你要把“I love you”翻译成“我爱你”,Encoder接收的是英文单词,每个词被转成一个高维向量,然后这些向量经过多层计算,融入了彼此之间的上下文信息。Decoder这边,先看到起始符,然后预测第一个词“我”,再把“我”作为输入预测“爱”,以此类推。
但这里有个很多初学者第一次看架构图会懵的地方:Encoder和Decoder长得非常像,它们里面都有Self-Attention和Feed-Forward Network,那它们到底有什么区别?
区别在Decoder里多了一个Cross-Attention(交叉注意力)层,同时它的Self-Attention是被mask掉的。Cross-Attention的作用是让Decoder在生成每一个词的时候,都能去“回看”Encoder输出的向量,从源语言信息里提取自己需要的部分。而被mask掉的Self-Attention则是为了保证一个基本原则:生成当前位置的词时,不能看到未来的词。
我当年踩过一个认知坑,以为Decoder的输入是完整的目标句子,一次性并行预测所有位置的词。但实际上训练时确实可以并行——因为目标句子是已知的,可以把整句喂进去,用Mask遮挡未来信息。而推理时就惨了,只能一个词一个词地生成,每次都得把前面生成过的词重新过一遍模型,这也引出了后面KV Cache那套优化方案。
2.2 张量形状变化:从token到向量的每一步
理解Transformer最有效的手段之一,是盯住张量的形状。
假设batch size为2,输入序列长度为10,词的embedding维度为512。那么输入经过embedding层后,形状是[2, 10, 512]。
这三个数字的含义要刻在脑子里:
- 第一个维度是batch,样本之间互不影响;
- 第二个维度是序列长度,这是Self-Attention计算的“轴”;
- 第三个维度是特征维度,每个token用512个数表示自己。
进入Self-Attention层后,这个张量会分别乘上三组权重矩阵,产生Q、K、V三个向量。注意这里不是每个token单独一组的QKV,而是整批token共享同一个权重矩阵。形状从[2, 10, 512]变成[2, 10, 512],只是语义变了:原本是一堆混合了语法、语义、位置信息的原始向量,现在被投影成了“我应该关注谁”“我的内容是什么”“我能提供什么信息”三个不同角色。
多头注意力会把最后一个维度切成多份。比如8个头,每个头分到64维,形状变成[2, 8, 10, 64]。然后每个头单独做注意力计算,结果再拼回去,经过一个输出投影矩阵,形状又恢复成[2, 10, 512]。
后面接的Feed-Forward Network(前馈网络)很有意思,它在这条链路里干的事是对每个token独立地做非线性变换。它的输入输出形状都是[2, 10, 512],但中间会先放大到2048维(常见是4倍),再压缩回512维。这个“先放大再压缩”的设计,本质上是在给模型腾出空间做更复杂的特征组合。
整个流程你可以类比成:注意力负责“众人讨论”,让每个词看到所有词的信息;前馈网络负责“各自消化”,把讨论得到的综合信息在本地做深度加工。两者交替堆叠N层,就是Transformer的全部宏观逻辑。
2.3 为什么Decoder不能像Encoder一样并行
这是我在面试别人时特别喜欢问的一个问题,因为它能检验一个人是背过架构图,还是真的理解了。
Encoder的Self-Attention在计算第i个位置的输出时,是可以看到序列中所有位置的,包括i之后的。这意味着Encoder可以一次性把整个输入序列的所有位置都算完。打个比方,阅读理解一篇全文,你可以看到后面的句子来理解前面的内容。
但Decoder生成任务不一样,生成第i个词时,模型不应该知道第i+1个词是什么。否则就成了考试开卷,答案写在卷子上,模型只是照着念,完全没有泛化能力。
这就是Causal Mask(因果掩码)的由来。在Decoder的Self-Attention中,计算第i个位置时,会把i之后所有位置的注意力权重设为一个极小的负数,经过Softmax后变成0。这样一来,第i个位置只能“看到”自己和之前的位置。
顺着这个逻辑,你也就明白了为什么推理阶段慢。因为Decoder只能从左到右一步步走:生成第一个词,拿它当输入生成第二个词,再拿这两个当输入生成第三个词……每一步都得重新执行一遍整个Decoder。这也是为什么现在的LLM推理延迟和显存压力一直是个工程热点,因为每一步的KV都要缓存下来,不然每一步都重算历史全部token,成本高到无法接受。
3. 微观细节解剖:Self-Attention、多头机制和位置编码的底层逻辑
3.1 Scaled Dot-Product Attention的数学直觉
先给出那行被背烂了的公式:
[ Attention(Q, K, V) = softmax(\frac{QK^T}{\sqrt{d_k}})V ]
看着简单,但里面的门道特别多。我一个个讲。
为什么用点积来衡量相关性?这是最直观的一种相似度度量方式。如果两个向量方向一致(在Transformer的投影空间里含义相近),点积值就大;如果正交,点积为0;如果方向相反,点积为负。把Q和K做点积,本质上就是在问:“我这个查询(query),与你这个键(key)的匹配程度有多高?”
我用一个日常场景辅助理解。你在百度搜“番茄炒蛋做法”,搜索框里的文字是Query,搜索引擎索引里每一篇文章的标题是Key,文章内容是Value。搜索引擎先把Query和所有Key做匹配(计算相关性分数),选出相关性最高的几篇,再把这几个Value(正文内容)组合起来返回给你。Transformer的Attention做的事情几乎一模一样,只不过这里的Query、Key、Value都是向量,匹配程度用点积表示。
为什么除以√d_k?这是无数人第一次看论文时忽略的细节,但恰恰是训练稳定性的关键。
当d_k比较大的时候(比如512),点积结果的方差会跟着变大。原因很简单:假设Q和K的每个分量都是均值为0、方差为1的独立随机变量,那么它们的点积是d_k个独立乘积的和,方差就是d_k。方差大意味着数值范围会被推到很大的正数或很小的负数,Softmax的指数函数在这个区域梯度极其平缓,很容易导致梯度消失,模型训练一潭死水。
除以√d_k,等于把方差重新拉回1这个量级,让Softmax落在梯度比较“陡峭”的区域,反向传播时信息能顺畅流动。这个细节当年坑了不少人:有人为了提高模型容量把d_k调大,结果训练不收敛,卡了几天才发现是这个缩放因子没跟着适配。
Softmax在这里真正的使命是什么?把原始分数转成加权系数。因为分数范围不定,直接用原始分数去加权V会有数值不稳定的问题,而Softmax天然把分数归一化到[0,1]之间且和为1,相当于给每个Value分配一个“注意力预算”。谁的分数高,谁的预算就多,最终输出的向量就越偏向谁。
打个比方:你在三四个候选餐厅里选晚饭,你内心有一个原始打分,但最后决定时考虑的是相对权重——哪家最想去、哪家只是备选。Softmax就是把这个“相对权重”显式算出来。
3.2 多头注意力:为什么一个头不够用
Single-Head Attention算出来的关系矩阵,实际上只有一种“关系视角”。但自然语言里词与词之间的关系太复杂了:The animal didn't cross the street becauseitwas too tired,这里的it指代animal;而在另一句子里,it可能指代street。一个头很难同时捕捉“指代关系”和“位置关系”和其他所有语法语义关系。
多头注意力就是给模型开了多套并行的投影空间。每个头有自己独立的QKV权重矩阵,它们关注的关系维度不同:有研究表明,有些头主要负责语法依赖(比如动词和宾语的关系),有些头负责指代消解,有些头负责位置的远近距离感知。多个头各自捕捉不同方面,最后拼接在一起,形成更丰富的特征表示。
我用一个比较接地气的类比:一群人同时看一幅画,有人关注色彩,有人关注构图,有人关注笔触,大家把自己最有感觉的部分说出来,拼在一起才是对这幅画比较完整的理解。
工程上还有个细节值得提:多头并不增加太多计算量。因为每个头的维度是原来的1/h,总计算量基本不变,但模型表达能力和特征的多样性却大幅提升。这也是注意力机制“性价比高”的体现之一。好多初学者以为多头就是8个完整的Attention并行算,那是把复杂度理解错了。
3.3 位置编码:让模型知道谁先谁后
Self-Attention有一个天然的缺陷——它是置换不变的。把“我打你”的三个词顺序随便换一下,Attention计算出来的结果一模一样。这显然不行,因为“你打我”和“我打你”意思天差地别。
Transformer的解决方案是往输入embedding里加位置信息。原文用的是正弦余弦函数:
[ PE_{(pos, 2i)} = sin(pos / 10000^{2i/d_{model}}) ] [ PE_{(pos, 2i+1)} = cos(pos / 10000^{2i/d_{model}}) ]
这公式看起来神神叨叨的,但思路其实很朴素:用一组和位置正相关的数值,把“这个词在第几个位置”这个信息编码进向量里。用正弦余弦的好处有两个:
- 因为正弦和余弦函数是有界的(值域在[-1,1]之间),加到embedding里不会把数值撑爆;
- 不同维度用不同频率,相当于用多个“尺子”去度量位置,既能区分位置差异,某些频率还能让模型比较好地泛化到比训练时更长的序列。
我见过很多新手看完这公式,照着实现了,但从没想过一个问题:为什么用加法而不是拼接?这个问题的答案比较深刻:如果拼接,位置信息成了和语义信息并列的独立通道,模型必须通过额外的权重矩阵去融合它们;而加起来是直接把“我+位置5”编码成一个整体向量,经过线性层后,语义信息和位置信息天然就藕合在一起,处理起来更高效。
现在大模型里还流行另一类做法,比如用可学习的绝对位置编码(Learned Positional Embedding)和旋转位置编码(RoPE)。RoPE在目前的LLM中应用最广泛,思路是把位置信息编码成向量旋转的角度,用旋转矩阵作用在Q和K上,这样点积结果里自然带上相对位置的差异。它对长度的外推性比正弦编码更好,这也是为什么新一代模型动辄支持几十万token上下文的原因之一。
3.4 残差连接与LayerNorm:训练稳定的功臣
如果只看Attention和前馈网络,Transformer没传说中那么神奇。真正让这种深层网络能够稳定训练起来的,是每个子层后面的“装修队”:Residual Connection(残差连接)+ Layer Normalization(层归一化)。
残差连接的结构很简单:输出 = 子层输出 + 输入。也就是说,每个子层在完成计算后,会把原始的输入“原封不动”地再加回来。
为什么这么重要?两个原因。
一是梯度流动。深层网络的梯度在反向传播时容易消失,残差连接相当于给梯度开了一条高速公路,让它可以绕开复杂的非线性变换,直接从深层传回浅层。有论文证明,即使网络堆到上千层,残差连接依然能让梯度保持有效流动。没有残差连接的Transformer,堆到6层就够呛,更别说现在的上百层大模型了。
二是信息保留。Attention本质上是加权求和,理论上输出向量是输入向量的某种“重组合”,但多次变换后原始信息难免被稀释。残差连接确保任何一层都能拿到最原始的输入信息,相当于始终有一个“底线保障”:即使这一层什么都没学到,纯粹的恒等映射也能顺利通过。
LayerNorm在Transformer里起的作用和BatchNorm完全不是一个路数。BatchNorm是沿着batch维度做归一化,一堆样本一起算均值方差;LayerNorm是对单个样本自己的所有特征维度做归一化。为什么序列任务不能直接用BatchNorm?因为文本序列长度不一,batch里有的句子长有的句子短,尾部的padding位置会影响统计量。LayerNorm绕开了这个问题,它不管batch里其他样本什么样,只对当前这个样本归一化,天然适配变长序列。
此外有个训练细节:Decoder在做增量推理时,BatchNorm统计的是训练时那个batch的分布,推理阶段用滚动均值,会有训练和推理不一致的问题。LayerNorm没有这个毛病,训练和推理的计算方式完全一致。这也是Transformer能在工程上顺利落地的重要前提。
4. 训练与推理中的冷知识:warmup、Mask与KV Cache
4.1 训练稳定性的隐藏功臣:学习率warmup
很多照着论文复现Transformer的人,上来直接用一个固定的学习率,结果发现loss掉不下去或者训练震荡得厉害。其实OpenAI官方对Transformer训练有一个几乎标配的操作:Warmup + 学习率衰减。
Warmup的意思是,训练的前几千步,让学习率从一个很小的值线性增长到设定的最大值,之后再按余弦曲线或者逆平方根曲线逐步衰减。
为什么要这么干?核心原因是Transformer的LayerNorm和Adam优化器在训练初期特别容易“打架”。训练最开始,模型参数是随机初始化的,各层输出的分布非常不稳定,梯度范数的波动也很大。如果此时直接用较大的学习率,相当于在剧烈晃动的地基上盖楼,很容易把参数推到一发不可收拾的角落。先用小学习率“热身”,让模型参数稍微走几步,把LayerNorm的尺度稳定下来,再把学习率提上去,训练过程会平滑很多。
我在实际调参中发现,warmup步数对最终效果的影响比很多人想象中大。步数太少,模型一开始就被“带偏”,后面很难拉回来;步数太多,训练前期学习率过小,收敛速度明显变慢。对小规模任务,推荐warmup 2000-4000步;对大规模预训练,warmup比例一般占总训练步数的1%-3%。
4.2 推理阶段KV Cache:生成速度与显存的博弈
推理时Decoder是自回归的,每生成一个token,都要把整个序列从头过一遍自注意力。如果每一步都重新计算所有历史token的QKV,计算量是序列长度的平方级,效率低到没法看。
KV Cache优化方案非常巧妙:把历史token算出的K和V缓存起来,下一步只需要用当前token的Q去和缓存的K做注意力计算就行。因为自注意力中,历史token对其他token的“贡献”是通过K和V体现的——它们提供被查询的内容和值,这些内容和值不会因为新token的到来而改变。
有人可能问:为什么不缓存Q?因为Q是“查询方”,每来一个新token,新token自己的Q去查询历史KV;历史token的Q在它们被生成的时候已经用过了,不需要二次使用,所以缓存KV就够了。
具体算一下账:序列长度1000,embedding维度512,KV Cache要存储1000×512×2个浮点数,两个方向各一份。如果batch size是32,那就是32000×512×2个float,约1.3亿个数值,单精度下约524MB。这还是不计算多层的情况。所以大模型的推理显存占用非常惊人,这也是现在各种量化、分页KV Cache、投机采样技术层出不穷的根本原因。
这块我建议所有想深入Transformer的人在源码层面看一遍KV Cache的实现逻辑,能极大加深你对“训练”和“推理”两套流程差异的理解。
4.3 从BERT、GPT到Swin Transformer:架构变体到底改了些什么
理解了基础Transformer,再看各种变体其实就是带着“差在哪”这个问题去对比,轻松很多。
BERT:只用了Encoder部分,通过Masked Language Model预训练,适合做理解类任务。它的自我监督学习方式是随机遮住15%的token,然后让模型预测被遮住的内容。因为Encoder能看到所有位置,所以它是一个双向上下文模型。
GPT:只用了Decoder部分,通过自回归语言建模预训练,适合做生成类任务。它从头到尾只允许从左往右看,所以是单向模型。GPT大获成功之后,业界发现把Decoder部分搞大搞深,配合海量语料,涌现出极强的生成能力。后续的LLM清一色走的这个路线。
Swin Transformer:它的改动核心在视觉领域。原来Vision Transformer(ViT)把图像切成一个个patch,直接做全局Self-Attention。但图像的尺寸一般很大,比如256×256的图像切分成16×16的patch,也有256个patch,全局注意力计算量还能接受。可如果是高清大图,patch数量上千,全局注意力的平方复杂度根本扛不住。Swin的思路是在局部窗口内做Self-Attention,然后通过窗口移动,让不同窗口之间的信息能够跨窗口流动,既保留了注意力建模长距离依赖的能力,又把计算复杂度从平方级降到了线性级。这本质上是一种“局部优先,多轮交互”的思维,跟卷积神经网络里局部感受野的设计哲学如出一辙。
如果想快速掌握深度学习技术演进的脉络,最好的方式是拿着“它改变了什么”“为什么这样改”这些问题去阅读模型源码,而不是只看博客看二手概念。
5. 手推一遍注意力:用最小例子把公式走通
5.1 手工验证一个3×3的注意力计算
光看公式容易飘,我建议自己动手推一遍。用3个token,每个token的维度为2,来一步步算Self-Attention。
假设输入X是:
token1: [1, 0] token2: [0, 1] token3: [1, 1]为了简化,先让初始化后Q、K、V和X完全一致(也就是说,没有任何投影矩阵参与,等价于单位矩阵做投影)。这样我们先算Q和K的点积矩阵:
QK^T就是X和X的转置相乘,得到一个3×3的矩阵:
token1与token1: 1*1 + 0*0 = 1 token1与token2: 1*0 + 0*1 = 0 token1与token3: 1*1 + 0*1 = 1 token2与token1: 0*1 + 1*0 = 0 token2与token2: 0*0 + 1*1 = 1 token2与token3: 0*1 + 1*1 = 1 token3与token1: 1*1 + 1*0 = 1 token3与token2: 1*0 + 1*1 = 1 token3与token3: 1*1 + 1*1 = 2所以:
[[1, 0, 1], [0, 1, 1], [1, 1, 2]]这里维度d_k是2,所以除以√2,然后对每一行做Softmax:
第一行:[1/√2, 0, 1/√2],算指数后约[2.028, 1, 2.028],归一化得到约[0.401, 0.198, 0.401]。
第二行:[0, 1/√2, 1/√2],同理约[0.198, 0.401, 0.401]。
第三行:[1/√2, 1/√2, 2/√2],约[0.244, 0.244, 0.512]。
然后拿这个注意力权重矩阵去加权求和V(这里V也是X):
第一行的输出 = 0.401×[1,0] + 0.198×[0,1] + 0.401×[1,1] = [0.802, 0.599]。
这个结果很直观地表现了:token1的输出向量,融合了所有token的信息,且权重由相关性决定。token1和token3的相关性最高,所以token3的信息占了更大的比例。
5.2 为什么缩放因子√d_k是必须的——眼见为实
上面例子维度小,没体现出缩放的影响。我把维度放大点模拟一下:假设d_k=64,Q、K中的元素都是均值0方差1的分布。那么点积结果就是64个这样的随机数相加,方差=64,标准差=8。
这意味着什么?意味着随便两个token之间的点积分,大概率落在[-16, 16]这个区间内,而不是[-2, 2]。当分数达到16时,Softmax里e^16约等于8886110,这个数已经大到让其他分数全部“黯然失色”,Softmax的结果几乎成了one-hot。换句话说,注意力变成了“硬选择”,只挑分数最大的那个token,其他的一律忽略,这样模型就丢掉了“综合考虑多个信息源”的能力。
而除以√64=8之后,分数回到[-2,2]这个舒适区,Softmax的输出分布更平滑,注意力分配的区分度依然存在,但不会极端到完全忽略其他token。
这个细节对训练尤其是模型规模扩大时至关重要。大模型动辄数十层、数千维,如果不做缩放,早期迭代时注意力分布几乎必然坍缩到极端形态,梯度信号非常差。同时也可以算一笔账:如果想要去掉缩放,同时保持训练稳定,可以在初始化阶段对权重乘上特别小的系数,但这样会让训练早期信息流动太慢。实践检验下来,缩放因子这个方案最简洁稳定,这也是它能保留在论文里的原因。
5.3 实现陷阱与调试建议
自己实现Transformer时,有几个极易踩坑的地方,每个都是我用DEBUG时间换来的经验。
第一个坑:Softmax的数值稳定性。直接对原始分数做Softmax,如果某个分数特别大,e^x会溢出变成inf。正确做法是每行先减去最大值再做指数运算,这样既不影响Softmax的相对结果,又能保证数值稳定。PyTorch里softmax函数内部已经处理了,但如果你是从零手写,这一步必须自己实现。
第二个坑:Mask的位置。Decoder里的因果Mask是在Softmax之前做的。做法是把需要mask的位置的分数加上一个绝对值很大的负数,比如-1e9,然后再进Softmax。加负无穷不是不能用,但实际编程时,负无穷会导致Softmax计算时出现NaN的风险,尤其是后续算子对NaN特别敏感。用-1e9这类有限大负数,既能让Softmax输出趋近于0,又不会引发数值异常。
第三个坑:多头注意力的维度变形。很多人在PyTorch里把[batch, seq_len, d_model]的向量reshape成[batch, heads, seq_len, head_dim]时顺序搞错,导致信息混成一团。正确顺序是先把d_model拆成heads * head_dim两个维度,然后transpose到[batch, heads, seq_len, head_dim],确保每个头处理的是独立的子空间切片。如果顺序搞反,你所谓的多个头其实共享了同一个特征,多头就名存实亡了。
调试建议只有一个:拿小规模数据做数值对齐验证。随机初始化一个小模型,用同样的输入分别跑你的实现和一个成熟框架的实现,对比每一层的输出。第一层对了,再继续看第二层,逐层定位。这个方法虽然土,但是是排查注意力实现问题最高效的手段。我自己当年调Transformer代码用了整整两天,最后发现就是Mask维度broadcast时多了一个维度。
6. 从零到一的学习路线与常见误区
6.1 初学者最容易卡住的三个地方
这些卡点不是我编的,是这些年带人总结出来的共性规律。
第一,“注意力权重”和“输出向量”的关系容易混淆。很多人看注意力可视化图,一片五颜六色的连线,以为那就是模型学到的“知识”。实际上注意力权重只是中间产物,模型最终用的是加权求和后的向量。注意力权重只在解释性上有价值,但并不能简单等同于“模型学到了什么”。有些论文试图用注意力权重做推理依据,结果争议很大,就是这个原因。
第二,Encoder和Decoder各层尺寸傻傻分不清。Encoder里有Self-Attention,Decoder里也有Self-Attention,但Decoder的这个Self-Attention是带Mask的;Encoder和Decoder之间还有一个Cross-Attention,很多人看架构图的时候直接忽略了这一层。建议把每个子层的输入输出形状完整写一遍,画在纸上,会清晰很多。
第三,对“参数”和“激活”的概念混淆。Transformer里的参数是权重矩阵,它们是训练时更新的,模型推理时是固定的;激活是数据流过各层时的临时中间结果,每个token的激活都不一样。激活会占用大量显存,参数也占显存,但两者生命周期完全不同。做显存优化时,经常需要区分这两者,比如gradient checkpointing就是用计算换激活的存储,而量化是压缩参数的位宽。
6.2 从论文到源码:我推荐的复现路径
很多初学者一上来就直奔GPT-3这种超大模型,这是不现实的。我建议按这样的路径走,每一步都能巩固一个层面的理解:
第一步,用NumPy从零实现一个单层Self-Attention,只做前向传播,不做反向传播。这一步的目的是让你彻底理解QKV、Softmax、加权求和这几个操作的数学含义。代码量很小,但价值非常大。
第二步,用PyTorch实现完整的Encoder和Decoder,验证在简单的复制任务或者加法任务上能不能训练成功。这里你会意识到LayerNorm的位置、Dropout的开关、残差连接的写法都会影响训练结果。
第三步,拿一个开源预训练模型(比如BERT或GPT-2),用HuggingFace的库加载进来,直接看它的源码和中间层的输出。这一步是让你看清楚生产级实现和教学级实现的区别在哪。
第四步,尝试对一个已经训练好的模型做Fine-tuning,完成一个小任务,比如文本分类或摘要生成。你会发现,真正耗时耗力的往往不是模型结构本身,而是数据预处理、训练策略、评估指标这一套工程链路。
我踩过的最大教训是,复现Transformer时,不要一上手就追求“超过基线”。先跑通、再调快、最后调好,这个顺序不能乱。我当年为了追求效果,一上来就加了各种花哨的trick,结果代码越改越乱,最后只能推倒重来。反而是老老实实先把一个朴素的Transformer在简单任务上跑通,再逐个叠加优化手段,效率最高。