“=”自己的 8 个数里没有题目;逐行计算永远拿不到左边的 7、×、0
TokenEmb 与 PosEmb 相加后,每个位置只得到自己的一行。无论单独把“=”这一行算多少次,它都没有读取第 0~2 行的运算。因此我们首先需要一个能把“别的行”写进“=”行的矩阵运算。
一个能逐张矩阵手算到底的 Transformer
我们在浏览器里从随机数开始训练一个真正的 Transformer,让它学会 0~9 × 0~9 的全部 100 道乘法。模型特意缩到只有 3,136 个可训练参数、2 层、2 个头、每个 token 8 维:小到每张矩阵都能摊开,每一步都能亲手推出。
全文只证明一件事:每个接收位置根据当前输入现场算出一行读取比例,再按这些比例混合各来源位置提供的内容。Q、K、V 的作用都从这两次矩阵乘法中推出。
它与公开的 GPT-3 使用同一套 Decoder-only Transformer 核心机制:token 变向量,Masked Self-Attention 让位置间交换信息,残差与 FFN 继续加工,再用真实的下一个 token 反向训练全部参数。GPT-4 技术报告没有披露参数量、层数和具体架构,所以这里只能说基础范式相承,不能声称每个实现细节都相同。
公开数据:GPT-3 论文 · GPT-4 技术报告
从必须解决的问题亲手推出注意力 ↓数据流从下往上 ↑ · 每个方框可点击 高亮部分是全文重点
Softmax ↑ LM8×14 输出矩阵 ↑ Final LayerNorm每个位置 8 维 ↑同一套模型贯穿全文
8 维、2 层、2 个头,参数随机初始化。0~9 × 0~9 的 100 道题在当前浏览器里训练;没有 Python 后端,也没有预训练权重。
正在准备 7×8=…
模型初始化中…
不从公式开始。先规定唯一任务:输入 7×0= 后,最后位置“=”必须根据左边三个位置改变自己的 8 个数,最终让 token“0”得到最高概率。下面每一步只解决上一步留下的一个缺口。
TokenEmb 与 PosEmb 相加后,每个位置只得到自己的一行。无论单独把“=”这一行算多少次,它都没有读取第 0~2 行的运算。因此我们首先需要一个能把“别的行”写进“=”行的矩阵运算。
A [4×4]×来源内容 V [4×4]=收到的内容 H [4×4]
A 的第 3 行属于接收位置“=”;这一行的第 0~3 列分别对应来源 7、×、0、=。于是 H(“=”)=A[3,0]V(7)+A[3,1]V(×)+A[3,2]V(0)+A[3,3]V(=)。到这里,“跨行搬运”已经由普通矩阵乘法完成。
7×0= 中 0 在第 2 列;0×7= 中 0 在第 0 列——关系必须随本次输入现场重算如果 A 是训练后固定保存的 4×4 参数表,“=”每次都会读取同一列;内容交换位置后,它不会跟着移动。所以参数不能直接保存最终连接,而要保存一种“看到当前两行时,怎样计算它们连接强弱”的方法。
LayerNorm 先逐行整理 8 个数,但不跨位置通信;把它的输出记作 X_norm。设接收位置是 X_norm 的第 i 行 xᵢ、来源位置是第 j 行 xⱼ。最直接的可训练比较,就是让两行先经过两张不同参数矩阵,再做点积:
接收行 xᵢ [1×8] × WQ_head [8×4] = qᵢ [1×4]
与
来源行 xⱼ [1×8] × WK_head [8×4] = kⱼ [1×4]
比较
S[i,j] = qᵢ × kⱼᵀ ÷ 2
对所有 i、j 同时计算,就是 Q×Kᵀ。Q 在左边,所以它的行号留在 S 的行上;K 转置后在右边,所以它原来的行号变成 S 的列。Softmax 再把 S 的每一行变成总和为 1 的比例 A。
如果把负责匹配的 K 同时当作被搬运内容,就把“怎样判断关系”和“关系成立后交付什么”绑死在同一组数上。因此再让来源行乘 WV 产生 V。V 不参与生成 A;只有 A 完成后,A 的列才与 V 的行对齐并执行 A×V。
A(X)=softmax(QKᵀ÷2+Mask)输出是一张随当前输入改变的 4×4 读取比例表。
Write(X)=A(X)×X_norm×WV_head×WO_head来源内容沿 A 搬到接收行,再写回 8 维残差流。
Head(X) = softmax((X_norm WQ_head)(X_norm WK_head)ᵀ÷2 + Mask)
×X_norm WV_head×WO_head
前半段的结果 A 每次随输入重算;后半段把来源行内容写进接收行。写入后的“=”状态继续经过残差、FFN、第 2 层与 Final LayerNorm,最后乘 LM 的 14 列,才得到下一个 token 的 14 个概率。
7×8= 的“=”怎样预测 5;7×8=5 的“5”又怎样预测 6这是观察注意力最有解释力的一组对照:参数没有换,题目没有换;只是在模型生成 5 后,把 5 接回输入,于是出现一个新的接收位置和一张新的连接图。
7 × 8 = → 下一个 token“5”接收位置是“=”。它只能读取 7、×、8、=;未来的 5 不存在,也不能被注意力看到。
7 × 8 = 5 → 下一个 token“6”接收位置变成“5”。它现在可以读取题目以及已经生成的 5;未来的 6 仍然不可见。
第 1 层中所有位置同时读取本层入口;“5”不能在同一层读取“=”刚刚算完的结果。到了第 2 层,“=”已经携带第 1 层汇入的题目信息,“5”便可以读取这个已经上下文化的中间位置。于是多层允许信息分多跳组合。
训练从未收到“请关注 7 和 8”的文字命令。它只收到正确下一个 token;下面从这一个要求逐步推出 S、Q、K 为什么会被修改,以及 100 道题为何能把可复用规律累积进同一组参数。
Loss = −ln P(正确 token)例如 7×8= 的正确目标是 5。P(5) 越小,Loss 越大;没有任何一项直接要求某个注意力比例变成最大。
G_H = ∂Loss / ∂HG_H 是后面所有层、LM 和 Softmax 把误差传回 H 后得到的 1×4 数字,不是新参数。
u_j = G_H · V_j这只是把“若多取一点第 j 行 V,Loss 怎样变化”写成一个数。不同来源的 V 不同,所以得到的 u_j 也不同。
∂Loss/∂S_j = A_j (u_j − Σₖ A_k u_k)括号内比较“来源 j”与“当前整行加权平均”。梯度为负时,梯度下降倾向提高 S_j;为正时倾向降低 S_j。
H = Σⱼ A_j V_j,所以 A_j 多一点,H 就多一点 V_j,得到 ∂Loss/∂A_j = G_H·V_j = u_j。而 A=Softmax(S) 会让一项比例上升时挤压其他项;把 Softmax 的行内导数乘进去,就得到上面的 A_j(u_j−加权平均)。
每条完整序列都向左错开一格作为输入与目标,因此一次训练同时教语法、题目结构、答案首位和答案是否结束。
7 在10道题中完全相同,目标也全是 ×∂Loss/∂logits = P − one_hot(×)输入相同、目标相同,所以这10份梯度逐项相同。传到共享参数后就是同一个方向的10倍:凡是能让 P(×) 上升的参数变化,都得到10票支持。
7× 完全相同,目标却是0~9各一次Σ ∂Loss/∂logit(c) = 10P(c) − 1若某个数字概率超过10%,右式为正,梯度下降就把它压低;低于10%则把它抬高。最终平衡是十个数字各约10%,不是“随便挑一个最大”。
a×b= 都产生自己的连接梯度,再汇入同一张 WQ/WKΔWQ ∝ −Σ₍ₐ,ᵦ₎ Xᵀ(∂Loss₍ₐ,ᵦ₎/∂Q)ΔWK ∝ −Σ₍ₐ,ᵦ₎ Xᵀ(∂Loss₍ₐ,ᵦ₎/∂K)上方真实表格给出单题的 ∂Loss/∂S;这里说明优化器不会只听一道题。能在许多题中反复降低误差的比较方式同向累积,只对个别题有利的方向会被其他题抵消。
它追求的是降低当前训练集平均 Loss。若某个来源的 V 在许多样本中反复提供有利方向,QK 分数会被持续推高,它才可能成为最大连接;若多个来源共同有用,比例可以分散。梯度下降也可能学到数据中的捷径。本教程把全部 100 道题都用于训练,因此 100/100 只证明拟合成功,不单独证明对未见数据的泛化。
7×0=、头 2、来源“0”不换题、不换头、不换来源。先保存随机初始化时的真实数字,再训练同一个模型;训练后仍沿同一条矩阵路线追到 P(0)。只有位置交换验证会把同一个“0”移到 0×7= 的第 0 列。
正在准备同一条路线的真实数字…
正文已经从问题、训练和矩阵接线推出注意力的作用。附录不再承担概念叙述:需要核对哪个箭头,就从架构图进入同名账本,不必按顺序读完。
输入矩阵 X 的一行对应一个位置。逐行 FFN 可以把每一行算得很复杂,却不能把第 0 行的 7 搬到第 3 行的“=”。所以必须添加一个“行与行之间传信息”的运算。
TokenEmb [4×8]+PosEmb [4×8]=X [4×8]7×8= 与 8×7= 的“=”输入行完全相同;注意力后却不同两题的“=”是同一字符、同一位置,所以注意力之前最后一行一样。处理后不同,只可能来自左边各行已经通过注意力进入“=”这一行。
7×0= 与 0×7=:0 换了位置,连接的最大列会不会跟着移动?两题包含同样的数字、运算符和等号,正确答案也同为 0;差别只是 0 与 7 交换位置。因此它适合检查 QK 生成的 A 是否真的随当前输入改变。它展示的是模型学到的一条“遇到 0”子规律,不代表一个头独自实现了完整乘法。
看到“最大列跟着 0 移动”以后,接下来只追问两件事:这条连接怎样由 Q×Kᵀ 算出来?连接确定后,V 中什么数字沿它进入“=”?
每个 token 直接对应 TokenEmb 的一行 8 个 float32。随机数一开始没有含义;训练反复修改它们。PosEmb 是预留位置 0~15 的 16×8 可训练表,因为模型允许最长 16 个 token;这 100 道乘法训练实际只让用到的位置收到有效梯度。
Q、K、V 在这里先只是三张结果矩阵的名字。它们都由同一个 X_norm 乘一张 8×8 参数矩阵得到;到这一步,三者只有数字不同,计算形式和形状完全相同,不能从名字推出作用。作用只能从它们下一步接到矩阵乘法的哪个位置推出来。
A 的行保留 Q 的行号A 的列来自 K 转置后的列号V 的行与 A 的列按 token 对齐H 的行再次保留 A 的行号先不解释单词,只跟踪行列怎样继承:Q × Kᵀ → A 决定行与列的连接比例;A × V → H 再让 V 的行进入 H 的行。完整算完以后,我们才解释为什么这三个位置分别被称为 Query、Key、Value。
三种运算形式此刻完全相同
X_norm 是 X 经过本层第一次 LayerNorm 后的 4×8 结果。三张不同的 8×8 参数矩阵读取同一份输入,产生三张同为 4×8 的结果。WQ、WK、WV 只是代码里的名字;本步骤不要从名字偷取结论。
X_norm [4×8]×WQ [8×8]=Q [4×8]X_norm [4×8]×WK [8×8]=K [4×8]X_norm [4×8]×WV [8×8]=V [4×8]第一次出现分工:Q 放左边,K 转置后放右边
Q_head [4×4]×K_headᵀ [4×4]÷√4S [4×4]softmax_rows(S + Mask)=A [4×4]Q(“=”) [1×4]×K_headᵀ [4×4]÷√4S(“=”) [1×4]下面把 S 最后一行的 4 个格子全部展开。每个格子都使用同一行 Q(“=”),分别乘 Kᵀ 中对应 token 的一列:4 对数字逐项相乘,再把 4 个乘积累加,最后除以 √4。
第二次出现分工:V 不参与算比例,只在比例确定后进入
A [4×4]×V_head [4×4]=H_head [4×4]A(“=”) [1×4]×V_head [4×4]=H(“=”) [1×4]上一节把 Q 的一行乘 Kᵀ 的四列逐格展开;这里用完全相同的行乘列规则,把 H(“=”) 的来源账本摊开。A(“=”) 的每个比例分别乘对应 token 的整行 V,得到四条 1×4 贡献向量;四条向量逐列相加,才得到 H(“=”)。
矩阵为什么能搬运信息:A(“=”) 的四列与 V 的四行按同一个 token 顺序对齐。于是 V 中属于 7、×、8、= 的四行,会按照 A(“=”) 给出的四个比例合成为 H(“=”)。H 的行号继承自 A,也就是最初 Q 的“=”行;它的数值却来自 V 的多个 token 行。
从注意力输出一直追到答案列
concat(H₁,H₂) [4×8]× WOAttentionOut [4×8]+ X上下文状态 [4×8]→ 第2层与 FFNFinal(“=”) [1×8]× LMlogits [1×14]→ SoftmaxP [1×14]
Final(“=”) [1×8]×LM[:, “5”] [8×1]=logit(“5”) [1×1]不增加任何新定义,把前四步原样代回
下面每个符号都已经在前面出现。这里只做代入:用 X_norm×WQ_head 替换 Q,用 X_norm×WK_head 替换 K,用 X_norm×WV_head 替换 V;再把这个头对应的 WO_head 接在 H 后面。
Q = X_norm × WQ_headK = X_norm × WK_headQ×Kᵀ = X_norm × WQ_head × WK_headᵀ × X_normᵀV = X_norm × WV_headH = A × VHeadOut = H × WO_headsoftmax_rows((X_norm × WQ_head × WK_headᵀ × X_normᵀ) ÷ √4 + Mask)
×
X_norm × WV_head × WO_head
同一组参数面对不同输入 X_norm,会现场生成不同的 4×4 矩阵 A。它不是背下一张固定连接表,而是学会一种比较两个位置的方法。
来源位置的 X_norm 先经过 WV_head 形成 V,再经过这个头对应的 WO_head 写回 8 维残差流。A 决定每个来源贡献多少。
Q、K、V 的生成公式相同。它们之所以后来不同,不是因为名称本身,而是矩阵乘法把它们放在了三个不同的位置,并让行号以不同方式传到结果中。
Q_head [4×4]×K_headᵀ [4×4]→A [4×4]A [4×4]×V_head [4×4]→H [4×4]Q 位于第一次矩阵乘法左侧。Q 的第 i 行生成 A 的第 i 行,所以第 i 个位置用这一行决定自己怎样连接所有来源。因为它形成接收位置的匹配条件,后来才用 Query 作为简称。
K 转置后位于右侧。K 原来的第 j 行成为 A 的第 j 列,所以第 j 个来源位置用这一行参与全部接收位置的匹配。因为它使来源可以被匹配,后来才用 Key 作为简称。
V 完全不参与生成 A;A 确定后,它才位于第二次矩阵乘法右侧。A 的列与 V 的行对齐,因此 V 的数字被组合进 H。因为它是被取得的数值内容,后来才用 Value 作为简称。
训练数据没有“=”应该关注哪个位置的标签。它只把序列错开一位,告诉模型每个位置真实的下一个 token。关系是预测误差沿矩阵链倒传时自动形成的。
P [1×14]−Y [1×14]=G_logits [1×14]G_logits [1×14]×LMᵀ [14×8]=G_Final [1×8]这 8 个数继续穿过最终 LayerNorm、第 2 层、FFN 和残差,最终抵达 H=A×V。到这里才分成 G_A 与 G_V,再继续训练 WQ、WK、WV。于是正确 token“5”确实能沿一条没有断点的矩阵链反过来塑造 Q/K/V。
G_T 表示“Loss 对矩阵 T 的梯度”,与 T 形状完全相同。于是 G_H 是 H 收到的错误方向,G_A、G_V、G_Q、G_K 分别是同一错误继续传到 A、V、Q、K 后得到的矩阵;它们都不是新的训练参数。
Aᵀ×G_H=G_V路线权重越大,错误越多地传给相应 Value,所以 WV 学“被读取时应该提供什么”。
G_H×Vᵀ=G_A某来源的 Value 越能推动正确答案,它的路线就收到越有利的训练信号,所以 A 学“应该读谁”。
G_A→ Softmax 反向G_SG_S × K ÷√4=G_QG_Sᵀ × Q ÷√4=G_K这些表由 TensorFlow.js 对当前模型执行反向传播得到,不是示意数。它们只供观察,不更新参数;训练按钮仍用 100 道题的全部有效位置共同更新。
100 道题的误差共同更新同一组 WQ、WK、WV。能跨很多样本反复降低 Loss 的寻址规律会累积下来:Q 逐渐形成可复用的检索条件,K 形成可复用的地址,V 形成可复用的内容。每次输入仍会重新计算 A,因此连接图随问题改变。
X→LayerNorm→Masked Multi-Head Self-Attention→ +XLayerNorm→FFN→ +残差第1层输出→第2层重复→Final LayerNorm→LM→Softmax
block_input 只是“本层入口矩阵”的代码名。第 1 层入口是 X;第 2 层入口就是第 1 层输出,没有新数据凭空出现。
LM 是 Language Model 输出矩阵的代码名,也常写 W_out。本模型为 8×14:把 8 维状态映射成词表 14 列分数。
计算套路没有换:训练时永远是预测真实的下一个 token;推理时永远是输出一个 token、接回左侧、再算一次。变化的是词表、维度、层数、头数、参数量和训练文本规模。
海量 token 序列→ 错开一位下一个 token 目标→ 反向传播可复用的路由与特征→ 自回归循环长文本行为
Q/K 连接主语、谓语、指代和远处约束;V 搬运相关内容;FFN 变换组合后的特征。每次只选一个下一个 token,但生成结果被接回输入,连续预测就形成句子。
疑问类型、实体和限制分散在不同位置。多层注意力让答案位置动态读取它们;问答文本的下一个 token 误差会训练能导向正确回答的路线和内容。
变量定义与引用、函数签名与调用、左括号与右括号相隔很远。Q/K 按内容寻找相关位置,V 搬运名称、类型和结构特征,LM 选择下一段代码 token。
QKV 是关键基础设施:它让网络拥有输入依赖、可训练、可并行的动态通信。没有它或同类机制,远距离关系很难高效组合。
但涌现不只来自 QKV:多层和多头让简单关系继续组合,FFN 保存与变换特征,残差保留信息,海量数据提供语言、知识和代码模式,规模与优化让可复用规律胜过逐条记忆。这 100 道乘法证明的是机制,不证明这个 8 维模型已经学会人类乘法推理。
A 能准确告诉我们这一层、这一个头怎样混合 V;但最终输出还经过 V、WO、残差、FFN、后续层和 LM。某列比例最大,只说明这一步从该来源取得的份额最大,不能单独证明该 token 对最终答案最重要。