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的出现,从根本上解决了这两个问题。
RNN顺序处理 vs Transformer并行注意力机制
注意力机制:直接关注重要位置
注意力机制的核心思想可以用一个日常类比来理解。想象你走进图书馆,心里有一个问题(Query,简称Q):"我想找一本关于机器学习的入门书"。图书馆里的每本书都有一个标题(Key,简称K),以及书本身的内容(Value,简称V)。
找书的过程是:你把自己的问题和每本书的标题逐一比较,相关度越高的书,你越倾向于翻开它阅读。最终你从最相关的几本书里汇总信息,得到你的答案。
注意力机制做的事情完全一样:
- Q(Query,查询):当前需要被表达的词,它发出"我需要从周围词那里获取什么信息"的请求
- K(Key,键):序列中每个词的"标签",代表它能提供什么信息
- V(Value,值):每个词实际携带的语义信息
- 注意力分数:Q和每个K的相关程度,决定从对应V中取多少信息
这个"查询-匹配-取值"的过程,让模型可以在序列中的任意两个位置之间建立直接联系,完全绕过了RNN逐步传递的限制。
自注意力机制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$),分别投影成三个新向量。这三个矩阵是模型训练时学到的参数——它们决定了"什么叫做一个好的查询"、"什么叫做一个好的键"。
原始输入 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 个词,它的输出是序列中所有词的值向量的加权平均,权重就是它对各词的注意力概率。权重越大的词,其语义信息贡献越多。这个输出向量就是该词在融入了全局上下文之后的新表示。
自注意力代码
理解了以上五个步骤,再来看代码就清晰很多:
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个并行注意力头
多头注意力代码
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的模型,理论上可以外推到更长的序列,不像可学习的位置嵌入需要预先分配固定大小的参数矩阵。
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:
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 Block架构图
将多个TransformerBlock堆叠起来,在输入端加上词嵌入(Embedding)和位置编码,就构成了完整的Transformer编码器。GPT-2 Small堆叠了12个这样的Block;GPT-3堆叠了96个。参数量的增长主要来自Block数量的增加以及每个Block内 d_model 和 d_ff 的扩大。
BERT vs GPT:两种Transformer变体
| BERT | GPT | |
|---|---|---|
| 架构 | 编码器(Encoder) | 解码器(Decoder) |
| 训练目标 | 掩码语言模型(MLM,随机遮住部分词让模型预测) | 因果语言模型(CLM,预测下一个词) |
| 注意力 | 双向(看全文) | 单向(只看前文) |
| 适用任务 | 分类、NER、问答、语义相似度 | 文本生成、对话、指令跟随 |
| 代表模型 | BERT, RoBERTa | GPT系列, LLaMA, Qwen |
BERT(双向编码器)vs GPT(单向解码器)架构对比
两者的本质区别在于注意力的方向性。BERT使用双向注意力,处理每个词时可以同时看到它前面和后面的所有词,因此对语义理解更充分。GPT使用单向(因果)注意力,处理每个词时只能看到它之前的词——这是文本生成的必要限制,生成下一个词时当然不能"偷看"答案。
实际如何选择
这个选择在实际项目中直接影响架构决策:
选择BERT类模型(双向编码器)的场景:
- 文本分类(情感分析、主题分类):需要理解整句语义
- 信息抽取(命名实体识别、关系抽取):需要双向上下文
- 语义相似度、检索召回:需要高质量的文本向量表示
选择GPT类模型(因果解码器)的场景:
- 文本生成(续写、摘要、翻译):天然适合自回归生成
- 对话系统、指令跟随:GPT系列在instruction tuning上积累更深
- 少样本/零样本推理:大参数GPT模型的上下文学习能力更强
决策树:
你的任务是什么?
├─ 需要生成文本?→ 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矩阵的含义)