同一套 GPT,两条完整路线:训练更新与逐 Token 生成
一段输入经过 Embedding、Attention 和 MLP,最后得到 logits。到这里,训练与生成使用的是同一种前向网络。真正决定下一步做什么的是:有没有真实的下一 Token 标签,以及打算用输出改变什么。
训练将 logits 与标签对照,算损失、求梯度,再让优化器更新参数。生成使用固定参数,从最后位置的 logits 选择一个新 ID,把它接回上下文。训练循环改变参数;生成循环改变输入序列。
先固定模型和数值的含义
下面统一使用一份 GPT-2 风格的教学模型:保留完整词表,缩小网络,便于逐项观察。
| 配置 | 教学模型 | GPT-2 124M 参考尺寸 |
|---|---|---|
| 词表大小 V_vocab | 50,257 | 50,257 |
| 隐藏维度 D | 16 | 768 |
| Block 数 | 2 | 12 |
| 注意力头数 | 2 | 12 |
| 每头维度 d_head | 8 | 64 |
| 上下文上限 | 16 | 1,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,连线表示线性变换。圆点只画出部分维度或候选,实际计算仍使用标注的完整尺寸,连线粗细不代表真实权重大小。
动画中的“全流程神经网络图”使用同一张结构图,可点击模块进入相应环节。第二层展示完整结构,但数值仍只展示保存下来的输入输出边界。
训练的第一件事:把答案错开一位
固定的 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] |
|---|---|---|---|
| 0 | 17250 | Hi | 220,空格 |
| 1 | 220 | Hi 加空格 | 19526,字节 e4 bd |
| 2 | 19526 | 再加“你”的前两个字节 | 254,字节 a0 |
| 3 | 254 | Hi 你 | 0,! |
| 4 | 0 | Hi 你! | 50256,EOS |
每个位置都在回答“这个前缀之后的下一项是什么”。答案来自真实序列,所以能同时构造五个监督任务;前向中仍需因果遮挡,防止位置 2 读取位置 3,直接看见自己的答案。
这里训练的是语言模型。BPE 的合并规则、词表与 ID 含义事先确定,后面的反向传播不会重新训练分词器。EOS 也由数据流程显式追加,不能推断原始 GPT-2 encode() 会自动追加它。
动画可以按主步骤跳转,再在当前环节中逐小步查看。进入网络计算后,可切换位置、注意力头、输出维度和求和项;表格保留完整维度,窄屏时横向查看。
一次前向:哪些是参数,哪些是本次算出来的数
查两张表,逐项相加
Token ID 决定查 E 的哪一行,位置编号决定查 P 的哪一行:
E:50,257×16 P:16×16id_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_QK = U W_K + b_KV = U W_V + b_V| 对象 | 本例形状 | 身份 |
|---|---|---|
| U | T×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] / √8S_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.2051Q/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×16R = X + Δ_attn T×16第一次加回的是 LN1 之前的 Block 输入 X。现在保存 R,让另一条分支读取它:
U₂ = LN2(R) T×16Z₁ = U₂ W_up + b_up T×64G = GELU(Z₁) T×64Z₂ = G W_down + b_down T×16Δ_mlp = Dropout(Z₂) T×16B₁ = R + Δ_mlp T×16第二次加回的是 LN2 之前的 R。Δ_mlp 是分支的更新量,B₁ 才是完整 Block 的输出。
展开后虽然有 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 |
|---|---|---|---|
| 0 | 220 | 0.00001889275 | 10.876732 |
| 1 | 19526 | 0.00001947999 | 10.846123 |
| 2 | 254 | 0.00001788837 | 10.931360 |
| 3 | 0 | 0.00002217304 | 10.716633 |
| 4 | 50256 | 0.00002101977 | 10.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_sv_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 |
| 梯度 g | 0.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,下一轮才能以它为条件。序列内部矩阵运算的并行,与未来输出的先后依赖,可以同时成立。
从分数到一个 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] | 220 | 20,显示空格 |
| 2 | [17250,220] | 19526 | e4 bd,暂存,尚无完整字符 |
| 3 | [17250,220,19526] | 254 | a0,拼成 e4 bd a0,显示“你” |
| 4 | [17250,220,19526,254] | 0 | 21,显示 ! |
| 5 | [17250,220,19526,254,0] | 50256 | EOS,停止,不作为正文显示 |
第二轮没有新字符,第三轮仍能正常计算,因为模型已经收到了 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、v | backward 累积梯度,AdamW 更新历史统计 |
| 生成上下文与解码缓冲 | ID 列表、未完整的 UTF-8 字节 | 每轮追加 ID;完整字符到齐时更新显示 |
训练从标签得到损失,把影响传回参数;推理从分数得到新 ID,把它送回输入。沿着这两条路线,就能判断一个数字属于哪次计算,以及下一步究竟该改变什么。
自检:用自己的话解释这六个边界
- LN 的输出与 Q 有何区别? 前者是逐位置归一化后的激活,后者还要经过 Q 的可学习投影。
- 两次残差加回谁? 第一次加回 X;第二次加回已经包含注意力更新的 R。
- 为什么 MLP 后还不能算交叉熵? 还需完成其余 Block、LN_f 与词表投影,才能得到目标 ID 对应的分数。
- backward 与 step 分别改变什么? 前者计算并累积梯度;后者使用梯度与优化器状态修改参数。
- 五个训练位置为什么不等于一次生成五个未来 Token? 训练用已知真实前缀;生成的后续条件必须先被选出来。
- 不完整字符的 ID 能进入下一轮吗? 能。ID 追加与 UTF-8 字符显示分别推进。