Transformer核心原理大揭秘:从零读懂Self-Attention,这一篇就够了

0 阅读16分钟

你每天都在用的ChatGPT、文心一言、通义千问,背后最核心的技术都叫“Transformer”。别被这个英文单词吓到,今天我用最直白的大白话,把它的每一个零件都拆开给你看。全文配有图解和可运行的代码示例,注释写到你能背下来。下一章咱们直接上手写代码做项目,这一章先把“心法”练好。


一、RNN这个老同志,到底哪里不行了?

在Transformer横空出世之前,处理句子、翻译、写诗这些活儿,基本都靠RNN(循环神经网络) 家族。RNN的思路很朴素:读句子就像我们看小说,一个字一个字按顺序读,每读一个字就更新一下自己的“记忆”,然后把记忆传给下一个字。

比如处理“我爱中国”:

  • 读到“我”,记忆里记下“我”

  • 读到“爱”,结合“我”的记忆,知道是“我爱”

  • 读到“中”,结合前面记忆,知道是“我爱中”

  • 读到“国”,最终知道整个句子是“我爱你中国”

听起来很合理对吧?但它有两个致命伤:

第一:不能并行。 要读第100个字,必须先把前面99个字读完。这就好比让你同时读10本书,你只能一本一本地读,没法同时进行。GPU再厉害也只能干瞪眼,训练速度上不去。

第二:记不住太远的内容。 RNN的记忆会随着距离慢慢“遗忘”。比如一个长句子:“小明昨天去了超市,他买了一瓶牛奶,然后他遇到了小红,他俩一起喝了咖啡,最后……他付了钱。”这里的“他”指的是小明,但RNN读到后面时,前面的“小明”信息已经衰减得差不多了,很容易搞错指代对象。这就是所谓的长距离依赖问题

后来大家发明了注意力机制(Attention):解码的时候,可以回头去“看”编码器里所有位置的信息,哪个位置重要就多看两眼。这招很管用,但RNN这个老骨架还在,效率问题没根除。

直到2017年,Google的几位大神发了一篇论文,标题就叫 《Attention Is All You Need》——我们只需要注意力。他们把RNN整个踢出局,设计了一个纯靠注意力机制的新模型:Transformer。从此,NLP的黄金时代开始了。


二、Transformer长什么样?5张图看懂全局

Transformer仍然采用编码器-解码器这套组合拳,但里面的零件全换了。

简单说:

  • 编码器(Encoder)

    :负责读懂输入的句子,比如“我 爱 你”,把它转化成一组富含上下文信息的向量。

  • 解码器(Decoder)

    :负责根据编码器的理解,一个字一个字地生成目标句子,比如“I love you”。

编码器和解码器都不是单层结构,而是由多个相同的层堆叠起来。原论文用了6层编码器 + 6层解码器。堆得越深,模型提取特征的能力就越强。

每个编码器层内部包含两个子层:

  1. 自注意力层(Self-Attention)

    :让每个词去看句子中所有词(包括自己),确定该重点关注谁。

  2. 前馈神经网络层(Feed-Forward)

    :对每个词的表示再做一次加工,提升表达能力。

每个解码器层内部包含三个子层:

  1. 带掩码的自注意力层(Masked Self-Attention)

    :生成当前词时,只能看它左边的词,不能偷看后面的词。

  2. 编码器-解码器注意力层(Encoder-Decoder Attention)

    :让解码器的当前词去“问”编码器的输出,找到源语言中最相关的信息。

  3. 前馈神经网络层

    :同上。

每个子层后面还都跟着残差连接层归一化,这两兄弟是训练深层网络的定海神针,后面会细说。

下面我们一头扎进编码器,把自注意力这个灵魂组件彻底搞懂。


三、自注意力(Self-Attention):每个词都要“眼观六路”

3.1 为什么要自注意力?

假如有一句话:“那只动物没有过马路,因为它太累了。” 你要理解“它”指代什么,光看这个词本身是不够的,你需要把它和前面的“那只动物”联系起来。自注意力要做的,就是给句子里的每个词生成一个融合了全局信息的新表示。

在自注意力出现之前,RNN是通过隐藏状态逐层传递来“融合”上下文,慢且容易丢。自注意力一步到位:每个词直接跟所有词打交道,谁的关联强就多吸收谁的信息

3.2 第一步:生成Query、Key、Value

这是自注意力最经典的一步。对于输入序列中的每一个词(比如它的初始向量是 xi𝑥𝑖),我们通过三个不同的矩阵把它映射成三个新向量:

  • Query(查询)

    :相当于这个词发出去的“问题”,它想知道句子中哪些词和自己相关。

  • Key(键)

    :相当于这个词贴在外面的“标签”,用来回答别人的Query。

  • Value(值)

    :这个词真正要传递的“内容信息”。

数学上就是:

# 假设输入矩阵 X 形状为 (seq_len, d_model)
# 三个参数矩阵形状均为 (d_model, d_k) 或 (d_model, d_v)
Q = X @ W_Q   # (seq_len, d_k)
K = X @ W_K   # (seq_len, d_k)
V = X @ W_V   # (seq_len, d_v)

其中 d_k 和 d_v 是缩放后的维度,原论文取 d_k = d_v = d_model / 8 = 64(当 d_model=512 时)。

3.3 第二步:计算注意力分数

现在每个词都有了它的 Query 和 Key。接下来,我们要计算每对词之间的“相关程度”。具体做法是:用第 i𝑖 个词的 Query 去和 所有词(包括自己)的 Key 做点积。点积越大,说明这两个词越相关。

但是当向量维度 d_k 比较大时,点积的值可能会变得很大,导致后面的 softmax 进入梯度极小的饱和区。所以需要除以 dk 来缩放:

score(i,j)=qi⋅kjdkscore(i,j)=dkqi⋅kj

对整个序列而言,我们可以把所有 Query 堆成矩阵 Q𝑄,所有 Key 堆成矩阵 K𝐾,一次性算出所有分数矩阵:

S=QKTdkS=dkQKT

S𝑆 的形状是 (seq_len, seq_len),Sij𝑆𝑖𝑗 表示第 i𝑖 个词对第 j𝑗 个词的原始注意力分数。

3.4 第三步:Softmax 归一化成权重

拿到原始分数后,需要对每一行(也就是每个词对所有词的分数)做 Softmax,让它们变成概率分布(每一行加起来等于1):

AttentionWeights=softmax(S)(对每一行独立做)AttentionWeights=softmax(S)(对每一行独立做)

这样,每个词对其他所有词的关注程度就变成了0到1之间的权重。

3.5 第四步:加权求和得到最终输出

最后,模型会根据注意力权重对所有位置的 Value 向量进行加权求和,得到每个位置融合全局信息后的新表示。

对于整个序列,同样可以通过矩阵运算一次性计算所有位置的输出,如下图所示

综上所述,可得整个自注意力机制的完整的计算公式如下


四、多头注意力:一个脑子不够,多来几个

自注意力看起来很完美,但它有一个潜在问题:每个词只做一次匹配。可自然语言中的关系往往是多层次的。

再看那个句子:“那只动物没有过马路,因为它太累了。” 这里面至少有三层关系:

  • 代词“它”和名词“动物”的指代关系;

  • “因为”连接的前后因果逻辑;

  • “过马路”这个动宾短语内部的搭配关系。

如果只用一套 WQ,WK,WV𝑊𝑄,𝑊𝐾,𝑊𝑉,模型很难同时兼顾这么多不同性质的关系。怎么办呢?

多头注意力(Multi-Head Attention) 的思路非常直接:我不只用一组参数,而是用 hℎ 组(原论文 h=8ℎ=8)。每组独立计算自注意力,得到 hℎ 个不同的输出矩阵。每个“头”可以专注于不同的关系类型。最后把这 hℎ 个输出拼起来,再经过一个线性变换,就得到了最终的多头注意力输出。

合并多头注意力

多个输出矩阵按维度拼接,再乘以得到最终多头注意力的输出。

多头注意力是Transformer能够深刻理解语言的关键之一。你可以把它想象成一个团队:每个人(头)用不同的视角观察同一个句子,最后把大家的观察结果汇总起来,就得到了更全面的理解。


五、前馈网络:给每个词“再加工”

自注意力(或多头注意力)的输出,已经让每个词融合了全局信息。但这还不够——注意力机制本质上是线性变换(加权求和)。为了增强模型的非线性表达能力,每个编码器层和解码器层都接了一个前馈神经网络(FFN)

这个FFN非常简洁:两个全连接层,中间夹一个ReLU激活函数。其计算公式如下:

写成代码:

class FeedForward(nn.Module):
    def __init__(self, d_model, d_ff=2048):
        super().__init__()
        self.linear1 = nn.Linear(d_model, d_ff)   # 先升维
        self.linear2 = nn.Linear(d_ff, d_model)   # 再降回d_model
 
    def forward(self, x):
        # x shape: (batch, seq_len, d_model)
        return self.linear2(F.relu(self.linear1(x)))

注意:这个FFN是对每个位置独立作用的,即同一个FFN参数会应用到序列中的每一个词向量上,但不同词之间不互相影响。这就像给每个词单独过一个小型神经网络。


六、残差连接和层归一化:让深层网络“活”起来

你可能会问:上面不是说每个子层后面都跟着“残差连接”和“层归一化”吗?它们有什么用?

6.1 残差连接(Residual Connection)

深层神经网络很容易出现梯度消失——反向传播时,越往底层梯度越小,最后底层参数几乎不更新,网络就学不动了。

残差连接的解法简单粗暴:把子层的输入直接加到输出上。这样就有了一个“捷径”,梯度可以绕过子层直接传回去。

将子层的输入直接与其输出相加,形成一条跨越子层的“捷径”,其数学形式为:

具体计算过程如图所示:

残差连接确保反向传播时,梯度至少有一条稳定通路可回传,是深层网络可稳定训练的关键结构。

6.2 层归一化(Layer Normalization)

在残差连接之后,还要做一次层归一化。它的作用是:让每个样本的每一个特征维度的数值分布稳定下来(均值为0,方差为1),避免训练过程中数值过大或过小,从而加速收敛。

具体步骤:

  1. 对于某个样本的某个词向量 x∈Rd,计算均值 μ=1d∑i=1dxi

  2. 计算方差 σ2=1d∑i=1d(xi−μ)2

  3. 归一化:x^i=xi−μσ2+ϵ

  4. 再学习两个参数 γ 和 β:yi=γx^i+β

注意:层归一化和**批归一化(BatchNorm)**不同。LayerNorm是对每个样本的每一层做归一化,不依赖batch大小,非常适合处理变长序列。

在Transformer中,每个子层(自注意力或FFN)之后的操作顺序是:残差连接 → 层归一化。也有论文采用先层归一化再进子层的Pre-LN结构,但原Post-LN更为经典。

class EncoderLayer(nn.Module):
    def __init__(self, d_model, num_heads, d_ff, dropout=0.1):
        super().__init__()
        self.self_attn = MultiHeadAttention(d_model, num_heads)
        self.feed_forward = FeedForward(d_model, d_ff)
        self.norm1 = nn.LayerNorm(d_model)
        self.norm2 = nn.LayerNorm(d_model)
        self.dropout = nn.Dropout(dropout)
 
    def forward(self, x, mask=None):
        # 自注意力子层(带残差和层归一化)
        attn_output = self.self_attn(x, x, x, mask)
        x = self.norm1(x + self.dropout(attn_output))   # 残差 + dropout + 层归一化
 
        # 前馈子层
        ff_output = self.feed_forward(x)
        x = self.norm2(x + self.dropout(ff_output))
        return x

七、位置编码:没有顺序感怎么办?

RNN天生有顺序:第一个词先进入,第二个词后进入。但Transformer的核心是自注意力,它对输入序列的所有位置是一视同仁的——换句话说,如果把“我爱你”换成“你爱我”,在没有任何位置信息的情况下,Transformer看到的是完全一样的词袋,分不清谁在前谁在后。

这显然不行。我们需要给模型注入位置信息

位置编码(Positional Encoding)就是干这个的:为每个位置 pos𝑝𝑜𝑠 生成一个向量,然后加到对应的词向量上。

最简单的想法是用绝对位置:第0个词加0,第1个词加1,第2个词加2……依此类推:

这样做虽然简单,但有一个明显的问题,越靠后的 token 位置编码就越大,若直接与词向量相加,会造成数值倾斜,让模型更关注位置,而忽视词义。

另一种想法是归一化到0~1之间:用 pos/T𝑝𝑜𝑠/𝑇,T𝑇 是句子长度。但问题来了:同一个位置(比如第5个词)在长度为10的句子中编码是0.5,在长度为100的句子中编码是0.05,不一致。

Transformer采用了一种非常巧妙的正余弦位置编码

对于位置 pos𝑝𝑜𝑠 和维度 i𝑖:

  • 如果 i 是偶数:PE(pos,2i)=sin⁡(pos100002i/dmodel)

  • 如果 i 是奇数:PE(pos,2i+1)=cos⁡(pos100002i/dmodel)

这种编码有几个好处:

  • 值始终在 [−1,1] 之间,不会破坏词向量

  • 对于固定的偏移量 k,PEpos+k 可以表示为 PEpos 的线性函数,便于模型学习相对位置关系

  • 不依赖训练,可以提前计算好

def get_positional_encoding(seq_len, d_model):
    """返回形状 (seq_len, d_model) 的位置编码矩阵"""
    pe = torch.zeros(seq_len, d_model)
    position = torch.arange(0, seq_len, dtype=torch.float).unsqueeze(1)  # (seq_len, 1)
    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)   # 奇数维
    return pe

实际使用时,位置编码会加到输入嵌入上:

embedding = nn.Embedding(vocab_size, d_model)
x = embedding(input_ids)   # (batch, seq_len, d_model)
x = x + positional_encoding[:seq_len, :].to(x.device)

八、解码器的特殊之处:Mask和Cross Attention

解码器和编码器结构相似,但多了两个关键点:Masked自注意力 和 编码器-解码器注意力

8.1 Masked自注意力:防止“偷看未来”

解码器生成句子时是自回归的:先输出第一个词,然后用第一个词输出第二个词,依此类推。在训练时,我们为了效率会把整个目标句子一次性喂给解码器,希望模型并行地预测每个位置的词。但如果不加限制,模型预测第2个词时就能看到第3、第4个词(未来的词),这就会导致信息泄露。

解决办法:在自注意力计算分数矩阵时,把当前位置对未来位置的分数设为 −∞−∞(或者一个非常大的负数)。

这样softmax之后,未来位置的权重就会变成0,模型就看不到未来了。

这个掩码只在解码器的第一个自注意力子层使用,而且训练和推理时都要用(推理时虽然每次只有一个新词,但掩码逻辑一样)。

8.2 编码器-解码器注意力:让解码器“回看”源句子

这个子层的作用就是经典的注意力机制:解码器当前的Query,去和编码器输出的Key、Value做注意力。这样,解码器在生成每个词时,都能动态地从源句子中提取最相关的信息。

  • Query

    :来自解码器前一子层的输出(已经包含了目标侧前文信息)

  • Key / Value

    :来自编码器的最终输出(整个源句子的表示)

class DecoderLayer(nn.Module):
    def __init__(self, d_model, num_heads, d_ff, dropout=0.1):
        super().__init__()
        self.masked_attn = MultiHeadAttention(d_model, num_heads)
        self.cross_attn = MultiHeadAttention(d_model, num_heads)
        self.feed_forward = FeedForward(d_model, d_ff)
        self.norm1 = nn.LayerNorm(d_model)
        self.norm2 = nn.LayerNorm(d_model)
        self.norm3 = nn.LayerNorm(d_model)
        self.dropout = nn.Dropout(dropout)
 
    def forward(self, x, encoder_output, src_mask=None, tgt_mask=None):
        # 1. Masked自注意力(只看前文)
        attn_output = self.masked_attn(x, x, x, tgt_mask)
        x = self.norm1(x + self.dropout(attn_output))
 
        # 2. 编码器-解码器注意力(Q来自解码器,K,V来自编码器)
        attn_output = self.cross_attn(x, encoder_output, encoder_output, src_mask)
        x = self.norm2(x + self.dropout(attn_output))
 
        # 3. 前馈网络
        ff_output = self.feed_forward(x)
        x = self.norm3(x + self.dropout(ff_output))
        return x

其中 src_mask 可以用来屏蔽源句子中的填充位置(比如 ),tgt_mask 是未来词掩码加上填充掩码。


九、训练和推理:一个并行,一个串行

9.1 训练阶段

训练时,我们手里有完整的源句子和目标句子(比如“我爱你”和“I love you”)。我们可以:

  • 把源句子一次性送进编码器

  • 把目标句子(前面加一个起始符)一次性送进解码器

  • 利用掩码机制,让解码器并行地预测每个位置的下一个词

  • 计算预测结果和真实目标之间的交叉熵损失,反向传播

注意:解码器的输入是 I love you(长度4),输出是I love you (长度4),每个位置预测的是下一个词。掩码保证了预测“love”时只能看到 I,看不到后面的you。

这种并行训练比RNN逐词训练快了几个数量级。

9.2 推理阶段(生成)

推理时,我们只知道源句子,目标句子要一个字一个字地生成。过程如下:

  1. 编码器先跑一次,得到源句子的表示。

  2. 解码器输入只有一个,预测第一个词(比如“I”)。

  3. 把 I作为新输入,预测第二个词(“love”)。

  4. 重复,直到预测出结束。

注意:每一步都要重新把整个已生成的序列送进解码器(因为Transformer没有记忆,每次都要从头计算注意力)。这就没法并行了,只能串行。不过通常推理长度有限,速度可以接受。


十、总结与展望

好啦,Transformer的核心原理我们已经从头到尾捋了一遍。我们来快速回顾一下:

  • 抛弃RNN

    ,完全基于注意力机制,实现并行训练,解决了长距离依赖问题。

  • 自注意力

    :每个词通过Q、K、V机制,与所有词交互,生成融合全局信息的表示。

  • 多头注意力

    :多个注意力头并行,捕捉不同类型的语义关系。

  • 前馈网络

    :增加非线性,提升表达能力。

  • 残差连接 + 层归一化

    :让深层网络训练更稳定。

  • 位置编码

    :给模型注入顺序信息,弥补并行结构的缺陷。

  • 解码器掩码

    :防止训练时看到未来信息,保持因果一致性。

  • 编码器-解码器注意力

    :让解码器在生成时动态参考源句子。

你可能会问:我什么时候能看到代码?别急,下一章我会用PyTorch从零搭建一个完整的Transformer模型,并用它来做一个真实的机器翻译项目。从数据预处理到训练、推理,逐行代码带你跑通。

Transformer不仅统治了NLP(BERT、GPT、T5都是它的子孙),还被用到了计算机视觉(ViT)、语音识别、蛋白质结构预测等领域。理解Transformer,就是拿到了现代深度学习的半张门票。

如果你觉得这篇文章对你有帮助,记得点赞收藏,也欢迎在评论区留下你的问题。下一章我们代码见!