模型核心:注意力与因果掩码(GPT 训练分步学习·第二步)

本文是「GPT 训练分步学习路径」的第二步,基于 GPT_teacher-3.37M-cn 这个教学项目,讲清楚「一串数字进了模型后,到底发生了什么,最后怎么变成对下一个字的预测」。文中所有代码引用均来自本项目真实的 src/model.py

分步学习路径共五步:① 看懂数据长什么样 → ② 模型核心(注意力+因果掩码)→ ③ 训练循环 → ④ 验收评估 → ⑤ 推理与部署。本文是第二步。


目录

  1. 这一步属于哪个环节
  2. 输入与输出:x 进 logits 出
  3. 第一站:把字 id 变成向量
  4. 注意力:字与字之间互相「看」
  5. QKV 到底是什么
  6. 位置信息:RoPE 旋转编码
  7. 因果掩码:只看前文不看未来
  8. MLP、SwiGLU 与残差连接
  9. 收尾:归一化与词表打分
  10. teacher forcing:训练时的并行秘密
  11. 核心结论速览

1. 这一步属于哪个环节

整个项目的全流程是:

1
构建分词器 → 数据处理 → 训练 → 验收 → Web 演示

本文讲的是 模型本身的结构与前向计算,对应代码 src/model.py。它的作用是:

接收上一步(数据处理)产出的输入 x(一串字 id),经过嵌入、注意力、前馈、归一化,最后输出对下一个字的打分。

这一步是「训练」的核心——训练循环反复调用模型的前向计算,再根据输出的对错去调整模型内部的参数。


2. 输入与输出:x 进 logits 出

看模型的主入口 GPT.forwardsrc/model.py 第 181 行):

1
2
3
4
5
6
7
8
def forward(self, idx, kv_caches=None):
B, T = idx.shape # 输入:一批句子,每句 T 个字
x = self.tok_emb(idx) # 字 id → 向量
for blk in self.blocks: # 走 4 层 Block
x, _ = blk(x, ...)
x = self.norm(x) # 归一化
logits = self.head(x) # 打分
return logits, ...
  • 输入 idx:形状 [B, T]整数 id,不是向量。B 是一批几句话,T 是每句多少个字。
  • 输出 logits:形状 [B, T, vocab_size],每个位置对全词表每个字的打分。

一句话:进去的是「一串字 id」,出来的是「每个位置该接哪个字的打分表」。


3. 第一站:把字 id 变成向量

src/model.py 第 157 行定义了词嵌入表:

1
self.tok_emb = nn.Embedding(vocab_size, n_embd)   # 每个字一个 256 维向量

它就是一张查找表:给一个字 id,返回对应的 256 维向量。

  • 训练开始前,这张表就已经存在,只是里面装的是随机初始化的值(第 179 行 std=0.02 的正态分布)。
  • 训练过程中,这些向量被一点点调准,最终学出「字与字之间的语义关系」。

所以第 183 行的 x = self.tok_emb(idx),就是把 [B, T] 的 id 变成 [B, T, 256] 的向量。


4. 注意力:字与字之间互相「看」

注意力是 Transformer 的心脏。它做的事只有一句话:让每个字去「看」句子里其他的字,按重要性把它们的信息汇总过来。

核心代码在 src/model.py 第 47 行的 standard_attention

1
2
3
attn = (q @ k.transpose(-2, -1)) * (D ** -0.5)   # 第50行:算「谁该关注谁」
attn = F.softmax(attn, dim=-1) # 第56行:变成权重
return attn @ v # 第57行:加权汇总

三步走:

  1. Q·Kᵀ:算出每个字对其他字的「关注度打分」。
  2. softmax:把打分变成加起来等于 1 的权重。
  3. @V:按权重把其他字的信息加权求和,得到「融合了上下文」的新向量。

注意力权重示意

类比:开会时每个人(字)都在听别人发言,但会更关注跟自己相关的人,最后把听到的重点记下来。


5. QKV 到底是什么

Q(Query 查询)、K(Key 键)、V(Value 值)不是查表得来的,而是把字向量分别乘以三个可学习的矩阵投影出来的。看 src/model.py 第 87-89 行:

1
2
3
q = self.wq(x)...    # 字向量 × Wq → Query
k = self.wk(x)... # 字向量 × Wk → Key
v = self.wv(x)... # 字向量 × Wv → Value
  • Wq / Wk / Wv(第 71-73 行的三个 Linear)是训练学出来的矩阵,训练完就固定,存进 best.pt
  • 喂进矩阵的字向量因上下文而异,所以同一个字在不同句子里,算出的 Q/K/V 也不同。

类比:Q 是「我想找什么」,K 是「我能提供什么标签」,V 是「我实际的内容」。用 Q 去和所有 K 比对,找到最匹配的,再取它们的 V。


6. 位置信息:RoPE 旋转编码

我爱你你爱我 用的字一样,意思却相反——所以模型必须知道每个字的位置

本项目用 RoPE(旋转位置编码),代码在 src/model.py 第 19 行的 rope(),在第 92 行被调用:

1
q, k = rope(q, k, T, ...)   # 算完 Q/K 后,按位置把它们「旋转」一个角度

关键点:

  • 位置信息不在 tok_emb 那一步注入,而是在注意力内部、算完 Q/K 之后用旋转的方式注入。
  • 只旋转 Q 和 K,不旋转 V——因为位置的作用是影响「谁关注谁」(体现在 Q·K 里)。

类比:把每个字的 Q/K 向量想象成钟表指针,第几个字就转几个角度,于是点积时就能感知「谁在前、谁在后、隔多远」。


7. 因果掩码:只看前文不看未来

GPT 的任务是「用前文猜下一个字」。如果算某个字时能偷看到后面的字,就等于提前看到答案,训练出的模型在真实推理时会废掉。

因果掩码强制「只看前文」,代码在 src/model.py 第 54 行:

1
2
3
mask = torch.tril(torch.ones(T, T_kv))          # 下三角矩阵
attn = attn.masked_fill(mask == 0, float("-inf")) # 未来位置填 -inf
attn = F.softmax(attn, dim=-1) # softmax 后 -inf → 0

我爱吃饭 为例,注意力打分表被划掉右上角(未来):

1
2
3
4
5
         我    爱    吃    饭
我 [ ✓ -inf -inf -inf ]
爱 [ ✓ ✓ -inf -inf ]
吃 [ ✓ ✓ ✓ -inf ]
饭 [ ✓ ✓ ✓ ✓ ]

因果掩码示意

softmax 会把 -inf 变成 0 权重,未来的字对当前字贡献彻底归零。

这就是 GPT(单向、只看前文)和 BERT(双向、能看全文)最本质的区别。


8. MLP、SwiGLU 与残差连接:为什么还不够

注意力只会「搬运 + 加权平均」,是线性的。但语言规律高度非线性(「不」+「好」是贬义,「不」+「错」是褒义)。所以每层注意力之后还要接一个 **MLP(前馈网络)**做非线性加工。

src/model.py 第 137 行的 Block

1
2
3
4
def forward(self, x, ...):
x = x + self.attn(self.norm1(x), ...) # 注意力:字间搬信息
x = x + self.mlp(self.norm2(x)) # MLP:每个字自己深加工
return x
  • 注意力负责「字与字之间交换信息」(开会通气)。
  • MLP负责「每个字自己消化加工」(会后整理笔记)。
  • x = x + ...残差连接,保证深层不丢信息。

下面把这两块单独展开讲。

8.1 前馈网络(MLP)到底怎么加工

MLP 不是简单一个线性层。看 src/model.py 第 120 行的 MLP,它用的是 Llama 风格的 SwiGLU(第 134 行):

1
2
def forward(self, x):
return self.drop(self.w_down(F.silu(self.w_gate(x)) * self.w_up(x)))

拆开三个线性层(第 128-130 行):

步骤 作用 类比
w_gate+silu 算一个「门控」信号,决定放行多少 阀门开度
w_up 把特征升维展开(dhidden 摊开细看
gate * up 门控逐元素相乘,筛选信息 阀门控水流
w_down 再降回原维度(hiddend 收拢结论

关键是「先升维再降维」:中间维度 hidden 比输入大(第 126 行约 4d×2/3),先把特征摊到更大空间做非线性加工,再压回来。模型绝大多数参数其实堆在这三个矩阵里。

经典版 FFN 是 Linear → ReLU → Linear 两层;SwiGLU 多了一个门控分支(w_gate),加工更精细,是现在主流做法,这也是本项目要三个线性层的原因。

8.2 残差连接:子层只算「修改量」

再回看 Block.forward 里那两个不起眼的 x + (第 147-148 行):

1
2
x = x + attn_out              # 残差①
x = x + self.mlp(self.norm2(x)) # 残差②

注意那个 x +——子层(注意力/MLP)算出来的不是「新的 x」,而是「要在 x 上做的修改量」。原始 x 原封不动保留,只加上一个增量。

打个比方:像用修订模式改文档,不是每次重写全文,而是在原稿上批注修改,原稿永远在。

为什么必须要它:本项目多层 Block 叠起来,如果没有残差,信号每穿一层就被彻底重写一次,堆几层后梯度消失、原始信息丢光,深层网络根本训不动。残差给梯度开了条「高速公路」——反向传播时梯度能顺着 + 抄近路直达浅层,这是能堆深层的前提

和归一化的配合:注意第 146 行是 self.attn(self.norm1(x))——先归一化再进子层,叫 Pre-Norm(norm 在残差分支内部),而 x + 那一路始终是干净的原始 x。这比老式 Post-Norm 更好训、更稳,是 GPT-2 之后的标准做法。

这一套「注意力 + MLP」重复 4 层(第 159 行),逐级抽象:

1
第1层:相邻字搭配 → 第2层:短语 → 第3层:句子结构 → 第4层:综合判断

模型结构


9. 收尾:归一化与词表打分

走完 4 层后,src/model.py 第 191-192 行收尾:

1
2
x = self.norm(x)          # 最终归一化,校准数值尺度
logits = self.head(x) # 投影到词表 → 打分数组

head(第 165 行)是一个线性层,把每个字的 256 维向量投影成词表长度的打分数组:

1
2
3
x       : [B, T, 256]
↓ self.head(x)
logits : [B, T, vocab_size] 每个位置 → 全词表每个字的打分

比如「饭」这个位置,输出类似 ["的":2.1, "了":5.8, "。":6.3, ...],分越高越可能是下一个字。

一个巧妙设计——权重共享(第 166 行):

1
self.head.weight = self.tok_emb.weight

输入的查表 tok_emb 和输出的打分 head同一份矩阵:进门用它「字 id → 向量」,出门用它「向量 → 字打分」,省参数、且编解码语义一致。


10. teacher forcing:训练时的并行秘密

一个常见误解:以为训练时模型也像推理那样「一个字一个字往下猜」。其实训练是整批并行的

因为训练数据里答案本来就完整给了(上一步的 x 已包含全部字):

  • 预测第 4 个字时,前 3 个字是现成的真实字。
  • 预测第 9 个字时,前 8 个字也是现成的真实字。

各个位置的「前文」都是现成的、互不依赖,所以所有位置可以一次性并行预测——这就是 teacher forcing(老师直接把标准答案喂进去)。

对比:

阶段 方式 原因
训练 整批并行,一次前向预测所有位置 答案已知,前文都是现成的
推理 自回归,一个字一个字生成 答案未知,得用自己刚生成的字当前文

因果掩码(第 7节)正是让「并行」和「不作弊」能同时成立的关键:并行算所有位置,但每个位置被掩码挡住了未来。


11. 核心结论速览

问题 一句话答案
模型输入是什么 整数 id 序列 [B, T],不是向量
模型输出是什么 logits 打分 [B, T, vocab],不是向量、不是字
id 怎么变向量 tok_emb 查表(第 183 行)
注意力做什么 Q·K 算关注度 → softmax → @V 加权汇总(第 50-57 行)
QKV 哪来的 字向量 × 可学习矩阵 Wq/Wk/Wv(第 87-89 行)
位置信息在哪 RoPE 旋转 Q/K(第 92 行),不碰 V
怎么保证不作弊 因果掩码 tril 下三角 + softmax(第 54-56 行)
为什么要 MLP 注意力是线性的,MLP 补非线性(第 134 行)
为什么要多层 逐级抽象:字→短语→句子→判断(4 层)
怎么得到打分 归一化 + head 线性投影到词表(第 191-192 行)
训练为何能并行 teacher forcing:答案已知,前文现成,各位置互不依赖

下一步预告:第三步「训练循环」,我们会看这些随机初始化的参数(tok_embWq/Wk/Wvhead……)是怎么通过 loss 和反向传播被一点点调准的。