课程0基础Agent开发课 / 深度学习基础 / Transformer架构详解-从RNN到注意力机制
— 23 min read

Transformer架构详解-从RNN到注意力机制

Transformer是所有大语言模型的基础架构。理解了它,就理解了ChatGPT和Claude在计算层面的工作原理。

Transformer架构详解:从RNN到注意力机制

Transformer是所有大语言模型的基础架构。理解了它,就理解了ChatGPT和Claude在计算层面的工作原理。

注意力机制的核心类比:想象你在考试复习,手里拿着一支荧光笔。你读到一个句子时,会给"重要的词"画重点——哪些词对理解当前这个词最关键,就给哪些词画上颜色。注意力机制做的正是这件事:对于序列中的每个词,计算它应该给其他每个词"画多重的颜色"(分配多少注意力),然后把加权后的信息合并进来。

为什么Transformer能训练超大模型:它的计算天然是并行的——所有词同时计算注意力,GPU的几千个核心可以全部工作。相比之下,RNN必须串行处理,一次只能用一个核心。这个区别让Transformer能高效利用大规模算力,从而催生了GPT、Claude这样的超大模型。

前置知识:本文涉及的概念需要以下基础:

特别是矩阵乘法:注意力机制的核心计算 QK^T 就是矩阵乘法,理解了矩阵乘法才能理解注意力分数的含义。

RNN的问题:长距离依赖

在Transformer出现之前,NLP(自然语言处理)领域主要用RNN(循环神经网络)处理序列数据。RNN的工作方式是逐词处理,每处理一个词就更新一个"隐藏状态",将信息向后传递。用第04篇的话说,它是一个有"记忆"的网络——处理每个词时,都会把之前所有词的信息压缩进一个固定大小的向量里。

这个设计有一个致命缺陷:长距离依赖问题。考虑这样一个句子——"我在北京出生,在上海长大,在深圳工作,说的是___语。"答案是"普通话",这需要模型关联到句子最开头的"北京"。但在RNN里,处理到最后的空格时,"北京"这个词的信息已经经过了几十次向量变换,被反复覆盖和稀释,几乎消失殆尽。这不是偶发现象,而是RNN的结构性缺陷。

更深层的问题是并行性。RNN必须一个词接一个词地处理,词100必须等词99处理完才能开始。在现代GPU拥有数千个并行计算单元的今天,这种严格的串行结构极大地浪费了硬件能力,导致训练极慢。Transformer的出现,从根本上解决了这两个问题。

Transformer:并行处理 全局注意力,无梯度消失

Python

编程

学习

RNN:顺序处理 存在梯度消失问题

Python

编程

学习

RNN顺序处理 vs Transformer并行注意力机制

注意力机制:直接关注重要位置

注意力机制的核心思想可以用一个日常类比来理解。想象你走进图书馆,心里有一个问题(Query,简称Q):"我想找一本关于机器学习的入门书"。图书馆里的每本书都有一个标题(Key,简称K),以及书本身的内容(Value,简称V)。

找书的过程是:你把自己的问题和每本书的标题逐一比较,相关度越高的书,你越倾向于翻开它阅读。最终你从最相关的几本书里汇总信息,得到你的答案。

注意力机制做的事情完全一样:

  • Q(Query,查询):当前需要被表达的词,它发出"我需要从周围词那里获取什么信息"的请求
  • K(Key,键):序列中每个词的"标签",代表它能提供什么信息
  • V(Value,值):每个词实际携带的语义信息
  • 注意力分数:Q和每个K的相关程度,决定从对应V中取多少信息

这个"查询-匹配-取值"的过程,让模型可以在序列中的任意两个位置之间建立直接联系,完全绕过了RNN逐步传递的限制。

输入向量 x
n × d_model

W_Q 线性投影

W_K 线性投影

W_V 线性投影

Query (Q)
查询:我在找什么信息?

Key (K)
键:我有什么信息标签?

Value (V)
值:我能提供什么内容?

Q · K^T
点积计算相似度

Scale ÷ √d_k
缩放防止梯度消失

SoftMax
归一化为注意力权重

注意力权重 × V
加权求和信息

输出新表示
融入了全局上下文

自注意力机制Q/K/V计算流程

为什么叫"自注意力"

普通的注意力机制最初用于机器翻译,让解码器(翻译端)查询编码器(原文端)的信息——Q来自一侧,K和V来自另一侧。

自注意力(Self-Attention)是一种特殊情况:Q、K、V全部来自同一个序列。序列中的每一个词都在问"我应该关注这个序列里的哪些其他词"。处理"猫"这个词时,它会同时查询序列里所有词(包括自己),找出哪些词对理解"猫"最有帮助。这种自我关注的机制让每个词的最终表示都能融入全局上下文。

QKV的计算过程:逐步拆解

理解了直觉之后,我们来看计算是如何实现的。假设输入序列中有 n 个词,每个词被表示成一个 d_model 维的向量(例如512维)。

第一步:线性投影,得到Q、K、V

每个词的原始向量,通过三个独立的可学习线性变换(权重矩阵 $W_Q$、$W_K$、$W_V$),分别投影成三个新向量。这三个矩阵是模型训练时学到的参数——它们决定了"什么叫做一个好的查询"、"什么叫做一个好的键"。

code
原始输入 x(n × d_model)
    ↓ 乘以 W_Q(d_model × d_k)
Q 矩阵(n × d_k)—— 每行是一个词的"查询向量"

    ↓ 乘以 W_K(d_model × d_k)
K 矩阵(n × d_k)—— 每行是一个词的"键向量"

    ↓ 乘以 W_V(d_model × d_v)
V 矩阵(n × d_v)—— 每行是一个词的"值向量"

第二步:计算注意力分数

用Q矩阵和K矩阵做点积:$\text{scores} = Q K^T$,得到一个 n × n 的分数矩阵。矩阵里第 i 行第 j 列的数值,代表第 i 个词对第 j 个词的"原始关注度"——两个向量越相似(方向越接近),点积越大,关注度越高。

第三步:缩放——为什么要除以 $\sqrt{d_k}$?

直接使用点积结果会有问题。当 $d_k$(键向量的维度)很大时,点积的数值会非常大。以512维向量为例,两个随机初始化的单位向量点积的期望标准差是 $\sqrt{512} \approx 22.6$。这么大的数值送入Softmax之后,会导致梯度极度集中在最大值附近,其余位置的梯度趋近于零,模型很难学习(这正是"梯度消失"的一种体现)。

解决方案很简单:把分数除以 $\sqrt{d_k}$,把方差重新缩放回1左右:

$$\text{scores} = \frac{Q K^T}{\sqrt{d_k}}$$

这个看似微小的改动,对训练稳定性有显著影响。

第四步:Softmax——把分数变成概率分布

缩放后的分数矩阵,对每一行(每个词的查询结果)做Softmax变换。Softmax的作用是将一组任意实数转化为一组加和为1的正数,可以理解为"注意力概率分布"。

Softmax有一个重要性质:它是非线性的,会放大最大值、压缩小值。假设某行的原始分数是 [2.0, 0.5, -1.0, 0.3],Softmax后可能变成 [0.72, 0.17, 0.03, 0.08]。这意味着模型会把大部分注意力集中在最相关的词上,而不是均匀分散。

第五步:加权求和——聚合信息

最后,用注意力权重矩阵对V矩阵做加权求和:$\text{output} = \text{Attention}(Q, K, V) = \text{softmax}!\left(\frac{QK^T}{\sqrt{d_k}}\right) V$

对于第 i 个词,它的输出是序列中所有词的值向量的加权平均,权重就是它对各词的注意力概率。权重越大的词,其语义信息贡献越多。这个输出向量就是该词在融入了全局上下文之后的新表示。

自注意力代码

理解了以上五个步骤,再来看代码就清晰很多:

python
import torch, math
import torch.nn as nn
import torch.nn.functional as F

def self_attention(Q, K, V, mask=None):
    """缩放点积注意力。Q/K/V: (batch, heads, seq_len, d_k)"""
    d_k = Q.size(-1)
    scores = torch.matmul(Q, K.transpose(-2, -1)) / math.sqrt(d_k)  # 步骤2+3
    if mask is not None:
        scores = scores.masked_fill(mask == 0, -1e9)
    weights = F.softmax(scores, dim=-1)          # 步骤4:转为概率分布
    output  = torch.matmul(weights, V)           # 步骤5:加权聚合
    return output, weights

多头注意力:多角度理解语言

为什么需要多个头

单头注意力每次只能用一种"关系模式"来计算注意力。但自然语言中存在多种并行的关系:同一个句子里,词和词之间同时有语法关系(主语-谓语)、语义关系(动词-宾语)、指代关系(代词-指代对象)、局部上下文关系(相邻修饰)。

如果只用单头注意力,模型要么只学到其中一种关系,要么把多种关系混合成一个模糊的平均,效果都不理想。

多头注意力(Multi-Head Attention)的解决思路:把 d_model 维的空间切分成 h 个子空间,每个"头"在自己的子空间里独立做注意力计算,各自学习不同类型的关系。最后把所有头的输出拼接起来,再经过一个线性变换合并。

以处理句子"Tom fed his cat"为例:

  • 头1 可能学习到主谓关系:fed 强烈关注 Tom
  • 头2 可能学习到动宾关系:fed 强烈关注 cat
  • 头3 可能学习到代词指代:his 强烈关注 Tom
  • 头4 可能学习到局部修饰:cat 关注相邻的 his

这种分工并不是人为规定的,而是模型在训练过程中自发形成的。研究者可视化了BERT不同注意力头的权重分布,证实了不同头确实专注于不同类型的语言关系。

从数学角度看,h 个头共享模型总参数量 d_model,每头维度 d_k = d_model / h。以 d_model=512, h=8 为例,每个头的维度是64。多头并行计算后再合并,总计算量与单头相当,但表达能力大幅增强。

多头注意力机制
多头注意力:8个并行注意力头

多头注意力代码

python
class MultiHeadAttention(nn.Module):
    """多头自注意力(依赖上方的 self_attention 函数)"""

    def __init__(self, d_model, n_heads):
        super().__init__()
        self.d_model = d_model
        self.n_heads = n_heads
        self.d_k = d_model // 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, x, mask=None):
        B, T, _ = x.size()
        # 线性投影后切分成多头:(B, heads, T, d_k)
        def proj_and_split(W):
            return W(x).view(B, T, self.n_heads, self.d_k).transpose(1, 2)

        Q, K, V = proj_and_split(self.W_q), proj_and_split(self.W_k), proj_and_split(self.W_v)
        context, weights = self_attention(Q, K, V, mask)

        # 合并所有头,再经过输出投影
        context = context.transpose(1, 2).contiguous().view(B, T, self.d_model)
        return self.W_o(context), weights

位置编码:弥补无序的先天缺陷

为什么需要额外的位置信息

注意力机制有一个不那么显眼的根本缺陷:它本身是排列不变的(permutation-invariant)。也就是说,如果把序列中的词随机打乱顺序,自注意力的计算结果是完全相同的——因为注意力分数只取决于词与词之间的点积,与它们在序列中的位置无关。

对比一下RNN:RNN天然有序,处理词3时已经见过词1和词2,位置信息隐含在处理顺序里。Transformer舍弃了这种串行结构换来了并行计算,但同时也失去了位置感知。"猫吃鱼"和"鱼吃猫"对Transformer来说,如果不加额外处理,将产生相同的中间表示。

解决方案是在词向量输入网络之前,叠加一个位置编码(Positional Encoding)向量,把位置信息直接"嵌入"到词的表示里。

正弦位置编码的设计动机

原始Transformer论文(Vaswani等人,2017)提出用正弦和余弦函数生成位置编码:

$$PE_{(pos, 2i)} = \sin!\left(\frac{pos}{10000^{2i/d_{model}}}\right)$$

$$PE_{(pos, 2i+1)} = \cos!\left(\frac{pos}{10000^{2i/d_{model}}}\right)$$

其中 pos 是词在序列中的位置,i 是向量维度的索引。这个设计有几个精妙之处:

不同维度使用不同频率:第0、1维使用高频(变化快),越往高维频率越低(变化慢)。类比时钟的秒针、分针、时针:低维就像秒针,每个位置变化明显;高维就像时针,变化缓慢但能区分更大范围的位置。多个频率组合在一起,理论上可以唯一标识任意长度序列里的每个位置。

相对位置的线性关系:正弦/余弦函数有一个数学性质:$PE_{pos+k}$ 可以表示为 $PE_{pos}$ 的线性变换。这意味着模型可以通过注意力权重矩阵,学习到两个词之间的相对位置差,而不仅仅是绝对位置。对于语言理解,"两个词相距3个位置"往往比"它们分别在第5和第8个位置"更有意义。

对未见过的序列长度的泛化:由于是函数生成,训练时最大长度为512的模型,理论上可以外推到更长的序列,不像可学习的位置嵌入需要预先分配固定大小的参数矩阵。

python
class PositionalEncoding(nn.Module):
    """正弦位置编码"""

    def __init__(self, d_model, max_len=5000, dropout=0.1):
        super().__init__()
        self.dropout = nn.Dropout(p=dropout)
        pe = torch.zeros(max_len, d_model)
        pos = torch.arange(0, max_len, dtype=torch.float).unsqueeze(1)
        # 不同维度的频率系数,从高频到低频指数递减
        div = torch.exp(torch.arange(0, d_model, 2).float() * (-math.log(10000.0) / d_model))
        pe[:, 0::2] = torch.sin(pos * div)   # 偶数维度用sin
        pe[:, 1::2] = torch.cos(pos * div)   # 奇数维度用cos
        self.register_buffer('pe', pe.unsqueeze(0))  # 不参与训练

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

残差连接:让深层网络成为可能

深层网络的梯度消失困境

在ResNet(2015年)出现之前,深层神经网络几乎无法训练。反向传播时,梯度要从最后一层逐层乘以各层的局部导数,才能传回第一层。当每层的导数稍小于1时,经过几十层的连乘,梯度会指数级缩小,趋近于零——靠近输入层的参数几乎得不到任何更新信号,网络无法学习。这就是梯度消失问题

GPT-3有96层Transformer,没有有效的梯度通路,这样的深度完全无法训练。

残差连接的原理

残差连接的思路出奇地简单:在每个子层(注意力层或前馈层)外面加一条**"高速公路"**,让输入直接绕过该层,与该层的输出相加:

$$\text{output} = \text{LayerNorm}(x + \text{SubLayer}(x))$$

这里的 x 是绕过子层直接传来的,SubLayer(x) 是经过注意力计算或前馈网络处理后的结果,二者相加后再归一化。

从梯度角度来看,对 output 求关于 x 的梯度:

$$\frac{\partial \text{output}}{\partial x} = \frac{\partial \text{SubLayer}(x)}{\partial x} + 1$$

无论 $\frac{\partial \text{SubLayer}(x)}{\partial x}$ 多么小(哪怕接近0),梯度里总有一个常数项 +1。这条直通通路保证了梯度信号不会因残差路径而衰减——即使子层的梯度接近零,整体梯度仍能传递下去。

准确说明:残差连接并不是说梯度"不经过任何乘法"——反向传播仍然要经过各层的链式法则。残差连接的真正贡献是:在每一层都保留了一条"梯度高速公路",使得即使某些子层的梯度极小,信号也不会完全消失。这让96层的深度网络可以被稳定训练,而不会陷入梯度消失。

残差连接的另一层含义

残差连接还有一个直觉上的意义:每个子层学习的是"对当前表示的修正量",而不是"完全重新计算的表示"。如果一个词的表示已经足够好了,注意力层只需要输出接近零的残差,保留原有表示即可。这让网络的学习目标更简单,也让深层网络具备了一种"自适应深度"——需要变换时深度处理,不需要时直接透传。

LayerNorm vs BatchNorm:为什么Transformer选择LayerNorm

BatchNorm(批归一化)是ResNet等图像模型的标配:对一个batch内所有样本的同一个特征做归一化,计算该特征在整个batch里的均值和方差。

LayerNorm(层归一化)与之不同:对同一个样本内的所有特征做归一化,每个token独立计算自己的均值和方差。

Transformer选择LayerNorm,有三个直接原因:

序列长度可变:NLP中不同句子的长度各不相同。BatchNorm要在batch维度上做统计,需要序列对齐(填充),引入了大量无意义的零值,污染统计量。LayerNorm只看每个token自身,完全不受序列长度影响。

推理时batch=1:模型生成文本时,通常一次处理一个样本。BatchNorm在batch=1时,无法计算有意义的统计方差(一个样本的方差为0),计算直接崩溃。LayerNorm无此问题。

分布式训练友好:大模型训练时跨多个GPU或节点。BatchNorm需要在所有设备间同步统计量,通信开销巨大。LayerNorm只依赖单个样本,天然适合数据并行和模型并行。

完整的Transformer Block

把上述所有组件组装在一起,就得到一个Transformer Block:

python
class TransformerBlock(nn.Module):
    """一个完整的Transformer编码器块"""

    def __init__(self, d_model, n_heads, d_ff, dropout=0.1):
        super().__init__()
        self.attention = MultiHeadAttention(d_model, n_heads)
        # 前馈网络:升维→激活→降维,例如 512→2048→512
        self.ff = nn.Sequential(
            nn.Linear(d_model, d_ff), nn.GELU(),
            nn.Dropout(dropout), nn.Linear(d_ff, d_model)
        )
        self.norm1 = nn.LayerNorm(d_model)
        self.norm2 = nn.LayerNorm(d_model)
        self.drop  = nn.Dropout(dropout)

    def forward(self, x):
        # 子层1:自注意力 + 残差 + LayerNorm
        attn, _ = self.attention(x)
        x = self.norm1(x + self.drop(attn))    # x 直接加回来 = 残差连接

        # 子层2:前馈 + 残差 + LayerNorm
        x = self.norm2(x + self.drop(self.ff(x)))
        return x

Transformer块架构
完整的Transformer Block架构图

将多个TransformerBlock堆叠起来,在输入端加上词嵌入(Embedding)和位置编码,就构成了完整的Transformer编码器。GPT-2 Small堆叠了12个这样的Block;GPT-3堆叠了96个。参数量的增长主要来自Block数量的增加以及每个Block内 d_modeld_ff 的扩大。

BERT vs GPT:两种Transformer变体

BERT GPT
架构 编码器(Encoder) 解码器(Decoder)
训练目标 掩码语言模型(MLM,随机遮住部分词让模型预测) 因果语言模型(CLM,预测下一个词)
注意力 双向(看全文) 单向(只看前文)
适用任务 分类、NER、问答、语义相似度 文本生成、对话、指令跟随
代表模型 BERT, RoBERTa GPT系列, LLaMA, Qwen

BERT vs GPT架构对比
BERT(双向编码器)vs GPT(单向解码器)架构对比

两者的本质区别在于注意力的方向性。BERT使用双向注意力,处理每个词时可以同时看到它前面和后面的所有词,因此对语义理解更充分。GPT使用单向(因果)注意力,处理每个词时只能看到它之前的词——这是文本生成的必要限制,生成下一个词时当然不能"偷看"答案。

实际如何选择

这个选择在实际项目中直接影响架构决策:

选择BERT类模型(双向编码器)的场景:

  • 文本分类(情感分析、主题分类):需要理解整句语义
  • 信息抽取(命名实体识别、关系抽取):需要双向上下文
  • 语义相似度、检索召回:需要高质量的文本向量表示

选择GPT类模型(因果解码器)的场景:

  • 文本生成(续写、摘要、翻译):天然适合自回归生成
  • 对话系统、指令跟随:GPT系列在instruction tuning上积累更深
  • 少样本/零样本推理:大参数GPT模型的上下文学习能力更强

决策树:

code
你的任务是什么?
├─ 需要生成文本?→ GPT类(LLaMA/Qwen)
├─ 需要文本向量/分类/抽取?→ BERT类(BGE/Qwen-Embedding)
└─ 既需要理解又需要生成?→ 直接用GPT类大模型(能力更全面)

现实情况:随着GPT类模型越来越大、能力越来越强,很多原本BERT擅长的分类任务也被GPT类模型通过few-shot prompting超越。除非有严格的延迟和成本限制,否则优先考虑GPT类模型。

小结

Transformer的核心组件:

组件 作用 关键设计决策
自注意力 建立序列中任意位置的直接联系 解决RNN长距离依赖问题
多头注意力 从多个角度关注不同类型的关系 类似集成学习,多视角特征提取
位置编码 向模型注入词序信息 弥补注意力机制排列不变的先天缺陷
前馈网络 对每个位置做非线性变换 增加模型表达能力,升维再降维
残差连接 保证梯度直连通路 让96层的深度网络可以被训练
层归一化 稳定深层网络的训练 适合NLP的变长序列和小batch场景

GPT、BERT、LLaMA、Qwen,架构全部基于这几个组件。理解了这些组件的设计动机,就理解了为什么Transformer在2017年出现之后能快速取代一切先前架构,成为大模型时代唯一的基础结构。

后续应用:本文知识将在以下章节直接用到:

  • 第6章 LLM基础(Transformer是所有LLM的核心架构,GPT/LLaMA都是Transformer解码器的堆叠)
  • 第16章 模型微调(LoRA微调的target_modules选择注意力层的Q/K/V矩阵,正是基于本文的注意力机制原理)
  • 第16章 LoRA原理(LoRA对注意力层的权重矩阵做低秩分解,需要理解Q/K/V矩阵的含义)
本页目录