bobofamilyNIU CHUN
36/36

同一套 GPT,两条完整路线:训练更新与逐 Token 生成

2026-10-1417 分钟—阅读

一段输入经过 Embedding、Attention 和 MLP,最后得到 logits。到这里,训练与生成使用的是同一种前向网络。真正决定下一步做什么的是:有没有真实的下一 Token 标签,以及打算用输出改变什么。

训练将 logits 与标签对照,算损失、求梯度,再让优化器更新参数。生成使用固定参数,从最后位置的 logits 选择一个新 ID,把它接回上下文。训练循环改变参数;生成循环改变输入序列。

前向主干相同,循环回到的地方不同:训练带着新参数取下一批;生成带着新 ID 做下一轮

先固定模型和数值的含义

下面统一使用一份 GPT-2 风格的教学模型:保留完整词表,缩小网络,便于逐项观察。

配置教学模型GPT-2 124M 参考尺寸
词表大小 V_vocab50,25750,257
隐藏维度 D16768
Block 数212
注意力头数212
每头维度 d_head864
上下文上限161,024

模型使用可学习的绝对位置向量、Pre-LN、GELU,以及输入输出权重共享。共有 810,960 个独立可训练参数,其中 Token 表占 50,257×16=804,112,位置表占 16×16=256。共享 Token 表只计算一次。

B 表示批量大小,T 表示当前序列长度。下文推导通常省略批量维;T×16 在这份记录里实际是 1×T×16。词表大小写作 V_vocab,注意力的 Value 写作 V,两者用途不同。

正文以原理串联为主。动画中的具体数值来自已保存的教学计算轨迹,浏览器负责重放和展开数值。 训练动画观察第一次前向与第一次更新;推理动画使用经过 120 次更新的参数。结构相同,参数时刻不同,不能要求两页同名矩阵数值相等。下文标为“手算示例”的小数组只用于说明公式。

把整张神经网络接起来

从输入向下看,两个 Block 都包含注意力和 MLP,两次残差分别绕过各自的分支。注意力方格表示不同位置之间的读取关系;MLP 圆点表示特征维度,词表输出侧的圆点表示候选 ID,连线表示线性变换。圆点只画出部分维度或候选,实际计算仍使用标注的完整尺寸,连线粗细不代表真实权重大小。

完整网络:查表与位置相加 → 两层 Pre-LN Block → LN_f → 共享 E 的词表投影;底部接出训练和生成两种用法

动画中的“全流程神经网络图”使用同一张结构图,可点击模块进入相应环节。第二层展示完整结构,但数值仍只展示保存下来的输入输出边界。

训练的第一件事:把答案错开一位

固定的 GPT-2 Tokenizer 将 Hi 你! 编为五个 ID。数据准备程序另在文档末尾追加 EOS:

普通文本: [17250, 220, 19526, 254, 0]
加上 EOS:[17250, 220, 19526, 254, 0, 50256]
x: [17250, 220, 19526, 254, 0]
y: [ 220, 19526, 254, 0, 50256]
位置 t当前输入 ID已知的前缀目标 y[t]
017250Hi220,空格
1220Hi 加空格19526,字节 e4 bd
219526再加“你”的前两个字节254,字节 a0
3254Hi 你0,!
40Hi 你!50256,EOS

每个位置都在回答“这个前缀之后的下一项是什么”。答案来自真实序列,所以能同时构造五个监督任务;前向中仍需因果遮挡,防止位置 2 读取位置 3,直接看见自己的答案。

这里训练的是语言模型。BPE 的合并规则、词表与 ID 含义事先确定,后面的反向传播不会重新训练分词器。EOS 也由数据流程显式追加,不能推断原始 GPT-2 encode() 会自动追加它。

训练全流程 · 从右移标签到参数更新新窗口打开 ↗

动画可以按主步骤跳转,再在当前环节中逐小步查看。进入网络计算后,可切换位置、注意力头、输出维度和求和项;表格保留完整维度,窄屏时横向查看。

一次前向:哪些是参数,哪些是本次算出来的数

查两张表,逐项相加

Token ID 决定查 E 的哪一行,位置编号决定查 P 的哪一行:

E:50,257×16 P:16×16
id_t → E[id_t] t → P[t]
X⁽⁰⁾[t] = Dropout(E[id_t] + P[t])
输出:[1,T,16]

位置编号按分词后的位置排列为 0,1,…,T−1,它是确定的整数。可训练的是 P[t] 中的数值。

手算示例:[0.2,−0.1,0.5,0.3] + [0.01,0.02,−0.03,0.04] = [0.21,−0.08,0.47,0.34]。相加后仍为四维;动画里则是两个 16 维向量相加。同一 ID 在不同位置查到同一 Token 向量,但位置向量不同。

从零训练时,Embedding 和线性权重按初始化规则赋值,之后随训练更新。线性偏置初始为 0,LayerNorm 的 γ=1、β=0;“初始化”不等于所有数都随机。也不能在每个批次开始时重新初始化,否则刚学到的参数就丢了。

LN1 处理特征尺度,后面的投影才产生 QKV

设当前 Block 的输入为 X。LayerNorm 对每个位置的 16 个特征独立计算:

μ_t = (1/D) Σ_d X[t,d]
σ²_t = (1/D) Σ_d (X[t,d] − μ_t)²
U[t,d] = γ[d] × (X[t,d] − μ_t) / √(σ²_t + ε) + β[d]

本例 ε=10⁻⁵。这里沿特征维统计,不把五个位置混起来求均值;γ、β 是可训练参数。因此经过缩放和平移后,输出不必严格均值为 0、方差为 1。LayerNorm 定义。

LN1 输出 U,再经过三套投影:

Q = U W_Q + b_Q
K = U W_K + b_K
V = U W_V + b_V
对象本例形状身份
UT×16当前输入经过 LN1 的激活
W_Q、W_K、W_V各 16×16可训练参数
Q、K、V各 T×16当前前向计算的结果

固定参数换一段输入,W_Q 不变,Q 通常改变。QKᵀ 相乘的是本次输入产生的两个结果。

实现可以合并成一次 16→48 的投影,再拆成 Q、K、V。每份 16 维随后拆为两个 8 维的头;拆的是特征维,每个头仍处理所有允许的位置。本文按行向量写 U W+b;PyTorch Linear.weight 按“输出×输入”保存,因此合并 QKV 的存储形状为 48×16。

Attention 先给位置分配权重,再汇总 V

固定一个头,Q、K、V 都是 T×8:

S[i,j] = Σ_d Q[i,d] K[j,d] / √8
S_masked[i,j] = S[i,j],若 j ≤ i;否则为 −∞
A[i,:] = softmax(S_masked[i,:])
A_used = Dropout(A)
H_head[i,d] = Σ_j A_used[i,j] V[j,d]

i 是查询位置,j 是提供信息的位置。除以 √8 是按单头维度控制点积分数尺度:在各维乘积近似独立、尺度相近的假设下,求和的方差随维度增长,标准差约随平方根增长。

对角线可以保留,因为当前输入已知,当前位置负责预测下一项。遮挡改的是匹配分数,不是删除输入 Token,也不是把 V 的未来行永久改成零。

用一个不含 Dropout 的手算示例看完整链路:

第 2 行缩放分数: [2, 1, 3, 4, 5]
遮挡未来: [2, 1, 3, −∞, −∞]
softmax: [0.244728, 0.090031, 0.665241, 0, 0]
V 的某一维: [10, 20, 30, 40, 50]
输出这一维: 0.244728×10 + 0.090031×20 + 0.665241×30
≈ 24.2051

Q/K 决定读取比例,V 提供被汇总的值。形状是 T×T 的权重矩阵乘 T×8 的 V,得到 T×8。

注意力 softmax 之后的一行和为 1。训练时接着做 Dropout,实际乘 V 的 A_used 一行未必仍和为 1。 推理关闭 Dropout,才有 A_used=A。

一个 Block 的两次残差,各加回谁

两个头对同一位置各输出八维,沿特征维拼接为 16 维。拼接保留两份结果;接下来的 W_O 才负责混合不同头的特征:

C = Concat(H_head1, H_head2) T×16
Δ_attn = Dropout(C W_O + b_O) T×16
R = X + Δ_attn T×16

第一次加回的是 LN1 之前的 Block 输入 X。现在保存 R,让另一条分支读取它:

U₂ = LN2(R) T×16
Z₁ = U₂ W_up + b_up T×64
G = GELU(Z₁) T×64
Z₂ = G W_down + b_down T×16
Δ_mlp = Dropout(Z₂) T×16
B₁ = R + Δ_mlp T×16

第二次加回的是 LN2 之前的 R。Δ_mlp 是分支的更新量,B₁ 才是完整 Block 的输出。

两次残差依次发生:MLP 读取第一次相加后的 R,第二次也把更新量加回 R

展开后虽然有 B₁=X+Δ_attn+Δ_mlp,但 Δ_mlp 根据 R 计算,两个分支并不是各自独立读取原始 X。残差也不是损失里的预测误差;它保留主路径,让分支学习对表示的调整。

16→64→16,为什么不能并成一层

MLP 对每个位置分别使用同一套参数,64 是隐藏特征数,不是 Token 数。若去掉中间的非线性:

(U₂ W_up + b_up) W_down + b_down
= U₂ (W_up W_down) + (b_up W_down + b_down)

两次仿射变换可合成一次。GELU 使这一步无法这样折叠。其精确形式为 GELU(x)=xΦ(x),Φ 是标准正态分布的累积分布函数:GELU(1)≈0.8413,GELU(−1)≈−0.1587。它逐元素计算,没有可学习参数,也不随机抽样;负输入并非一律变成零。GELU 定义及近似选项。

这里沿用 nanoGPT 的默认 nn.GELU();历史 GPT-2 的 tanh 近似不与这些数值混用。

Dropout 则是另一种操作。手算例子:丢弃概率 0.5、输入 [2,4,6,8],一次掩码 [1,0,1,0] 得到 [4,0,12,0]。留下的数除以保留概率,每项的期望仍为原值。它不永久删除特征或参数。

一个 Block 完成,后面还没有到损失

这份模型有两个 Block。第一个输出 B₁,第二个用自己的参数继续加工,然后才做最终 LN:

B₁ → Block₂ → B₂ → LN_f → H_final
[1,T,16]

动画逐项展开第一个 Block。第二个 Block 保存了真实输入与输出,但没有相同粒度的全部内部张量;因此只展示其结构与边界数值。LN_f 拥有自己的 γ/β,最终输出仍是向量,还不是概率。

到词表这一维,才开始给下一 Token 打分

输入输出共享 E 时,词表投影为:

Z = H_final Eᵀ
Z[t,j] = Σ_d H_final[t,d] E[j,d]
[1,5,16] × [16,50257] → [1,5,50257]

j 现在表示词表 ID,已不再表示文本中的位置。每个位置得到 50,257 个实数,这些数本身就叫 logits,不必再转换一次才成为 logits。一个候选对应一个 16 维点积,整个词表通过矩阵乘法批量计算。

这里没有归一化向量长度,点积不能直接称为余弦相似度。它是共同训练出来的预测分数。

softmax 在哪里对谁归一化之后做什么
Attention 内部上下文位置加权汇总 V
词表 logits 之后下一 Token 的候选 ID求目标概率,或选择生成的 ID

交叉熵从整行 logits 取出目标项

训练标签 y 已知,不必先采样:

p[t,j] = exp(Z[t,j]) / Σ_k exp(Z[t,k])
ℓ_t = −ln p[t,y[t]] = −Z[t,y[t]] + log Σ_k exp(Z[t,k])
L = (ℓ₀ + ℓ₁ + ℓ₂ + ℓ₃ + ℓ₄) / 5

实际实现使用数值稳定的交叉熵算子,不需要先显式保存 softmax 再取对数。目标概率虽然只有一个数,分母仍取决于整张词表。

第一次训练前向的保存记录是:

t目标 ID目标概率−ln p
02200.0000188927510.876732
1195260.0000194799910.846123
22540.0000178883710.931360
300.0000221730410.716633
4502560.0000210197710.770047

五项平均约 10.828178。这里每行来自一个不同位置,不能再把这五个目标概率当作同一个分布做 softmax。均匀分布的损失约为 ln(50,257)≈10.825,可以帮助判断初始值的量级。

backward 之后,参数还没有更新

由上面的损失式,对任意 logit 求导:

∂ℓ_t/∂Z[t,j]
= −1[j=y[t]] + exp(Z[t,j]) / Σ_k exp(Z[t,k])
= p[t,j] − 1[j=y[t]]
∂L/∂Z[t,j] = (p[t,j] − 1[j=y[t]]) / 5

位置 2 的正确目标为 254,对应梯度约为 (0.00001788837−1)/5=−0.19999642。负号说明直接沿负梯度调整这个 logit 时应提高它。但实际更新的是网络的共享参数,多个位置和多条路径的梯度会共同累加。

backward() 沿计算图计算并累积梯度。真正修改参数的是优化器的 step。 Token ID 220 不会被“更新成另一个编号”;发生变化的是 E[220] 中的浮点数。

沿 E[220,0] 看一次 AdamW

AdamW 为每个参数保留一阶统计 m 与二阶统计 v。第 s 次更新:

m_s = β₁ m_(s−1) + (1−β₁) g_s
v_s = β₂ v_(s−1) + (1−β₂) g_s²
m̂_s = m_s / (1−β₁ˢ)
v̂_s = v_s / (1−β₂ˢ)
θ_s = (1−ηλ) θ_(s−1) − η m̂_s / (√v̂_s + ε)

最后一式适用于带权重衰减的参数组;本例矩阵权重参与衰减,一维偏置和 LayerNorm 参数采用 λ=0。m/v 是优化器状态,既不是 Token 向量,也不是当前网络的激活。AdamW 更新规则。

保存的首步记录追踪了共享表中的 E[220,0]:

项目数值
更新前 θ0.0083439769
梯度 g0.1401842982
m₁0.0140184304
v₁0.0009825819
更新后 θ−0.0116576916

这里 η=0.02、β₁=0.9、β₂=0.95、λ=0.01、ε=10⁻⁸。首步从 m₀=v₀=0 开始,偏差修正后 m̂₁=g、v̂₁=g²,所以可直接理解为:

θ₁ ≈ (1−0.02×0.01)×0.0083439769
−0.02×0.1401842982 / (0.1401842982+10⁻⁸)
≈ −0.0116576916

接着清空梯度,下一批从更新后的参数继续前向。zero_grad() 清的是梯度,不会清掉参数,也不会清掉 m/v。需要累积多个小批次时,清空时机另行安排。

验证和保存分别做什么

验证同样进行前向、对真实标签算损失,但不更新参数。eval() 切换 Dropout 等模块的行为;no_grad() 或推理模式控制梯度记录,二者职责不同。PyTorch 的 eval 说明。

教学轨迹对 Hi 你! 重复更新 120 次,验证文本仅为 Hi 你好!。最终在 eval 下,前者损失约 0.001070,后者约 7.975595。这些数只用于展示短句记忆与验证的区别,不能据此判断模型具备中文能力。

检查点保存参数和结构配置;继续训练还需要优化器状态及步数,精确恢复往往还涉及随机数与数据读取状态。推理仍需匹配的 tokenizer,不能给同一组参数随意换一套 ID 含义。

推理:参数固定,每轮的上下文在变

提示词 Hi 编为 [17250]。载入检查点,进入 eval 并关闭梯度记录,仍需计算 Embedding、所有 Block 与 LN_f。这里只将最后一个位置交给 lm_head:

H_final[:, −1:, :]:[1,1,16]
乘 Eᵀ [16,50257]
得到下一项 logits:[1,1,50257]

位置 t 的输出预测它后面那一项。因此已知 T 个 Token,要扩展整个序列就使用位置 T−1 的输出。更早位置对应更早前缀,不能把它们误当成尚未生成的未来第 1、2、3 项。

训练能够并行处理多个位置,是因为真实后续序列已经存在;生成则必须先确定新的 ID,下一轮才能以它为条件。序列内部矩阵运算的并行,与未来输出的先后依赖,可以同时成立。

推理全流程 · 五轮生成与 UTF-8 字节缓冲新窗口打开 ↗

从分数到一个 ID

这份轨迹采用 temperature=0.8、top-k=5,最多生成 10 个新 Token,采样种子为 7:

原始 logits → 除以 0.8 → 保留 top-5 → softmax → 按概率抽一个 ID

正温度不改变原始排序,但改变分数差距。被过滤项设为负无穷,保留项重新归一化。采样使用的是处理后的概率,不是原始 logits 在全词表上的概率。

第一轮 ID 220 的原始 logit 约 16.329382,缩放后约 20.411728,过滤归一化后概率约 0.99998498,本轮选中了它。分布很尖锐与这份模型反复记忆短句有关。一次采样选中了最大概率项,也不能据此认定使用的是贪心;贪心是直接取 argmax。

新 Token 到了,为什么屏幕上还没有新字符

生成循环追加的是原样的整数 ID。它不需要先解码为字符、拼成字符串,再重新分词。五轮记录如下:

轮次本轮输入 ID新 ID字节与显示
1[17250]22020,显示空格
2[17250,220]19526e4 bd,暂存,尚无完整字符
3[17250,220,19526]254a0,拼成 e4 bd a0,显示“你”
4[17250,220,19526,254]021,显示 !
5[17250,220,19526,254,0]50256EOS,停止,不作为正文显示

第二轮没有新字符,第三轮仍能正常计算,因为模型已经收到了 ID 19526。模型上下文与字符显示是两层状态:前者按 ID 推进,后者等字节构成完整 UTF-8 序列再显示。字符边界由 UTF-8 编码规则解释,不由模型另作预测。

若将不完整片段单独解码,并使用替换错误的策略,可能提前显示 �。这里使用增量解码,保留未完成的字节;EOS 到来时检查结束条件,既不把它的表面拼写显示出来,也不需要再以 EOS 为输入多算一轮。

max_new_tokens 统计新选出的 ID 数,不含提示词;此例把 EOS 计入,所以实际生成五个新 Token 后停止。

KV cache 减少重算,不改变生成依赖

这份动画每轮重算当前上下文,没有 KV cache。常见带缓存的实现先处理提示词,保存各层历史 K/V;之后计算新位置,并利用缓存读取历史信息。它减少重复计算,下一轮仍要依赖刚选出的 ID。

本例的上下文始终没有超过 16。更长序列还要规定窗口与位置处理,不能从这段短轨迹直接推断。EOS 停止也是教学驱动加入的规则;nanoGPT 的简单 generate() 本身按最大新 Token 数循环。

最后把四种状态放回各自的位置

状态例子什么情况下变化
模型参数E、P、W_Q、W_up、LN 的 γ/β优化器更新;普通生成时固定
本次前向激活U、Q/K/V、R、logits随输入、参数和训练模式变化
梯度与优化器状态∂L/∂θ、m、vbackward 累积梯度,AdamW 更新历史统计
生成上下文与解码缓冲ID 列表、未完整的 UTF-8 字节每轮追加 ID;完整字符到齐时更新显示

训练从标签得到损失,把影响传回参数;推理从分数得到新 ID,把它送回输入。沿着这两条路线,就能判断一个数字属于哪次计算,以及下一步究竟该改变什么。

自检:用自己的话解释这六个边界
  1. LN 的输出与 Q 有何区别? 前者是逐位置归一化后的激活,后者还要经过 Q 的可学习投影。
  2. 两次残差加回谁? 第一次加回 X;第二次加回已经包含注意力更新的 R。
  3. 为什么 MLP 后还不能算交叉熵? 还需完成其余 Block、LN_f 与词表投影,才能得到目标 ID 对应的分数。
  4. backward 与 step 分别改变什么? 前者计算并累积梯度;后者使用梯度与优化器状态修改参数。
  5. 五个训练位置为什么不等于一次生成五个未来 Token? 训练用已知真实前缀;生成的后续条件必须先被选出来。
  6. 不完整字符的 ID 能进入下一轮吗? 能。ID 追加与 UTF-8 字符显示分别推进。