Skip to content

石破天惊 — Transformer架构的革命

2017年,Google的一篇论文《Attention Is All You Need》像一颗石子投入了NLP(Natural Language Processing,自然语言处理)的湖面。那篇论文提出了一个全新的架构——Transformer。在此之前的序列建模领域,RNN(Recurrent Neural Network,循环神经网络)和LSTM(Long Short-Term Memory,长短期记忆网络)是绝对的霸主。Transformer问世之后,整个领域被彻底改写。BERT、GPT、T5、Llama、Claude,你能叫出名字的所有大语言模型,底层架构全部源于Transformer。这不是一次简单的技术迭代,而是一次范式的颠覆。

我是怕浪猫,一个在LLM开发一线踩坑无数的工程师。上一章我们拆解了注意力机制的核心原理——QKV三要素、四步计算流程、缩放点积的工程细节。那些是零件,今天怕浪猫要带你进入总装车间。我们来看这些零件如何组装成一台完整的Transformer引擎。这一章的信息密度会很高,从整体架构到位置编码,从多头注意力到残差连接,从掩码机制到解码器的自回归生成,每一个模块都会拆开看清楚。读完这一章,你就具备了阅读和理解任何主流大模型架构图的能力。

"Transformer的伟大之处不在于它发明了什么新东西,而在于它把已有的组件重新组合成了一个足够简洁、足够强大、足够可扩展的架构。"

13.1 Transformer整体结构

编码器-解码器架构

Transformer沿用了序列到序列(Sequence-to-Sequence,Seq2Seq)模型的经典架构:编码器(Encoder)负责理解输入序列,解码器(Decoder)负责生成输出序列。这种"先理解再生成"的二阶段设计在机器翻译任务中非常自然——编码器把源语言句子压缩成一个语义表示,解码器再从这个表示生成目标语言。

原始Transformer的架构图你可能在无数博客里见过。怕浪猫不打算再画一遍那张经典图,而是用一个精简的结构示意来展示核心数据流:

Transformer 数据流总览

输入序列: [I, love, coding]
    |
    v
[Token Embedding + Positional Encoding]
    |
    v
========== 编码器 x N ==========
  |  多头自注意力 (Multi-Head Self-Attention)
  |  残差连接 + 层归一化
  |  前馈网络 (FFN, Feed-Forward Network)
  |  残差连接 + 层归一化
  v
编码器输出: 语义表示矩阵 H
    |
    +-------> 交叉注意力的 K, V
    |
输出序列(已生成): [我, 喜欢]
    |
    v
[Token Embedding + Positional Encoding]
    |
    v
========== 解码器 x N ==========
  |  掩码多头自注意力 (Masked Multi-Head Self-Attention)
  |  残差连接 + 层归一化
  |  多头交叉注意力 (Multi-Head Cross-Attention)
  |  残差连接 + 层归一化
  |  前馈网络 (FFN)
  |  残差连接 + 层归一化
  v
线性层 + Softmax -> 下一个词的概率分布

编码器由N个相同的层堆叠而成,原始论文中N等于6。每层包含两个子模块:多头自注意力和前馈网络。每个子模块外面都包裹着残差连接和层归一化。解码器也是N层堆叠,但每层有三个子模块:掩码多头自注意力、多头交叉注意力、前馈网络。多出来的那个交叉注意力是解码器从编码器输出中提取信息的通道。

这个架构设计有几个值得深思的特点。首先是它的对称性——编码器和解码器的结构高度相似,都由注意力层和前馈网络交替组成。这种对称性使得两者可以通过交叉注意力自然衔接,不需要额外的适配层。其次是它的可堆叠性——每一层的输入输出维度完全一致,都是形状为(batch, seq_len, d_model)的张量,因此可以像搭积木一样堆叠任意层数。最后是它的模块化——每个子模块(注意力、FFN、归一化)都是独立的,可以单独替换或修改而不影响其他部分。这种模块化设计使得Transformer具有极强的可扩展性和可改造性,后来的各种变体(如用RoPE替换位置编码、用RMSNorm替换LayerNorm)都是在保持整体架构不变的前提下替换局部组件。

用PyTorch代码来描述这个结构,核心骨架非常清晰:

python
class Transformer(nn.Module):
    def __init__(self, src_vocab, tgt_vocab, d_model=512,
                 n_heads=8, n_layers=6, d_ff=2048):
        super().__init__()
        self.encoder = Encoder(
            src_vocab, d_model, n_heads, n_layers, d_ff)
        self.decoder = Decoder(
            tgt_vocab, d_model, n_heads, n_layers, d_ff)
        self.out_proj = nn.Linear(d_model, tgt_vocab)

    def forward(self, src, tgt, src_mask, tgt_mask):
        enc_out = self.encoder(src, src_mask)
        dec_out = self.decoder(tgt, enc_out, src_mask, tgt_mask)
        return self.out_proj(dec_out)

看这个代码骨架,Transformer的本质就是编码器处理输入、解码器接收编码器输出和已生成的token、最后投影到词表空间。所有的复杂性都封装在Encoder和Decoder的内部模块里,后面几节怕浪猫会逐一拆开。

Transformer vs RNN的并行计算优势

为什么Transformer能彻底取代RNN?关键不在精度——在特定小数据集上LSTM的精度并不差。关键在于并行性和长距离依赖建模能力。

RNN的计算是串行的。要计算第t个位置的隐藏状态,必须先算完第t-1个位置。这意味着一个长度为n的序列,RNN需要O(n)个串行步骤才能完成前向传播。GPU最擅长的就是大规模并行计算,RNN的串行特性直接浪费了GPU的并行能力。在现代硬件上,这个限制是致命的——你买了8张A100,但RNN的串行计算让它们只能排队工作,大部分算力被闲置。

Transformer用自注意力替代了循环结构。自注意力机制中,序列中所有位置可以同时计算注意力权重,一次矩阵乘法搞定所有位置之间的关系。这意味着前向传播只需要O(1)个串行步骤(当然矩阵乘法本身有O(n^2)的计算量),GPU的并行能力被充分利用。

RNN vs Transformer 并行性对比

RNN (串行):
  h1 -> h2 -> h3 -> h4 -> ... -> hn
  |      |      |      |              |
  Step1  Step2  Step3  Step4         StepN
  (必须等前一步完成才能算下一步,N个串行步骤)

Transformer (并行):
  x1  x2  x3  x4  ...  xn
  |   |   |   |   |    |
  +---+---+---+---+----+
       一次矩阵乘法
       (所有位置同时计算,1个步骤)

除了并行性,长距离依赖也是一个核心优势。RNN中,位置1和位置100之间的信息传递需要经过99个时间步,梯度在传播过程中不断衰减(即便有LSTM的门控机制也只能缓解而非解决)。位置1的梯度传到位置100时可能已经小到几乎为零,这就是梯度消失问题的本质。而Transformer的自注意力机制中,任意两个位置之间的"距离"永远是1——它们直接通过注意力权重连接,不存在信息层层传递的问题。位置1的token可以直接"看到"位置100的token,计算它们的相互注意力只需要一次矩阵乘法。

金句:RNN像排队传话,信息从第一个人传到最后一个人,中间任何一环出错都会丢失信息。Transformer像所有人同时坐在会议室里,谁跟谁说话只需要一个眼神。

这个区别在实际训练中的影响是巨大的。训练一个RNN模型处理长度256的序列,单步前向传播需要256个串行操作。而Transformer只需要一次并行矩阵运算。在8张A100 GPU上训练一个大模型,RNN架构可能需要数周而Transformer只需要数天,这个效率差距是压倒性的。当模型规模扩大到数十亿参数、训练数据扩大到数万亿token时,RNN的串行瓶颈让它完全不可行,而Transformer的并行性使得大规模训练成为可能。

当然,Transformer的自注意力也有代价:计算复杂度是O(n^2 * d),序列长度翻倍,计算量和内存消耗变为四倍。这就是为什么早期Transformer处理长文本很困难,也是后来Flash Attention、Longformer、Linformer等优化的出发点。但相比于RNN的串行瓶颈,这个代价在绝大多数场景下是完全值得的。

还有一个不太常被提及但同样重要的优势:Transformer的架构天然适配现代深度学习框架的优化。矩阵乘法是深度学习中最核心的操作,所有GPU框架(CUDA、cuDNN、Triton)都对矩阵乘法做了极致优化。Transformer的计算几乎全部由矩阵乘法、元素级操作和归一化组成,完美匹配这些优化。而RNN中的逐时间步计算涉及大量的小规模矩阵运算和控制流,难以高效利用GPU。

13.2 词嵌入与位置编码

Token Embedding

在进入Transformer的架构之前,有一个前置步骤必须讲清楚:如何把文本变成模型能处理的向量。这就是Token Embedding(词嵌入)的工作。

Token Embedding的本质是一个查找表。你有一个大小为V的词表,每个token用一个整数ID表示。Embedding层是一个形状为(V, d_model)的矩阵,其中d_model是模型隐藏维度(原始论文中是512)。给定一个token ID,Embedding层做的就是取出矩阵中对应的那一行,得到一个d_model维的向量。这个向量是该token在模型语义空间中的表示,在训练过程中会不断更新以学习到更好的语义编码。

python
class TokenEmbedding(nn.Module):
    def __init__(self, vocab_size, d_model):
        super().__init__()
        self.embedding = nn.Embedding(vocab_size, d_model)
        self.d_model = d_model

    def forward(self, x):
        # x: (batch, seq_len) -> (batch, seq_len, d_model)
        return self.embedding(x) * math.sqrt(self.d_model)

这里有一个细节值得注意:代码中乘了一个math.sqrt(self.d_model)。这是原始论文中的做法,目的是让Embedding的数值尺度跟位置编码在同一量级上。不乘这个系数的话,Embedding的值通常比较小(初始化时方差约为1/d_model),而位置编码的值在[-1, 1]之间,两者相加时Embedding会被位置编码淹没。乘以sqrt(d_model)后,Embedding的数值被放大到与位置编码可比的尺度,保证了语义信息和位置信息都能被有效保留。

这个尺度缩放看似是个小技巧,但在实际训练中影响很大。怕浪猫曾经因为漏了这个系数导致模型训练完全不收敛——Embedding信号被位置编码掩盖,模型学到的全是位置信息而丢失了语义信息。调试这种问题非常困难,因为代码逻辑上没有错误,只是数值尺度不对。所以当你实现Transformer时,一定不要忘记这个sqrt(d_model)的缩放。

权重共享

原始Transformer论文中提到了一个有趣的技巧:编码器和解码器的Embedding矩阵以及最后的输出投影层共享同一组权重。这叫做权重共享(Weight Tying)。

为什么能共享?因为这三个操作本质上都在同一语义空间中工作。编码器Embedding把源语言token映射到d_model维空间,解码器Embedding把目标语言token映射到d_model维空间,输出投影层把d_model维向量映射回词表大小的概率分布。如果源语言和目标语言使用同一个词表(比如多语言模型),这三个矩阵的语义是对齐的。

权重共享的好处是大幅减少参数量。假设词表大小为50000,d_model为512,一个Embedding矩阵有50000 * 512 = 2560万参数。三个矩阵共享后省了5000多万参数。此外,权重共享还有正则化效果——共享权重等于强约束了不同模块之间的语义对齐,在实践中往往能带来轻微的精度提升。

但权重共享并不总是适用。如果源语言和目标语言的词表不同(比如英文和中文使用不同的BPE词表),编码器和解码器的Embedding就不能共享。此时可以只共享解码器Embedding和输出投影层的权重,这在几乎所有Decoder-Only模型中都是标准做法。

为什么需要位置编码

自注意力机制有一个致命的缺陷:它是位置无关的。

这句话的含义需要仔细理解。自注意力的计算过程中,模型给序列中每个位置分配注意力权重时,依据的是Query和Key的内容相似度,而不是位置关系。如果把"我爱你"的三个token按不同顺序输入,自注意力层会根据内容计算出不同的注意力权重,但它不会"知道"哪个token在前、哪个在后。位置信息在自注意力的计算中是完全缺失的。

更直白的验证是:如果你把输入序列的顺序完全打乱,自注意力层仍然会正常计算出一个结果。它不会报错,不会感知到顺序被改变了。这与RNN形成鲜明对比——RNN按时间步顺序处理,位置信息天然编码在计算顺序中,打乱顺序意味着改变隐藏状态的传播路径,结果会完全不同。

金句:没有位置编码的Transformer是一个"语序盲"模型——它知道句子中有哪些词,但不知道这些词的排列顺序。对一个语言模型来说,这比什么都致命。

"猫咬狗"和"狗咬猫"在位置无关的模型看来,只是同一组词的不同加权组合,语义差异无法被有效区分。为了让模型感知序列顺序,必须把位置信息显式地注入到输入中。这就是位置编码(Positional Encoding,PE)的使命。

正弦余弦位置编码

原始论文中使用的是固定位置编码,用正弦和余弦函数构造。公式如下:

PE(pos, 2i)   = sin(pos / 10000^(2i/d_model))
PE(pos, 2i+1) = cos(pos / 10000^(2i/d_model))

其中:
  pos: token在序列中的位置 (0, 1, 2, ...)
  i:   维度索引 (0, 1, ..., d_model/2 - 1)
  d_model: 模型隐藏维度

这个公式看起来有点抽象,怕浪猫来拆解它的设计直觉。每个位置生成一个d_model维的向量,向量的偶数维度用sin、奇数维度用cos。不同维度对应不同的频率——低维度对应高频(周期短),高维度对应低频(周期长)。最低频率的维度周期可以长达2pi10000,约62832个位置,这意味着即使序列长达数万个token,最低频维度仍然能区分不同位置。

为什么用sin和cos而不是简单的0, 1, 2, 3...编号?因为简单的整数编号有几个问题:一是数值无界,位置10000的编码值远大于位置1,训练时数值不稳定,梯度分布会严重偏向高位置。二是无法泛化到训练时未见过的更长序列——如果训练时最长序列是512,推理时来了一个1024的序列,位置编号513到1024是模型从未见过的。sin和cos函数的值域是[-1, 1],数值稳定。而且由于不同维度对应不同频率,模型可以通过不同频率的组合来推断相对位置。

一个关键性质:对于固定的偏移量k,PE(pos+k)可以表示为PE(pos)的线性变换。这意味着模型可以通过学习一个线性投影来捕捉相对位置关系。具体来说:

sin(pos + k) = sin(pos)*cos(k) + cos(pos)*sin(k)
cos(pos + k) = cos(pos)*cos(k) - sin(pos)*sin(k)

这个性质使得模型在处理"位置3的词"和"位置5的词"之间的关系时,可以通过PE(3)和PE(5)的线性变换来表达,而不需要显式地知道绝对位置。相对位置信息在很多语言现象中非常重要——比如"在...之前"和"在...之后"的关系,修饰语和中心词的距离关系等。

来看代码实现:

python
class PositionalEncoding(nn.Module):
    def __init__(self, d_model, max_len=5000):
        super().__init__()
        pe = torch.zeros(max_len, d_model)
        position = torch.arange(0, max_len).unsqueeze(1).float()
        div_term = torch.exp(
            torch.arange(0, d_model, 2).float() *
            -(math.log(10000.0) / d_model)
        )
        pe[:, 0::2] = torch.sin(position * div_term)
        pe[:, 1::2] = torch.cos(position * div_term)
        self.register_buffer('pe', pe.unsqueeze(0))

    def forward(self, x):
        return x + self.pe[:, :x.size(1)]

注意几个实现细节。第一,div_term用的是指数形式而不是直接幂运算,这是因为10000^(2i/d_model)在i较大时数值可能溢出,改用exp(log(10000) * 2i / d_model)形式更加数值稳定。第二,register_buffer把PE注册为不参与梯度更新的常量,但它会跟着模型一起移动到GPU。第三,forward中直接把PE加到Embedding上,这就是位置信息注入的方式——不是拼接,是相加。

位置编码与Embedding相加

位置编码和Token Embedding的融合方式是逐元素相加,而不是拼接。初学者经常问:相加不会让信息混淆吗?为什么不拼接?

拼接确实能更清晰地保留两路信息,但它会让维度翻倍,从d_model变成2*d_model,后续所有层的参数量都要增加。相加是在不增加维度的前提下融合信息,虽然两路信号叠加在一起,但模型通过训练能够学会从叠加信号中分离出内容和位置两路信息。

这个直觉有一个数学支撑。在高维空间中,两个随机向量的加和结果几乎是正交于原始向量的——这意味着高维空间有足够的"容量"让位置信息和内容信息共存于同一个向量中而不严重干扰。在d_model=512的维度下,这种干扰在实际训练中几乎不会造成问题。

可学习 vs 固定位置编码

位置编码有两种主流方案:固定位置编码(如上面的sin/cos)和可学习位置编码(Learnable Positional Encoding)。

固定位置编码的优点是不增加可训练参数,且有良好的数学性质(周期性、相对位置可表达性)。缺点是设计固定,模型无法通过训练调整位置编码的策略。

可学习位置编码的做法更简单粗暴:直接初始化一个形状为(max_len, d_model)的可训练矩阵,跟模型一起训练。每个位置对应一个可学习的向量,模型自己学出每个位置应该用什么编码。

python
class LearnablePositionalEncoding(nn.Module):
    def __init__(self, d_model, max_len=5000):
        super().__init__()
        self.pe = nn.Parameter(
            torch.randn(1, max_len, d_model) * 0.02
        )

    def forward(self, x):
        return x + self.pe[:, :x.size(1)]

可学习位置编码在BERT、GPT、Llama等模型中被广泛使用。实际效果上,两者在大多数任务中差异不大。可学习方案的缺点是max_len需要在训练前确定,推理时序列长度不能超过训练时的max_len。这也是为什么很多大模型有最大上下文长度的限制——位置编码的"长度预算"在训练时就固定了。后来ALiBi、RoPE(Rotary Position Embedding,旋转位置编码)等方案试图解决这个问题,但那超出了本章的范围。

金句:位置编码是Transformer架构中看似不起眼但至关重要的组件。没有它,Transformer就是一个语序盲的模型——能看见每个词,却看不见词的顺序。

13.3 多头注意力机制

多头概念:多组QKV并行

上一章我们讲的是单头注意力:一组Q、K、V,计算出一组注意力权重和一个输出向量。但人类理解语言时,关注的维度是多方面的——语法关系、语义相似度、指代消解、情感倾向,这些不同维度的信息需要不同的注意力模式来捕捉。单一的注意力头很难同时学习这么多不同类型的模式,因为它们会在同一组权重中互相干扰。

多头注意力(Multi-Head Attention,MHA)的核心思想就是:与其用一组高维QKV,不如拆成多组低维QKV并行计算,每个"头"专注捕捉一种模式。这就像团队协作中,与其让一个人同时处理语法分析、语义理解和情感识别,不如分配给不同的人各自专注一项任务,最后汇总结果。

具体来说,假设d_model=512,头数h=8,每个头的维度d_k = d_model / h = 64。每个头独立计算自己的注意力,最后把8个头的输出拼接起来,用一个线性映射投影回d_model维度。

多头注意力计算流程

输入: Q, K, V (batch, seq_len, d_model=512)
    |
    +-- 拆分为 h=8 个头
    |
    |  Head 0: Q0,K0,V0 (dim=64) -> Attention -> Out0
    |  Head 1: Q1,K1,V1 (dim=64) -> Attention -> Out1
    |  ...
    |  Head 7: Q7,K7,V7 (dim=64) -> Attention -> Out7
    |
    +-- 拼接: [Out0, Out1, ..., Out7] (dim=512)
    |
    +-- 线性投影 W^O -> 最终输出 (dim=512)

为什么要拆成低维?这涉及到表达力和效率的平衡。如果直接用512维的QKV做单头注意力,计算量是O(seq_len^2 * 512)。拆成8个64维的头后,每个头的计算量是O(seq_len^2 * 64),8个头加起来还是O(seq_len^2 * 512),总计算量不变。但拆分后每个头在64维子空间中独立计算注意力,不同头之间不会互相干扰,各自可以发展出不同的注意力模式。最后通过线性投影把多视角的信息融合在一起。这就是"拆分维度换取多样性"的思路。

头数选择head_num

头数的选择是一个需要权衡的工程决策。头数越多,每个头的维度越小,模型能同时关注更多不同的模式。但如果头数太多、每个头维度太小,单个头的表达能力会下降——64维足够学习有意义的注意力模式,但如果只有8维,注意力权重可能退化成噪声。

常见的配置有:BERT-base使用12个头(d_model=768,每头64维),GPT-2 small使用12个头(d_model=768,每头64维),Llama-2 7B使用32个头(d_model=4096,每头128维)。你会注意到一个规律:每头维度通常在64到128之间。这不是巧合,而是经验最优区间。低于64维时注意力模式的表达力不足,高于128维时并行度不够且多视角优势减弱。

在实际开发中,头数的选择通常由模型规模和硬件约束共同决定。一个经验法则是:d_model确定后,选择使d_k在64到128之间的头数。如果d_model=4096,选32头(d_k=128)或64头(d_k=64)都是合理的。

拆分维度与并行计算

多头注意力的实现有一个关键技巧:不是真的拆成h个独立计算,而是利用矩阵的reshape操作一次性并行计算所有头。如果真的写h个循环来分别计算每个头,计算效率会大幅下降,GPU的并行能力也无法被充分利用。

python
class MultiHeadAttention(nn.Module):
    def __init__(self, d_model, n_heads):
        super().__init__()
        self.d_k = d_model // n_heads
        self.n_heads = n_heads
        self.w_q = nn.Linear(d_model, d_model)
        self.w_k = nn.Linear(d_model, d_model)
        self.w_v = nn.Linear(d_model, d_model)
        self.w_o = nn.Linear(d_model, d_model)

    def forward(self, q, k, v, mask=None):
        bs = q.size(0)
        # 线性投影后 reshape 为多头
        Q = self.w_q(q).view(bs, -1, self.n_heads, self.d_k).transpose(1, 2)
        K = self.w_k(k).view(bs, -1, self.n_heads, self.d_k).transpose(1, 2)
        V = self.w_v(v).view(bs, -1, self.n_heads, self.d_k).transpose(1, 2)
        # 计算注意力
        scores = Q @ K.transpose(-2, -1) / math.sqrt(self.d_k)
        if mask is not None:
            scores = scores.masked_fill(mask == 0, float('-inf'))
        attn = F.softmax(scores, dim=-1)
        out = attn @ V  # (bs, n_heads, seq_len, d_k)
        # 拼接多头
        out = out.transpose(1, 2).contiguous().view(bs, -1, self.n_heads * self.d_k)
        return self.w_o(out)

这段代码的核心在reshape和transpose操作。view(bs, -1, n_heads, d_k)把d_model维度拆成n_heads * d_k,然后transpose(1, 2)把头维度提到前面,这样矩阵乘法就自动在所有头上并行执行。最后transpose(1,2).contiguous().view(bs, -1, d_model)把多头结果拼接回来。

这里有一个容易踩的坑:view操作要求张量在内存中是连续的,而transpose之后张量不再连续,所以必须加.contiguous()才能再调用view。忘记加contiguous是PyTorch中最常见的报错之一。解决方法是用.reshape()替代.view(),reshape会自动处理连续性问题,但可能涉及一次内存拷贝。对性能敏感的场景建议用contiguous()+view(),让内存布局更清晰。

不同子空间关注不同模式

多头注意力最迷人的地方在于:不同头确实学到了不同的注意力模式。研究人员可视化Transformer中的注意力头后发现,有些头专注于学习相邻词的关系,有些头关注句法依赖(比如主谓关系),有些头学会了指代消解(pronoun coreference,把代词指向它替代的名词),还有些头看起来在关注分隔符等结构标记。

这种"自动分工"不是人为设计的,而是训练过程中自然涌现的。多头的并行结构为这种分工提供了硬件条件——不同头之间在前向传播时没有信息交流,各自独立计算注意力,只有在最后的拼接投影时才融合。这种独立性使得不同头可以各自发展出不同的专长,而不会互相干扰。

金句:多头注意力就像一个团队里不同背景的专家——有人关注语法,有人关注语义,有人关注情感。每个人看到的面不同,汇总起来就是更全面的理解。

实践中有时候会发现某些头的注意力权重接近均匀分布,这些"冗余头"在推理时可以被剪枝(Pruning)掉而不影响性能。这也是后来Multi-Query Attention(MQA,多查询注意力)和Grouped-Query Attention(GQA,分组查询注意力)等变体的灵感来源——既然有些头是冗余的,不如让多个头共享同一组KV,减少内存占用。Llama-2就采用了GQA来降低推理时的KV Cache内存消耗。

13.4 残差网络与层归一化

残差连接Residual Connection

Transformer中每一个子模块(多头注意力、前馈网络)外面都包裹着一个残差连接。残差连接的公式极其简单:

output = x + Sublayer(x)

其中Sublayer是子模块的函数(比如多头注意力或FFN),x是子模块的输入。残差连接做的事就是:把子模块的输入直接加到子模块的输出上。

这个设计来自何恺明的ResNet(Residual Network,残差网络)。ResNet解决的核心问题是深层网络的梯度消失。当一个网络很深时,反向传播的梯度需要经过很多层非线性变换,每经过一层梯度可能缩小一点(尤其是使用Sigmoid或Tanh激活函数时),层数多了之后梯度趋近于零,前面的层根本无法更新参数,模型也就无法学习。

残差连接提供了一条"高速公路":梯度可以通过加法分支直接传回上一层,不需要经过子模块内部的非线性变换。数学上,如果y = x + F(x),那么dy/dx = 1 + F'(x),那个常数1保证了梯度至少为1,不会因为F'(x)太小而消失。这就像在每一层之间修了一条直达通道,梯度不需要经过复杂的中间路径就能直接传到前面的层。

在Transformer中,残差连接还有另一个重要作用:保留原始信息。多头注意力层在重新组合序列信息时,有可能会丢失某些位置的关键信息——注意力机制本质上是一种加权平均,而平均操作天然会平滑掉一些细节信息。残差连接把原始输入直接加到注意力输出上,确保原始信息有一条无损通道传递到后面的层。模型可以选择通过学习F(x)接近零来"跳过"某个子模块,也可以让F(x)较大来充分利用子模块的变换能力。这种"可跳过"的设计给了模型极大的灵活性。

层归一化Layer Normalization

层归一化(Layer Normalization,LayerNorm)是Transformer中另一个基础组件。它的作用是稳定训练过程,加速收敛。深层网络中不同层的激活值尺度可能差异很大,如果不做归一化,前面的层输出尺度波动会传导到后面的层,导致训练不稳定。

LayerNorm的做法是:对每个样本在特征维度上做归一化。具体来说,给定一个d_model维的向量x,计算它在d_model个维度上的均值和方差,然后用这两个统计量做归一化:

python
class LayerNorm(nn.Module):
    def __init__(self, d_model, eps=1e-6):
        super().__init__()
        self.gamma = nn.Parameter(torch.ones(d_model))
        self.beta = nn.Parameter(torch.zeros(d_model))
        self.eps = eps

    def forward(self, x):
        mean = x.mean(dim=-1, keepdim=True)
        var = x.var(dim=-1, keepdim=True, unbiased=False)
        x_norm = (x - mean) / torch.sqrt(var + self.eps)
        return self.gamma * x_norm + self.beta

gamma和beta是可学习的缩放和偏移参数,让模型自己决定归一化的程度。eps是为了防止除零错误,当方差接近零时(比如所有特征值几乎相同),除以一个极小的数会导致数值爆炸。

LayerNorm vs BatchNorm

做CV(Computer Vision,计算机视觉)出身的同学对Batch Normalization(批归一化,BatchNorm)一定很熟悉。为什么Transformer不用BatchNorm而用LayerNorm?

BatchNorm在一个批次(batch)内、对每个特征维度做归一化。它要求批次内有足够多的样本才能估计出可靠的统计量。但在NLP任务中,序列长度是变化的,一个batch中不同序列的有效长度不同,padding(填充)位置的值会严重干扰BatchNorm的统计量。此外,BatchNorm在推理时需要使用训练时积累的running mean和variance,这在小batch size或在线学习场景下不太方便。

LayerNorm没有这些问题。它在单个样本的特征维度上做归一化,不依赖batch中的其他样本,因此对batch size不敏感,对变长序列也友好。推理时也不需要额外的统计量,每个样本独立计算。

LayerNorm vs BatchNorm 对比

维度        BatchNorm              LayerNorm
---------------------------------------------------------
归一化方向   batch内, 每个特征维度    每个样本, 特征维度
依赖batch   是                      否
变长序列     不友好(padding干扰)     友好
推理统计量   需要running stats       不需要
典型应用     CV (图像分类)           NLP (Transformer)

用一个具体例子来帮助理解。假设batch中有4个句子,padding到相同长度20,但实际有效长度分别是5、8、12、15。BatchNorm在计算每个特征维度的均值时,会把4个句子中同一位置的值一起算,但位置16到20的值全是padding值0,这些0会严重拉低均值和方差。LayerNorm则是对每个句子的20个位置独立做归一化,padding位置的0只影响自己那个样本的统计量,不会干扰其他样本。

金句:残差连接保住了梯度的传播路径,层归一化稳定了每层的数值分布。这两个看似简单的组件,是深层Transformer能稳定训练的基石。

Post-Norm vs Pre-Norm

原始论文中残差连接和LayerNorm的组合方式是Post-Norm:先做子模块计算和残差加法,再做LayerNorm。公式是output = LayerNorm(x + Sublayer(x))

后来研究发现,Pre-Norm更稳定:先做LayerNorm,再做子模块计算和残差加法。公式是output = x + Sublayer(LayerNorm(x))

Post-Norm的问题在于残差路径上有一个LayerNorm,梯度传播会受到阻碍。Pre-Norm把LayerNorm移到子模块内部,残差路径是纯净的加法通道,梯度可以无损传播。对于深层模型(比如几十上百层的大模型),这个差异会显著放大——Post-Norm的深层模型容易出现训练不稳定甚至发散。

现代大模型几乎都采用Pre-Norm架构。GPT系列、Llama系列用的都是Pre-Norm的变体(如RMSNorm,Root Mean Square Normalization,均方根归一化,是LayerNorm的简化版本,去掉了均值减法和偏移参数)。

python
# Pre-Norm 结构示例
class EncoderLayer(nn.Module):
    def __init__(self, d_model, n_heads, d_ff):
        super().__init__()
        self.norm1 = nn.LayerNorm(d_model)
        self.attn = MultiHeadAttention(d_model, n_heads)
        self.norm2 = nn.LayerNorm(d_model)
        self.ffn = nn.Sequential(
            nn.Linear(d_model, d_ff),
            nn.GELU(),
            nn.Linear(d_ff, d_model)
        )

    def forward(self, x, mask=None):
        # Pre-Norm: norm -> sublayer -> residual
        x = x + self.attn(self.norm1(x), self.norm1(x),
                          self.norm1(x), mask)
        x = x + self.ffn(self.norm2(x))
        return x

注意看forward函数中的结构:每个子模块先对输入做LayerNorm,再进入子模块计算,最后用残差连接加回原始输入。这就是Pre-Norm的标准写法。跟Post-Norm相比,Pre-Norm的残差路径上没有LayerNorm的阻碍,梯度可以更顺畅地在层间传播。对于浅层模型(6层左右),两种方式的差异不大,但对于深层模型(24层以上),Pre-Norm的稳定性优势非常明显。

13.5 掩码注意力与解码器

Padding Mask忽略padding

在实际训练中,一个batch内的序列长度往往不一致。短的序列需要用padding token填充到相同长度才能组成batch矩阵。这些padding位置是无效的,它们不包含任何语义信息,不应该参与注意力计算。如果让padding位置参与注意力计算,模型会把"注意力"分配给这些无意义的padding token,导致真正的语义信息被稀释。

Padding Mask的作用就是让注意力机制忽略这些padding位置。实现方式很简单:在计算注意力权重之前,把padding位置对应的score设为负无穷,这样Softmax后这些位置的权重就趋近于零。

python
# 生成 padding mask
# src: (batch, seq_len), padding token id = 0
padding_mask = (src != 0).unsqueeze(1).unsqueeze(2)
# padding_mask: (batch, 1, 1, seq_len)
# 值为True的位置是有效token, False是padding

# 在注意力计算中应用
scores = scores.masked_fill(padding_mask == 0, float('-inf'))

mask的形状需要跟attention score矩阵广播对齐。对于多头注意力,score矩阵的形状是(batch, n_heads, seq_len_q, seq_len_k),padding mask的形状通过广播扩展到这个维度。关键细节是mask中值为0(False)的位置被填充为负无穷,值为1(True)的位置保持不变。这里用的是masked_fill而不是直接乘法,因为乘以0会让score等于0,而0经过Softmax后会有一个非零的权重,虽然小但不为零。用负无穷才能确保Softmax后的权重精确为零。

Causal Mask防止看到未来信息

解码器在生成序列时是自回归的——每次生成一个词,基于已生成的所有词来预测下一个词。这意味着在训练时,解码器不能"偷看"当前位置之后的token。如果模型在预测第3个词时能看到第5个词,那就是数据泄露,模型会学到一个在推理时无法使用的作弊策略。

Causal Mask(因果掩码)就是用来防止这种泄露的。它是一个下三角矩阵,上三角部分(未来位置)被mask掉:

python
def make_causal_mask(seq_len):
    # 下三角矩阵, 上三角为0
    mask = torch.tril(torch.ones(seq_len, seq_len))
    return mask.unsqueeze(0).unsqueeze(0)
    # (1, 1, seq_len, seq_len)

# 应用因果mask
causal_mask = make_causal_mask(seq_len)
scores = scores.masked_fill(causal_mask == 0, float('-inf'))

torch.tril生成下三角矩阵,对角线及以下为1(允许关注),以上为0(禁止关注)。这样在第t个位置的注意力计算中,只有位置0到t的Key可以参与,位置t+1及以后的Key被mask为负无穷,Softmax后权重为零。

掩码矩阵构造

在实际的解码器中,需要同时应用Causal Mask和Padding Mask。两个mask通过按位与操作合并。合并后的mask同时满足两个约束:因果约束(不能看未来)和有效约束(不能看padding)。

python
def combine_masks(causal_mask, padding_mask):
    # 两个mask都是1/0格式, 用逻辑与合并
    combined = causal_mask & padding_mask
    return combined
Causal Mask + Padding Mask 合并示意

Causal Mask (seq_len=5):
[1, 0, 0, 0, 0]
[1, 1, 0, 0, 0]
[1, 1, 1, 0, 0]
[1, 1, 1, 1, 0]
[1, 1, 1, 1, 1]

Padding Mask (假设位置4是padding):
[1, 1, 1, 1, 0]
[1, 1, 1, 1, 0]
[1, 1, 1, 1, 0]
[1, 1, 1, 1, 0]
[1, 1, 1, 1, 0]

合并后:
[1, 0, 0, 0, 0]
[1, 1, 0, 0, 0]
[1, 1, 1, 0, 0]
[1, 1, 1, 1, 0]
[1, 1, 1, 1, 0]

注意合并后的矩阵最后一列全为0(因为位置4是padding,所有位置都不能关注它),同时上三角也为0(因果约束)。这个合并后的mask确保了模型在训练时的信息流是正确的——每个位置只能关注它之前的有效(非padding)位置。

解码器结构

理解了mask之后,解码器的三个子模块就清晰了。每个解码器层包含三个子模块,每个都用Pre-Norm和残差连接包裹:

python
class DecoderLayer(nn.Module):
    def __init__(self, d_model, n_heads, d_ff):
        super().__init__()
        self.norm1 = nn.LayerNorm(d_model)
        self.self_attn = MultiHeadAttention(d_model, n_heads)
        self.norm2 = nn.LayerNorm(d_model)
        self.cross_attn = MultiHeadAttention(d_model, n_heads)
        self.norm3 = nn.LayerNorm(d_model)
        self.ffn = nn.Sequential(
            nn.Linear(d_model, d_ff),
            nn.GELU(),
            nn.Linear(d_ff, d_model)
        )

    def forward(self, x, enc_out, causal_mask, padding_mask):
        # 1. 掩码自注意力: 解码器内部, 带因果mask
        x = x + self.self_attn(
            self.norm1(x), self.norm1(x), self.norm1(x), causal_mask)
        # 2. 交叉注意力: Q来自解码器, KV来自编码器
        x = x + self.cross_attn(
            self.norm2(x), enc_out, enc_out, padding_mask)
        # 3. 前馈网络
        x = x + self.ffn(self.norm3(x))
        return x

第一个子模块是掩码多头自注意力(Masked Multi-Head Self-Attention)。Q、K、V都来自解码器输入,但使用Causal Mask确保不会看到未来信息。这是解码器"自回归"特性的保障——训练时虽然所有位置并行计算,但每个位置的注意力只能覆盖它之前的位置。

第二个子模块是多头交叉注意力(Multi-Head Cross-Attention)。Q来自解码器,K和V来自编码器的输出。这是解码器从编码器获取源序列信息的唯一通道。注意这里不需要Causal Mask(因为编码器的所有位置都可以被关注),但需要Padding Mask来忽略编码器输入中的padding位置。交叉注意力的存在使得解码器在生成每个词时都能"回看"源序列的全部信息,决定当前生成应该关注源序列的哪些部分。

第三个子模块是前馈网络(Feed-Forward Network,FFN)。跟编码器中的FFN一样,是两层线性变换中间夹一个非线性激活函数。FFN的作用是对注意力层聚合的信息做进一步的非线性变换,增加模型的表达能力。原始论文中FFN的中间维度d_ff是d_model的4倍(512 -> 2048),这个比例在后来的模型中也基本沿用。

自回归生成

解码器在推理时的生成过程是自回归的(Autoregressive)。"自回归"的意思是:每一步的输出依赖于之前所有步的输出,形成一个递归的生成链。这种特性使得解码器在推理时无法像编码器那样一次性并行处理所有位置,必须逐个token生成。

自回归生成流程

步骤1: 输入 [<BOS>]
      -> 解码器 -> 输出概率分布 -> 取最大概率词 "我"

步骤2: 输入 [<BOS>, 我]
      -> 解码器 -> 输出概率分布 -> 取最大概率词 "喜欢"

步骤3: 输入 [<BOS>, 我, 喜欢]
      -> 解码器 -> 输出概率分布 -> 取最大概率词 "编程"

步骤4: 输入 [<BOS>, 我, 喜欢, 编程]
      -> 解码器 -> 输出概率分布 -> 取最大概率词 <EOS>
      -> 生成结束

每一步都把之前生成的所有token作为输入,通过解码器计算下一个token的概率分布,然后选择概率最高的token(Greedy Decoding,贪心解码)或通过采样选择token。新生成的token被追加到输入序列中,进入下一步。这个过程一直重复,直到生成结束符EOS(End of Sequence,序列结束符)或达到最大长度限制。

训练时不需要这种逐步骤的递归。训练用的是Teacher Forcing策略:把完整的目标序列一次性输入解码器,配合Causal Mask确保每个位置只能看到它之前的位置。这样一步前向传播就能计算出所有位置的损失,训练效率远高于推理时的逐步骤生成。Teacher Forcing的名字来源于"老师强迫"——老师(训练数据)把正确答案直接喂给模型,模型不需要自己猜上一步的输出。

金句:训练时Transformer用Causal Mask把"未来"藏起来,一次前向传播完成所有位置的损失计算。推理时未来真的不存在——每一步只有过去,没有未来。这就是自回归的精髓。

这种训练和推理的不对称性是初学者经常困惑的点。训练时,位置3的预测可以同时跟位置5的预测并行计算(因为mask保证了位置3看不到位置5的信息)。但推理时,必须先算完位置1到4才能算位置5,因为位置5的输入依赖于位置4的输出。这种不对称性也导致推理的计算量随生成长度线性增长,而训练的计算量是固定的(一次性处理整个序列)。这也是为什么大模型推理时需要KV Cache等优化技术来加速——每一步生成时不必重新计算之前所有位置的KV,而是复用缓存中的结果,只计算新位置的KV。

Transformer核心组件速查清单

怕浪猫把这一章的所有核心组件整理成了一张速查表,建议截图保存。后续阅读Transformer相关论文或源码时,随时对照这张表:

Transformer 核心组件清单

1. Token Embedding
   - 查找表: (vocab_size, d_model)
   - 乘以sqrt(d_model)做尺度缩放
   - 可与输出投影层共享权重

2. Positional Encoding
   - 固定: sin/cos, 无参数, 可泛化长序列
   - 可学习: nn.Parameter, 需预设max_len
   - 与Embedding相加, 非拼接

3. Multi-Head Attention (MHA)
   - 拆分: d_model -> h * d_k (通常d_k=64~128)
   - 并行: reshape+transpose, 非循环
   - 拼接: transpose+contiguous+view
   - 常见头数: 8/12/32

4. 残差连接 (Residual Connection)
   - 公式: x + Sublayer(x)
   - 作用: 缓解梯度消失, 保留原始信息
   - Pre-Norm优于Post-Norm

5. Layer Normalization
   - 特征维度归一化, 不依赖batch
   - RMSNorm: LayerNorm简化版, 大模型常用
   - gamma,beta可学习

6. Mask机制
   - Padding Mask: 忽略padding位置
   - Causal Mask: 防止看到未来信息
   - 合并: 按位与(&)

7. 解码器三子模块
   - 掩码自注意力 (QKV同源, 带Causal Mask)
   - 交叉注意力 (Q来自Decoder, KV来自Encoder)
   - 前馈网络 (两层Linear+激活函数)

8. 自回归生成
   - 训练: Teacher Forcing + Causal Mask
   - 推理: 逐token生成, 逐步追加
   - 结束: EOS或最大长度

完整Transformer参数量估算

理解了架构之后,我们来算一算一个标准Transformer有多少参数。这不仅能帮助你理解模型规模,在面试中也经常被问到。参数量估算是LLM工程师的基本功——拿到一个模型架构,你应该能快速估算出它的参数量。

以原始论文配置为例:d_model=512,n_heads=8,n_layers=6(编码器6层+解码器6层),d_ff=2048,vocab_size=37000(英语BPE词表)。

参数量估算 (原始Transformer)

模块                    参数量
------------------------------------------------------
Token Embedding         37000 * 512 = 18.9M (共享)
位置编码                 0 (固定sin/cos)

编码器 x6:
  QKV投影               3 * 512 * 512 = 0.79M
  输出投影               512 * 512 = 0.26M
  FFN                   2 * 512 * 2048 = 2.10M
  LayerNorm x2          2 * 2 * 512 = 2K
  小计每层               ~3.15M
  6层合计                ~18.9M

解码器 x6:
  掩码自注意力QKV+输出   4 * 512 * 512 = 1.05M
  交叉注意力QKV+输出     4 * 512 * 512 = 1.05M
  FFN                   2 * 512 * 2048 = 2.10M
  LayerNorm x3          3 * 2 * 512 = 3K
  小计每层               ~4.20M
  6层合计                ~25.2M

输出投影 (共享Embedding) 0

总参数量                 ~62M

原始Transformer只有约6200万参数,比现在动辄百亿千亿的大模型小了几个数量级。但架构的精巧之处在于:它是可扩展的。把d_model从512增到4096,n_layers从6增到80,参数量就可以轻松突破百亿。Transformer架构的核心优势之一就是——结构不变,只改超参数就能放大模型,而且放大后性能会持续提升(这就是Scaling Law的基础)。

参数量估算有一个快速心算法则:每个注意力层约4 * d_model^2个参数(QKV投影加输出投影),每个FFN层约8 * d_model^2个参数(两倍放大再缩回,2 * d_model * d_ff,d_ff通常等于4 * d_model)。所以一个Transformer层大约12 * d_model^2个参数,总参数量约为n_layers * 12 * d_model^2加上Embedding的参数。用这个法则你可以快速估算任何模型的参数量。

前馈网络FFN的细节

在进入踩坑环节之前,怕浪猫要补充一个前面多次提到但还没详细讲的组件:前馈网络(Feed-Forward Network,FFN)。FFN是Transformer每一层中跟注意力层并列的子模块,虽然结构简单,但它在模型参数量中占了很大比例。

FFN的结构是两层线性变换中间夹一个非线性激活函数。原始论文用的是ReLU(Rectified Linear Unit,修正线性单元)激活函数,后来的模型普遍改用GELU(Gaussian Error Linear Unit,高斯误差线性单元)或SwiGLU等更平滑的激活函数。FFN的中间维度d_ff通常是d_model的4倍,这意味着FFN先把手头维度从512放大到2048,再缩回512。这个

这个先放大再缩小的设计看似浪费,实则是为了增加非线性表达能力。注意力层本质上是线性加权求和(虽然Softmax是非线性的,但加权求和本身是线性的),模型需要FFN来引入足够的非线性变换能力。没有FFN的Transformer深度上堆叠多层也难以学习复杂的函数映射。实验表明,去掉FFN后模型在大多数任务上的表现会显著下降。

踩坑实战经验

怕浪猫在实现Transformer的过程中踩过不少坑,挑几个最典型的分享出来。这些坑有些会导致训练直接崩溃、损失函数完全不下降,有些会导致训练虽然收敛但效果远低于预期、模型性能大打折扣,非常隐蔽难以排查。

坑1:Causal Mask方向搞反了。 上三角还是下三角,这个看似简单的细节出错的频率高得离谱。torch.tril是下三角为1,意味着当前位置可以关注过去的位置。如果你不小心用了torch.triu,模型就变成了"只能看未来不能看过去",训练loss会完全不收敛或者收敛到无意义的结果。调试技巧:打印出mask矩阵,确认对角线及以下为1,以上为0。更保险的做法是在代码中加注释说明mask的含义。

坑2:忘记contiguous。 多头注意力的reshape操作中,transpose之后张量不再连续,直接view会报错。解决方法是加.contiguous()或者改用.reshape()(reshape会自动处理连续性问题,但可能涉及内存拷贝)。这个错误PyTorch会直接报RuntimeError,比较容易发现。但如果你用了reshape以为没问题,实际上每次都在做内存拷贝,性能会悄悄下降。

坑3:Padding位置参与了LayerNorm统计。 有些实现中,padding位置的值是0,LayerNorm在计算均值和方差时把这些0也算进去了,导致归一化结果被污染。正确的做法是在LayerNorm之前用mask把padding位置的影响消除掉,或者使用不依赖padding位置的归一化方案。这个坑不会导致报错,但会显著影响模型效果,非常隐蔽。

坑4:训练时用了推理模式生成。 训练时应该用Teacher Forcing,一次性把完整目标序列喂给解码器配合Causal Mask。有些初学者在训练时也用逐步生成的方式,导致训练速度慢了n倍,而且梯度传播路径不对——自回归生成中的argmax操作不可微,梯度无法回传。

坑5:交叉注意力的K和V搞混了来源。 交叉注意力中,Q来自解码器,K和V来自编码器。如果你不小心把K也设成来自解码器,模型就退化成了自注意力,丢失了从源序列获取信息的能力。代码检查时确认cross_attn(x, enc_out, enc_out)中第二个和第三个参数都是enc_out

坑6:Embedding忘记乘sqrt(d_model)。 前面讲过,这个缩放是为了让Embedding和位置编码在同一量级。漏了这个系数,位置编码会淹没Embedding信号,模型训练不收敛或效果很差。这个坑的特点是代码逻辑没有错误,只是数值尺度不对,调试非常困难。

金句:Transformer的实现细节多如牛毛,但每一个"坑"背后都有一个清晰的原理。理解了原理,踩坑次数自然就少了。

架构演进与变体

原始Transformer自2017年问世以来,架构本身也在不断演进。怕浪猫简要梳理几个重要变体,为你后续阅读论文和源码提供路线图。

Encoder-Only架构(BERT系列): 只保留编码器,去掉解码器。BERT(Bidirectional Encoder Representations from Transformers)是典型代表。由于没有解码器,BERT不能做自回归生成,但它在理解类任务上表现出色——文本分类、命名实体识别、问答等。BERT的注意力是双向的,每个位置可以同时关注左右两侧的所有token,这得益于编码器中没有Causal Mask的限制。双向注意力使得BERT能获得比单向模型更丰富的上下文表示,因为它在编码每个位置时同时参考了前文和后文的信息,语义表示更加完整。这也是为什么BERT在阅读理解任务上的表现一直优于纯Decoder模型。

Decoder-Only架构(GPT系列): 只保留解码器,去掉编码器和交叉注意力。GPT(Generative Pre-trained Transformer)是典型代表。由于只有掩码自注意力,每个位置只能关注左侧(过去)的token,天然适合自回归生成。GPT系列、Llama系列、Claude系列都是Decoder-Only架构。现代大模型中Decoder-Only是绝对主流,原因有两点:一是生成任务对模型能力的要求更高,Decoder-Only天然适配生成;二是Decoder-Only架构更简单,训练和推理的工程实现更成熟。

Encoder-Decoder架构(T5、BART): 保留完整的编码器-解码器结构。T5(Text-to-Text Transfer Transformer)把所有NLP任务统一为"文本到文本"的生成任务,用Encoder-Decoder架构处理。这类架构在翻译、摘要等需要深度理解源文本再生成的任务上仍有优势。编码器提供深度理解能力,解码器提供生成能力,交叉注意力把两者连接起来。

架构选择本质上是任务驱动的。理解类任务用Encoder-Only,生成类任务用Decoder-Only,需要深度理解源文本的生成任务用Encoder-Decoder。没有绝对的最优架构,只有最适合任务特性的架构。不过从当前大模型的发展趋势来看,Decoder-Only架构凭借其简洁性和 Scaling Law 下的优异表现,已经成为绝对主流。即便是翻译这种传统上由Encoder-Decoder主导的任务,Decoder-Only模型在规模足够大后也能达到甚至超越Encoder-Decoder的效果。

写在最后

这一章怕浪猫带你完整拆解了Transformer架构。从编码器-解码器的整体结构到RNN的并行性对比,从Token Embedding的尺度缩放到位置编码的sin/cos公式,从多头注意力的并行拆分到不同子空间的自动分工,从残差连接和LayerNorm的稳定训练到Pre-Norm的改进,从Padding Mask和Causal Mask的构造到解码器三子模块的协作,最后到自回归生成的训练推理不对称性。这些知识点构成了一个完整的架构理解链——每个组件都有它存在的理由,每个设计决策背后都有明确的工程或数学动机。当你理解了这些设计决策的来龙去脉,再去看任何大模型的架构图,不管是Llama、Claude还是其他什么模型,你都能快速抓住核心,理解它们在原始Transformer基础上做了哪些改动、为什么做这些改动。

Transformer的问世是深度学习历史上的一个重要转折点,它的影响远远超出了自然语言处理领域,在计算机视觉、语音识别、蛋白质结构预测等领域都有广泛应用。它之所以被称为石破天惊的架构,不是因为它发明了全新的理论,而是因为它用一种极其优雅的方式把已有组件重新组合——注意力机制提供信息聚合能力,位置编码提供顺序感知,多头机制提供多视角表达,残差连接和LayerNorm保证深层可训练,掩码机制保证自回归正确性。每个组件单独看都不复杂,但组合在一起就产生了质变。

金句:Transformer的优雅在于,它用最简单的组件(矩阵乘法、加法、归一化)搭建了最强大的序列建模系统。复杂不是目的,简洁而强大才是。

收藏引导:这一章信息密度极高,从整体架构到每个子模块的代码实现,从参数量估算到踩坑经验,建议先收藏。后续阅读任何Transformer相关论文或源码时,随时翻出来对照。

互动引导:你在实现Transformer时踩过最离谱的坑是什么?是mask方向搞反还是contiguous忘记加?评论区分享你的故事,怕浪猫会挨个回复。

追更引导:架构讲完了,下一章怕浪猫要带你动手实现一个迷你版大语言模型。从零开始写代码,把这一章的所有组件组装成一个可以训练、可以推理的完整模型。理论到实践的跨越,就在下一章。点个关注,别掉队。

系列进度 13/19

怕浪猫说:理解Transformer架构,就像拿到了大模型世界的地图。编码器、解码器、注意力、位置编码、掩码机制——每一个模块都是地图上的一个地标。下一章,我们用这张地图导航,从零搭建一个能真正跑起来的迷你大语言模型。

热爱生活,喜好美食,追求未来!