本文是「GPT 训练分步学习路径」的第二步,基于
GPT_teacher-3.37M-cn这个教学项目,讲清楚「一串数字进了模型后,到底发生了什么,最后怎么变成对下一个字的预测」。文中所有代码引用均来自本项目真实的src/model.py。分步学习路径共五步:① 看懂数据长什么样 → ② 模型核心(注意力+因果掩码)→ ③ 训练循环 → ④ 验收评估 → ⑤ 推理与部署。本文是第二步。
目录
- 这一步属于哪个环节
- 输入与输出:x 进 logits 出
- 第一站:把字 id 变成向量
- 注意力:字与字之间互相「看」
- QKV 到底是什么
- 位置信息:RoPE 旋转编码
- 因果掩码:只看前文不看未来
- MLP、SwiGLU 与残差连接
- 收尾:归一化与词表打分
- teacher forcing:训练时的并行秘密
- 核心结论速览
1. 这一步属于哪个环节
整个项目的全流程是:
1 | 构建分词器 → 数据处理 → 训练 → 验收 → Web 演示 |
本文讲的是 模型本身的结构与前向计算,对应代码 src/model.py。它的作用是:
接收上一步(数据处理)产出的输入
x(一串字 id),经过嵌入、注意力、前馈、归一化,最后输出对下一个字的打分。
这一步是「训练」的核心——训练循环反复调用模型的前向计算,再根据输出的对错去调整模型内部的参数。
2. 输入与输出:x 进 logits 出
看模型的主入口 GPT.forward(src/model.py 第 181 行):
1 | def forward(self, idx, kv_caches=None): |
- 输入
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 | attn = (q @ k.transpose(-2, -1)) * (D ** -0.5) # 第50行:算「谁该关注谁」 |
三步走:
- Q·Kᵀ:算出每个字对其他字的「关注度打分」。
- softmax:把打分变成加起来等于 1 的权重。
- @V:按权重把其他字的信息加权求和,得到「融合了上下文」的新向量。

类比:开会时每个人(字)都在听别人发言,但会更关注跟自己相关的人,最后把听到的重点记下来。
5. QKV 到底是什么
Q(Query 查询)、K(Key 键)、V(Value 值)不是查表得来的,而是把字向量分别乘以三个可学习的矩阵投影出来的。看 src/model.py 第 87-89 行:
1 | q = self.wq(x)... # 字向量 × Wq → Query |
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 | mask = torch.tril(torch.ones(T, T_kv)) # 下三角矩阵 |
以 我爱吃饭 为例,注意力打分表被划掉右上角(未来):
1 | 我 爱 吃 饭 |

softmax 会把 -inf 变成 0 权重,未来的字对当前字贡献彻底归零。
这就是 GPT(单向、只看前文)和 BERT(双向、能看全文)最本质的区别。
8. MLP、SwiGLU 与残差连接:为什么还不够
注意力只会「搬运 + 加权平均」,是线性的。但语言规律高度非线性(「不」+「好」是贬义,「不」+「错」是褒义)。所以每层注意力之后还要接一个 **MLP(前馈网络)**做非线性加工。
看 src/model.py 第 137 行的 Block:
1 | def forward(self, x, ...): |
- 注意力负责「字与字之间交换信息」(开会通气)。
- MLP负责「每个字自己消化加工」(会后整理笔记)。
x = x + ...是残差连接,保证深层不丢信息。
下面把这两块单独展开讲。
8.1 前馈网络(MLP)到底怎么加工
MLP 不是简单一个线性层。看 src/model.py 第 120 行的 MLP,它用的是 Llama 风格的 SwiGLU(第 134 行):
1 | def forward(self, x): |
拆开三个线性层(第 128-130 行):
| 步骤 | 作用 | 类比 |
|---|---|---|
w_gate+silu |
算一个「门控」信号,决定放行多少 | 阀门开度 |
w_up |
把特征升维展开(d→hidden) |
摊开细看 |
gate * up |
门控逐元素相乘,筛选信息 | 阀门控水流 |
w_down |
再降回原维度(hidden→d) |
收拢结论 |
关键是「先升维再降维」:中间维度 hidden 比输入大(第 126 行约 4d×2/3),先把特征摊到更大空间做非线性加工,再压回来。模型绝大多数参数其实堆在这三个矩阵里。
经典版 FFN 是
Linear → ReLU → Linear两层;SwiGLU 多了一个门控分支(w_gate),加工更精细,是现在主流做法,这也是本项目要三个线性层的原因。
8.2 残差连接:子层只算「修改量」
再回看 Block.forward 里那两个不起眼的 x + (第 147-148 行):
1 | x = x + attn_out # 残差① |
注意那个 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 | x = self.norm(x) # 最终归一化,校准数值尺度 |
head(第 165 行)是一个线性层,把每个字的 256 维向量投影成词表长度的打分数组:
1 | x : [B, T, 256] |
比如「饭」这个位置,输出类似 ["的":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_emb、Wq/Wk/Wv、head……)是怎么通过 loss 和反向传播被一点点调准的。