200 行纯 Python 实现完整 GPT

Andrej Karpathy 在他的 microgpt.py 开头写了一句话:

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.

整个文件大约 200 行,零依赖(连 NumPy 都不用),实现了一个完整的 GPT:自动微分引擎、Transformer 架构、Adam 优化器、文本生成。这不是教学玩具,它真的能训练、能生成文本。这篇文章逐行拆解这份代码,讲清楚每一步在做什么、为什么这么做。

全貌:五大组件一览

microgpt.py 由五个部分组成,每个部分解决一个核心问题:

下面逐一拆解。

核心引擎:手写自动微分

Value 类的设计

整个 GPT 不依赖 PyTorch 的 autograd,而是手写了一个标量级自动微分引擎。核心是 Value 类——每一个数字被包装成 Value 对象,记录三件事:

举个例子,加法 a + b = c:c 的 children 是 (a, b),local_grads 是 (1, 1),因为 dc/da = 1dc/db = 1。乘法 a * b = c:local_grads 是 (b.data, a.data),因为 dc/da = bdc/db = a

通过 Python 的运算符重载,所有数学运算自动构建计算图:

def __add__(self, other):     # 加法:梯度 (1, 1)
    other = other if isinstance(other, Value) else Value(other)
    return Value(self.data + other.data, (self, other), (1, 1))

def __mul__(self, other):     # 乘法:梯度 (other.data, self.data)
    other = other if isinstance(other, Value) else Value(other)
    return Value(self.data * other.data, (self, other), (other.data, self.data))

def log(self):                # d/dx(ln(x)) = 1/x
    return Value(math.log(self.data), (self,), (1/self.data,))

def exp(self):                # d/dx(e^x) = e^x
    return Value(math.exp(self.data), (self,), (math.exp(self.data),))

def relu(self):               # d/dx(max(0,x)) = 1 if x>0 else 0
    return Value(max(0, self.data), (self,), (float(self.data > 0),))

其他运算通过组合实现:减法 a - b = a + (-b),除法 a / b = a * b^(-1)

反向传播的实现

backward() 方法只有十几行,完成了整个反向传播:

def backward(self):
    topo = []
    visited = set()
    def build_topo(v):
        if v not in visited:
            visited.add(v)
            for child in v._children:
                build_topo(child)
            topo.append(v)
    build_topo(self)
    self.grad = 1
    for v in reversed(topo):
        for child, local_grad in zip(v._children, v._local_grads):
            child.grad += local_grad * v.grad

三步走:第一,拓扑排序,从损失节点出发递归遍历计算图;第二,损失节点自身梯度设为 1;第三,按拓扑逆序遍历,把梯度乘以局部导数累加到子节点。这就是链式法则的机械实现:dL/dchild += dL/dparent * dparent/dchild

注意用 += 而非 =。一个节点可能被多个父节点引用(比如残差连接中的 x),梯度必须累加。这里没有矩阵运算,每个权重都是一个独立的 Value 对象,一次前向传播创建海量的计算图节点。效率极低,但正确性一目了然。

Transformer 架构详解

数据与分词

数据集用的是 makemore 项目的 names.txt,32000 多个英文人名。模型的任务是学习人名的分布规律,然后生成新的、不存在的人名。分词策略是字符级:

uchars = sorted(set(''.join(docs)))
BOS = len(uchars)
vocab_size = len(uchars) + 1

把所有出现过的字符去重排序,每个字符对应一个整数 id。额外加一个 BOS(Beginning of Sequence)token,标记序列的开始和结束。整个词表只有 27 个 token(26 个字母加 BOS),字符级分词的好处是词表极小,模型结构更简单。

GPT 前向传播

先看三个基础函数。线性层就是矩阵乘向量 y = Wx

def linear(x, w):
    return [sum(wi * xi for wi, xi in zip(wo, x)) for wo in w]

Softmax 减去最大值防止 exp 溢出。RMSNorm 是 LayerNorm 的简化版,不做均值中心化,只除以均方根。主函数 gpt() 的签名值得注意——每次只处理一个 token

def gpt(token_id, pos_id, keys, values):

前向传播流程:token embedding 加 position embedding,经过 rmsnorm,进入 Transformer 层。每层做两件事:多头注意力块(rmsnorm → Q/K/V 投影 → 注意力 → 输出投影 → 残差连接)和 MLP 块(rmsnorm → 升维到 4 倍宽度 → ReLU → 降维回原宽度 → 残差连接)。最后通过 lm_head 投影到词表大小的 logits。

多头注意力

注意力是 Transformer 的核心:

for h in range(n_head):
    hs = h * head_dim
    q_h = q[hs:hs+head_dim]
    k_h = [ki[hs:hs+head_dim] for ki in keys[li]]
    v_h = [vi[hs:hs+head_dim] for vi in values[li]]
    attn_logits = [sum(q_h[j] * k_h[t][j] for j in range(head_dim)) / head_dim**0.5
                   for t in range(len(k_h))]
    attn_weights = softmax(attn_logits)
    head_out = [sum(attn_weights[t] * v_h[t][j] for t in range(len(v_h)))
                for j in range(head_dim)]
    x_attn.extend(head_out)

每个头独立工作:从 Q/K/V 中切出对应片段,计算缩放点积注意力 QK^T / sqrt(d_k),softmax 得到权重,加权求和 value。

keys[li] 包含了从位置 0 到当前位置的所有 key,所以当前 token 可以"看到"自己及之前的所有 token——这就是因果注意力。不需要额外的 mask,因为未来位置的 key 还没加入列表。这也自然实现了 KV cache——PyTorch 里需要显式管理的优化,在这里因为逐 token 前向传播而自然出现。

训练、推理与设计取舍

训练循环与 Adam 优化器

每个 step 取一条人名,前后各加 BOS token。比如 "emma" 变成 [BOS, e, m, m, a, BOS]。逐位置输入 token,预测下一个 token,损失是交叉熵 -log(P(target))。反向传播一行搞定:loss.backward()

Adam 优化器是手写的,包含所有关键组件:

m[i] = beta1 * m[i] + (1 - beta1) * p.grad           # 一阶矩(动量)
v[i] = beta2 * v[i] + (1 - beta2) * p.grad ** 2      # 二阶矩(自适应学习率)
m_hat = m[i] / (1 - beta1 ** (step + 1))              # 偏差校正
v_hat = v[i] / (1 - beta2 ** (step + 1))              # 偏差校正
p.data -= lr_t * m_hat / (v_hat ** 0.5 + eps_adam)    # 参数更新

一阶矩是梯度的指数移动平均,提供动量方向;二阶矩是梯度平方的指数移动平均,实现自适应学习率;偏差校正补偿初始零值的影响。学习率从 0.01 线性衰减到 0。

推理:让模型说话

自回归生成:从 BOS 开始,每步预测下一个 token 作为下一步输入,直到遇到 BOS 或达到最大长度。temperature 控制随机性——值越低越倾向概率最高的 token,值越高越随机。

关键设计决策

microgpt.py 相对 GPT-2 做了三个简化:RMSNorm 代替 LayerNorm(更简单)、去掉 bias(省参数)、ReLU 代替 GeLU(导数更简单)。最核心的设计选择是标量级运算——这不只是简化,它让每一步运算都完全透明,没有广播、没有 reshape、没有 einsum。代价是速度,PyTorch 通过张量运算和 CUDA 把这加速了几个数量级。

正如 Karpathy 说的:这个文件就是完整的算法,其他一切都只是为了效率。