六个词的句子,算出一张 6×6 的注意力矩阵,每行加起来是 1.00。 把词序打乱,输出最大差值只有 4.4e-16——完全没变。 这就是为什么所有 Transformer 都必须再加一层位置编码。
上一节讲神经网络能拟合复杂形状,这一节讲 Transformer 这个特定形状。 它 2017 年出来,之后语言、图像、语音、蛋白质结构全换成了它。
一、注意力在算「谁跟谁有关系」
输入一句话,每个词先变成一个向量。 然后每个词都发出三个询问:我是谁(query)、我能提供什么(key)、我携带什么内容(value)。 拿我的 query 去和每个词的 key 算相似度,归一化成权重, 再按权重把所有人的 value 加权求和——这就是我这个位置的新表示。 Demo 里六个词「训练 师 标注 数据 训练 模型」,算出的矩阵里, 「数据」这一行最关注「标注」(权重 0.74),「模型」这一行最关注「标注」(0.70)。 这些关系是从向量算出来的,没人教它。 矩阵每一行加起来是 1.00——这就是「把 100% 的注意力分配出去」。 权重大的地方多看几眼,小的地方基本忽略。
二、它对顺序是盲的
Demo 里有个刺眼的细节:第 1 行和第 5 行一模一样。两个「训练」的向量相同, 算出来的注意力也相同。 这不是 bug,是这套结构的天生性质:注意力的计算只用到「有哪些词、彼此多相关」, 完全没用到「谁在前谁在后」。 验证一下:把六个词的顺序打乱,重新算,输出和原来打乱后的顺序完全一致, 最大差值 4.4e-16(就是浮点误差)。 所以「狗咬人」和「人咬狗」在纯注意力眼里是同一个东西。不补位置信息,这套结构根本分不清语序。
三、位置编码补上这一手
解决办法很朴素:给每个位置的向量加一个「位置指纹」。 第 1 个位置加一个向量、第 2 个加另一个,加完之后同样内容的词在不同位置就不一样了。 最早的版本用正弦余弦函数生成指纹(不同频率的波),后来的模型改成让模型自己学一组位置向量, 效果差不多。 这里有个设计上的巧思:加法而不是拼接。因为加完之后维度不变,后面的矩阵乘不用改形状。 而且模型能从「加了什么」里反推出位置。 必须说清:位置编码不是锦上添花,是必需品。 去掉它, 语言模型立刻退化成「词袋模型」——知道有哪些词,不知道什么顺序。
四、为什么它能「催生一切」
因为它把「序列建模」这件事抽象成了一个与具体任务无关的形式:一堆向量进,一堆向量出, 中间靠注意力互相看。 文本是词的序列,图像是 patch 的序列,语音是帧的序列,蛋白质是氨基酸的序列。 只要你能把输入切成序列,同一个结构就能用。 再加上两个工程上的好处:一是能并行(不像 RNN 必须一步一步来), 二是能堆很深(残差连接让梯度传得下去)。前者让训练变快,后者让模型变大。 代价写在最后那张表里:注意力的计算量和显存是序列长度的平方。 序列一万时单头就要 190.7 MB,十万时 18.6 GB——这还只是一层一个头。 所以「上下文长度」一直是硬成本。 后来所有的长文本技术(稀疏注意力、滑动窗口、线性注意力)都在干同一件事:把这个平方项压下去。
五、动手 Demo:6×6 的注意力矩阵,打乱顺序输出一点没变
给六个词各造一个 8 维向量,用三组随机矩阵算出 query、key、value,再算注意力权重。 然后看每一行最关注谁、把词序打乱后输出变不变、以及序列变长时这张矩阵要占多少显存。
Q, K, V = X @ Wq, X @ Wk, X @ Wv
S = Q @ K.T / np.sqrt(d) # 相似度
A = np.exp(S - S.max(axis=1, keepdims=True))
A /= A.sum(axis=1, keepdims=True) # 每行归一化到 1
out = A @ V # 加权求和
# 打乱词序,重算一遍
perm = [4, 0, 2, 5, 1, 3]
X2 = X[perm]
A2 = attn(X2)
print(np.abs(A2 @ V[perm] - out[perm]).max())
实跑输出:
--------------------------------------------------------------------
Transformer:每个字都去问其他字「你跟我什么关系」
--------------------------------------------------------------------
一句话切成 6 个词:训练 师 标注 数据 训练 模型
注意力权重矩阵(行是「谁在问」,列是「被问的是谁」):
训练 师 标注 数据 训练 模型
---------------------------------------------
训练 0.44 0.00 0.01 0.04 0.44 0.06
师 0.05 0.18 0.02 0.08 0.05 0.62
标注 0.18 0.03 0.00 0.01 0.18 0.59
数据 0.10 0.00 0.74 0.02 0.10 0.03
训练 0.44 0.00 0.01 0.04 0.44 0.06
模型 0.08 0.03 0.70 0.12 0.08 0.00
每一行加起来是 1.00——**这就是「把注意力分配出去」的意思**。看每行最亮的那个:
「训练」(第 1 位)最关注「训练」(第 1 位),权重 0.44
「师」(第 2 位)最关注「模型」(第 6 位),权重 0.62
「标注」(第 3 位)最关注「模型」(第 6 位),权重 0.59
「数据」(第 4 位)最关注「标注」(第 3 位),权重 0.74
「训练」(第 5 位)最关注「训练」(第 1 位),权重 0.44
「模型」(第 6 位)最关注「标注」(第 3 位),权重 0.70
有个细节值得盯住:**第 1 行和第 5 行一模一样。**两个「训练」的向量相同,注意力又看不见位置,所以它俩算出来的东西分毫不差。
这不是巧合,是这套结构的天生毛病——下面验证一下:
把词序打乱后,输出的最大差值 4.4e-16
差值是 4.4e-16,等于**完全没变**。注意力只知道有哪些词、彼此多相关,不知道谁在前谁在后。
所以所有 Transformer 都得再加一层**位置编码**——不补这一手,「狗咬人」和「人咬狗」在它眼里是同一句话。
最后算一下这东西的账:
序列长度 6 个词
注意力矩阵大小 6 × 6 = 36 个数
序列长度 矩阵元素个数 单头 fp16 占用
-----------------------------------------
1000 1,000,000 1.9 MB
10000 100,000,000 190.7 MB
100000 10,000,000,000 18.6 GB
注意这是**一层单头**的量。真实模型几十层、多个头,还要存中间结果,所以「上下文长度」一直是硬成本——不是模型不想看更长,是这张表装不下。
done -> _out_39b.txt
权重矩阵里每行加起来是 1.00,这是 softmax 保证的。 看最大值的位置:「数据」最关注「标注」(0.74), 「模型」最关注「标注」(0.70)—— 这两个关系都是从随机向量里算出来的,说明这套机制确实在提取「谁跟谁近」。 第 1 行和第 5 行完全相同,因为两个「训练」的向量相同、而计算过程完全没用到位置。 这个「相同」就是位置编码存在的理由。 打乱顺序后最大差值 4.4e-16——机器精度级别的差异,等于没变。 注意这不是「差不多」,是「数学上恒等」。 最后那个账要算清楚:序列 1000 时单头 fp16 是 1.9 MB,10000 时 190.7 MB,100000 时 18.6 GB。 平方关系,涨得比直觉快。
30 秒实验
- 把 d 从 8 改成 64,看注意力矩阵变不变形状。 答案是不变——矩阵大小只跟序列长度有关,跟向量维度无关。这是注意力最省的地方。
- 手动给每个位置的向量加一个随机向量(模拟位置编码),再打乱顺序重跑,差值立刻变成非零。 你就把「位置编码」这件事亲手做了一遍。
- 把序列长度从 6 改成 12,看矩阵从 36 个数变成 144 个——差 4 倍,正好是长度翻倍的平方。
常见误区
- 「注意力就是解释性,看权重就知道模型在想什么」。 权重只反映这一层的这一头,几十层叠加之后,单层权重说明不了什么。
- 「Transformer 比 RNN 强是因为注意力更强」。 更重要的原因是能并行——RNN 必须一步步算,训练时没法同时算一万个位置。
- 「位置编码是可选的优化项」。去掉它,模型立刻分不清「狗咬人」和「人咬狗」。 这是必需品,不是优化项。
自测三问(点开对答案)
1. 为什么注意力矩阵的每一行都要归一化到 1? —— 因为它表示「这个位置把多少注意力分给每个位置」。 归一到 1 之后,加权求和才不会放大或缩小数值,输出和输入在同一个量级上。 2. 打乱词序输出完全不变,这个性质叫什么?后果是什么? —— 叫「置换等变」——输入怎么打乱,输出就跟着怎么打乱,每个位置的表示本身不变。 后果是模型分不清语序,必须靠位置编码补。 3. 为什么说注意力的显存是「平方」的? —— 因为每个位置都要和所有位置算相似度,序列长 L 就有 L×L 个权重要存。长度翻倍,显存翻四倍。1.2 节这六篇把大模型的内部结构拆完了。 接下来 1.3 节换个角度——看 DeepSeek 这个具体模型,它凭什么在同样的参数规模下更便宜。
- ☐ (本节完,下一节 1.3.1 DeepSeek为什么爆火)