大语言模型原理(五):Transformer 多头注意力、残差与归一化

文章内容

上一篇把「女生撩刘海」这三个 token 算到了注意力的输出 Z。但 Z 还不是这一层的结果。从 Z 到真正送给下一层的东西,中间还隔着多头拼接、两次残差、两次层归一化和一个前馈网络。这几步各自解决一个具体问题,去掉任何一个,模型要么学不到足够丰富的关系,要么深度一堆上去就训不动。

这一篇把它们逐个计算完,例子和维度都沿用上一篇:三个 token,d = 2。

一、回顾:注意力机制算到了哪

输入矩阵 X 是词嵌入加位置编码的结果:

plain text
X = [[3.0000, 2.0000]     ← 女生     [2.8415, 2.5403]     ← 撩     [2.9093, 3.5839]]    ← 刘海

X 乘三个参数矩阵得到 Q、K、V,然后走缩放点积注意力:

plain text

三个角色可以套一个检索的比方:Q 是搜索框里敲进去的关键词,K 是每篇文档的标题,V 是文档正文。Q·Kᵀ 算出关键词和每个标题的匹配度,除 √d_k 压住量级,softmax 换算成占比,再拿这组占比去加权正文,得到的 Z 就是每个 token 融合上下文之后的新表示。

上一篇算出来的 Z 是:

plain text
Z = [[1.5636, 2.5717]     [1.6144, 2.4205]     [1.6969, 2.1815]]

完整 Transformer 层结构

二、多头注意力:一个头看不全面

2.1 一句话里的多种联系

「女生撩刘海」这五个字里同时存在好几层关系。女生做出了一个动作,这是主谓;动作作用在刘海上,这是动宾;刘海是女生头发的一部分,这是从属;再退一步,如果「刘海」是个人名,「撩」的含义就完全变了。

一次注意力计算只有一组 W_Q、W_K、W_V,它学到的检索规则是固定的一套,只能沿着一个方向去找相关性。上一篇算出的那个头把 80.8% 的权重压在「刘海」上,捕到的是动宾关系,其他几层关系它看不见。

Transformer 的应对方式直接:一个头不够就多摆几个,各看各的角度,最后合起来。

2.2 多组参数矩阵并行算多个 head

准备 h 组互相独立的 W_Q、W_K、W_V,每组各自跑一遍完整的缩放点积注意力,互不干扰。本例取 h = 3。

第一个头沿用上一篇的参数,权重矩阵和输出已经算过。另外两个头换一组随机初始化的参数,跑出来的注意力权重是这样:

plain text
head1 的 A:[[0.0551, 0.1371, 0.8078]      女生 →  80.8% 给「刘海」             [0.1000, 0.1896, 0.7105]             [0.1838, 0.2518, 0.5644]] head2 的 A:[[0.3486, 0.3916, 0.2598]      女生 →  分布最平,略偏「撩」             [0.2707, 0.3464, 0.3829]             [0.1593, 0.2523, 0.5884]] head3 的 A:[[0.6500, 0.2768, 0.0732]      女生 →  65% 留给自己             [0.6873, 0.2512, 0.0615]             [0.7611, 0.2009, 0.0380]]

同一句话,同一个 X,三个头给出三种完全不同的关注分布:一个盯住动作的对象,一个在三个 token 之间大致均摊,一个把注意力收回到词自身。这正是多头想要的效果。

三个头各自的输出:

plain text
head1 = [[1.5636, 2.5717]    head2 = [[3.2351, 2.6812]    head3 = [[1.2439, 2.9709]         [1.6144, 2.4205]             [3.3874, 2.6427]             [1.2024, 2.9615]         [1.6969, 2.1815]]            [3.6335, 2.5836]]            [1.1201, 2.9428]]

2.3 Concat 拼接 + W_O 投影回原维度

三个头各是 3×2,横向拼接成 3×6:

plain text
Concat = [[1.5636, 2.5717, 3.2351, 2.6812, 1.2439, 2.9709]          [1.6144, 2.4205, 3.3874, 2.6427, 1.2024, 2.9615]          [1.6969, 2.1815, 3.6335, 2.5836, 1.1201, 2.9428]]

维度涨到了 6,而下一层要的还是 2。补一个 6×2 的输出投影矩阵 W_O 把它压回去:

plain text
       [ 0.10,  0.30]       [-0.20,  0.20]       [ 0.30,  0.05]       [ 0.10, -0.20]       [ 0.15,  0.25]]

手算「女生」那一行的第 0 维:

plain text
1.5636×0.20 + 2.5717×0.10 + 3.2351×(-0.20)+ 2.6812×0.30 + 1.2439×0.10 + 2.9709×0.15 = 0.3127 + 0.2572 - 0.6470 + 0.8044 + 0.1244 + 0.4456 = 1.2973

三行两列算完:

plain text
MultiHead = [[1.2973, 1.8902]             [1.2447, 1.8742]             [1.1594, 1.8523]]

W_O 不是一个凑维度的补丁。拼接之后各头的输出还是彼此独立的六个数,W_O 让它们线性混合起来,模型才能学到「哪个角度在什么场景下更重要」。它和三组 QKV 参数一样,是训练中要更新的权重。

2.4 多头注意力公式

plain text

实际实现里每个头的维度是 d_model / h,h 个头拼起来刚好还是 d_model,总计算量和单头基本持平。多头换来的是表达能力,不是算力开销。

多头注意力:多组参数并行计算再拼接

三、残差连接:新信息叠加在旧信息上

3.1 X' = X + MultiHead(X)

把多头的输出直接加回输入:

plain text
女生  [3.0000, 2.0000] + [1.2973, 1.8902] = [4.2973, 3.8902]撩    [2.8415, 2.5403] + [1.2447, 1.8742] = [4.0862, 4.4145]刘海  [2.9093, 3.5839] + [1.1594, 1.8523] = [4.0687, 5.4362]

形状不变,仍是 3×2。

3.2 为什么不是覆盖

注意力输出携带的是 token 之间的关系,原始的 X 携带的是 token 自身的词义和位置。如果直接用注意力输出替换 X,词本身是什么就被冲掉了——「女生」这一行会变成一堆关于「它和刘海很相关」的信息,却丢了「它是一个人」。相加保住两头:底子还在,新的上下文叠上去。

学新方法不必把旧方法擦掉,这是同一个道理。

更关键的作用在训练。残差给梯度开了一条直通的路:反向传播时,梯度可以绕过注意力子层原样传回去,不必逐层穿过一串矩阵乘法被反复缩放。没有这条路,几十层叠上去梯度就消失了,Transformer 也就堆不深。

残差连接:X 与注意力输出相加

四、层归一化:把数值拉回稳定区间

4.1 残差之后数值分布失衡

看残差之后的三行:第一行两个数都在 4 附近,第三行一个 4.07 一个 5.44。不同 token、不同维度上的数值量级已经开始散开,再往上叠十几层,有的通道会持续放大,有的持续缩小。后面的激活函数和 softmax 都对输入量级敏感,量级失控直接拖垮训练。

层归一化把每个 token 向量单独拉回标准区间:均值 0,方差 1。它只在一行内部做统计,和别的 token 无关,也和 batch 无关——这是它比 BatchNorm 更适合变长序列的原因。

4.2 三步手算:均值、方差与标准差、归一化

拿残差后的第一行 [4.2973, 3.8902]。

第一步,均值:

plain text
μ = (4.2973 + 3.8902) / 2 = 4.0937

第二步,方差与标准差。方差是每个数减均值再平方,求和后除以个数:

plain text
偏差:4.2973 - 4.0937 =  0.2035      3.8902 - 4.0937 = -0.2035σ² = (0.2035² + (-0.2035)²) / 2 = 0.0414σ  = √0.0414 = 0.2035

第三步,每个数减均值除以标准差:

plain text
(4.2973 - 4.0937) / 0.2035 =  1(3.8902 - 4.0937) / 0.2035 = -1

三行都做一遍:

plain text
X' = [[ 1, -1]      μ=4.0937  σ=0.2035      [-1,  1]      μ=4.2504  σ=0.1641      [-1,  1]]     μ=4.7524  σ=0.6838

三行的原始量级差了三倍多,归一化之后回到同一个尺度,只剩下方向信息。

4.3 γ 与 β:归一化之后的线性变换

标准化不是最后一步。LayerNorm 完整的公式是:

plain text
LayerNorm(x) = γ · (x - μ) / √(σ² + ε) + β

γ 和 β 是与向量维度等长的可学参数,训练中和其他权重一起更新。强行把每一层的输出都摁成均值 0、方差 1,会剥夺模型调整分布的自由;γ 和 β 把这份自由还回去——需要的话,模型可以学着把分布重新拉宽或者平移,极端情况下甚至学回原样。ε 是一个很小的常数(通常 1e-5),防止方差为零时除零。

本例取 γ = 1、β = 0,上面的结果不变。

4.4 维度为 2 时结果必为 ±1

上面三行归一化后全是 1 和 -1,这不是巧合。d = 2 时设两个数为 a 和 b:

plain text
μ = (a+b)/2偏差 = ±(a-b)/2σ² = ((a-b)/2)² ,  σ = |a-b|/2归一化 = ±(a-b)/2 ÷ |a-b|/2 = ±1

只要 a ≠ b,结果一定是一个 +1 一个 -1,跟具体数值无关。真实模型里 d 是几百上千,归一化后是一组分布在 0 附近的小数,信息不会像这样被压没。这里出现整齐的 ±1,纯粹是把维度砍到 2 的副作用。

层归一化三步计算

五、前馈神经网络:每个 token 独立思考

5.1 注意力是开会,FFN 是回到工位

注意力做的是 token 之间的信息交换,每个 token 从别人那里取信息,但取回来之后并没有对这些信息做什么加工——A·V 是一次加权平均,是线性的。

FFN 补的就是这一步:会开完了,各自回到工位,把刚才收集到的东西在自己的向量里做一次非线性变换。它对每一行独立作用,不跨 token,参数在所有位置上共享。

Transformer 的参数量大头也在这里。注意力负责路由信息,FFN 负责存储和加工知识。

5.2 公式:两次线性变换 + ReLU

plain text
FFN(x) = ReLU(x·W₁ + b₁)·W₂ + b₂

W₁ 把维度升上去,W₂ 再降回来。原论文里 d_model = 512、中间层 2048,四倍放大;本例把中间层取成 4:

plain text
W₁ = [[0.9, -0.7,  0.4, 1.2]      b₁ = [0.1, 0.2, -0.3, 0.0]      [0.5,  0.8, -1.1, 0.3]] W₂ = [[ 0.6, -0.4]                b₂ = [0.05, -0.10]      [ 0.2,  0.9]      [-0.5,  0.3]      [ 0.7,  0.5]]

5.3 计算整合

拿归一化后的第一行 x = [1, -1]。

第一次线性变换:

plain text
x·W₁ = [1×0.9 + (-1)×0.5,  1×(-0.7) + (-1)×0.8,        1×0.4 + (-1)×(-1.1),  1×1.2 + (-1)×0.3]     = [0.4, -1.5, 1.5, 0.9] 加 b₁ → [0.5, -1.3, 1.2, 0.9]

过 ReLU,大于 0 的原样保留,小于 0 的直接归零:

plain text
ReLU([0.5, -1.3, 1.2, 0.9]) = [0.5, 0, 1.2, 0.9]

-1.3 被切掉了。这个截断就是非线性的来源:没有它,两次矩阵乘法可以合并成一次,四维中间层白升,整个 FFN 退化成一个 2×2 的线性变换,加不加没区别。

第二次线性变换:

plain text
第 0 维:0.5×0.6 + 0×0.2 + 1.2×(-0.5) + 0.9×0.7 = 0.33第 1 维:0.5×(-0.4) + 0×0.9 + 1.2×0.3 + 0.9×0.5 = 0.61加 b₂ → [0.38, 0.51]

三行都算完:

plain text
FFN(X') = [[0.38, 0.51]           [0.39, 1.43]           [0.39, 1.43]]

后两行完全一样,因为归一化把它们都压成了 [-1, 1]。这依然是 d = 2 的后遗症。

FFN:两次线性变换加 ReLU

六、第二次残差与归一化:一层的收尾

6.1 两次残差保护不同阶段的信息

FFN 的输出同样不覆盖,而是加回它的输入:

plain text
女生  [ 1, -1] + [0.38, 0.51] = [ 1.38, -0.49]撩    [-1,  1] + [0.39, 1.43] = [-0.61,  2.43]刘海  [-1,  1] + [0.39, 1.43] = [-0.61,  2.43]

两次残差保护的不是同一份东西。第一次接在注意力后面,护住的是进注意力之前的 X,也就是纯粹的词义加位置;第二次接在 FFN 后面,护住的是进 FFN 之前的 X',也就是已经完成上下文交换的表示。每个子层都被包成「原样通过 + 增量修正」的形式,子层学到的是修正量,不是全新的表示。

再做一次层归一化:

plain text
第一行:μ = (1.38 - 0.49)/2 = 0.445,σ = 0.935 → [ 1, -1]第二行:μ = (-0.61 + 2.43)/2 = 0.910,σ = 1.520 → [-1,  1]第三行:同上                                    → [-1,  1]

这一层的最终输出:

plain text
Output = [[ 1, -1]          [-1,  1]          [-1,  1]]

形状还是 3×2,和输入 X 完全一致。所以这一层可以原样再叠一遍,叠 12 层、48 层都行。

6.2 一个完整 Transformer 层的结构

plain text
X ├──────────────────────────┐ ↓                          │多头注意力                   │ ↓                          │ (+) ←──────────────────────┘        残差 ↓LayerNorm ↓  X' ├──────────────────────────┐ ↓                          │前馈网络 FFN                 │ ↓                          │ (+) ←──────────────────────┘        第二次残差 ↓LayerNorm ↓Output

四个组件,各管一件事:多头注意力负责 token 之间交换信息,残差负责保住旧信息并让梯度通得到底,LayerNorm 负责把数值分布摁在稳定区间,FFN 负责每个 token 独立做非线性加工。

两次残差保护不同阶段的信息

七、最小实现Demo

下面这段代码把前六节的数字完整跑一遍

python
import numpy as np  # 只依赖 numpy,不用任何深度学习框架 # ========== 上一篇算出来的输入矩阵:词嵌入 + 位置编码 ==========X = np.array([[3.0000, 2.0000],      # 女生              [2.8415, 2.5403],      # 撩              [2.9093, 3.5839]])     # 刘海d_model = X.shape[1]                 # 模型维度,本例为 2n_heads = 3                          # 注意力头的个数  # ========== 基础算子 ==========def softmax(scores):                                 # 按行做 softmax    m = scores.max(axis=1, keepdims=True)            # 取每行最大值    e = np.exp(scores - m)                           # 减最大值再取指数,防止 e^x 溢出,结果不变    return e / e.sum(axis=1, keepdims=True)          # 除以行和,每行归一化成和为 1 的权重  def relu(x):                                         # ReLU 激活函数    return np.maximum(x, 0.0)                        # 大于 0 的保留,小于 0 的置零,引入非线性  def layer_norm(x, gamma, beta, eps=1e-9):            # 层归一化,逐行独立计算    mu = x.mean(axis=1, keepdims=True)               # 每行的均值 μ    var = x.var(axis=1, keepdims=True)               # 每行的方差 σ²    x_hat = (x - mu) / np.sqrt(var + eps)            # 标准化:均值拉到 0,方差拉到 1    return gamma * x_hat + beta                      # 再做可学的缩放 γ 与平移 β  # ========== 缩放点积注意力:单个头 ==========def attention(X, W_Q, W_K, W_V):                     # 一个注意力头的完整计算    Q = X @ W_Q                                      # Q:每个 token 想找什么信息    K = X @ W_K                                      # K:每个 token 自己带着什么信息    V = X @ W_V                                      # V:每个 token 实际要传出去的内容    d_k = K.shape[1]                                 # 键向量的维度    S = (Q @ K.T) / np.sqrt(d_k)                     # 点积打分再除以 √d_k,避免 softmax 饱和    A = softmax(S)                                   # 分数转成每行和为 1 的注意力权重    return A, A @ V                                  # 返回权重矩阵和加权求和的结果  # ========== 三个头各自的参数矩阵(训练前随机初始化) ==========head_params = [    (np.array([[-0.6, 0.9], [0.8, -0.5]]),           # head1 的 W_Q     np.array([[0.6, 0.1], [-0.1, 1.4]]),            # head1 的 W_K     np.array([[1.0, -0.5], [-0.4, 1.2]])),          # head1 的 W_V,与上一篇一致    (np.array([[0.8, 1.0], [-1.2, -0.5]]),           # head2 的 W_Q     np.array([[-0.6, -1.2], [-0.7, -0.2]]),         # head2 的 W_K     np.array([[0.3, 1.1], [0.9, -0.2]])),           # head2 的 W_V    (np.array([[-0.9, 0.3], [1.1, -1.3]]),           # head3 的 W_Q     np.array([[0.3, -0.8], [0.7, 0.9]]),            # head3 的 W_K     np.array([[-0.5, 0.7], [1.2, 0.4]])),           # head3 的 W_V] W_O = np.array([[0.20, -0.10],                       # 输出投影矩阵 W_O,把拼接后的 6 维压回 2 维                [0.10, 0.30],                [-0.20, 0.20],                [0.30, 0.05],                [0.10, -0.20],                [0.15, 0.25]])  def multi_head_attention(X, head_params, W_O):       # 多头注意力    heads, weights = [], []                          # 分别收集每个头的输出和注意力权重    for W_Q, W_K, W_V in head_params:                # 逐个头计算,各头之间互不干扰        A, head = attention(X, W_Q, W_K, W_V)        # 该头的注意力权重与输出        weights.append(A)                            # 记下权重,便于观察各头关注点        heads.append(head)                           # 记下输出,稍后拼接    concat = np.concatenate(heads, axis=1)           # 沿维度方向横向拼接,3×2 × 3 → 3×6    return weights, concat, concat @ W_O             # 乘 W_O 混合各头信息并投影回 3×2  # ========== 前馈网络的参数 ==========W1 = np.array([[0.9, -0.7, 0.4, 1.2],                # 第一次线性变换,把 2 维升到 4 维               [0.5, 0.8, -1.1, 0.3]])b1 = np.array([0.1, 0.2, -0.3, 0.0])                 # 第一次线性变换的偏置W2 = np.array([[0.6, -0.4],                          # 第二次线性变换,把 4 维降回 2 维               [0.2, 0.9],               [-0.5, 0.3],               [0.7, 0.5]])b2 = np.array([0.05, -0.10])                         # 第二次线性变换的偏置  def feed_forward(x, W1, b1, W2, b2):                 # 前馈网络,对每一行独立作用    return relu(x @ W1 + b1) @ W2 + b2               # 升维 → ReLU → 降维  # ========== 一个完整的 Transformer Encoder 层 ==========gamma = np.ones(d_model)                             # LayerNorm 的缩放参数,本例取全 1beta = np.zeros(d_model)                             # LayerNorm 的平移参数,本例取全 0  def encoder_layer(X):                                # 完整一层:注意力 → 残差 → 归一化 → FFN → 残差 → 归一化    weights, concat, mha = multi_head_attention(X, head_params, W_O)  # 多头注意力输出    residual1 = X + mha                              # 第一次残差:保住进注意力之前的 X    X1 = layer_norm(residual1, gamma, beta)          # 第一次层归一化,逐行拉成均值 0 方差 1    ffn = feed_forward(X1, W1, b1, W2, b2)           # 每个 token 独立过前馈网络    residual2 = X1 + ffn                             # 第二次残差:保住进 FFN 之前的 X1    out = layer_norm(residual2, gamma, beta)         # 第二次层归一化,得到这一层的最终输出    return weights, concat, mha, residual1, X1, ffn, residual2, out  weights, concat, mha, r1, X1, ffn, r2, out = encoder_layer(X)  # 跑一遍完整的一层 np.set_printoptions(precision=4, suppress=True)      # 打印保留 4 位小数,关掉科学计数法for i, A in enumerate(weights):                      # 逐个打印每个头的注意力权重    print(f"head{i + 1} 的注意力权重 A =\n{A}\n")for name, mat in [("Concat", concat), ("MultiHead", mha),   # 依次打印每一步的中间矩阵                  ("X + MultiHead", r1), ("LayerNorm1", X1),                  ("FFN", ffn), ("X1 + FFN", r2), ("Output", out)]:    print(f"{name} =\n{mat}\n")                      # 输出与正文里手算的数字一致

代码里 LayerNorm 的 ε 取了 1e-9,为的是让打印结果和正文计算相匹配;真实实现通常用 1e-5 或 1e-6。

一层跑完,「女生」这个 token 从最初的 [3, 2] 变成了 [1, -1]:它先在三个角度上和另外两个 token 交换了信息,又保留了自己的底子,做过一次非线性加工,最后被拉回标准分布。堆到第二层,它会带着这份表示继续和上下文交互。编码器就是这样一层一层把上下文卷进每个 token 里的。


本文由 AgentPlanFlow 生成

觉得不错?给作者点个赞吧

分类:大模型

评论 (0)

无需注册,留言经站长审核后公开。

加载评论中…

大语言模型原理(五):Transformer 多头注意力、残差与归一化 · Yinchen 的博客