Paper Reading #paper-reading#transformer#AI#LLM#python

论文共读:《Attention Is All You Need》(注意力就是你所需要的全部)

2026-01-06 · 4237 字 · 21 分钟

分享我对 Transformer 论文的理解,附真实 Python 代码

2017 年 6 月 12 日,八个人在 arXiv(一个学术论文预印本网站,论文不用等期刊审稿就能直接发布)上传了一篇论文,标题只有五个词:《Attention Is All You Need》(注意力就是你所需要的全部)。

这八个人是 Ashish Vaswani、Noam Shazeer、Niki Parmar、Jakob Uszkoreit、Llion Jones、Aidan N. Gomez、Łukasz Kaiser 和 Illia Polosukhin,当时大多在 Google Brain 和 Google Research 工作。

论文发出之后,这个八人组几乎全部散开。Noam Shazeer 离开 Google 创立了 Character.AI,后来又被 Google 高价请回;Aidan Gomez 从多伦多大学博士还没毕业就创立了 Cohere,做企业级大模型;Llion Jones 去了日本,创立了 Sakana AI;Illia Polosukhin 走了一条谁都没想到的路,创立了 NEAR Protocol,做区块链;Ashish Vaswani 和 Niki Parmar 搭档创立了 Adept AI,后来又一起创立了 Essential AI;Jakob Uszkoreit 创立了 Inceptive,用 AI 设计 RNA 药物;Łukasz Kaiser 则加入了 OpenAI,参与了 GPT 系列的研发。

八位作者,七家公司,横跨 AI、区块链、生物技术。

近九年后的今天,ChatGPT、Claude、DeepSeek、Qwen,这些 AI 产品的底层架构思路,大多都能追溯到这 15 页纸。

这篇文章是我读完论文后的理解,附带真实 Python 代码示例。不是翻译,不是摘要。没有技术背景也能读下去。

0. 先认几个词

如果你没有机器学习背景,先按这篇论文真正想替换掉的旧方案,记住下面几个词就够了:

  • RNN / 循环神经网络:一种更早的序列模型。它处理句子时必须一个词一个词往后读,像人用手指着文章逐行看。
  • attention:从很多信息里,挑出当前最该看的那几部分。你可以先把它理解成“有选择地回头看重点”。
  • Query / Key / Value:注意力机制里的三个角色。Query 像“我现在想找什么”,Key 像“每段信息贴着什么标签”,Value 则是“真正被取出来的内容”。
  • Transformer:以 attention 为核心搭起来的一整套架构。它不靠循环一步步往前推,而是让每个位置都能直接看其他位置。
  • 并行:这里不是说模型更聪明,而是说它能同时处理很多位置,不必像 RNN 那样排队。

1. 一句话说清楚

在 Transformer 之前,AI 处理语言的方式像是一个人用手指着书,一个字一个字地往下读。读到第 100 个字的时候,第 1 个字说了什么,已经记不太清了。句子越长,遗忘越严重。这就是循环神经网络(RNN,一种早期的 AI 架构)的根本瓶颈。

论文的作者们问了一个问题:为什么一定要按顺序读?

和必须逐步处理的 RNN 不同,Transformer 可以并行处理整段输入,直接建模任意两个位置之间的关系。不用排队,不用等前一个词处理完才能看下一个。

论文管这个核心能力叫「注意力」。注意力机制最早由 Bahdanau 等人在 2014 年提出,当时是作为 RNN 的辅助组件。这篇论文的标题要表达的不是「模型里真的只剩注意力」,而是:在序列建模里,注意力第一次被推到了主角的位置,不再需要循环和卷积(一种通过滑动窗口提取局部特征的方法)作为骨架。

2. 注意力到底在做什么

想象你走进一个嘈杂的酒吧,二十个人同时在说话。你的大脑不会平均分配注意力给每个人。有人喊了你的名字,你的耳朵瞬间锁定那个方向,其他声音自动变成背景噪音。

Transformer 对每个词做同样的事。论文里定义了三个角色:

  • Query(查询):这个词在找什么信息。相当于你的耳朵在搜索「谁在叫我」
  • Key(键):这个词能提供什么信息。相当于酒吧里每个人的声音特征
  • Value(值):这个词实际携带的内容。相当于那个人说的具体话

每个词的 Query 会和其他词的 Key 做匹配。匹配度高的,就从对方的 Value 里获取更多信息。匹配度低的,直接忽略。

论文给出的公式叫 Scaled Dot-Product Attention:

Attention(Q,K,V)=softmax(QKTdk)V\operatorname{Attention}(Q, K, V) = \operatorname{softmax}\left(\frac{QK^T}{\sqrt{d_k}}\right)V

看到公式别慌。一步一步拆:

  • QK^T:Q 和 K 做点积。什么是点积?把两组数字对应位置相乘,再加起来。比如 [1, 2] 和 [3, 4] 的点积是 1×3 + 2×4 = 11。数字越大,说明两个词越相关。这一步算的就是每对词之间的「匹配分数」
  • / √d_k:除以一个数来缩放。d_k 是向量的长度(向量可以理解为「一串用来描述某个东西的数字」,比如用 64 个数字描述一个词的含义)。为什么要除?因为数字串越长,点积结果越大。不缩放时,维度越大点积的方差越大,softmax 容易进入饱和区(几乎所有概率集中在一个词上),梯度(模型用来调整自身参数的信号)变得很小,训练会不稳定
  • softmax:把一组分数转换成概率,所有概率加起来等于 1。比如三个词的分数是 [10, 2, 1],softmax 之后大概变成 [0.99, 0.007, 0.003]。分数最高的那个词几乎拿走了全部注意力,其他的被压到接近零
  • × V:用这些概率去加权每个词的实际内容。概率高的词贡献大,概率低的词贡献小。最终输出是一个融合了关键信息的新向量

用 Python(基于 PyTorch)写出来:

import math
import torch
def scaled_dot_product_attention(
query: torch.Tensor,
key: torch.Tensor,
value: torch.Tensor,
) -> torch.Tensor:
d_k = key.size(-1)
scores = query @ key.transpose(-2, -1) / math.sqrt(d_k)
weights = torch.softmax(scores, dim=-1)
return weights @ value

就这么几行代码。很多后来改变行业的能力,底层都建立在这几行运算之上。

3. 多头注意力:同时从多个角度看

单个注意力头通常只能偏向某一类关系模式。但语言这东西,一句话里藏着好几层意思。

拿「我昨天在深圳吃了潮汕牛肉火锅」来说:

  • 「我」和「吃了」之间是谁做了什么的关系
  • 「昨天」和「吃了」之间是时间关系
  • 「深圳」和「潮汕牛肉火锅」之间是地点与食物的关系

让一个头同时兼顾这么多层次,很难。论文的做法是用多头机制:派出 8 个头并行运算,让模型有机会从不同子空间同时观察一句话,最后把各自的发现拼起来。

论文原文的公式:

MultiHead(Q,K,V)=Concat(head1,,headh)WO\operatorname{MultiHead}(Q, K, V) = \operatorname{Concat}(\text{head}_1, \ldots, \text{head}_h)\, W^O

拆开看:

  • head_1, …, head_h:8 个头各自独立做一次注意力运算,得到 8 份结果
  • Concat:把 8 份结果首尾相连,拼成一个长向量
  • W^O:一次线性变换(可以理解为「乘以一个矩阵」),把拼接后的长向量压回原来的维度。相当于一个主管听完 8 个调查员的汇报,输出一份综合结论
import math
import torch
from torch import nn
class MultiHeadAttention(nn.Module):
def __init__(self, d_model: int, num_heads: int) -> None:
super().__init__()
if d_model % num_heads != 0:
raise ValueError("d_model must be divisible by num_heads")
self.num_heads = num_heads
self.d_head = d_model // num_heads
self.q_proj = nn.Linear(d_model, d_model)
self.k_proj = nn.Linear(d_model, d_model)
self.v_proj = nn.Linear(d_model, d_model)
self.out_proj = nn.Linear(d_model, d_model)
def _split_heads(self, x: torch.Tensor) -> torch.Tensor:
batch_size, seq_len, _ = x.shape
x = x.view(batch_size, seq_len, self.num_heads, self.d_head)
return x.transpose(1, 2)
def forward(
self,
query: torch.Tensor,
key: torch.Tensor,
value: torch.Tensor,
) -> torch.Tensor:
q = self._split_heads(self.q_proj(query))
k = self._split_heads(self.k_proj(key))
v = self._split_heads(self.v_proj(value))
scores = q @ k.transpose(-2, -1) / math.sqrt(self.d_head)
weights = torch.softmax(scores, dim=-1)
heads = weights @ v
batch_size, _, target_len, _ = heads.shape
merged = heads.transpose(1, 2).contiguous()
merged = merged.view(batch_size, target_len, self.num_heads * self.d_head)
return self.out_proj(merged)

论文里的参数:模型用 512 个数字描述一个词(d_model = 512),8 个头,每个头分到 64 个数字(512 ÷ 8 = 64)。8 个头的总计算量和 1 个 512 维的头差不多,但表达能力强得多。用同样的代价,换来多视角的理解力。这笔账算得漂亮。

4. 位置编码:告诉模型词的顺序

Transformer 并行处理整个句子,速度是快了,但代价是它丢掉了词的先后顺序。如果没有额外的位置信息,注意力机制本身并不知道「猫吃鱼」和「鱼吃猫」有什么区别。这显然不行。

怎么补救?给每个位置生成一个独一无二的「地址编码」,加到词的向量上。模型看到的不再是「猫」和「鱼」,而是「第 1 个位置的猫」和「第 3 个位置的鱼」。

论文用正弦和余弦函数来生成这个编码:

PE(pos,2i)=sin(pos100002i/dmodel)\operatorname{PE}(pos, 2i) = \sin\left(\frac{pos}{10000^{2i / d_{\text{model}}}}\right) PE(pos,2i+1)=cos(pos100002i/dmodel)\operatorname{PE}(pos, 2i + 1) = \cos\left(\frac{pos}{10000^{2i / d_{\text{model}}}}\right)

公式看着唬人,核心思路很直观:

  • pos:词在句子里的位置(第 1 个、第 2 个、第 3 个……)
  • i:向量的第几个维度。偶数位置用 sin,奇数位置用 cos
  • 10000^(2i/d_model):一个随维度变化的缩放因子。低维度变化快,高维度变化慢。就像时钟:秒针一分钟转一圈,时针十二小时才转一圈。不同「指针」覆盖不同的时间尺度,组合在一起就能精确定位任意时刻

最终效果:每个位置得到一串独一无二的数字指纹,模型靠这个指纹区分词的先后顺序。

import math
import torch
def positional_encoding(seq_len: int, d_model: int) -> torch.Tensor:
positions = torch.arange(seq_len, dtype=torch.float32).unsqueeze(1)
div_term = torch.exp(
torch.arange(0, d_model, 2, dtype=torch.float32)
* (-math.log(10000.0) / d_model)
)
encoding = torch.zeros(seq_len, d_model)
encoding[:, 0::2] = torch.sin(positions * div_term)
encoding[:, 1::2] = torch.cos(positions * div_term)
return encoding

为什么偏偏选正弦余弦?因为它有一个优雅的数学性质:两个词相隔固定距离,无论它们出现在句首还是句尾,位置编码之间的关系是一样的。模型不用死记「位置 3 和位置 8」的关系,只需要学会「相隔 5 个位置」意味着什么。论文团队也试过让模型自己学位置编码,效果差不多,但正弦版本有一个额外的优势:它能处理训练时没见过的更长句子。

5. 编码器与解码器

Transformer 的完整架构分两半。

编码器(6 层堆叠)负责读懂输入。每层包含两个子层:一个多头自注意力,一个前馈网络。每个子层都有两个保护机制:

  • 残差连接:把子层的输入直接加到输出上,即 x + Sublayer(x)。为什么?想象你给一张照片加滤镜。如果滤镜效果不好,残差连接保证你还能看到原图。在深层网络里,信息每经过一层都会被变换,传到第六层可能已经面目全非。残差连接让原始信号可以「抄近道」直达深层,防止信息在传递中丢失
  • 层归一化(LayerNorm):把数值调整到统一范围,防止有的数字大到爆炸、有的小到消失。类似于考试成绩标准化,不管原始卷面分差异多大,标准化后都在一个可比较的区间

解码器(6 层堆叠)负责生成输出。结构和编码器类似,但多了两个关键设计:

第一,交叉注意力:解码器生成每个词时,会回头「看」编码器的输出。翻译场景下,就是一边写英文一边回头看中文原文。

第二,遮罩(masking):生成第 3 个词时,只允许看到前 2 个词,第 4 个及之后的位置被屏蔽(注意力分数设为负无穷,经过 softmax 后变成零)。道理很简单:你写作文的时候,下一个字还没写出来,不能偷看。

from typing import Optional
import torch
from torch import nn
class Transformer(nn.Module):
def __init__(
self,
vocab_size: int,
d_model: int = 512,
num_heads: int = 8,
num_layers: int = 6,
d_ff: int = 2048,
dropout: float = 0.1,
) -> None:
super().__init__()
self.embedding = nn.Embedding(vocab_size, d_model)
encoder_layer = nn.TransformerEncoderLayer(
d_model=d_model,
nhead=num_heads,
dim_feedforward=d_ff,
dropout=dropout,
batch_first=True,
)
decoder_layer = nn.TransformerDecoderLayer(
d_model=d_model,
nhead=num_heads,
dim_feedforward=d_ff,
dropout=dropout,
batch_first=True,
)
self.encoder = nn.TransformerEncoder(encoder_layer, num_layers=num_layers)
self.decoder = nn.TransformerDecoder(decoder_layer, num_layers=num_layers)
self.output_proj = nn.Linear(d_model, vocab_size)
def forward(
self,
src_token_ids: torch.Tensor,
tgt_token_ids: torch.Tensor,
tgt_mask: Optional[torch.Tensor] = None,
) -> torch.Tensor:
memory = self.encoder(self.embedding(src_token_ids))
hidden = self.decoder(self.embedding(tgt_token_ids), memory, tgt_mask=tgt_mask)
return self.output_proj(hidden)

还有一个容易被忽略的组件:前馈网络。公式是 FFN(x) = max(0, xW1 + b1)W2 + b2。翻译成人话:先把每个词的 512 维向量扩大到 2048 维(乘以一个矩阵再加一个偏置),用 ReLU 过滤一遍(所有负数变成零,正数保留),再压回 512 维。ReLU 这一步是关键:它引入了「非线性」,让模型能学到直线画不出来的复杂模式。如果全是线性变换,多层叠加在数学上仍可合并为单层,非线性是模型表示复杂模式的前提。

6. 训练细节

架构设计完了,怎么训练它?论文在这里也有不少讲究。

硬件:8 块 NVIDIA P100 GPU。基础版模型训练 12 小时(10 万步),大号模型训练 3.5 天(30 万步)。放在今天看,这个成本低得惊人。

优化器:用的是 Adam(一种让模型自动调整参数的算法),但学习率的设计很巧妙。学习率决定了模型每一步「迈多大步子」。步子太大容易跨过最优解,太小又走得慢。论文的策略是前 4000 步逐渐提速(warmup,热身),避免一开始更新过猛;4000 步之后按计划逐渐减速,让训练后期更稳定。先升后降,前半段大胆探索,后半段精细打磨。

正则化:两招。第一招是 Dropout,训练时随机关掉 10% 的神经元(可以理解为网络中的计算节点),迫使模型不依赖任何单一路径,学到更鲁棒的特征。第二招是 label smoothing(标签平滑,ε = 0.1):训练时不告诉模型「正确答案的概率是 100%」,而是说「90% 是正确答案,剩下 10% 分给其他选项」。这会让模型在一个指标上变差(困惑度,衡量模型有多「拿不准」),但翻译质量反而更好。直觉上说,一个承认自己不是 100% 确定的模型,比一个过度自信的模型更可靠。

结果:论文用 BLEU 分数(机器翻译的标准评分,衡量机器翻译和人工翻译有多接近,满分 100)来衡量效果。英德翻译 28.4 分,英法翻译 41.8 分,都刷新了当时的记录。训练成本比之前的方法低了一到两个数量级。更快,更强,更便宜。

7. 我的思考

读完这篇论文,有几个感受。

第一,这篇论文的核心洞察极其简洁:扔掉顺序处理的包袱,让注意力机制直接建模任意两个位置之间的关系。Self-Attention、残差连接、Layer Normalization,没有一个是新发明。真正的突破不在于发明新工具,而在于作者们敢赌「这些简单的积木拼在一起就够了」,然后用实验证明了自己是对的。

第二,用真实 Python 代码写出来的过程让我更深地理解了每一个设计决策。当你自己写出 Scaled Dot-Product Attention,你会切实感受到那个 √d_k 的缩放有多重要。当你实现 masking,你会理解自回归生成的约束从何而来。论文读十遍,不如自己写一遍。

第三,真正让我震撼的,不是它后来衍生出了多少模型,而是它当年就把问题改写了:从「怎么按顺序记住一句话」,变成「怎么让每个位置直接找到它最该看的信息」。GPT、BERT、T5、LLaMA,全是这个问题改写之后的产物。

一个足够好的架构,能走多远,取决于有多少人愿意在它上面继续建设。

这篇论文给出了那个架构。

《Attention Is All You Need》(注意力就是你所需要的全部)。


论文共读系列

全文完 · 谢谢阅读

评论