上一部分把“商品有质量问题,运费谁承担?”变成了 Token ID,再查成浮点向量。但此时“运费”只是词表里固定的一行。无论前文说“质量问题”还是“无理由退货”,它最初查到的向量都一样。
模型要回答得合适,就必须让这个位置的表示受到前文影响。Transformer 反复做两类计算:让不同位置交换信息,再对每个位置得到的信息进行非线性变换。很多层之后,最后一个位置的表示用来预测下一个 Token。
本文以常见 decoder-only 自回归模型为主。先用小向量算清楚,再写一段完整前向程序。手算向量和代码中的随机权重都属于教学数据,它们帮助解释机制,不代表训练好的模型内部真的把某个维度命名成“质量”。
先看位置之间为什么需要交换信息
把一句话的输入表示记作矩阵 X。假设暂时只有四个位置,每个位置八个数,那么 X 的形状是 4 × 8。第 i 行就是第 i 个位置的向量;“隐藏维度”指每行有多少个数,并不表示有八个能直接读出来的概念。
如果只对每行独立做同样的变换,“运费”永远无法知道前面是“质量问题”还是“无理由退货”。它需要一种读取其他位置的方法。最简单可以对前文求平均,但无论问“谁承担”还是“多久能退”,平均方式都不变,缺少根据当前位置调整读取方式的能力。
Attention 就在这里加入匹配:当前位置产生一个查询表示,各个可见位置提供被匹配的表示和可汇总的内容。匹配分数决定读取比例,读取结果又成为当前位置的新信息。它不是把整句话检索出来,也不要求真的生成一个自然语言问题。
Q、K、V 从同一个输入出来,为什么却不同
设一个位置的输入是 x,模型用三组可学习参数做三种投影:
q = xWq,k = xWk,v = xWv
Wq、Wk、Wv 是不同的矩阵。对于整段输入,就是 Q = XWq、K = XWk、V = XWv。Q 称为 Query,K 称为 Key,V 称为 Value。
可以用文件检索理解它们的分工,但不能把类比当实现:查询条件决定你找什么,索引描述用于比较,找到后读到的是正文。Q 和 K 决定怎么比较,V 决定汇总什么。神经网络中的三者都是数值投影;哪些数值关系有用,要靠训练学出来。
为什么不直接用 X 做全部计算?直接点积也能构造一种注意力,但会把“怎样匹配”和“传递什么信息”绑在同一表示上。独立投影给模型更多自由:有的特征适合用于匹配,却不一定适合原样传给下一个位置。
例如一个位置可能依据“退货类型”与其他位置匹配,却需要从匹配位置取得“责任条件”的信息。这只是对功能的解释,不能据此认定某一个真实向量分量就是退货类型,也不能保证每个头都具有清晰的人类标签。
手算一次 Attention,看看它输出了什么
先只看当前位置,设 q 为 [1, 0]。它能读取两个位置:
| 位置 | Key | Value |
|---|---|---|
| 位置 A | [1, 0] | [2, 0] |
| 位置 B | [0, 1] | [0, 4] |
点积逐维相乘后相加,因此 q 与两个 Key 的分数分别是 1 和 0。Key 有两个分量,缩放因子为 √2,得到约 [0.707, 0]。
为什么除以维度平方根?在分量尺度相近等简化假设下,更多项相加会让点积分数的波动增大。过大的分数差经过 Softmax 后容易变成接近 1 和 0 的权重,使梯度和选择过早集中。缩放帮助控制量级,但它不是“把所有分数归一化到固定区间”。
Softmax 把分数变成非负、总和为 1 的权重。对分数 s,位置 j 的权重是:
aⱼ = exp(sⱼ) / Σₖ exp(sₖ)
这里 exp(0.707) ≈ 2.028,exp(0) = 1,所以两个权重约为 0.670 和 0.330。接着用它们对 Value 加权:
0.670 × [2, 0] + 0.330 × [0, 4] ≈ [1.340, 1.321]
输出是混合后的向量,没有哪个步骤直接返回“位置 A 是答案”。即使位置 A 权重大,也仍然保留了位置 B 的贡献。把第二个 Key 改成 [2, 0],它与 q 的匹配就更强;即使 Value 不变,混合比例也会变化。
对所有位置同时计算,把每个 Query 放在 Q 的一行,Key 放在 K 的一行,就能用 QKᵀ 得到所有两两匹配。四个位置产生 4 × 4 分数矩阵:第 i 行第 j 列表示位置 i 读取位置 j 时的匹配分数。
完整单头形式是:
Attention(Q, K, V) = softmax(QKᵀ / √dₖ + M)V
dₖ 是 Key 的维度,M 表示不允许读取的位置。这个形式来自原始 Transformer 论文。下面还要解释,为什么同一句话中有些位置必须被禁止读取。
训练时答案已经在后面,模型怎样避免偷看
假设训练文本是“商家 承担 运费”。模型在“商家”的位置学习预测“承担”。如果它能直接读取后面已经写好的“承担”,任务就变成抄答案,学不到仅凭已有前文生成的能力。
因果掩码把未来位置的分数设为负无穷。负无穷经过指数变成 0,因此 Softmax 后这些位置的权重为 0。四个位置的允许关系如下:
| Query 所在位置 | 看位置 1 | 看位置 2 | 看位置 3 | 看位置 4 |
|---|---|---|---|---|
| 1 | 可以 | 不可以 | 不可以 | 不可以 |
| 2 | 可以 | 可以 | 不可以 | 不可以 |
| 3 | 可以 | 可以 | 可以 | 不可以 |
| 4 | 可以 | 可以 | 可以 | 可以 |
这里的“可以看自己”很重要。位置 2 的输入 Token 已经存在,用它预测位置 3 没有泄露目标。真正需要排除的是未知的后续位置。训练时可以并行计算整张表,因为所有限制已经由矩阵掩码表达;并行不等于每个位置能读取全部内容。
上一部分的 padding mask 则处理补齐位置。例如一条请求左边补了两个空位,它们即使位于过去,也不应该被当成有效内容读取。实际实现会同时考虑两类约束。
Attention 权重可以帮助观察一次信息汇总,但不是模型决定的完整因果解释。后面还有其他头、输出投影、残差和 FFN;单凭某个词得到高权重,不能证明最终结论“只因为这个词”。
多头不是多算几遍同样的东西
若隐藏维度 d 为 8,使用两个头,每头维度可以是 4。Q、K、V 先由不同参数投影,再按头拆成两组。每个头独立计算自己的分数和加权结果,最后把两个四维输出拼成八维,再乘输出矩阵 Wo。
从形状看,带批次的 X 是 [B, N, 8];拆头后 Q 是 [B, 2, N, 4]。每个头有一张 [N, N] 权重表,最后合并回 [B, N, 8]。拆头改变的是表示组织方式,不是把句子切成两半给两个头分别看。
不同头有不同的投影参数,因而能够学习不同的关系。在售后问题中,一组关系可能帮助连接主体,另一组关系可能帮助连接条件。但这些功能没有预先写死,也可能分散在多个头和多层里。
输出投影 Wo 让模型重新组合各头结果。否则各头只是并排放着,后续只能直接接收固定分区。投影与下一层计算一起,使不同表示子空间的信息能够继续融合。
位置编码怎样让顺序进入匹配
“客户退款给商家”和“商家退款给客户”包含相似片段,却交换了付款方向。模型需要利用顺序。因果 mask 决定能看谁,但显式位置信息还帮助模型区分距离和排列关系。
一种方法是在输入中加入位置向量:xᵢ = token_embeddingᵢ + position_embeddingᵢ。相同 Token 出现在不同位置时,进入后续层的向量就不同。位置向量可以学习,也可以由正弦、余弦构造。
RoPE 采用另一种方式:对 Q、K 的成对分量做与位置相关的旋转。先在二维平面里理解,向量 [a, b] 旋转角度 θ 后变成 [a cosθ − b sinθ, a sinθ + b cosθ]。位置不同,旋转角度不同。
设原始 q、k 都为 [1, 0]。同一位置旋转后,两者仍同方向,点积为 1;若它们的角度差为 60°,点积变成 0.5。一般写作 (Rᵢq)ᵀ(Rⱼk) = qᵀRⱼ₋ᵢk,点积里出现的是相对旋转,也就是位置差。
真实 RoPE 在多个二维分量对上使用不同频率,不会简单让每个 Token 都旋转 60°。这个小例子只解释“位置如何影响 QK 匹配”。RoPE 本身也不保证任意扩大窗口都有效;训练见过的长度、频率配置与其他计算约束仍会影响长上下文表现。RoFormer 论文给出了旋转位置编码的推导。
Attention 后面为什么还需要 FFN、残差和归一化
Attention 解决了从其他位置读取信息的问题,但加权汇总之后还需要加工。例如当前位置得到一些与“质量”“核实”“承担”有关的表示后,模型要形成更有用的组合,供后续层使用。
FFN 对每个位置应用同一组带非线性的变换。简化形式是:
FFN(x) = activation(xW₁ + b₁)W₂ + b₂
第一层可以从 d 维扩展到更大的中间维度,经过激活函数,再投影回 d 维。假设 x 为 [1, -1],人为设置第一层输出为 [1, -1, 2],ReLU 把它变成 [1, 0, 2];如果第二层分别取第一项和后两项之和,结果就是 [1, 2]。
如果想把上面的数继续核算出来,可以取 W₁ = [[1, 0, 1], [0, 1, -1]],于是 [1,-1]W₁ = [1,-1,2];取 W₂ = [[1,0], [0,1], [0,1]],ReLU 后的 [1,0,2] 乘 W₂ 就得到 [1,2]。本例偏置都设为零,两张矩阵的形状分别为 2×3 和 3×2,输出因此回到两维。
这个例子重点在中间的非线性。如果没有激活函数,两次线性变换可以合成一次,单靠增加线性层数不会得到同样丰富的函数。实际模型常用 GELU 或带门控的变体;SwiGLU 可以写成两条投影分支,一条经 SiLU 后与另一条逐元素相乘,再投影回隐藏维度。
门控可以用两个数理解:一条分支给出待传递量 3,另一条分支经激活后给出系数 0.2,乘起来是 0.6。它让输入决定不同分量怎样调制,不代表每个门都严格取 0 或 1。FFN 的各位置计算彼此独立,但输入已经由 Attention 混入上下文,因此它并不是在处理孤立词语。
多层网络还有一个问题:每一层都完全替换输入,重要信息和梯度可能难以稳定传下去。残差连接写作 y = x + f(x),允许子层学习在原表示上增加的修正。例如 x 为 [2, 1],子层输出 [0.1, -0.2],结果为 [2.1, 0.8],原表示仍有一条直接路径。
残差不是“原文永远不变”的保证,后续仍会修改和混合表示。它也要求相加的形状兼容,所以多头输出与 FFN 最后都要回到隐藏维度。
归一化帮助控制每个位置的数值尺度。LayerNorm 对一个位置的隐藏分量减均值、除以标准差,再使用可学习缩放和偏移。比如 [1, 3] 的均值是 2,不计 epsilon 时标准化为 [-1, 1];RMSNorm 通常不减均值,用均方根缩放,简化部分计算。
用同样的 [1,3] 观察 RMSNorm:均方根是 √((1²+3²)/2) = √5,忽略 epsilon 且缩放为 1 时得到约 [0.447,1.342]。它与 LayerNorm 的 [-1,1] 不同,因为没有减去均值。两者都管理尺度,但具体变换不同。
epsilon 是分母里防止极小尺度导致不稳定的小数。可学习缩放意味着归一化不要求所有层永久维持完全一样的表示分布。现代模型常在子层之前归一化,简化的 Pre-Norm Block 可以写成:
h = x + Attention(Norm(x))
y = h + FFN(Norm(h))
Attention 在位置之间通信,FFN 在各位置加工,残差保留直接通路,Norm 管理尺度。它们各自解决问题,也共同决定整层的行为。
多层之后,怎样变成下一个 Token 的分数
一个 Block 输出的形状仍是 [B, N, d],可以交给下一个 Block。后面的层面对的是已经融合过信息的表示,因此能够继续组合关系。层数不能简单理解成“思考几步”,但多层非线性计算确实扩大了可表达的函数范围。
最后取用于预测的隐藏状态 h,乘 LM head,得到词表大小的 logits。若词表有 V 个条目,输出是 V 个实数,不是 V 个已确认的事实。某些模型把输出投影权重与输入 Embedding 共享,但方向和作用仍不同:输入按 ID 取行,输出用隐藏表示给所有候选打分。
例如教学词表只含“商家”“买家”“未知”,logits 是 [2, 1, 0],Softmax 约为 [0.665, 0.245, 0.090]。这些概率描述当前输入下模型的续写分布,不等于商家承担责任的法律概率,也没有自动查询订单。
选出一个 ID 后,Tokenizer 恢复文字,再把新 ID 接到序列后继续。第一步即使选中“商家”,后续仍可能生成“商家需要先核实情况”;单看开头一个 Token 并不能确定最终完整结论。采样与训练如何影响这些分数,在下一篇展开。
把整条链写成一个小型前向程序
下面实现一个带两个头的教学 Block,使用 PyTorch 自动处理矩阵运算。位置编码采用可学习位置表,FFN 使用 GELU,归一化采用 Pre-Norm;它没有同时实现 RoPE、GQA、MoE,避免把基本计算和结构变体混在一段代码里。
下载文件:transformer_forward.py下载。程序只初始化随机小模型,不下载权重。下面的形状可以从代码定义推导,输出的具体随机数不代表真实语言模型的判断。
import math
import torch
from torch import nn
# 固定初始化种子方便在同一环境观察;它不会让随机模型获得语言能力。
torch.manual_seed(7)
class TinyDecoder(nn.Module):
def __init__(self, vocab=50, width=8, heads=2, max_length=16):
super().__init__()
# 每个头平均分配隐藏维度,因此 width 必须能被 heads 整除。
if width % heads:
raise ValueError("隐藏维度必须能被头数整除")
self.heads, self.head_dim = heads, width // heads
self.token = nn.Embedding(vocab, width)
self.position = nn.Embedding(max_length, width)
self.norm1 = nn.LayerNorm(width)
# 一次投影生成 Q/K/V,再沿最后一维切开;三部分参数互不相同。
self.qkv = nn.Linear(width, 3 * width, bias=False)
self.out = nn.Linear(width, width, bias=False)
self.norm2 = nn.LayerNorm(width)
self.ffn = nn.Sequential(nn.Linear(width, 4 * width), nn.GELU(),
nn.Linear(4 * width, width))
self.final_norm = nn.LayerNorm(width)
self.lm_head = nn.Linear(width, vocab, bias=False)
def forward(self, ids):
batch, length = ids.shape
# 本例只有等长、无填充输入;因果 mask 之外没有 padding mask。
positions = torch.arange(length, device=ids.device)
x = self.token(ids) + self.position(positions)
q, k, v = self.qkv(self.norm1(x)).chunk(3, dim=-1)
# [B,N,d] → [B,N,H,D] → [B,H,N,D],让各头独立做矩阵乘法。
def split_heads(tensor):
return tensor.reshape(batch, length, self.heads, self.head_dim).transpose(1, 2)
q, k, v = map(split_heads, (q, k, v))
scores = q @ k.transpose(-2, -1) / math.sqrt(self.head_dim)
# 上三角代表未来位置;设为负无穷后,softmax 权重变成零。
future = torch.ones(length, length, dtype=torch.bool, device=ids.device).triu(1)
weights = scores.masked_fill(future, float("-inf")).softmax(dim=-1)
mixed = weights @ v
# 合并各头,投影回原隐藏维度,再接残差与逐位置 FFN。
mixed = mixed.transpose(1, 2).contiguous().reshape(batch, length, -1)
x = x + self.out(mixed)
x = x + self.ffn(self.norm2(x))
logits = self.lm_head(self.final_norm(x))
return logits, weights
# 延续第一篇的教学 ID;这里不使用真实模型的 Tokenizer。
ids = torch.tensor([[3, 8, 12, 5, 17, 42, 23, 6]], dtype=torch.long)
model = TinyDecoder().eval()
with torch.no_grad():
logits, attention = model(ids)
# 仅最后一个已知位置用于本次的下一个 Token 预测。
probabilities = logits[0, -1].softmax(dim=-1)
values, indices = probabilities.topk(3)
print("各位置词表分数形状:", tuple(logits.shape)) # [1,8,50]
print("各头注意力形状:", tuple(attention.shape)) # [1,2,8,8]
print("首头第一行:", attention[0, 0, 0]) # 只能读第一个位置
print("随机模型候选 ID 与概率:", list(zip(indices.tolist(), values.tolist())))
观察时先别关心哪个 ID 得分最高。真正要看的是:八个位置各自产生五十个分数;两个头各自形成八乘八的权重表;第一行只能读取第一个位置;最后一行可以读取八个位置。随机候选 ID 没有业务含义,不能用它回答运费问题。
把 heads 从 2 改成 4,width 保持 8,每头维度就从 4 变成 2。权重表的头数变化,最终 logits 形状仍然不变。若删除因果 mask,第一行也能读取后面的内容,程序仍可运行矩阵运算,却不再符合当前自回归训练任务。
再看 GQA 和 MoE:它们修改了哪一部分
上面的多头注意力让每个 Query 头都有自己的 K/V 头。若 Query 有 32 个头,K/V 也有 32 个头,长序列就要保存很多历史 K/V。GQA 把多个 Query 头分成一组,共享较少的 K/V 头;MQA 可以看成只保留一组 K/V 的特殊情形。
例如 32 个 Query 头配 8 个 KV 头,每四个 Query 头使用同组 K/V。各 Query 仍然不同,所以不等于四个头得到完全相同的权重;但需要存储的 K/V 头从 32 减为 8,在其余条件相同下,缓存中的这一部分降到四分之一。GQA 论文讨论了这种结构。
MoE 通常改造的是 FFN。把一个稠密 FFN 换成多个专家 FFN,再由路由器根据当前 Token 表示选择少数专家。例如八个专家中选择两个,分别计算后按路由权重组合。不同 Token 可以选不同专家,不是整句固定分给同一个模型。
假设八个专家各有一千万参数,专家总量是八千万;一个 Token 只经过其中两个时,这部分活跃参数约两千万。Attention、路由器等其他部分还要计算,部署也可能要存放全部专家,所以“只激活两个”不代表整个模型只占两千万参数的内存。
路由集中到少数专家还会导致负载不均;分布式部署可能需要把 Token 表示传到专家所在设备。因此 MoE 的总参数量、活跃参数量、实际延迟是三个不同指标。理解组件改在哪里,比单看一个巨大的参数数字更有意义。
最后区分三种骨干结构。Encoder-only 通常让位置双向读取,适合产生整段输入的表示;decoder-only 使用因果读取逐步生成;encoder-decoder 先编码输入,再让因果解码器通过交叉注意力读取编码结果。例如翻译时输入整句英文,解码器生成中文,交叉注意力的 Q 来自中文侧,K/V 来自英文编码侧。
它们共享许多组件,但可见范围和任务不同。使用 Transformer 并不意味着一个模型一定能聊天,也不意味着不同模型拥有同样能力。接下来需要解释这些矩阵怎样从随机数变成有用参数,见训练、微调与生成策略。