
原文:https://growingswe.com/blog/microgpt
作者:growingSWE
我尽量把它讲得更可视化一些。不过先说明,我自己也是机器学习小白。
Andrej Karpathy 写了一个 200 行的 Python 脚本(https://gist.github.com/karpathy/8627fe009c40f57531cb18360106ce95),只靠纯 Python、没有任何库和依赖,就从零训练并运行了一个 GPT。
这个脚本里,包含了 ChatGPT 这类大语言模型背后的核心算法。
下面我们就一段一段拆开来看,顺着每个部件实际是怎么工作的,一路走完。

Andrej 自己也在博客里写过一版讲解(https://karpathy.github.io/2026/02/12/microgpt/),但这篇文章更偏可视化,也更照顾初学者。
说明:原文是一篇交互式网页。文中提到的“在下方输入”“拖动滑块”“逐步执行”等内容,在原站都可以实际操作;这里保留文字说明,并补入关键代码片段,方便静态阅读。
这个模型用 32,000 个人名做训练数据,每行一个名字,比如 emma、olivia、ava、isabella、sophia…… 每个名字都可以看作一篇文档。
模型的任务,就是学习这些名字中的统计规律,再生成一些听起来像真名的新名字。
训练结束后,模型会生成像 "kamon"、"karai"、"anna"、"anton" 这样的名字。
它学会了哪些字符通常会跟在另一些字符后面,哪些发音更常出现在开头或结尾,以及一个典型名字一般有多长。从 ChatGPT 的视角看,你与它的整段对话也只是一篇文档;当你输入一个提示词时,模型做的本质上就是对这篇文档进行统计意义上的续写。
神经网络处理的是数字,不是字符。所以我们需要一种办法,把文本变成整数序列,再把整数序列还原回文本。
最简单的 tokenizer,会给数据集里的每一个唯一字符分配一个整数。
26 个小写字母对应 0 到 25,再加一个特殊 token:BOS(Beginning of Sequence,序列开始),id 是 26,用来标记名字的起止。
在原文里,你可以在下方输入一个名字,看到它如何被切成 token。每个字符都会映射到对应的整数 id,而两端会自动补上 BOS。
这些整数本身没有任何语义。token 4 并不比 token 2 “更多”什么;每个 token 只是一种不同的符号,就像给每个字母分配了一种不同颜色。
生产环境里的 tokenizer,比如 GPT-4 使用的 tiktoken,会为了效率按字符块而不是单个字符切分,词表规模大约在 10 万左右,但底层原理是一样的。
核心任务其实很简单:给定目前已经看到的 token,预测下一个 token 是什么。
我们沿着序列逐个位置往前滑动。在位置 0,模型只看到 BOS,必须预测第一个字母;在位置 1,它看到 BOS 和第一个字母,必须预测第二个字母;后面依此类推。
原文在这里放了一个逐步演示,可以看到上下文如何不断增长,而目标 token 又如何向前移动。
这个过程里的每一步,都会生成一个训练样本:左边的上下文是输入,右边那个绿色 token 是模型应该预测的目标。
以名字 "emma" 为例,它会产生五组输入-目标对。所有语言模型,包括 ChatGPT,都是用这种滑动窗口方式训练出来的。
在每个位置上,模型都会输出 27 个原始分数,对应 27 种可能的下一个 token。
这些分数叫做 logits,它们可以是正数、负数、大数、小数,什么都行。
我们需要把它们变成概率,而且这些概率必须都是正数、总和为 1。Softmax 做的就是这件事:先对每个分数取指数,再除以总和。
原文这里有一个可以拖动 logits 的演示,你能直接看到概率分布如何变化。一个很大的 logit 会迅速占据主导地位,而指数运算会进一步放大这种差异。
下面是 microgpt 里真正的 softmax 代码。原文会让你逐行点过去,看每一行执行后中间值发生了什么:
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]
在取指数之前先减去最大值,数学上不会改变结果,因为这等价于分子和分母同时除以同一个常数;但它可以防止数值溢出。否则像 exp(100) 这样的值,很容易直接变成无穷大。
模型到底错得有多离谱?我们需要一个单独的数字,来表达“模型觉得正确答案不太可能”这件事。
如果模型给正确 token 分配了 0.9 的概率,损失就很低,大约是 0.1;如果它只给了 0.01,损失就很高,大约是 4.6。
这个公式写作 -log(p),其中 p 是模型给正确 token 分配的概率。这就叫交叉熵损失(cross-entropy loss)。
原文在这里放了一个滑块,可以直接调节正确 token 的概率,观察损失值如何变化。
这条曲线有两个很重要的性质。
第一,当模型对正确答案完全自信时,也就是 p = 1,损失为 0。
第二,当模型给正确答案分配的概率逼近 0 时,也就是 p -> 0,损失会趋近无穷大。
也就是说,越是“自信地答错”,惩罚就越重。训练的目标,就是把这个数字尽可能降下来。
模型要想变好,就必须回答这样一个问题:“对于我那 4,192 个参数里的每一个,如果我把它轻轻往上拨一点,loss 是会上升还是下降?又会变化多少?”
反向传播(Backpropagation)就是用来算这件事的。它会沿着计算过程反向走回去,在每一步应用链式法则(chain rule)。
每一个数学运算,比如加法、乘法、exp、log,都可以看成图里的一个节点。每个节点都记得自己的输入,也知道自己的局部导数。
反向传播会从损失开始往回走;在损失那个点上,梯度(gradient)天然就是 1.0,然后它把局部导数沿着每一条路径一路乘回去,最终传到输入上。
原文先给了一个很小的例子:L = a · b + a,并设 a = 2、b = 3,让你一步步看前向计算,再看反向传播是怎么回来的。
接着,文章会让你逐步执行真正的 Value 类实现,看看每个运算如何记录自己的子节点和局部梯度,以及 backward() 如何按逆拓扑序把梯度累积回去:
class Value:
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):
return Value(self.data + other.data,
(self, other), (1, 1))
def __mul__(self, other):
return Value(self.data * other.data,
(self, other), (other.data, self.data))
def backward(self):
# topological sort
topo = []
visited = set()
def build_topo(v):
if v notin 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, lg in zip(v._children, v._local_grads):
child.grad += lg * v.grad
注意,这个例子里 a 的梯度是 4.0,不是 3.0。
因为 a 被用了两次:一次出现在乘法里,贡献了 ∂(a·b)/∂a = b = 3;一次出现在加法里,贡献了 ∂(c+a)/∂a = 1。
这两条路径上的梯度会相加,所以总梯度就是 3 + 1 = 4。
这正是多变量链式法则在起作用:如果某个值通过多条路径影响最终损失,那么它的总导数就是这些路径贡献的总和。
这和 PyTorch 里的 loss.backward() 用的是同一套算法,只不过 PyTorch 处理的是张量,而这里处理的是标量。
我们已经知道如何衡量误差,也知道如何把误差一路追溯回每个参数。接下来该真正搭建模型了,先从它如何表示 token 开始。
像 4 这样的原始 token id,本质上只是一个索引。模型没法直接拿一个裸整数去做有意义的计算。
所以,每个 token 都会先去 Embedding 表里查出一个学习得到的向量,也就是一个由 16 个数字组成的列表。
你可以把它理解为:每个 token 都拥有一个 16 维的“个性向量”,训练的过程就是不断调整这些向量。
位置也同样重要。位置 0 上的字母 "a",和位置 4 上的 "a",所扮演的角色并不相同。所以这里还会有第二张按位置索引的 embedding 表。token embedding 和 position embedding 会相加,作为后续网络真正接收到的输入。
原文在这里做了一个可视化演示:点选不同的 token,就能看到它的 embedding 向量、位置向量,以及两者相加后的结果。
这些 embedding 一开始只是一些很小的随机数,训练过程中才会慢慢被调到合适的位置。
训练结束后,行为相似的 token,比如元音,往往也会落在相似的 embedding 区域里。模型完全是从零开始学出这些表示的,它事先并不知道“元音”是什么。
这就是 Transformer 的工作方式。在每个位置上,模型都需要从前面的位置收集信息,而它做到这一点的办法,就是注意力(Attention):每个 token 都会从自己的 embedding 里生成三个向量。
它们分别是 Query(“我在找什么?”)、Key(“我这里有什么?”)和 Value(“如果你选中我,我能提供什么信息?”)。
当前位置的 Query 会和前面所有位置的 Key 做点积(dot product)比较;点积越大,说明相关性越高。
然后 Softmax 会把这些分数变成注意力权重,再用这些权重对各个 Value 做加权求和,得到输出。
原文在这里给了一个注意力热力图。
每个格子都表示某个位置对另一个位置分配了多少注意力;你还可以在四个 attention head 之间切换,看它们分别学出了什么模式。
右上角那块灰色区域,就是因果掩码(causal mask)。位置 2 不能去看位置 4,因为位置 4 还没有发生。
这就是模型之所以是自回归(autoregressive)的原因:每个位置只能看到过去,不能偷看未来。
不同的 head 会学到不同的偏好。某个 head 可能特别关注最近一个 token;另一个可能专盯 BOS,用来提醒自己“我们现在是在生成一个名字”;再一个可能会偏向寻找元音。
四个 head 并行工作,每个 head 只处理 16 维 embedding 中的一块 4 维切片,最后再把它们拼回去,投影回 16 维空间。
整个模型在每个 token 上走的是这样一条流水线:embedding、归一化、注意力、加 residual、再归一化、MLP、再加 residual,最后投影成输出 logits。
这里的 MLP(multilayer perceptron,多层感知机)是一个两层前馈网络:先升到 64 维,过一遍 ReLU(把负数清零),再投回 16 维。
可以把 attention 理解成 token 之间的交流,而 MLP 则是每个位置各自独立地“想一想”。
原文这里有一个逐步动画,让你盯着单个 token,一路看数据怎样穿过整条流水线。
下面是 microgpt 里的 gpt() 函数。原文会让你逐行执行它,并同步展示每一阶段的中间向量:
def gpt(token_id, pos_id, keys, values):
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):
x_residual = x
x = rmsnorm(x)
q = linear(x, attn_wq)
k = linear(x, attn_wk)
v = linear(x, attn_wv)
keys[li].append(k)
values[li].append(v)
# multi-head attention
for h in range(n_head):
attn_logits = [q_h . k_h[t] / sqrt(d)
for t in range(len(k_h))]
attn_weights = softmax(attn_logits)
head_out = weighted_sum(attn_weights, v_h)
x = linear(x_attn, attn_wo)
x = [a + b for a, b in zip(x, x_residual)]
x_residual = x
x = rmsnorm(x)
x = linear(x, mlp_fc1)
x = [xi.relu() for xi in x]
x = linear(x, mlp_fc2)
x = [a + b for a, b in zip(x, x_residual)]
logits = linear(x, lm_head)
return logits
残差连接(也就是那些 "Add" 步骤)是整套结构里的承重件。
没有它们,梯度在传回前面几层时会一路衰减到接近零,训练很快就会卡死。残差连接给梯度开出了一条捷径,这也是深层网络之所以还能训练起来的关键原因之一。
RMSNorm(root-mean-square normalization,均方根归一化)会把每个向量重新缩放到单位均方根长度。这能防止激活值在网络里越传越大,或者越传越小,从而让训练过程更稳定。
GPT-2 用的是 LayerNorm;RMSNorm 更简单,但效果同样好。
训练循环会重复 1,000 次:随机挑一个名字,把它 tokenize,然后让模型沿着整个位置序列做前向计算,在每个位置上算出交叉熵损失,对这些损失求平均,再做反向传播,得到每一个参数的梯度,最后更新参数,让损失再低一点点。
优化器用的是 Adam,它比朴素的梯度下降更聪明。
它会同时维护两份运行统计:一份是每个参数最近梯度的滑动平均值,也就是动量;另一份是梯度平方的滑动平均值,也就是自适应学习率的依据。
那些持续收到稳定梯度的参数,会迈出更大的步子;而那些来回震荡的参数,则会自动走得更谨慎。
原文在这里放了一张 1,000 步训练过程的损失曲线。模型一开始大约在 3.3 左右,这相当于在 27 个 token 里随机猜测时的水平,也就是 -log(1/27) ≈ 3.3;训练结束时,损失会降到大约 2.37,而生成出来的名字也会从一团乱码慢慢变得像个真名。
接下来,文章还会带你完整走完一次训练迭代:挑名字、前向计算、算 loss、反向传播、更新参数。
# Pick a document and tokenize it
doc = docs[step % len(docs)]
tokens = [BOS] + [uchars.index(ch) for ch in doc] + [BOS]
# Forward pass: predict each next token
keys, values = [[] for _ in range(n_layer)], [...]
losses = []
for pos_id in range(n):
token_id = tokens[pos_id]
target_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)
# Backward pass
loss.backward()
# Adam optimizer update
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 * m_hat / (v_hat**0.5 + eps)
p.grad = 0
训练结束之后,推理(Inference)其实很直接。先从 BOS 开始,做一次前向计算,得到 27 个概率,从中随机采样一个 token,再把它喂回去,继续下一轮。
直到模型再次输出 BOS,也就是“我说完了”,或者达到最大长度为止。
温度(temperature)控制的是采样方式。在送进 Softmax 之前,我们会先用 temperature 去缩放 logits,也就是把 logits 除以温度值。温度等于 1.0 时,就是直接按模型学到的分布采样;温度越低,分布越尖锐,模型越倾向于选择自己最看好的 token;温度越高,分布越平,输出会更多样,但也更容易失去连贯性。
原文这里有一个温度滑块,可以直接看不同温度下概率分布如何被拉平或拉尖。
再往下,作者还放了一个逐步生成名字的演示:模型会一个字符一个字符地生成,每一步都重新前向计算一次、产出概率、再采样出下一个 token。
temperature = 0.5
keys, values = [[] for _ in range(n_layer)], [...]
token_id = BOS
sample = []
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 = random.choices(
range(vocab_size),
weights=[p.data for p in probs])[0]
if token_id == BOS:
break
sample.append(uchars[token_id])
print("".join(sample))
当温度逼近 0 时,模型几乎总会选概率最高的 token,也就是所谓贪心解码(greedy decoding)。
这会得到最“平均”、最保守的输出。
温度为 1.0 时,对应的是模型原本学到的真实分布;大于 1.0 时,则会额外注入随机性,可能更有创造力,但也更容易胡说八道。
对于生成人名这个任务,作者认为比较合适的温度大约在 0.5 左右。
这个 200 行的脚本已经包含了完整的算法。
从它到 ChatGPT,在概念层面其实几乎没什么变化。
真正变的,是规模和工程实现:训练数据从 32,000 个名字,变成数万亿 token;分词从字符级,变成 10 万词表规模的子词分词;计算对象从 Python 里的标量 Value 对象,变成 GPU 上的大张量;参数从 4,192 个,变成数千亿个;层数从一层,变成上百层;训练时间从很短,变成要动用成千上万张 GPU 连跑几个月。
但那条循环本身没有变:分词、embedding、attention、计算、预测下一个 token、衡量“惊讶”程度、把梯度一路往回传、再把参数轻轻推一下。
然后继续重复。