引言:别人讲概念,我们看真数据
网上讲 Transformer 注意力机制的文章很多。但几乎所有文章都有一个共同的问题:数据是编的。
它们会画一个框,写上 “Q”、“K”、“V”,然后说 “Q 是查询向量”、“K 是键向量”、“V 是值向量”——但框里面到底是什么数字?乘完之后变成了什么?softmax 之后注意力权重长什么样?从来不说。
今天我们换个做法。
我们训练一个只有 4192 个参数的迷你 GPT(基于 Karpathy 的 microgpt),喂给它 10000 个英文常用单词,让它学会英语的拼写规律。然后,把这个训练好的模型完全打开——每一个权重矩阵、每一个中间向量、每一个注意力分数,全部用真实数字展示。
本文的所有数字都来自真实训练——不是示意图,不是假数据。你看到的每一个小数点,都是模型在 3000 步训练后的真实参数值。
Demo 速览 — 这不是聊天机器人,不能回答问题。 它做的事情更基础也更纯粹——预测下一个字母。
输入 一个英文单词的前几个字母,如 “t”、“th”、“the” 输出 下一个字母的概率分布(27 个候选:a-z + 结束符) 目标 学会英语拼写规律,能"发明"看起来像真词的新单词
代码量 外部依赖 硬件要求 训练时间 核心 200 行 / Demo ~350 行 零(纯 Python) 普通 CPU,无需 GPU 3000 步,~12 分钟
一、先搞清楚:这个模型在做什么?
比如,模型看到 “th” 之后,会输出:
下一个字母的概率:
'a' → 22.7% ← that, thank, than, ...
'o' → 17.0% ← those, though, ...
'e' → 16.6% ← the, them, there, ...
'i' → 11.4% ← this, think, third, ...
...
这和 ChatGPT 的核心原理完全一样——ChatGPT 也是在预测"下一个 token 最可能是什么",只不过它的 token 是子词而非单个字母,参数是千亿级而非四千个。
实验环境
| 项目 | 详情 |
|---|---|
| 原始代码 | Karpathy 的 microgpt —— 200 行纯 Python,从零实现 Transformer(含反向传播) |
| Demo 脚本 | demo_transformer_internals.py —— 约 350 行,含训练 + 中间数据采集 + 输出 |
| 训练数据 | Google 10000 常用英文单词 |
| 模型规模 | 1 层 Transformer,16 维嵌入,4 个注意力头,总共 4192 个参数 |
| 硬件 | 普通 CPU(无 GPU),8 核 Intel Xeon,32GB 内存 |
| 训练时间 | 3000 步,约 12 分钟 |
| 外部依赖 | 零——不需要 PyTorch、numpy 或任何第三方库 |
没错,200 行 Python + 一台普通电脑 + 12 分钟,就能训练出一个完整的 Transformer。这也是为什么选它来做教学——所有东西都小到可以看清。
模型配置
| 参数 | microgpt (本文) | GPT-2 |
|---|---|---|
| 嵌入维度 | 16 | 768 |
| 注意力头数 | 4 | 12 |
| 每头维度 | 4 | 64 |
| 层数 | 1 | 12 |
| MLP 隐藏层 | 64 | 3072 |
| 词表大小 | 27 | 50257 |
| 总参数 | 4,192 | ~124,000,000 |
词表很简单:26 个英文字母(a-z)加一个特殊的 <BOS>(Begin of Sequence,序列起始标记)。模型的任务是:给定前面的字母,预测下一个字母是什么。
训练效果
用 10000 个常用英文单词训练 3000 步(约 12 分钟),模型生成的"假词":
conte tout stor costs ex
derse alid ponde rotins aron
angir fure derits conen maliting
其中 conte、tout、stor、costs、ex 都是真词或极其接近真词。其余的如 derse、alid、ponde,虽然不是真词,但完全符合英语的拼写规律——它学到了 “th”、“st”、“ing”、“tion” 这些模式。
4192 个参数,12 分钟训练,就能做到这些。现在,让我们打开引擎盖,看看里面发生了什么。
二、第 1 步:把字母变成向量(Token Embedding)
模型不认识"字母",只认识数字。所以第一步是把每个字母翻译成一个 16 维向量。怎么翻译?查表。
这张表叫 Token Embedding 矩阵(wte),大小是 27 行 x 16 列。每行对应一个字母,每行有 16 个数字。这些数字不是人设计的——它们是训练出来的。
以输入单词 “the” 为例。首先,把它翻译成 token 序列:
<BOS> → t → h → e → <BOS>
26 19 7 4 26
前后各加一个 <BOS> 标记序列的开始和结束。然后查嵌入表:
字母 ’t’ 的嵌入向量(wte 的第 19 行)——这就是 ’t’ 在模型眼中的"样子":
wte['t'] = [ 0.099, 0.107, -0.422, 0.835, 0.152, -0.063,
0.230, 0.063, 0.169, -0.260, 0.088, -0.251,
0.415, 0.557, -0.581, 0.013]
16 个浮点数,这就是字母 ’t’ 的全部信息。同样:
wte['h'] = [-0.190, -0.024, 0.020, 0.431, -0.244, -0.080,
-0.055, 0.212, -0.247, 0.183, -0.130, -0.042,
-0.158, 0.766, -0.344, 0.105]
wte['e'] = [ 0.305, 0.093, 0.244, -0.408, -0.242, -0.402,
-0.124, 0.176, -0.345, -0.149, 0.720, 0.176,
0.412, -0.275, 0.338, -0.281]
加上位置编码
光有"是什么字母"不够,还得知道"在哪个位置"。同一个字母 ’e’ 出现在第 1 位和第 5 位,意义可能完全不同。
所以模型还有一张 位置编码表(wpe),大小 16 x 16(16 个位置,每个位置 16 维)。字母嵌入和位置编码相加,就是模型真正看到的输入。
以位置 1 的 ’t’ 为例:
Token 嵌入 wte['t']: [ 0.099, 0.107, -0.422, 0.835, 0.152, -0.063, ...]
位置编码 wpe[1]: [ 0.187, 0.071, 0.037, -0.033, -0.424, -0.085, ...]
──────────────────────────────────────────────────────────────────
相加结果 x: [ 0.286, 0.179, -0.385, 0.802, -0.272, -0.148, ...]
然后做一次 RMSNorm(归一化,让数值保持在合理范围)。这个 16 维向量 x,就是进入注意力层的输入。
三、第 2 步:QKV 投影——注意力的三件套
现在每个位置都有一个 16 维向量 x。接下来,模型要做一件关键的事:让每个位置"看看"其他位置,决定该关注谁。
这就是注意力机制的核心。它用三组权重矩阵,把每个 x 变成三个不同的向量:
Q = x × W_Q (Query: 我在找什么?)
K = x × W_K (Key: 我能提供什么?)
V = x × W_V (Value: 我的内容是什么?)
W_Q、W_K、W_V 各是一个 16x16 的权重矩阵——它们的每个元素都是训练过程中通过反向传播学到的参数。所以说"W_Q 是参数"和"W_Q 是矩阵"都对:它是一个由可训练参数组成的矩阵。
关于符号约定: 本文使用行向量约定,即
Q = x · W_Q(向量 x 在左,矩阵 W 在右),与 Transformer 原始论文和 PyTorch 代码(Q = x @ W_Q)一致。线性代数教科书常用列向量约定Q = W_Q · x(矩阵在左),两种写法数学上等价,只是矩阵需要转置。
图书馆比喻: 你走进图书馆找书。你手里有一张需求清单(Q)——“我要关于历史的书”。书架上每本书有一个标签(K)——“我是历史类”。清单和标签对上了(Q·K 的点积大),你就把那本书的**内容(V)**取出来。
真实数据:W_Q 权重矩阵长什么样
这是我们训练好的 W_Q 矩阵的前 4 行(一共 16 行,每行 16 个数字,共 256 个参数):
W_Q[0] = [ 0.486, -0.190, 0.927, -0.252, -0.179, -0.152, 0.252, 0.044,
-0.023, -0.119, 0.209, 0.389, 0.325, -0.083, -0.155, -0.364]
W_Q[1] = [-0.011, -0.067, -0.212, 0.200, -0.450, 0.061, -0.145, -0.091,
-0.204, 0.306, -0.080, 0.285, 0.486, 0.135, -0.705, -0.325]
W_Q[2] = [ 0.147, -0.066, -0.240, -0.380, 0.110, -0.037, -0.175, -0.254,
0.295, 0.532, -0.010, -0.093, 0.067, 0.035, -0.263, -0.668]
W_Q[3] = [ 0.074, -0.174, 0.666, -0.313, 0.291, -0.026, 0.022, 0.170,
0.014, -0.041, -0.053, 0.087, 0.061, -0.058, 0.182, -0.600]
... (共 16 行)
这些数字是随机初始化的,然后在 3000 步训练中被反向传播慢慢调整,最终定型。不是人设计的,是"长出来的"。
真实数据:Q、K、V 投影结果
输入 “the”,每个位置经过 QKV 投影后得到的向量:
位置 0 — <BOS>:
Q = [ 1.473, 1.605, 3.272, 2.116, -1.330, -0.985, -0.713, -2.096, ...]
K = [-1.639, 0.890, 0.309, -1.354, -0.483, -0.746, 0.623, 0.189, ...]
V = [ 0.092, 0.062, -0.102, 0.093, 0.159, 0.113, 0.083, 0.206, ...]
位置 1 — 't':
Q = [ 0.025, 3.516, 1.232, -1.403, -1.888, -1.277, 1.129, 0.560, ...]
K = [ 1.219, 1.326, 0.912, 1.092, 0.358, 0.026, 0.005, -0.851, ...]
V = [-0.029, -0.419, 0.622, 0.132, -0.165, -0.262, -0.168, 0.210, ...]
位置 2 — 'h':
Q = [ 1.177, 1.676, 0.221, -0.288, 0.026, 0.406, 0.313, 0.439, ...]
K = [ 1.960, 3.061, 0.669, 0.206, 0.155, 0.619, 0.074, -0.846, ...]
V = [ 0.346, 0.445, -0.499, -0.237, -0.390, -0.029, -0.190, -0.076, ...]
位置 3 — 'e':
Q = [ 2.605, 1.533, 1.134, 1.452, 0.315, 0.296, -1.225, -0.970, ...]
K = [-1.295, -0.088, -1.527, -1.227, 0.858, 0.607, 0.312, 1.019, ...]
V = [ 0.150, 0.646, 0.456, 0.297, 0.891, 0.388, 0.468, 0.692, ...]
注意:同一个字母 ’t’,经过不同的权重矩阵后,Q、K、V 三个向量完全不同。这就是矩阵乘法的魔力——同一份信息,从三个不同角度被"提问"。
四、第 3 步:多头注意力——4 个头各管一片
Q、K、V 各是 16 维向量。现在要用它们计算注意力。但 Transformer 不会直接用 16 维去算,而是拆成 4 个头,每个头只看 4 维。
Q (16维) → 拆成 4 份:
头0: Q[0:4] = [2.605, 1.533, 1.134, 1.452]
头1: Q[4:8] = [0.315, 0.296, -1.225, -0.970]
头2: Q[8:12] = [-0.477, 0.751, -1.333, 0.168]
头3: Q[12:16] = [0.505, 0.500, -2.082, -1.242]
K 和 V 同理,每个头 4 维。
为什么要拆? 因为不同的头可以学到不同的"关注模式"。一个头可能学会关注前一个字母,另一个头可能学会关注更远处。多角度看问题,比单一视角更强。
注意力计算:三步走
以位置 3(字母 ’e’)的头 0 为例。’e’ 是序列中最后一个字母,它可以看到所有前面的 token:<BOS>、t、h、e。
Step 1:打分(Q · K)
’e’ 的 Query 和每个历史位置的 Key 做点积,再除以 √d_k = √4 = 2。这个 √d_k 缩放是固定的,不是可调参数——它的作用是防止点积值随向量维度增大而变得过大,导致 softmax 输出趋向极端(全 0 或全 1):
Q_e(头0) = [2.605, 1.533, 1.134, 1.452]
Q_e · K_<BOS> / √4 = -2.261 ← 和 <BOS> 不太相关
Q_e · K_t / √4 = 3.914 ← 和 't' 比较相关
Q_e · K_h / √4 = 5.428 ← 和 'h' 非常相关!
Q_e · K_e / √4 = -3.512 ← 和自己不太相关
Step 2:归一化(softmax)
把分数转成 0 到 1 之间的概率,总和为 1:
softmax([-2.261, 3.914, 5.428, -3.512])
= [0.0%, 18.0%, 81.9%, 0.0%]
<BOS> t h e
→ 头 0 把 81.9% 的注意力放在了 'h' 上!
Step 3:加权求和(× V)
用注意力权重对各位置的 Value 加权求和,得到这个头的输出:
头 0 输出 = 0.0% × V_<BOS> + 18.0% × V_t + 81.9% × V_h + 0.0% × V_e
≈ V_h (因为 'h' 的权重占了 82%)
这意味着:当模型处理 ’e’ 时,头 0 主要提取了 ‘h’ 的信息。为什么?因为在英语中 “he” 是一个极其常见的组合——模型学到了 “看到 e,回头找 h”。
4 个头的完整注意力(“the” 中的 ’e’)
| 头 | <BOS> | t | h | e | 解读 |
|---|---|---|---|---|---|
| 头0 | 0% | 18% | 82% | 0% | 紧盯 ‘h’,捕捉 “he” 模式 |
| 头1 | 12% | 37% | 37% | 14% | 均匀看 ’t’ 和 ‘h’,综合判断 |
| 头2 | 7% | 13% | 72% | 8% | 也紧盯 ‘h’,强化信号 |
| 头3 | 19% | 51% | 28% | 1% | 更关注 ’t’,看更远的上下文 |
看到了吗?4 个头自动学到了不同的策略:头 0 和头 2 聚焦紧邻的 ‘h’,头 3 却去看更远的 ’t’。没人教它们这样分工——这是 3000 步训练后自然"涌现"的行为。
注意力热力图(完整)
下面展示头 0 在处理 “the” 时,每个位置看向哪里(行=当前位置,列=被关注的位置):
头 0 注意力权重:
<BOS> t h e
<BOS> [100% ]
't' [ 63% 37% ]
'h' [ 2% 12% 86% ]
'e' [ 0% 18% 82% 0% ]
注意对角线的规律:
- ‘h’ 把 86% 的注意力给了自己——在单独出现时,最重要的信息就是自身
- ’e’ 把 82% 给了 ‘h’——学到了 “he” 这个强组合
- 下三角形状说明这是因果注意力(causal attention):每个 token 只能看到前面的,不能偷看后面的
不同单词,不同的注意力模式
换个单词看看。输入 “and”,看 ’d’ 位置的注意力:
| 头 | <BOS> | a | n | d | 解读 |
|---|---|---|---|---|---|
| 头0 | 3% | 5% | 91% | 2% | 几乎只看 ’n’! |
| 头1 | 6% | 21% | 62% | 12% | 也盯着 ’n’ |
| 头2 | 17% | 3% | 63% | 17% | 还是 ’n’ |
| 头3 | 7% | 0% | 68% | 25% | 同上 |
处理 “and” 时,所有 4 个头都集中关注 ’n’(62%~91%)。为什么?因为 “nd” 是英语中极其常见的双字母组合(and, end, find, hand, kind…),模型学到了 “看到 n 后面大概率跟 d” 这个模式。
再看 “cat” 中处理 ’t’ 时:
头0: c=87% a=4% → 几乎只看 'c'
头3: t=61% a=24% → 主要看自己
头 0 学到了 “回头看首字母” 的策略——对于判断单词结尾(是否该停下来)非常关键。
拼接 + 输出投影
4 个头各输出 4 维,拼接成 16 维,再乘以一个 W_O 矩阵(16x16)做输出投影,最后加上残差连接(直接把注意力之前的 x 加回来)。
4个头拼接 (16维) → × W_O → 注意力输出 (16维) → + x (残差) → 进入 MLP
残差连接的作用: 即使注意力算出的结果不好,原始信息 x 也不会丢失——总有一条"直通车"保底。这让深层网络的训练变得可能。
五、第 4 步:MLP——注意力之后的"思考"
注意力层负责"看到什么"——从其他位置收集信息。MLP 层负责"怎么理解"——对收集到的信息进行非线性变换。
结构很简单:先升维,再激活,再降维。
x (16维) × W_fc1 (16×64) → hidden (64维) ← 升维 4 倍
ReLU(hidden) ← 非线性激活
hidden (64维) × W_fc2 (64×16) → out (16维) ← 降维回来
ReLU:极度稀疏的激活
ReLU 函数很简单:正数不变,负数变成 0。效果是什么?
| 输入单词 | 最后位置 ReLU 激活 | 激活率 |
|---|---|---|
| “the” → ’e’ | 2 / 64 个神经元 | 3% |
| “cat” → ’t’ | 0 / 64 个神经元 | 0% |
| “stop” → ‘p’ | 1 / 64 个神经元 | 2% |
| “and” → ’d’ | 0 / 64 个神经元 | 0% |
64 个神经元中,通常只有 0~2 个被激活!这叫稀疏激活——模型学会了只让极少数"专家"神经元参与每次计算。
比如处理 “the” 中的 ’e’ 时,64 个神经元中只有 #2(值 0.554)和 #57(值 1.107)被激活。可以把它们想象成两个"专家":一个可能负责检测 “元音在 h 后面” 的模式,另一个可能负责检测 “the 快要结束了” 的信号。
现实类比: 大型模型中也是如此。GPT-2 的 MLP 有 3072 个神经元,但对于任何给定输入,通常只有一小部分被激活。这也是"混合专家"(MoE)架构的理论基础——既然大部分神经元都不活跃,不如直接只激活其中一部分来节省计算。
六、第 5 步:预测下一个字符
经过嵌入 → 注意力 → MLP 之后,每个位置有一个 16 维的输出向量。最后一步是把它映射回词表空间:
x (16维) × lm_head (16×27) → logits (27维) → softmax → 概率分布
输出是 27 个概率值(对应 26 个字母 + <BOS>),表示模型认为下一个字符最可能是什么。
Temperature(温度)在这里: 人们说"调 ChatGPT 的温度",调的就是这一步输出 softmax 的温度,而不是前面注意力层的 √d_k 缩放。具体做法是在 softmax 之前把 logits 除以温度 T:
softmax(logits / T)。T < 1 让分布更尖锐(更确定),T > 1 让分布更平坦(更随机)。注意力层里的 √d_k 是固定的数学缩放,和这里的温度是完全不同的概念。
真实预测结果
输入 “the”,看模型在每个位置的预测:
| 当前字符 | 正确答案 | Top-3 预测 | 正确答案排名 |
|---|---|---|---|
| <BOS> | t | s(9.1%), c(8.9%), p(8.0%) | 第 5 (6.6%) |
| t | h | r(16.5%), o(14.9%), e(14.8%) | 第 7 (7.0%) |
| h | e | a(22.7%), o(17.0%), e(16.6%) | 第 3 |
| e | <BOS>(结束) | r(12.1%), n(11.2%), s(7.9%) | 第 4 (7.8%) |
几个有趣的观察:
- <BOS> → 预测首字母: s、c、p 排前三——因为英语中以 s、c、p 开头的单词确实最多
- t → 预测 h 只排第 7: 因为 t 后面可以跟 r(tr-)、o(to-)、e(te-)、i(ti-)、a(ta-)……选择太多了。但 7.0% 已经不低
- h → 预测 e 排第 3(16.6%): 这里注意力的效果显现了——前一步模型看到 t→h 组合后,成功提升了 ’e’ 的概率
- e → 预测结束排第 4: 模型知道短词应该结束,但也考虑了 er-、en-、es- 等常见后缀
记住,这是一个只有 4192 个参数、训练了 12 分钟的模型。它已经掌握了英语拼写的大量统计规律。
七、全景图:一个 Token 的完整旅程
把所有步骤串在一起:
输入: 字母 'e' (id=4, 在 "the" 的位置 3)
|
v
查表: wte[4] → 16 维向量
+ wpe[3] → 加上位置信息
→ RMSNorm → 归一化
|
v
+------ 多头注意力 -------------------------+
| |
| × W_Q → Q (16维): "我在找什么?" |
| × W_K → K (16维): "我能提供什么?" |
| × W_V → V (16维): "我的内容是什么?" |
| |
| 拆成 4 个头 (每头 4 维): |
| 头0: 82% 看 'h' → 捕捉 "he" 模式 |
| 头1: 37% 看 't' + 37% 看 'h' |
| 头2: 72% 看 'h' → 强化 "he" 信号 |
| 头3: 51% 看 't' → 看更远的上下文 |
| |
| 拼接 → × W_O → 注意力输出 (16维) |
+--------------------------------------------+
+ 残差连接 (加上原始 x)
|
v
+------ MLP (前馈网络) ----------------------+
| |
| 16维 → × W_fc1(16×64) → 64维 (升维 4 倍) |
| ReLU: 64 个神经元中只有 2 个被激活 (3%) |
| 64维 → × W_fc2(64×16) → 16维 (降维) |
| |
+--------------------------------------------+
+ 残差连接
|
v
× lm_head → 27 维 logits → softmax(logits/T) → 概率分布
|
v
输出: r(12.1%), n(11.2%), s(7.9%), <BOS>(7.8%), ...
整个流程涉及的矩阵乘法:
| 操作 | 矩阵大小 | 参数量 |
|---|---|---|
| Token 嵌入 (wte) | 27 x 16 | 432 |
| 位置编码 (wpe) | 16 x 16 | 256 |
| W_Q, W_K, W_V, W_O (各 16x16) | 4 × (16 x 16) | 1024 |
| MLP W_fc1 | 16 x 64 | 1024 |
| MLP W_fc2 | 64 x 16 | 1024 |
| 输出头 (lm_head) | 16 x 27 | 432 |
| 总计 | 4,192 |
4192 个浮点数,就是这个模型的全部"知识"。没有规则,没有词典,没有语法书——只有 4192 个在训练中被反复微调的数字。
八、从 4192 到 1750 亿:只有规模变了
这是本文最想传达的观点:
GPT-4 和我们这个 4192 参数的模型,用的是完全相同的算法。
同样的 Token Embedding,同样的 QKV 投影,同样的多头注意力,同样的 MLP,同样的残差连接,同样的 softmax。区别只是数字更大了。
| 本文 demo | GPT-2 | GPT-4 级别 | |
|---|---|---|---|
| 嵌入维度 | 16 | 768 | ~12288 |
| 注意力头 | 4 | 12 | ~96 |
| 层数 | 1 | 12 | ~120 |
| 词表 | 27 | 50257 | ~100000 |
| 总参数 | 4,192 | 1.24 亿 | ~1750 亿 |
| 训练时间 | 12 分钟 | ~1 周 | ~数月 |
| 能力 | 发明假词 | 写段落 | 理解与推理 |
这就是深度学习最深刻的特性之一:简单的算法 + 巨大的规模 = 涌现的智能。
我们在 4192 个参数中看到注意力头自动分工,看到 MLP 稀疏激活,看到模型学会英语拼写模式。同样的机制放大几万倍,就产生了 ChatGPT 的"理解"和"推理"能力。
核心算法并不神秘。
附录:公式速查
对于想看公式的读者,以下是本文涉及的所有核心计算:
1. 嵌入: x = wte[token_id] + wpe[position]
2. QKV 投影: Q = x · W_Q, K = x · W_K, V = x · W_V
(行向量约定: x 在左, W 在右, 与 PyTorch x @ W 一致)
3. 注意力: score = Q · K^T / √d_k ← √d_k 是固定缩放, 不是温度
weight = softmax(score)
output = weight · V
4. 多头拼接: MultiHead = Concat(head_0, ..., head_3) · W_O
5. MLP: hidden = ReLU(x · W_fc1)
output = hidden · W_fc2
6. 残差连接: x = x + Attention(x)
x = x + MLP(x)
7. 预测: logits = x · lm_head
probs = softmax(logits / T) ← T 就是 temperature (温度)
就这些。没有更多了。
即将发布:视频号 Demo 全过程演示
文字毕竟是静态的。接下来,我会发布一期微信视频号视频,在真实终端上,带你从零走完整个过程:
- 一行命令启动训练,实时看 loss 从 3.3 降到 2.3
- 模型从"胡乱拼字母"到"发明像样的假词"的进化过程
- 逐步展示每一个中间向量——嵌入、QKV、注意力权重、MLP 激活
- 对照本文的数据,手把手验证每个计算
200 行代码,12 分钟训练,从头到尾没有黑箱。
关注公众号 “AI-lab学习笔记”,第一时间收到视频推送!
