Skip to content

手撕 GPT:从一行文本到下一词预测的完整链路

这不是一篇"大模型科普",而是我在 7 天内从零构建 GPT 的过程中,真正理解的那些东西。

为什么要从零构建?

市面上不缺大模型教程,缺的是 "拆开黑箱之后的确定感"

我给自己定了一个目标:用一周时间,跟着 Sebastian Raschka 的《Build a Large Language Model (From Scratch)》,亲手用 PyTorch 实现一个类 GPT 模型——从分词器到注意力机制,从预训练到微调,每一行核心代码手写,不调现成 API。

学习方法很简单:每遇到一个不理解的概念,就把它变成一个问题,逼自己用大白话回答出来。 如果回答不了,说明没真懂。这篇博客就是这些问答的提炼。


一句话的完整旅程

为了把整条链路讲透,我们追踪一个具体例子:模型如何学会"看到 你好世界 就预测出 "。

整条数据流水线长这样:

文本 → 分词器(BPE) → Token ID → 嵌入层(向量化) → +位置编码 → Transformer(注意力) → 预测下一个词
       固定映射       编号      可训练向量        知道顺序      学词间关系

下面逐段拆解。


第一站:文本变数字 — 分词与编码

模型不认字,只认数字。第一步是把文本拆成 Token,再映射为 ID。

GPT 系列使用 BPE(字节对编码) 算法。它的精妙之处在于:常见词保持完整,生僻词拆成子词,任何文本都能编码,不存在"未知词"

python
import tiktoken
tokenizer = tiktoken.get_encoding("gpt2")

tokenizer.encode("hello")          # → [31373]         常见词 1 个 token
tokenizer.encode("tokenization")   # → [30001, 1634]   生词拆成 2 个
tokenizer.encode("你好")           # → [19526, 254, 25001, 121]  中文 4 个 token

为什么不按空格拆词? 词表会爆炸,且无法处理新词。为什么不按字符拆? 序列太长,计算效率极低。BPE 在两者之间找到了平衡。

不同模型的词表规模差异巨大:

模型词表大小编码方案
GPT-250,257gpt2
GPT-3.5/4100,256cl100k_base
GPT-4o200,019o200k_base

词表越大,同样的文本拆出的 token 越少(尤其是多语言场景),推理效率越高——但嵌入矩阵也更大。这是工程上的取舍。


第二站:数字变向量 — 嵌入层的本质

Token ID 只是编号,15496 并不比 995 "更有意义"。我们需要把它变成一个 高维向量,让数字承载语义。

嵌入层的本质就是一张 查找表(Lookup Table)

python
embedding = torch.nn.Embedding(50257, 256)
# 权重矩阵形状:[50257, 256]
# 每一行是一个 token 的 256 维向量

embedding(torch.tensor([3797]))   # 输入 token ID → 输出第 3797 行向量

没有任何复杂计算,就是"查行"。但关键在于:这张表里的每个数字都是可训练参数。

初始时向量是随机的。训练之后:

"cat"  → [0.2, -0.5, 0.8, ...]   ← 语义相近的词
"dog"  → [0.3, -0.4, 0.7, ...]   ← 向量距离很近
"the"  → [-0.1, 0.9, -0.3, ...]  ← 向量距离很远

除了词嵌入,还需要 位置嵌入(Positional Embedding)。因为注意力机制本身不感知顺序——"猫追狗"和"狗追猫"如果没有位置信息,对模型来说是一样的。位置嵌入让模型知道每个词在序列中的位置。


第三站:学会"看关系" — 注意力机制

单个词的含义靠嵌入解决了,但词与词之间的关系呢?"苹果"在"吃苹果"和"苹果公司"中含义完全不同。这就是注意力机制要解决的问题。

核心思想:每个词都在"提问"

每个词元被线性变换为三个向量:

  • Query(查询):我在找什么样的信息?
  • Key(键):我能提供什么样的信息?
  • Value(值):我的实际内容是什么?

通过计算 Query 和 Key 的 点积,模型得到一个相关性分数。点积值越高,两个词在特征空间中对齐程度越高——注意力就越多。

为什么要"缩放"?

当嵌入维度 d_k 很大时,点积的量级会膨胀,导致 Softmax 输出变得极端(几乎所有权重集中在一个词上)。从梯度角度看,Softmax 进入饱和区,梯度趋近于零,训练无法收敛。

除以 √d_k 把数值拉回梯度敏感区,这就是 Scaled Dot-Product Attention 的由来。

因果掩码:禁止"偷看未来"

GPT 是自回归模型,生成第 3 个词时只能看到前 2 个词。实现方式是在 Softmax 之前,把"未来位置"的注意力分数设为 -∞,经过 Softmax 后权重精确变为 0:

         位置1  位置2  位置3  位置4
位置1  [ 0.8   -∞     -∞     -∞  ]
位置2  [ 0.3   0.5    -∞     -∞  ]
位置3  [ 0.1   0.4    0.3    -∞  ]
位置4  [ 0.2   0.1    0.3    0.4 ]

多头注意力:多个视角并行观察

单头注意力只能从一个角度看关系。多头注意力把向量投影到多个子空间,每个"头"独立捕获不同类型的特征——有的关注语法关系,有的关注语义关联,有的关注距离模式。

所有头的输出拼接后通过线性层融合,将碎片化的信息重组为统一表示。


第四站:搭积木 — Transformer Block

有了注意力机制,接下来把它组装成完整的 Transformer 块。GPT-2 (124M) 堆叠了 12 个这样的块,GPT-3 堆叠了 96 个。

每个块包含四个关键组件:

输入 ──→ 层归一化 → 多头注意力 ──→ (+残差) → 层归一化 → 前馈网络 ──→ (+残差) → 输出

每个组件都有清晰的设计理由:

层归一化(Layer Normalization) — 将激活值标准化为均值 0、方差 1。没有它,激活值会随层数加深而爆炸或消失,模型根本训练不动。有了它,可以用更大的学习率,训练更快收敛。

GELU 激活函数 — 传统 ReLU 在输入为负时输出恒为 0("死区"),梯度完全消失。GELU 提供平滑的非零输出,即使负值也保留梯度信息,在深层网络中表现更优。

残差连接(Residual Connection)x + Sublayer(x) 结构。反向传播时梯度可以直接"跳过"复杂层,无损传回浅层。这是 96 层深度的 GPT-3 能够成功训练的关键——没有残差连接,深度网络的梯度会逐层衰减到几乎为零。

前馈网络(FFN) — 两个线性层夹一个 GELU。如果说注意力机制负责"词与词之间的关系",前馈网络就负责"每个词自身的特征变换",两者协同工作。


第五站:训练 — 自监督的下一词预测

模型搭好了,怎么训练?GPT 的训练目标极其简洁:给定前面的词,预测下一个词。

训练文本:"你好世界啊",max_length=4

input_ids(输入):   [你, 好, 世, 界]    → 喂给模型看的
target_ids(目标):  [好, 世, 界, 啊]    → 模型应该预测出的正确答案

target 就是 input 往右移了一位。模型需要同时学会:

看到 "你"           → 预测 "好"
看到 "你, 好"       → 预测 "世"
看到 "你, 好, 世"   → 预测 "界"
看到 "你, 好, 世, 界" → 预测 "啊"

这就是自监督学习的美妙之处:标签不需要人工标注,文本自身的下一个词就是答案。 GPT-3 的训练语料约 3000 亿词元,包括 CommonCrawl、WebText、Wikipedia 等,全部都是这样自动构造的训练对。

模型的预测结果通过 交叉熵损失(Cross-Entropy Loss) 与真实目标对比。如果目标词是"界"但模型给了很低的概率,损失就会很大,反向传播就会强力修正。

训练中用 滑动窗口 遍历全部文本。窗口大小决定上下文长度,步长决定窗口移动距离。步长为 1 时训练样本高度重叠,模型学到的转换关系最细致。


第六站:生成 — 从概率到文字

训练完成后,模型能为词表中每个词输出一个概率。但怎么选词?

贪婪搜索(永远选概率最高的词)看似最优,实际会导致文本枯燥重复,甚至陷入死循环。工程上需要更聪明的策略:

温度缩放(Temperature Scaling) — 控制概率分布的"尖锐度":

  • T < 1(低温度):分布更尖锐,高概率词优势放大 → 输出保守、连贯,适合要求准确性的场景
  • T > 1(高温度):分布被拉平,各词概率差距缩小 → 输出更有创意和多样性,但可能降低逻辑性

Top-k 采样 — 只从概率最高的 k 个词中随机采样,砍掉长尾低概率词。这些低概率词是"逻辑断裂"的元凶——它们单独出现概率极低,一旦被选中就会让句子偏离轨道。Top-k 在随机性和稳定性之间找到了平衡。


认知升级:从零构建之后的三个顿悟

1. 大模型的"理解"本质上是统计

GPT 不是"理解"了语言,而是在海量文本的统计模式中学到了极其精密的条件概率分布。但当这个分布精密到一定程度,涌现出的行为看起来就像"理解"。这不是降低它的价值,而是让我对它的能力边界有了更清晰的认知。

2. 工程设计处处是取舍

  • 词表大小:更大的词表让多语言编码更高效,但嵌入矩阵占用更多显存
  • 缩放注意力:不缩放数学上也能算,但梯度会消失,训练不动
  • 残差连接:看起来只是"加了一下",却是深度网络能训练的根本保障
  • 温度参数:没有"最优值",只有"适合当前任务的值"

每一个看似简单的设计选择,背后都是对"在当前约束下什么最有效"的回答。

3. 最好的学习方式是"自问自答"

整个学习过程中,我发现最有效的方法不是看完就过,而是每遇到一个概念就问自己:"如果面试官问我这个,我能用 30 秒讲清楚吗?"讲不清楚就说明理解有黑洞。把这些问题记录下来,逼自己用最通俗的语言回答——这篇博客里的每一段,都是这样打磨出来的。


写在最后

从零构建大模型这件事,最大的收获不是"会写一个 GPT",而是 拆掉了对黑箱的恐惧

当你亲手写过嵌入层的查表逻辑、手算过注意力矩阵的掩码、看过 Loss 从天文数字降到个位数——你对这个领域的理解就从"知道它很厉害"变成了"知道它为什么厉害、以及厉害在哪里"。

如果你也在学习 AI,推荐 Sebastian Raschka 的 Build a Large Language Model (From Scratch)。不用 GPU 也能跑前四章的代码,CPU 足够。关键是:核心代码一定要手写,不要复制粘贴。 手写一遍 Attention 类的理解深度,和看十遍教程完全不同。