首页
学习
活动
专区
圈层
工具
发布
社区首页 >专栏 >MicroGPT 原理讲解(译)

MicroGPT 原理讲解(译)

作者头像
JanYork_简昀
发布2026-03-30 17:19:25
发布2026-03-30 17:19:25
1490
举报

原文: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 个人名做训练数据,每行一个名字,比如 emmaoliviaavaisabellasophia…… 每个名字都可以看作一篇文档。

模型的任务,就是学习这些名字中的统计规律,再生成一些听起来像真名的新名字。

训练结束后,模型会生成像 "kamon""karai""anna""anton" 这样的名字。

它学会了哪些字符通常会跟在另一些字符后面,哪些发音更常出现在开头或结尾,以及一个典型名字一般有多长。从 ChatGPT 的视角看,你与它的整段对话也只是一篇文档;当你输入一个提示词时,模型做的本质上就是对这篇文档进行统计意义上的续写。

数字,而不是字母

神经网络处理的是数字,不是字符。所以我们需要一种办法,把文本变成整数序列,再把整数序列还原回文本。

最简单的 tokenizer,会给数据集里的每一个唯一字符分配一个整数。

26 个小写字母对应 025,再加一个特殊 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 代码。原文会让你逐行点过去,看每一行执行后中间值发生了什么:

代码语言:javascript
复制
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)。

每一个数学运算,比如加法、乘法、explog,都可以看成图里的一个节点。每个节点都记得自己的输入,也知道自己的局部导数。

反向传播会从损失开始往回走;在损失那个点上,梯度(gradient)天然就是 1.0,然后它把局部导数沿着每一条路径一路乘回去,最终传到输入上。

原文先给了一个很小的例子:L = a · b + a,并设 a = 2b = 3,让你一步步看前向计算,再看反向传播是怎么回来的。

接着,文章会让你逐步执行真正的 Value 类实现,看看每个运算如何记录自己的子节点和局部梯度,以及 backward() 如何按逆拓扑序把梯度累积回去:

代码语言:javascript
复制
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 处理的是张量,而这里处理的是标量。

从 ID 到语义

我们已经知道如何衡量误差,也知道如何把误差一路追溯回每个参数。接下来该真正搭建模型了,先从它如何表示 token 开始。

4 这样的原始 token id,本质上只是一个索引。模型没法直接拿一个裸整数去做有意义的计算。

所以,每个 token 都会先去 Embedding 表里查出一个学习得到的向量,也就是一个由 16 个数字组成的列表。

你可以把它理解为:每个 token 都拥有一个 16 维的“个性向量”,训练的过程就是不断调整这些向量。

位置也同样重要。位置 0 上的字母 "a",和位置 4 上的 "a",所扮演的角色并不相同。所以这里还会有第二张按位置索引的 embedding 表。token embedding 和 position embedding 会相加,作为后续网络真正接收到的输入。

原文在这里做了一个可视化演示:点选不同的 token,就能看到它的 embedding 向量、位置向量,以及两者相加后的结果。

这些 embedding 一开始只是一些很小的随机数,训练过程中才会慢慢被调到合适的位置。

训练结束后,行为相似的 token,比如元音,往往也会落在相似的 embedding 区域里。模型完全是从零开始学出这些表示的,它事先并不知道“元音”是什么。

token 之间如何交流

这就是 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() 函数。原文会让你逐行执行它,并同步展示每一阶段的中间向量:

代码语言:javascript
复制
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 用的是 LayerNormRMSNorm 更简单,但效果同样好。

学习

训练循环会重复 1,000 次:随机挑一个名字,把它 tokenize,然后让模型沿着整个位置序列做前向计算,在每个位置上算出交叉熵损失,对这些损失求平均,再做反向传播,得到每一个参数的梯度,最后更新参数,让损失再低一点点。

优化器用的是 Adam,它比朴素的梯度下降更聪明。

它会同时维护两份运行统计:一份是每个参数最近梯度的滑动平均值,也就是动量;另一份是梯度平方的滑动平均值,也就是自适应学习率的依据。

那些持续收到稳定梯度的参数,会迈出更大的步子;而那些来回震荡的参数,则会自动走得更谨慎。

原文在这里放了一张 1,000 步训练过程的损失曲线。模型一开始大约在 3.3 左右,这相当于在 27 个 token 里随机猜测时的水平,也就是 -log(1/27) ≈ 3.3;训练结束时,损失会降到大约 2.37,而生成出来的名字也会从一团乱码慢慢变得像个真名。

接下来,文章还会带你完整走完一次训练迭代:挑名字、前向计算、算 loss、反向传播、更新参数。

代码语言:javascript
复制
# 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。

代码语言:javascript
复制
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、衡量“惊讶”程度、把梯度一路往回传、再把参数轻轻推一下。

然后继续重复。

本文参与 腾讯云自媒体同步曝光计划,分享自微信公众号。
原始发表:2026-03-13,如有侵权请联系 cloudcommunity@tencent.com 删除

本文分享自 木有枝枝 微信公众号,前往查看

如有侵权,请联系 cloudcommunity@tencent.com 删除。

本文参与 腾讯云自媒体同步曝光计划  ,欢迎热爱写作的你一起参与!

评论
登录后参与评论
0 条评论
热度
最新
推荐阅读
目录
  • 数据集
  • 数字,而不是字母
  • 预测游戏
  • 从分数到概率
  • 衡量“惊讶”程度
  • 追踪每一次计算
  • 从 ID 到语义
  • token 之间如何交流
  • 全貌
  • 学习
  • 开始生成
  • 其余一切,都只是效率问题
领券
问题归档专栏文章快讯文章归档关键词归档开发者手册归档开发者手册 Section 归档