从 4192 个参数看懂 GPT:拆解 Andrej Karpathy 的 microGPT

真实的 GPT-4、ChatGPT 有上千亿参数,堆着几十上百层 Transformer,训练数据是整个互联网。这样的规模让"理解 GPT 到底在做什么"变成一件望而生畏的事——但如果把它缩小到 4192 个参数、1 层 Transformer、纯 Python 写、不依赖任何深度学习框架呢?

这正是 Andrej Karpathy(OpenAI 创始成员之一,前 Tesla AI 总监)写的 microGPT:一份约 200 行的纯 Python 脚本,从零手写自动求导引擎,到完整的 Transformer 架构,到训练循环,再到推理采样——一个字都不需要 PyTorch。它的开篇注释写得很直白:

The most atomic way to train and run inference for a GPT in pure, dependency-free Python. This file is the complete algorithm. Everything else is just efficiency.

也就是说:这份代码就是 GPT 的完整算法本身,ChatGPT 和它相比,多出来的只是规模和工程效率,核心逻辑一字未改。这篇文章按代码的自然结构,把 microGPT 拆成几个部分逐一讲清楚。

全貌:一个只做一件事的模型

microGPT 只干一件事:给一串字符,预测下一个字符是什么。整个文件按逻辑分成六块:

  1. 数据集(32,033 个英文名字,一行一个,比如 emma、olivia、ava)
  2. 分词器(字符级,每个字符对应一个整数 id)
  3. 自动求导引擎(手写的Value类和反向传播)
  4. GPT 架构(多头注意力 + MLP,模仿 GPT-2)
  5. 训练循环(交叉熵损失 + Adam 优化器)
  6. 推理循环(采样生成新名字)

模型只有 4192 个参数——GPT-4 级别的模型是它的几千万倍——但支撑它运转的算法,和真正的 ChatGPT 完全一致。


1. 数据与分词器:一切从"切成整数"开始

数据很朴素:3.2 万个英文名字。神经网络不认识字符串,第一步永远是把文本变成整数序列——这就是分词器(tokenizer)的工作。microGPT 用的是最简单的字符级分词:把数据集里出现过的所有字符去重排序,每个字符分配一个 id(这批数据只有小写字母,排序后a=0, b=1, ..., z=25)。

关键的一个设计是BOS(Beginning of Sequence)token:一个专门的特殊 id,被插在每个名字的开头结尾。比如emma会变成[BOS, e, m, m, a, BOS]。开头的 BOS 好理解,是"新名字开始了"的信号;容易被忽略的是结尾为什么也要放一个 BOS——这其实是整个训练目标的关键:训练时,模型会在每个位置学习预测"下一个字符是什么",当序列走到最后一个真实字符(比如a)时,“下一个字符"这道题的标准答案就是 BOS。模型因此学会了判断"这个名字该结束了”;到了推理阶段,模型自己生成到 BOS,就知道该停手了,不然它不知道什么时候该收尾。

词表大小 = 26 个字母 + 1 个 BOS = 27。

2. 自动求导引擎:没有 PyTorch,怎么手写反向传播

真实的深度学习框架把"自动求导"这件事藏得很深,但 microGPT 把它完整暴露了出来——这是全篇最值得细读的部分。

核心思路:把每个数字变成一个"会记账的数字"

classValue:def__init__(self,data,children=(),local_grads=()):self.data=data# 前向传播算出来的值self.grad=0# loss 对这个节点的导数self._children=children# 我是由哪几个 Value 计算出来的self._local_grads=local_grads# 我对每个 child 的局部导数

每次对Value做加、乘、幂、log、exp、relu 这些运算,代码不只是算出结果,还顺带把"求导公式"记了下来。以乘法为例:

def__mul__(self,other):returnValue(self.data*other.data,(self,other),(other.data,self.data))

c = a * b时,c记住了∂c/∂a = b.data∂c/∂b = a.data——这是乘积法则最朴素的样子。每做一次运算,就在"计算图"里连一条边,前向传播的过程,其实是在悄悄搭建这张图。

反向传播backward()做两件事:先用深度优先搜索对计算图做拓扑排序(保证每个节点都排在它所有"父节点"之后被处理),然后把这个顺序倒过来,从 loss 出发依次把梯度往下传:

forvinreversed(topo):forchild,local_gradinzip(v._children,v._local_grads):child.grad+=local_grad*v.grad

这一行就是链式法则的全部实现。为什么一定要先排序再倒序处理?因为一个节点常常被多处使用(比如同一个参数在算 Q 和 K 时都被用到),它的最终梯度必须等所有用到它的地方都把贡献传过来才算完整;顺序错了,会拿一个还没累加完、"过早读取"的梯度继续往下传,整个反向传播就是错的。这也解释了为什么代码里全是+=而不是=——梯度要把所有来源加总。

这套引擎还有个精巧的设计:减法、除法完全没有单独写求导规则,而是靠加法和乘法出来的(a - b被翻译成a + b*(-1))。之所以能这样拼,是因为加法(以及"乘一个常数")的局部导数永远是固定值,跟操作数的实际取值无关;而一般乘法a*b(两个变量)的局部导数依赖对方的实时值(乘积法则),这是加法规则推不出来的全新行为。所以整个引擎只需要手写+ * ** log exp relu六个"求导规则互不可推导"的原语,其余运算都是组合出来的——这是自动求导系统"少即是多"的经典设计。

3. 模型架构总览:参数到底是些什么

先看架构的几个超参数(这些是设计选择,不是训练出来的):

n_layer=1# Transformer 层数(这个玩具模型只有 1 层)n_embd=16# 每个 token 用一个 16 维向量表示block_size=16# 最大上下文长度n_head=4# 4 个注意力头,每头 4 维

真正的可训练参数用一个简单的工厂函数生成——matrix(nout, nin)造一个nout×nin的随机初始化Value矩阵,本质上就是手写版的nn.Linear权重。全局参数表里最重要的两张"查找表"是:

  • wte(词嵌入表,27×16):每个字符 id 查出一个 16 维向量,代表这个字符的"语义"
  • wpe(位置嵌入表,16×16):每个位置 0~15 查出一个 16 维向量,代表"我在序列里排第几"

为什么需要单独一张位置表?因为注意力机制本身天然不知道谁在前谁在后(下一节会看到,它本质是在做加权平均),必须靠wpe把顺序信息显式注入进去。输入向量就是两者相加(而不是拼接):x = tok_emb + pos_emb。之所以能直接相加,是因为这两张表都是训练出来的——梯度下降会调整它们,让相加后的向量同时保留身份和位置信息,只要维度够、后面的层够灵活,模型自己会学会怎么"读"出这两部分;相加也比拼接更省参数,不会让维度翻倍。

除了这两张表,每层还有 attention 用的四个 16×16 矩阵(wq/wk/wv/wo),以及 MLP 用的两个矩阵:mlp_fc1(16→64,升维)和mlp_fc2(64→16,降维)。这种"先胀 4 倍再压缩"的设计,是给 ReLU 非线性更大的空间做复杂的分段线性组合——如果直接用一个 16→16 的矩阵,整个 MLP 就退化成一次线性变换 + 一层 ReLU,表达能力大打折扣。

所有矩阵拍平成一个列表,就是 Adam 优化器要更新的全部对象:4192 个Value

4. 注意力机制:全篇的灵魂

Transformer 的核心问题只有一句话:每个位置,该去看"过去"的哪些位置,看多少(权重),看到什么(内容)

q=linear(x,wq)# Query:我在找什么k=linear(x,wk)# Key:我能提供什么信息(用来被匹配)v=linear(x,wv)# Value:如果被选中,我实际贡献的内容

直觉类比:Query 像"提问",Key 像每个历史 token 举的"关键词牌子",Value 像牌子后面真正的"货"。当前的 Query 和所有历史的 Key 做点积比对,越匹配权重越高,softmax 归一化后按权重把大家的 Value 加权混合,就是这个位置该看到的信息。

keys[li]values[li]这两个列表,就是KV cache——真实 LLM 推理加速用的同一套技巧。因为 microGPT 是逐位置跑gpt()(而不是一次性处理整句话),每处理一个新位置才append一次历史的 K/V,避免重复计算已经算过的部分。

多头注意力把 16 维切成 4 份,每份独立算一遍注意力再拼回去。意义在于:与其让一组 Q/K/V 塞下所有种类的"关系",不如切成几个小组,每组专注学一种模式(比如一个头学"上一个字符",另一个头学"元音字符"),拼起来信息更丰富。

有个数值细节值得展开:点积之后要除以√head_dim再做 softmax(scaled dot-product attention)。原因是点积本质上是head_dim个"随机数乘随机数"求和,根据统计规律,这个和的标准差会随维度按√d增长——维度越高,点积数值越"膨胀"。不缩放的话,softmax 会因为输入的绝对值过大而变得过度尖锐(几乎所有权重压在一个位置),进而在饱和区梯度趋近于零,训练很难推动。除以√head_dim把点积数值范围重新拉回到一个与维度无关的稳定区间。

还有一个容易被忽略但很精妙的地方:因果性(模型看不到未来)是"免费"获得的keys[li]严格按处理顺序增量append,处理位置 t 的时候,t+1、t+2… 的 key 根本还没被算出来,压根不在列表里——因果约束是时间顺序的自然副产品,不需要额外写一个"遮罩矩阵"。但这依赖"严格按序、增量计算"这个前提:如果反过来一次性把整条序列的 K/V 都提前算好放进列表(这正是真实 Transformer 训练时为了并行效率而做的事),因果性就不会自动成立,必须额外加限制——要么每个位置只看列表的前pos_id+1个,要么把未来位置的注意力得分强制设为负无穷。这就是标准 Transformer 里"显式加因果遮罩矩阵"的来历。

5. RMSNorm 与残差连接:让深层网络训练得动的两个关键

RMSNorm做的事很简单:算出向量的均方值ms = mean(x_i²),用1/√(ms+1e-5)去缩放整个向量。这相当于给每一层"重新校准音量",把不管多大多小的输入统一拉回稳定范围,避免数值随着层数增多越滚越大或越滚越小。那个很小的1e-5是防止x恰好接近全零时除以 0。(相比更常见的 LayerNorm,RMSNorm 省略了"减均值"这一步,更简单,但效果接近。)

残差连接的结构是:先备份原始x,归一化后的副本送进 attention/MLP 去计算,算完的结果再和最初备份的x相加,而不是替换掉它。

这里有个值得展开算一遍的地方——残差连接为什么能让很深的网络依然训练得动。把多层残差结构展开写出来:

x_1 = x_0 + f_1(x_0) x_2 = x_0 + f_1(x_0) + f_2(x_1) x_L = x_0 + f_1(x_0) + f_2(x_1) + ... + f_L(x_{L-1})

关键在于:x_0作为一个独立的加数,原封不动地出现在最终的x_L里,它从来没有被送进任何一次乘法或非线性变换。反向传播求∂x_L/∂x_0时,这个和式里必然有一项精确等于1,跟层数L完全无关——这正是加法局部导数恒为常数 1 的直接体现。其余经过f_i分支传回的梯度可能会被压缩、甚至消失,但这条"跳过一切计算"的恒等路径永远保底、不随深度衰减。这也是为什么残差连接必须用加法:如果写成乘法,局部导数就要依赖对方的具体数值,一旦某个分量接近 0,梯度照样会被压没,"恒为 1、不随深度衰减"的保证就没了。

6. MLP block:另一半分工

MLP 块的结构和 attention 块完全同构(pre-norm + 残差),只是中间换成一个两层小网络:linear(16→64)→ relu → linear(64→16)

真正值得强调的是它和 attention 的分工差异:attention 是唯一让"不同位置互相通信"的地方——当前位置去看其他位置的信息;MLP 只对单个位置自己的向量做独立变换,不会混合任何其他位置的信息(它依然"知道"自己的位置,因为位置编码已经在向量里,只是没有跨位置的信息流动)。如果去掉 attention 只堆 MLP,模型将无法完成"根据前几个字符预测下一个字符"这类任务——因为没有任何路径能把历史信息带到当前位置,模型会退化成孤立猜测。一个 Transformer block 的套路正是:先用 attention 让每个位置"看看别人",再用 MLP 对收集到的信息做一次"消化加工"。

7. 完整 forward 过程串起来看

把前面几节拼成一条主干流程:

  1. 查表取tok_emb(wte)+pos_emb(wpe),相加融合"身份+位置"信息
  2. rmsnorm归一化
  3. 进入 Transformer block:attention(吸收历史信息,残差保底)→ MLP(消化提炼,残差保底)
  4. lm_head把最终 16 维向量投影回 27 维logits(每个字符一个打分,还不是概率,要再过 softmax)

有个容易被忽视的关键点:gpt()函数本身根本不知道"下一个字符"是什么——它只接收当前的token_idpos_id,吐出logits。“logits 代表下一个字符的打分"这件事完全是训练循环人为规定的一种"错位配对”:

token_id,target_id=tokens[pos_id],tokens[pos_id+1]# 喂当前的,拿下一个当答案

这个规定之所以能训练出"预测下一个词"的能力,靠的正是第 4 节讲的因果性:模型在算某个位置时,看不到序列里更靠后的内容,所以拿真正的下一个字符去给它的输出打分、反向传播,是一个没有作弊的诚实训练信号——模型才能被梯度下降一步步调教成"输出的打分越来越像真正的下一个字符"。

8. 训练循环:交叉熵损失与 Adam

损失函数的核心是交叉熵

probs=softmax(logits)loss_t=-probs[target_id].log()

模型对正确答案越自信(概率接近 1),损失趋近 0;越不自信、错得越离谱(概率接近 0),损失趋向无穷大。为什么用-log(p)而不是更朴素的1-p?关键在于惩罚力度的性质:1-p最多罚到 1 就封顶,就算模型给正确答案打 0 分也不会罚得更重,且导数恒为-1,推力不随错误程度变化;-log(p)p→0时惩罚趋向无穷大,导数是-1/pp越小梯度推力越大——恰好在模型最离谱的时候,给出最强的纠正力度。

反向传播这里正好呼应第 2 节:一个名字有多个位置,但所有位置共享同一套参数(wtewq……),同一个参数在多次前向传播里被反复使用,梯度靠+=把所有位置的贡献累加起来。

参数更新用的是Adam优化器,不是简单的"减去梯度乘学习率",而是维护每个参数的一阶矩m(梯度的滑动平均,类似动量)和二阶矩v(梯度平方的滑动平均,代表典型波动幅度),用偏差修正后的m_hat/√v_hat去更新参数——梯度一直很小很稳定的参数步子相对更大,梯度大且波动的参数步子变小,每个参数有自己独立、自适应的步长。别忘了每次更新完要把p.grad手动清零:因为梯度是靠+=累加的,不清零下一步的新梯度会叠加在旧梯度上,训练直接崩溃。

9. 推理与采样:让模型开口"编"名字

生成时,每个样本从空的 KV cache、token_id=BOS出发(呼应第 1 节:BOS 既是开始也是结束信号)。关键的一个旋钮是temperature

probs=softmax([l/temperatureforlinlogits])

temperature < 1(比如 0.5)会放大 logits 之间的差距,softmax 分布更尖锐,生成结果更保守、更贴近训练数据里的常见模式;temperature > 1则相反,分布更平,生成更随机、更"天马行空"。极端情况下,temperature→0趋近于每次都选分数最高的(贪心/argmax),temperature→∞趋近于纯随机瞎选。

最后是按概率抽样而不是贪心选最大值——如果用贪心,同一个起点加同一套参数,每次生成结果都会一模一样;正是靠random.choices按概率抽样引入的随机性,20 次生成才能得到 20 个不同的名字。生成会一直进行到模型自己输出 BOS 为止,或者到达block_size的安全上限。

10. microGPT 与真实 ChatGPT 差多远

讲到这里,整个算法已经完整了——embedding、注意力、MLP、残差连接、反向传播、Adam、采样,这就是 GPT 的全部。真实的 ChatGPT 和它相比,核心算法一字未改,差距全部来自规模和多加的一个阶段:

  • 数据:3.2 万个名字,对比几千亿到几万亿 token 的全网文本(网页、书籍、代码、对话)
  • 分词器:字符级、词表 27,对比 BPE(Byte Pair Encoding)子词分词、词表 5 万到 10 万以上——BPE 把常见词或词根打包成一个 token,同样内容编码出的序列更短,同样的上下文窗口能装下更多实际内容
  • 规模:1 层、16 维、4192 个参数,对比 GPT-3 的 96 层、12288 维、1750 亿参数——大约四千万倍的差距。层数越多,信息被逐层"通信+加工"的轮次就越多
  • 后训练(microGPT 完全没有的一个阶段):microGPT 训练的唯一目标是"预测下一个字符",这叫预训练(pretraining)。ChatGPT 在预训练之后,还要经过监督微调(SFT,用人工写的问答示例)和基于人类反馈的强化学习(RLHF),专门调教"该怎么回应人类的指令"。这一步和"预测下一个词"完全是两码事——只是单纯放大预训练的规模(更多数据、更多参数、更好的分词器),并不会自动让模型学会听指令、拒绝有害请求;对齐这件事,必须靠额外的后训练阶段才能获得。

写在最后

microGPT 最打动人的地方,不是它多精巧,而是它证明了一件事:理解 GPT 不需要理解一千七百五十亿个参数,只需要理解 4192 个参数背后的那套逻辑——它们是完全一样的东西,只是被放大了几千万倍,又在后面多接了一道人类反馈的调教工序。如果你想真正把这套逻辑吃透,最好的办法就是像本文这样,把这两百行代码逐块拆开,一步步跟着推一遍。

源码:karpathy/microgpt.py