200行代码剥离GPT算法核心

目录

01 数据集

02 分词器

Token参数补充说明(token_id、pos_id、target_id)

03 自动微分(Autograd)

运算与局部导数一览

04 参数

05 模型架构

逐步骤解释

06 训练循环

逐部分拆解

07 运行

08 渐进式理解

09 真实大模型 vs microGPT

10 常见问题


作者Andrej Karpathy 是人工智能领域极具影响力的研究者、工程师与科普者,深耕大语言模型与计算机视觉领域多年,以“将复杂AI技术拆解为极简本质”而闻名。他曾担任特斯拉AI总监,主导自动驾驶系统的计算机视觉与机器学习研发,推动了自动驾驶技术的迭代升级;同时也是OpenAI早期核心成员,深度参与了早期大语言模型的研发工作,见证并推动了生成式AI的起步与发展。

本次的 microGPT 更是其十年技术积累的集大成之作,用200行纯Python代码剥离了GPT的核心算法,让普通人也能直观理解大语言模型的底层逻辑。他还通过视频、博客等形式,用通俗的语言讲解AI核心原理,成为无数AI学习者的入门引路人。

这是一篇关于作者全新艺术项目microGPT 的极简指南——一个仅 200行纯Python、零外部依赖 的单文件脚本,就能完成一个GPT的训练与推理。参考https://karpathy.github.io/2026/02/12/microgpt/

这份文件包含了实现GPT所需的全部算法核心:文档数据集、分词器、自动微分引擎、类GPT‑2的神经网络架构、Adam优化器、训练循环与推理循环。除此之外的一切,都只是为了效率。作者已经无法再将它简化分毫。

这个脚本是作者过往多个项目(micrograd、makemore、nanoGPT等)的集大成之作,也是作者十年来执念于将大语言模型剥离到最本质要素的结晶。作者觉得它美得令人心动。它甚至能完美地排版成三栏。

microGPT用纯Python、零依赖训练与推理GPT的最原子方式

这一个文件,就是完整的算法。其余一切,都只是效率。

  • 完整源码 GitHub Gist:microgpt.py
  • 网页版:karpathy.ai/microgpt.html

但是不要小看这两百行,由于高度浓缩,蕴含的信息量非常大,相当于把反向传播、计算图、自动微分,注意力机制(著名论文《All you need is attention》),optimizer(这里是Adam,不是SGD),(交叉熵)损失函数,残差连接,层归一化,Tokenizer ,词嵌入,KV cache等等重要概念一网打尽(参考Andrej Karpathy's microGPT Architecture — Complete Guide - DEV Community)。

更不提softmax,linear计算,激活函数,MLP等等常规概念。

01 数据集

大语言模型的燃料是文本流,可按文档划分。工业级场景里每份文档通常是一张网页,而在 microGPT 中,作者用更简单的例子:32,000个名字,一行一个。

# 定义输入数据集 docs:字符串列表,每份文档是一个名字
if not os.path.exists('input.txt'):
    import urllib.request
    names_url = 'https://raw.githubusercontent.com/karpathy/makemore/refs/heads/master/names.txt'
    urllib.request.urlretrieve(names_url, 'input.txt')
 
docs = [l.strip() for l in open('input.txt').read().strip().split('\n') if l.strip()]
random.shuffle(docs)
print(f"num docs: {len(docs)}")

数据集长这样,每个名字就是一份文档(一个doc,一个文档,就是一个名字)

emma

olivia

ava

isabella

sophia

charlotte

mia

amelia

harper

...(后续约32,000个名字)

一个文档是一个名字,那么单个 Token 是模型可识别的最小离散单位(文档中即单个字符或特殊标识 BOS),比如[BOS, e, m, m, a, BOS]是六个Token。

模型的目标是学习数据中的模式,然后生成共享统计规律的全新、合理的文档。先剧透一下:脚本跑完后,模型会“幻想”出像这样的新名字:

sample 1: kamon

sample 2: ann

sample 3: karai

sample 4: jaire

sample 5: vialan

sample 6: karia

sample 7: yeran

sample 8: anna

sample 9: areli

sample 10: kaina

sample 11: konna

sample 12: keylen

sample 13: liole

sample 14: alerin

sample 15: earan

sample 16: lenne

sample 17: kana

sample 18: lara

sample 19: alela

sample 20: anton

看起来不算惊人,但从 ChatGPT 这类模型的视角看,你和它的对话不过是一份长相奇特的“文档”。当你用提示词初始化这份文档时,模型给出回复,本质上只是一次统计式文档补全

02 分词器

神经网络只认数字,不认字符。作者需要把文本转成整数 token ID,再转回来。

工业级分词器(如 GPT‑4 用的 tiktoken)为效率会处理字符块,但最简单的分词器就是:给数据集中每个唯一字符分配一个整数(token ID

# 分词器:字符串 ↔ 离散符号互转
uchars = sorted(set(''.join(docs)))  # 数据集中所有唯一字符,成为 0~n-1 的 token
BOS = len(uchars)                    # 特殊 token:序列开始(Begin of Sequence)
vocab_size = len(uchars) + 1         # 总词表大小,+1 是 BOS
 
print(f"vocab size: {vocab_size}")

这里收集所有唯一字符(这里就是小写字母 a–z),排序后按索引分配 ID。这些整数本身没有任何语义,换成表情符号也完全可以。

额外加一个特殊 token BOS,作为分隔符,告诉模型“一份新文档从这里开始/到这里结束”。训练时每份文档会被 BOS 包裹:

[BOS, e, m, m, a, BOS]

模型会学到:BOS 开启一个新名字,另一个 BOS 结束它。最终词表大小为 27(26个小写字母 + 1个BOS)。

Token参数补充说明(token_id、pos_id、target_id)

在模型训练与推理过程中,token_id、pos_id、target_id 是三个核心输入参数,贯穿整个流程,其含义与作用如下:

  • token_id(token标识符):当前输入模型的token对应的整数ID,本质是字符的编码(由分词器分配,如字符'e'对应某个固定整数)。模型通过该ID获取对应的token嵌入向量,从而识别“当前输入的是什么字符”,是模型感知输入内容的核心参数,训练与推理场景中均会用到。
  • pos_id(位置标识符):当前token在序列中的位置索引(从0开始计数)。模型通过该ID获取对应的位置嵌入向量,从而感知字符在序列中的顺序(如名字“emma”中,第一个'm'和第二个'm'的pos_id不同),确保模型能捕捉到文本的顺序特征,训练与推理场景中含义完全一致。
  • target_id(目标标识符):仅用于训练场景,指当前token的下一个token对应的整数ID,是模型需要预测的目标字符编码。也就是target_id是预测出的token的token_id。模型通过对比预测结果与target_id,计算损失值,进而通过反向传播优化参数,实现“学习预测下一个字符”的核心训练目标。

三者协同作用:token_id告诉模型“输入是什么”,pos_id告诉模型“输入在什么位置”,target_id告诉模型“应该预测什么”,共同支撑模型的训练与推理流程。

03 自动微分(Autograd)

训练神经网络需要梯度:对每个参数,作者要知道“把它微微上调一点,损失会上升还是下降、幅度多大”。

autograd 是深度学习框架中实现自动求导功能的核心模块,主要作用是追踪张量(tensor)的所有运算操作,记录运算的计算图,从而自动计算张量的梯度,无需开发者手动推导和编写求导公式。简单来说,autograd 是负责 “记录运算、管理计算图、提供求导能力” 的一整套机制。backward 是 autograd 模块提供的一个具体方法(函数) ,主要用途是触发梯度计算和反向传播过程。它会基于 autograd 记录的计算图,从指定的损失函数(或目标张量)开始,反向计算各个可训练参数的梯度,并将梯度结果存储在对应参数的 grad 属性中。

但是本文中,autograd只用于(或者说只用到)backward,所以可以认为backward就是autograd自动微分。

计算图有很多输入(模型参数与输入 token),最终汇聚成一个标量输出:损失。反向传播从这个唯一输出开始,沿图反向遍历,用微积分链式法则计算损失对每个输入的导数。

这就是一个神经网络计算图,每个Value实例的children是指向它的那些节点,比如绿色输出层的绿色节点,它的children是layer2的那四个蓝色节点;backward反向传播时,每个节点遍历自己的children列表中的节点,更新节点的grad梯度值,递归进行(注意到build_topo函数自己调用了自己)。

工业界用 PyTorch 自动完成,而这里作者用一个 Value 类从零实现:

class Value:
    __slots__ = ('data', 'grad', '_children', '_local_grads')

    def __init__(self, data, children=(), local_grads=()):
        self.data = data                # 前向计算的标量值
        self.grad = 0                   # 损失对该节点的导数,反向时计算
        self._children = children       # 计算图中的子节点
        self._local_grads = local_grads # 本节点对子节点的局部导数

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

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

    # 幂次
    def __pow__(self, other):
        return Value(self.data**other, (self,), (other * self.data**(other-1),))

    def log(self): return Value(math.log(self.data), (self,), (1/self.data,))
    def exp(self): return Value(math.exp(self.data), (self,), (math.exp(self.data),))
    def relu(self): return Value(max(0, self.data), (self,), (float(self.data > 0),))
    
  # 运算符重载
    def __neg__(self): return self * -1
    def __radd__(self, other): return self + other
    def __sub__(self, other): return self + (-other)
    def __rsub__(self, other): return other + (-self)
    def __rmul__(self, other): return self * other
    def __truediv__(self, other): return self * other**-1
    def __rtruediv__(self, other): return other * self**-1

    # 反向传播
    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

作者知道这部分数学与算法密度最高(karpathy似乎不认为注意力机制部分算法和数学密度最高),作者为此做过一段2.5小时的视频讲解,叫做micrograd 视频。

简单说:

  • 每个 Value 包装一个标量 .data,并记录它是怎么算出来的。
  • 把每个运算看作一块乐高:输入→输出(前向),并知道输出相对每个输入的变化率(局部梯度)。
  • 自动微分只需要这些信息,剩下的全是链式法则,把乐高块串起来。

每次对 Value 对象做加减乘除等运算,都会生成新 Value,记住它的输入(_children)与该运算的局部导数(_local_grads)。

运算与局部导数一览

backward() 按逆拓扑序遍历整张图(从损失出发,到参数结束),每一步应用链式法则:

若损失为 L,节点 v 有子节点 c,局部导数 ∂v/∂c,则:

∂L/∂c += ∂v/∂c · ∂L/∂v

如果你不熟悉微积分,这看起来有点吓人,但本质上就是两个数相乘,非常直观。

可以这样理解:

如果汽车速度是自行车的2倍,自行车是步行的4倍,那么汽车是步行的 2×4=8 倍。链式法则就是这个道理:沿路径把变化率相乘

在损失节点设 self.grad = 1,因为 ∂L/∂L=1。之后链式法则就会沿着所有路径把局部梯度一路乘回参数。

注意这里是+= 累加,不是赋值。当一个值在图中被多次使用(分支),梯度会沿每条分支独立回流,必须求和。这是多元链式法则的结果:若 c 通过多条路径影响 L,总导数是各路径贡献之和。

backward() 完成后,图中每个 Value 的 .grad 都存着 ∂L/∂v,告诉我们微调该值会如何影响最终损失。

而且,注意到,这里的backward最多的操作只涉及乘法(为了简化,连截距bias都去掉了)

具体示例

a = Value(2.0)
b = Value(3.0)
c = a * b       # c = 6.0
L = c + a       # L = 8.0
L.backward()
print(a.grad)   # 4.0(dL/da = b + 1 = 3 + 1,两条路径)
print(b.grad)   # 2.0(dL/db = a = 2)

这和 PyTorch 的 .backward() 结果完全一致,只是 microGPT 用标量,PyTorch 用张量(标量数组)——算法完全相同,只是更小巧、更简单,当然也更慢。

04 参数

参数就是模型的“知识”,是一大堆被包装成 Value 的浮点数,初始随机,训练中不断优化。

n_embd = 16      # 嵌入维度
n_head = 4       # 注意力头数
n_layer = 1      # 层数
block_size = 16  # 最大序列长度
head_dim = n_embd // n_head  # 每个头的维度
 
# 矩阵初始化:高斯随机
matrix = lambda nout, nin, std=0.08: [
    [Value(random.gauss(0, std)) for _ in range(nin)]
    for _ in range(nout)
]
 
state_dict = {
    'wte': matrix(vocab_size, n_embd),    # token 嵌入
    'wpe': matrix(block_size, n_embd),   # 位置嵌入
    'lm_head': matrix(vocab_size, n_embd)# 语言模型头
}
 
for i in range(n_layer):
    state_dict[f'layer{i}.attn_wq'] = matrix(n_embd, n_embd)
    state_dict[f'layer{i}.attn_wk'] = matrix(n_embd, n_embd)
    state_dict[f'layer{i}.attn_wv'] = matrix(n_embd, n_embd)
    state_dict[f'layer{i}.attn_wo'] = matrix(n_embd, n_embd)
    state_dict[f'layer{i}.mlp_fc1'] = matrix(4 * n_embd, n_embd)
    state_dict[f'layer{i}.mlp_fc2'] = matrix(n_embd, 4 * n_embd)
 
params = [p for mat in state_dict.values() for row in mat for p in row]
print(f"num params: {len(params)}")

所有参数初始化为小高斯随机数。state_dict 按 PyTorch 风格组织成命名矩阵:嵌入表、注意力权重、MLP 权重、输出投影。所有参数展平成单列表 params,方便优化器遍历。

这个超小模型一共4192 个参数。对比一下:GPT‑2 有 15 亿,现代大模型有数千亿。

05 模型架构

模型是一个无状态函数:输入一个 token、位置、参数、之前位置缓存的键值对,输出词表上的 logit(得分),表示模型认为下一个 token 最可能是什么。

作者沿用 GPT‑2 并小幅简化:

  • RMSNorm 代替 LayerNorm
  • 无偏置
  • ReLU 代替 GeLU

先看三个小辅助函数:

# 线性层:矩阵-向量乘法
def linear(x, w):
    return [sum(wi * xi for wi, xi in zip(wo, x)) for wo in w]
 
# Softmax:logits → 概率分布
def softmax(logits):
    max_val = max(val.data for val in logits)
    exps = [(val - max_val).exp() for val in logits]
    total = sum(exps)
    return [e / total for e in exps]
 
# RMSNorm:均方根归一化
def rmsnorm(x):
    ms = sum(xi * xi for xi in x) / len(x)
    scale = (ms + 1e-5) ** -0.5
    return [xi * scale for xi in x]
  • linear:基础线性变换,神经网络的核心积木。
  • softmax:把任意范围的原始得分转成 [0,1] 且和为 1 的概率分布;先减最大值保证数值稳定,防止 exp 溢出。
  • rmsnorm:重缩放向量,让激活值稳定流动,防止训练中爆炸或消失,是 GPT‑2 所用 LayerNorm 的简化版。

现在是模型本体:

def gpt(token_id, pos_id, keys, values):
    # 嵌入:token + 位置
    tok_emb = state_dict['wte'][token_id]
    pos_emb = state_dict['wpe'][pos_id]
    x = [t + p for t, p in zip(tok_emb, pos_emb)]
    x = rmsnorm(x)

    for li in range(n_layer):
# 1)多头注意力块
        x_residual = x
        x = rmsnorm(x)
        q = linear(x, state_dict[f'layer{li}.attn_wq'])
        k = linear(x, state_dict[f'layer{li}.attn_wk'])
        v = linear(x, state_dict[f'layer{li}.attn_wv'])
        keys[li].append(k)
        values[li].append(v)
        x_attn = []
        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)
        x = linear(x_attn, state_dict[f'layer{li}.attn_wo'])
        x = [a + b for a, b in zip(x, x_residual)]  # 残差

        # 2)MLP 块
        x_residual = x
        x = rmsnorm(x)
        x = linear(x, state_dict[f'layer{li}.mlp_fc1'])
        x = [xi.relu() for xi in x]
        x = linear(x, state_dict[f'layer{li}.mlp_fc2'])
        x = [a + b for a, b in zip(x, x_residual)]  # 残差
        
logits = linear(x, state_dict['lm_head'])
    return logits

函数一次处理一个 token(token_id)、在特定时间步(pos_id),并利用之前迭代缓存的 keys/values(即 KV Cache)。

state_dict 是模型的“参数档案”,专门存储模型训练过程中所有需要更新、保存的参数(比如之前提到的梯度对应的参数),是模型训练后保存、后续加载复用的核心。它只存“可训练参数”(如模型的权重、嵌入表等,就是我们说的“需要按梯度调整的参数”),不存梯度、loss值等临时数据。

KV Cache在模型训练(模型训练流程)和推理(模型推理)的核心逻辑中,每次调用gpt()函数前定义,代码原文如下:

`keys, values = [[] for _ in range(n_layer)], [[] for _ in range(n_layer)]`

KV Cache变量含义与作用(对应KV缓存):

- keys:列表类型,用于存储「所有Transformer解码层」的K(键)矩阵,是KV缓存中“K(键)”的具体载体,每次前向传播(gpt()函数调用)时,会将当前token的K向量(k = linear(x, state_dict[f'layer{li}.attn_wk']))追加到对应层的keys列表中,实现K的缓存积累。

- values:列表类型,用于存储「所有Transformer解码层」的V(值)矩阵,是KV缓存中“V(值)”的具体载体,每次前向传播时,会将当前token的V向量(v = linear(x, state_dict[f'layer{li}.attn_wv']))追加到对应层的values列表中,实现V的缓存积累。

这两个变量是“临时缓存”,仅在单次前向传播(训练步骤)或单次推理(生成样本)中存在,随每次调用gpt()函数动态更新(追加新的K、V向量)。

与state_dict(模型参数)不同:keys和values是临时缓存变量,不存储、不持久化,仅用于当前步骤的注意力计算,推理/训练结束后自动释放,完美对应之前提到的“KV缓存是临时变量”的特点。

逐步骤解释

1.嵌入

模型不能直接处理原始 token ID(如 5),只能处理向量。

  • 给每个可能 token 分配一个学习到的向量
  • token 嵌入 + 位置嵌入,让模型同时知道“这是什么”和“它在哪”

现代大模型常改用 RoPE 等相对位置编码,省略显式位置嵌入。

2.注意力块

当前 token 被投影为三个向量:

  • lQ(Query):我在找什么?
  • lK(Key):我包含什么?
  • lV(Value):被选中时我提供什么?

比如名字“emma”中,模型在第二个 m 预测下一个字符时,可能学会查询“最近出现过哪些元音”。前面的 e 会有匹配度很高的 key,获得高注意力权重,它的元音信息就流入当前位置。

K、V 存入 KV 缓存,让过去位置可被访问。

每个注意力头计算查询与所有缓存键的缩放点积,softmax 得权重,再对值加权求和。所有头输出拼接后经 attn_wo 投影。

强调:注意力块是唯一让 t 位置 token 能看到 0~t−1 历史 token 的地方。注意力是 token 之间的通信机制。

3.MLP块

两层前馈网络:升维到 4×嵌入维度 → ReLU → 降维回来。

这是模型逐位置思考的主要地方。与注意力不同,MLP 计算完全局部于当前时间 t。

4.残差连接

注意力与 MLP 块都把输出加回输入(x = [a + b for ...]),让梯度直接流通,让深层模型可训练。

5.输出

最终隐状态经lm_head 投影到词表大小,得到每个 token 的 logit。本例中就是 27 个数,值越高,模型越认为该 token 是下一个。

你可能注意到:作者训练时也用 KV 缓存,这并不常见。人们通常把 KV 缓存和推理绑定。但 KV 缓存概念上始终存在,即便在训练中。工业实现里,它只是被隐藏在高度向量化、同时处理所有位置的注意力计算中。

因为 microGPT 一次只处理一个 token(无批次、无时间并行),我们显式构建 KV 缓存。与常规推理不同,这里缓存的 K、V 是计算图中的活 Value 节点,我们会对它们反向传播。

06 训练循环

把所有部分串起来。训练循环反复做:

1.取一份文档

2.对其 token 序列前向跑模型

3.计算损失

4.反向传播得梯度

5.更新参数

图| Full Training Pipeline — End to End

更加具体的训练循环描述如下,来自:microgpt: A 200-Line Pure Python GPT — The Daily Compress

Training loop logic

Pick a document, tokenize it, wrap with BOS on both sides (e.g. "emma" → [BOS, e, m, m, a, BOS])

Feed tokens through model one at a time, building KV cache; at each position compute cross-entropy loss: -log p(correct next token)

Average per-position losses into single scalar loss

loss.backward() backpropagates through entire computation graph, giving every parameter a .grad

Adam optimizer updates each parameter using momentum (m) and adaptive learning rate (v), with bias correction and linear LR decay

Reset all gradients to 0 for next step

训练的输入与输出说明:训练过程的核心输入分为两部分,一是模型参数(初始为随机高斯浮点数,存储在state_dict中,包括嵌入表、注意力权重、MLP权重等),二是预处理后的训练数据(即32033个名字文档,经分词后转化为token序列,每个序列用BOS token首尾包裹,例如“emma”转化为[BOS, e, m, m, a, BOS]);训练的输出是不断优化的模型参数(通过Adam优化器更新,逐步降低损失),同时每一步会输出当前训练步数和平均损失值,用于监控训练进度和模型学习效果,最终得到一组能捕捉名字统计规律的最优参数。

1.取一份文档

2.对其 token 序列前向跑模型

3.计算损失

4.反向传播得梯度

5.更新参数

# Adam 优化器
learning_rate, beta1, beta2, eps_adam = 0.01, 0.85, 0.99, 1e-8
m = [0.0] * len(params)  # 一阶矩缓冲
v = [0.0] * len(params)  # 二阶矩缓冲
 
num_steps = 1000  # 训练步数
 
for step in range(num_steps):
    # 取文档、分词、用 BOS 包裹
    doc = docs[step % len(docs)]
    tokens = [BOS] + [uchars.index(ch) for ch in doc] + [BOS]
    n = min(block_size, len(tokens) - 1)
 
    # 前向:构建计算图直到损失
    keys, values = [[] for _ in range(n_layer)], [[] for _ in range(n_layer)]
    losses = []
    for pos_id in range(n):
        # 关键参数解释:
        # token_id:当前输入模型的token对应的整数ID(即当前字符的编码),用于模型获取对应嵌入向量
        # target_id:当前token的下一个token对应的整数ID(即模型需要预测的目标字符编码)
        # pos_id:当前token在序列中的位置索引(从0开始),用于模型获取对应位置嵌入,让模型感知字符顺序
        token_id, target_id = tokens[pos_id], tokens[pos_id + 1]
        logits = gpt(token_id, pos_id, keys, values)
        probs = softmax(logits)
        loss_t = -probs[target_id].log()
        losses.append(loss_t)
    loss = (1 / n) * sum(losses)  # 平均损失
 
    # 反向:算所有参数梯度
    loss.backward()
   
    # Adam 更新
    lr_t = learning_rate * (1 - step / num_steps)  # 线性学习率衰减
    for i, p in enumerate(params):
        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)
        p.grad = 0
 
    print(f"step {step+1:4d} / {num_steps:4d} | loss {loss.data:.4f}")

逐部分拆解

  • 分词:每份文档用 BOS 首尾包裹,如“emma”→[BOS,e,m,m,a,BOS],模型任务是根据前文预测下一个 token。
  • 前向与损失:逐个喂 token,构建 KV 缓存。每位置输出 27 个 logit,转概率。逐位置损失是正确下一个 token 的负对数概率:−log p(target),这就是交叉熵损失

loss_t = -probs[target_id].log()

losses.append(loss_t)

loss = (1 / n) * sum(losses) # 平均损失

这是预测下一个token(也就是字符),是所有27个字符中每一个的可能性,其中只有一个字符是target,也就是监督学习的标签。下一个token是所有27个字符的概率,取对数log后取负,累加起来求平均,就是预测是否准确的loss。

直观理解:损失衡量预测错误程度——模型对真实下一个 token 有多“意外”。概率 1.0 → 不意外,损失 0;概率接近 0 → 极度意外,损失趋近 +∞。对整份文档所有位置损失取平均,得到单标量损失。

  • 反向传播:一次 loss.backward() 就从损失出发,经 softmax、模型、反向遍历整个计算图,直到每个参数。之后每个参数的 .grad 就告诉作者怎么调它能降低损失。

这里讲解一下,loss值(预测值与标签值之间差异的某种变体)是如何通过梯度反向传播逐层传回直到输入层的?loss值会决定loss节点的grad值,如下图。参考https://mp.weixin.qq.com/s/bHooecIBox8ZA3bdjDCKBg

  • lAdam 优化:比普通梯度下降更聪明。维护每个参数的两个滑动平均:
  • m:最近梯度均值(动量,像滚球)
  • v:最近梯度平方均值(自适应学习率)

m_hat/v_hat 是偏差校正,因为 m/v 初始为 0,需要预先调整一下。学习率随训练线性衰减。更新后重置 .grad=0,准备下一步。

Adam 优化 (或者说任何优化器) 都是在训练步的上一步和下一步之间,对原始梯度进行某种形式的微调,微调后的梯度代替原始梯度,进行下一步的反向传播(也就是参数调整)。不同的微调形式定义不同的优化算法或者说优化器。

1000 步内,损失从约 3.3(随机猜 27 个 token:−log(1/27)≈3.3)降到约 2.37。越低越好,理论最小值 0(完美预测)。模型显然已经学会名字的统计规律。10000步,loss是2.1409。

推理的输入与输出说明:推理过程的输入包括三部分,一是训练完成后冻结的模型参数(即训练阶段得到的最优参数,不再更新),二是初始输入token(固定为BOS token,用于告知模型开始生成新文档),三是温度参数(控制生成结果的随机性,取值范围(0,1]);推理的输出是模型生成的全新、合理的名字序列,生成过程中会动态缓存每一步的keys和values(KV Cache),每一步输出一个token,直到生成BOS token(表示名字生成结束)或达到最大序列长度(block_size=16),最终输出20个不同的生成样本。

训练完成后,冻结参数,循环前向采样,把生成的 token 喂回作下一个输入:

temperature = 0.5  # (0,1],控制生成“创意度”
print("\n--- inference (new, hallucinated names) ---")
for sample_idx in range(20):
    keys, values = [[] for _ in range(n_layer)], [[] for _ in range(n_layer)]
    # 推理阶段初始token_id设为BOS(序列开始标识),告知模型开始生成新文档
    token_id = BOS
    sample = []
    # pos_id:推理时当前生成token的位置索引(从0开始),与训练时含义一致,用于获取位置嵌入
    for pos_id in range(block_size):
        logits = gpt(token_id, pos_id, keys, values)
        probs = softmax([l / temperature for l in logits])
        # 采样得到下一个token的ID,作为下一轮输入的token_id
        token_id = random.choices(range(vocab_size), weights=[p.data for p in probs])[0]
        if token_id == BOS:
            break
        sample.append(uchars[token_id])
    print(f"sample {sample_idx+1:2d}: {''.join(sample)}")

每个样本以BOS 开始,告诉模型“开始新名字”。模型输出 27 个 logit(Logits是模型最后一层没经过 Softmax 归一化的原始打分值,是一堆无界、可正可负的原始分数,还不是概率),softmax把logits(复数是因为有27个logit)转概率,按概率随机采样一个 token。该 token 再喂回作下一个输入,重复直到模型输出 BOS(表示“结束”)或达到最大序列长度20。

temperature 控制随机性:softmax 前把 logit 除以 temperature。

  • 1.0:按模型学习到的分布直接采样
  • 更低(如 0.5):分布更尖锐,模型更保守,更倾向选最可能的 token
  • 趋近 0:总是选概率最高的 token(贪心解码)
  • 更高:分布更平坦,生成更多样,但可能更不连贯

07 运行

只需要 Python,无需任何 pip 安装:

python train.py

在作者的 MacBook 上跑约 1 分钟。你会看到每步损失打印:

num docs: 32033

vocab size: 27

num params: 4192

step 1 / 1000 | loss 3.3660

step 2 / 1000 | loss 3.4243

step 3 / 1000 | loss 3.1778

step 4 / 1000 | loss 3.0664

step 5 / 1000 | loss 3.2209

step 6 / 1000 | loss 2.9452

...

损失从约 3.3(随机)稳步降到约 2.37。数值越低,网络对下一个 token 的预测越准。训练结束时,训练序列的统计规律知识就浓缩在模型参数里。固定这些参数,作者就能生成全新的、“幻想”出来的名字。

win上运行大概是下图效果:

step越多,loss越低。

08 渐进式理解

建议按以下顺序逐层剥开代码,像剥洋葱一样:

作者建了一个 Gist 叫 build_microgpt.py(https://gist.github.com/karpathy/8627fe009c40f57531cb18360106ce95),在修订历史里可以看到所有这些版本及每一步的 diff。作者认为这是逐段理解代码的好方式:一次只加一个组件。但遗憾的是,本文受限于网络访问环境,未能找到train0-4文件,否则是很好的材料。

09 真实大模型 vs microGPT

microGPT 包含训练与运行 GPT 的全部算法本质。但它和 ChatGPT 这类工业级 LLM 之间,还有一长串差异。这些都不改变核心算法与整体结构,但决定了能否规模化可用。

1. 数据

工业级用万亿级 token 的互联网文本(网页、书籍、代码等),去重、过滤质量、精心混合领域。

2. 分词器

不用单字符,而用子词分词器(如 BPE),把高频共现字符序列合并成单个 token。常见词如“the”成一个 token,罕见词拆成片段。词表约 100K,效率更高。

3. 自动微分

microGPT 用纯 Python 标量 Value;工业系统用张量(多维数组),在 GPU/TPU 上并行跑亿次浮点运算。PyTorch 等框架自动处理张量自动微分,CUDA 内核(如 FlashAttention)融合多操作提速。数学完全一样,只是并行处理大量标量。

4. 架构

microGPT 仅 4192 参数;GPT‑4 级模型数千亿。整体 Transformer 结构非常相似,只是更宽(嵌入维度 10k+)、更深(100+ 层)。现代 LLM 还加入更多积木并调整顺序:如 RoPE 替代显式位置嵌入、GQA 减少 KV 缓存、门控线性激活替代 ReLU、MoE 层等。但注意力(通信)+ MLP(计算)+ 残差的核心结构完好保留。

5. 训练

非一步一个文档,而是用大批次(每步数百万 token)、梯度累积、混合精度(float16/bfloat16)、精细调参。训练顶尖模型需要数千 GPU 跑数月。

6. 优化

microGPT 只用带线性衰减的 Adam;优化自成一门学科。模型用低精度(bfloat16/fp8)在 GPU 集群训练,引入数值挑战。优化器设置(学习率、权重衰减、beta、预热/衰减策略)必须精调,且与模型大小、批次大小、数据集相关。缩放定律(如 Chinchilla)指导如何在固定算力预算下分配模型大小与训练 token 数。细节出错会浪费数百万美元算力,团队会先做大量小实验预测最优配置。

7. 后训练

训练出来的基座模型(“预训练模型”)只是文档补全器,不是聊天机器人。变成 ChatGPT 分两步:

SFT(监督微调):把文档换成精标对话,继续训练,算法不变。

RL(强化学习):模型生成回复,经人类/裁判模型/算法打分,模型从反馈中学习。本质仍是训练文档,只是文档由模型自己生成的 token 构成。

8. 推理

给百万用户服务需要专属工程栈:请求批处理、KV 缓存管理与分页(vLLM 等)、 speculative decoding 提速、量化(int8/int4 替代 float16)减内存、多 GPU 分布式。本质仍是逐 token 预测,只是大量工程优化让它更快。

所有这些都是重要工程与研究贡献,但只要你懂了 microGPT,就懂了算法本质

10 常见问题

  • 模型真的“理解”吗?

哲学问题,但计算机层面讲:没有魔法。它只是一个巨大数学函数,把输入 token 映射为下一个 token 的概率分布。训练中参数被调整,让正确下一个 token 概率更高。这算不算“理解”由你定义,但机制完全包含在这 200 行里。

  • 为什么它有效?

模型有成千上万可调参数,优化器每步微调节它们让损失下降。多步之后,参数收敛到能捕捉数据统计规律的数值。对名字来说,就是:常以辅音开头、“qu”常一起出现、很少三个辅音连在一起等。模型不学显式规则,而是学到反映这些规则的概率分布。

  • 和 ChatGPT 是什么关系?

ChatGPT 就是同一核心循环(预测下一个 token → 采样 → 重复)的巨量级缩放外加后训练让它能对话。你和它聊天时,系统提示、你的消息、它的回复都只是序列中的 token。模型逐词补全文档,和 microGPT 补全名字完全一样。

  • “幻觉”是怎么回事?

模型按概率分布采样生成 token,它没有“真”的概念,只知道基于训练数据哪些序列统计上合理。microGPT“幻想”出“karia”这样的名字,和 ChatGPT 自信说出错误事实是同一现象:都是统计上合理、但并非真实的补全。

  • 为什么这么慢?

microGPT 在纯 Python 里一次只处理一个标量,一步训练要数秒。同样数学在 GPU 上并行处理数百万标量,速度快若干数量级。

  • 能让它生成更好的名字吗?

可以。训练更久(加大 num_steps)、模型更大(n_embd/n_layer/n_head)、用更大数据集。这些就是规模化时同样关键的旋钮。模型更大和训练集更大,会产生涌现现象,量变引起质变。

  • 换数据集会怎样?

模型会学习数据里的任何模式。换成城市名、宝可梦名、英语单词、短诗,模型就会学着生成这些,其余代码完全不用改。

Logo

欢迎加入DeepSeek 技术社区。在这里,你可以找到志同道合的朋友,共同探索AI技术的奥秘。

更多推荐