外观
从零构建一个大模型
读十篇论文,不如亲手训练一个模型。本文带你用 nanoGPT 的最小路线,写一个真的能跑、真的会生成文本的小型 GPT:数据 → tokenizer → 模型 → 训练 → 采样,五个环节一个都不少。
很多人对大模型的第一反应是"那是大公司才碰得动的东西"。但事实是:用一块普通笔记本 GPU(甚至纯 CPU)就能训练一个能生成通顺文本的小型 GPT。本文把这个过程压缩到最小可运行的程度——所有代码加起来不到两百行,且全部给出。你得到的不是一个"玩具",而是一套理解 GPT 系列 工作原理的可动手验证的心智模型:从此再看到"大模型"三个字,你脑子里会有它的数据流、损失曲线和采样过程,而不是一团黑盒。
一、路线总览:五步走 + 四步验收
完整路线与每一步对应的核心问题:
莎士比亚原始文本(约 1MB 字符)
│
▼
① 数据准备 ──────── 回答"模型在学什么样的文本"
▼
② 分词 tokenizer ── 回答"文本如何切成模型的输入单元"
▼
③ 模型定义 ──────── 回答"预测下一个 token 的网络长什么样"
▼
④ 训练循环 ──────── 回答"损失如何一步步下降、模型如何变聪明"
▼
⑤ 采样生成 ──────── 回答"训练好的模型能输出什么"这就是 语言建模 范式的最小闭环:模型学习的唯一任务,是预测下一个 token。理解这一点,比记住任何公式都重要。
四步验收标准
动手之前先定好"什么算成功",避免训练完不知道怎么看结果:
| 序号 | 验收项 | 合格线(本配置) | 怎么检查 |
|---|---|---|---|
| ① | 代码能跑通 | 训练循环不报错,loss 从 4~5 一路下降 | 观察 print 出的 train loss |
| ② | 损失明显下降 | 3000 步内 train loss 从约 4.2 降到 1.8 以下 | 对照训练曲线(见第五节) |
| ③ | 过拟合信号合理 | val loss 先降后升、train loss 继续下降 | 对比 train / val loss 两条曲线 |
| ④ | 生成可读 | 采样输出是"看起来像英语/莎翁剧"的词串 | 运行采样函数,人工读一遍 |
验收的不是"像 ChatGPT"
小型 GPT(约 1M 参数)不可能产生有逻辑的对话。它的"成功"标准是:学会了莎士比亚的用词习惯、标点节奏和部分拼写。这恰恰是最好的教学——你能清楚地看到"统计规律"如何从数据里长出来。想验证更聪明的模型,先把本文的扩展清单(第七节)走一遍。
本地可跑的资源预算
| 配置 | 显存/内存 | 训练一个 1M 模型的耗时(估算) | 适合做什么 |
|---|---|---|---|
| 纯 CPU(如笔记本 8 核) | 仅需 ~2GB 内存 | 约 20~40 分钟 | 本文全部内容,耐心等即可 |
| 入门 GPU(如 RTX 3060 12GB) | ~2GB 显存 | 约 3~5 分钟 | 本文 + 调参玩 |
| 中端 GPU(如 RTX 4090 24GB) | ~2GB 显存 | 约 1~2 分钟 | 快速迭代;继续往 10M~50M 参数走 |
| 云 GPU(如 A100 40GB) | 无压力 | 秒级 | 本文不必要;留给第七节的扩展 |
一个残酷的对照
GPT-2(1.5B 参数)在 8 个 A100 上预训练要跑数天,而本文的 1M 模型在 CPU 上半小时就能训完——规模带来的差距是数量级的。这正是 规模法则 的现实注脚:大模型的"智能"很大程度上是算力和数据堆出来的,而本文让你亲手体会"小模型为什么不行"。
二、数据:从莎士比亚开始
1. 为什么用莎士比亚
Karpathy 的 nanoGPT 教程选择莎士比亚的剧作全集(约 1MB 纯文本)作为起点,理由极其务实:
- 足够小:1MB 文本在 CPU 上几秒加载完毕,任何机器都能训。
- 足够有规律:戏剧文本有角色、台词、场景结构,统计规律明显,小模型能学到"形"。
- 足够有趣:生成的"伪莎士比亚"直观可读,验收时有成就感。
下载数据:
bash
# 直接下载 tiny Shakespeare(Karpathy char-rnn 仓库里的数据文件)
mkdir -p data/shakespeare
wget https://raw.githubusercontent.com/karpathy/char-rnn/master/data/tinyshakespeare/input.txt -O data/shakespeare/input.txt
# Windows 无 wget 可用 curl:curl -o data/shakespeare/input.txt <同 URL>python
# 加载并快速体检
with open("data/shakespeare/input.txt", "r", encoding="utf-8") as f:
text = f.read()
print(f"总字符数: {len(text)}") # 约 1.1M
print(text[:500]) # 前 500 字符,感受一下如果你想挑战"更像真的预训练",可以把数据换成 OpenWebText(GPT-2 训练集的非官方重制版,见参考资料),但那是"从 1M 到 1B"阶段的事,本文先不展开。
2. 数据切分:训练 / 验证
与机器学习手册里"测试集最后碰一次"的纪律完全一致(详见常见陷阱与反模式),LLM 训练同样需要独立的验证集——但注意:LLM 的验证集是"同一文档里没让模型看过的文本片段",不是独立文档,这是文本数据与表格数据的关键差异。
python
# 用 90% 文本训练、10% 验证(不做随机打乱——保持文本顺序对语言模型很重要)
n = len(text)
train_data = text[: int(n * 0.9)]
val_data = text[int(n * 0.9):]为什么这里不 shuffle
文本序列有内在顺序(前文决定后文)。如果像表格数据那样把字符随机打乱,模型将学到"随机字符串的概率分布",毫无意义。训练时我们按顺序切出 batch,但每个 batch 内的位置是随机的(见第四节),这保证了每步梯度来自文本不同区域。
三、分词:从字符级到 BPE
1. 字符级 tokenizer:教学最优
大模型的分词(tokenizer)机制详见 分词与词表。本文先用最简单的字符级分词:每个字符就是一个 token。它有两个好处——代码最少、概念最清晰。
python
# 统计文本里出现的所有不同字符,作为词表
chars = sorted(list(set(train_data)))
vocab_size = len(chars)
print(f"词表大小: {vocab_size}") # 通常 65 个(字母+标点+空格等)
# 建立 字符<->整数 的双向映射
stoi = {ch: i for i, ch in enumerate(chars)} # 字符 -> id
itos = {i: ch for i, ch in enumerate(chars)} # id -> 字符
def encode(s):
"""把字符串变成整数列表"""
return [stoi[c] for c in s]
def decode(ids):
"""把整数列表变回字符串"""
return "".join(itos[i] for i in ids)
# 自检:编码再解码应还原原文
sample = "To be, or not to be"
assert decode(encode(sample)) == sample
print("tokenizer 自检通过")2. 从字符级升级到 BPE
字符级词表只有 65 个 token,但代价是每个 token 的信息量极低——模型需要更多步才能学到"单词"概念。真实大模型都使用 BPE 这类子词分词,词表通常 3 万~10 万个 token(Llama 3 用 128K,GPT-4 约 10 万)。用 OpenAI 开源的 tiktoken 可以一行体验:
python
pip install tiktoken
import tiktoken
enc = tiktoken.get_encoding("cl100k_base") # GPT-4 用的分词器
ids = enc.encode("To be, or not to be")
print(ids) # [904, 471, ..., 471]
print(enc.decode(ids)) # 还原原文
print("token 数:", len(ids), "vs 字符数:", len("To be, or not to be"))这个对比(一段英文被切成多少个 token)直接决定了调用 API 的成本——token 是 LLM 世界的计价单位,换算关系见上下文与长文本。
本文代码怎么选
后面四节的全部代码都基于字符级 tokenizer(保证最小可运行)。把第三节第 1 点的 encode/decode 换成 tiktoken 的 enc.encode/enc.decode,模型就能吃 BPE token——只需要改两行代码,这就是"tokenizer 是模型外置组件"的直观体现。
四、模型:最小 GPT(完整可运行代码)
1. 架构总览
模型本身是 Transformer 架构详解 的解码器(decoder-only)子集,核心组件四个:
| 组件 | 作用 | 备注 |
|---|---|---|
| TokenEmbedding | 把 token id 映射为稠密向量 | 可学习的查找表 |
| CausalSelfAttention | 让每个位置只看自己及之前的 token | 因果掩码是 GPT 的关键 |
| MLP(前馈网络) | 逐位置的非线性变换 | 每个 token 独立计算 |
| LayerNorm + 残差连接 | 稳定深层训练 | Pre-Norm 布局 |
2. 完整模型代码(PyTorch)
python
import torch
import torch.nn as nn
from torch.nn import functional as F
# ── 超参数(最小配置)──────────────────────────
batch_size = 32 # 每个 batch 的序列条数
block_size = 128 # 上下文长度:每个序列最多看前 128 个 token
max_iters = 3000 # 总训练步数
eval_interval = 300 # 每 300 步做一次验证
learning_rate = 3e-4 # 学习率
n_layer = 2 # Transformer 层数
n_head = 4 # 注意力头数
n_embd = 64 # 嵌入维度(模型宽度)
dropout = 0.0 # 小模型加不加 dropout 影响不大
class CausalSelfAttention(nn.Module):
"""单层多头因果自注意力:QKV + 缩放点积 + 因果掩码"""
def __init__(self):
super().__init__()
assert n_embd % n_head == 0
self.c_attn = nn.Linear(n_embd, 3 * n_embd) # 一次算 Q, K, V
self.c_proj = nn.Linear(n_embd, n_embd) # 输出投影
self.n_head = n_head
self.n_embd = n_embd
def forward(self, x):
B, T, C = x.shape # B 批大小, T 序列长, C 通道
qkv = self.c_attn(x) # (B, T, 3C)
q, k, v = qkv.split(self.n_embd, dim=2)
# 拆成多头:每个头通道数 = C/n_head
q = q.view(B, T, self.n_head, C // self.n_head).transpose(1, 2)
k = k.view(B, T, self.n_head, C // self.n_head).transpose(1, 2)
v = v.view(B, T, self.n_head, C // self.n_head).transpose(1, 2)
# 缩放点积注意力;att = softmax(QK^T / sqrt(d_k)) V
att = (q @ k.transpose(-2, -1)) * (1.0 / (k.shape[-1] ** 0.5))
# 因果掩码:下三角矩阵,右上角置为 -inf,softmax 后为 0
mask = torch.tril(torch.ones(T, T, device=x.device)).view(1, 1, T, T)
att = att.masked_fill(mask == 0, float("-inf"))
att = F.softmax(att, dim=-1)
y = att @ v # (B, n_head, T, head_dim)
y = y.transpose(1, 2).contiguous().view(B, T, C) # 多头拼回
return self.c_proj(y)
class MLP(nn.Module):
"""逐位置前馈网络(这里用简单的 GELU 激活)"""
def __init__(self):
super().__init__()
self.c_fc = nn.Linear(n_embd, 4 * n_embd)
self.c_proj = nn.Linear(4 * n_embd, n_embd)
def forward(self, x):
return self.c_proj(F.gelu(self.c_fc(x)))
class Block(nn.Module):
"""一个 Transformer 层 = LayerNorm + 注意力 + LayerNorm + MLP(Pre-Norm)"""
def __init__(self):
super().__init__()
self.ln1 = nn.LayerNorm(n_embd)
self.attn = CausalSelfAttention()
self.ln2 = nn.LayerNorm(n_embd)
self.mlp = MLP()
def forward(self, x):
x = x + self.attn(self.ln1(x)) # 残差 + Pre-Norm
x = x + self.mlp(self.ln2(x))
return x
class GPT(nn.Module):
"""最小 GPT:嵌入 + N 个 Block + 最后的线性层 + 损失"""
def __init__(self):
super().__init__()
self.token_embedding = nn.Embedding(vocab_size, n_embd)
self.position_embedding = nn.Embedding(block_size, n_embd) # 绝对位置编码
self.blocks = nn.Sequential(*[Block() for _ in range(n_layer)])
self.ln_f = nn.LayerNorm(n_embd)
self.lm_head = nn.Linear(n_embd, vocab_size) # 输出每个 token 的得分
def forward(self, idx, targets=None):
B, T = idx.shape
tok_emb = self.token_embedding(idx) # (B, T, n_embd)
pos = torch.arange(T, device=idx.device) # 位置 0..T-1
pos_emb = self.position_embedding(pos) # (T, n_embd)
x = tok_emb + pos_emb # 广播相加
x = self.blocks(x)
x = self.ln_f(x)
logits = self.lm_head(x) # (B, T, vocab_size)
loss = None
if targets is not None:
# 交叉熵:每个位置预测"下一个 token"
B, T, V = logits.shape
logits = logits.view(B * T, V)
targets = targets.view(B * T)
loss = F.cross_entropy(logits, targets)
return logits, loss
def generate(self, idx, max_new_tokens):
"""自回归采样:把新生成的 token 拼回去,再预测下一个"""
for _ in range(max_new_tokens):
idx_cond = idx[:, -block_size:] # 只取最后 block_size 个
logits, _ = self.forward(idx_cond)
logits = logits[:, -1, :] # 只看最后一个位置
probs = F.softmax(logits, dim=-1)
idx_next = torch.multinomial(probs, num_samples=1) # 按概率采样
idx = torch.cat((idx, idx_next), dim=1)
return idx逐行读懂这份代码,胜过抄十遍。三个最值得驻足的细节:
- 因果掩码(
torch.tril):保证位置 T 只能看到自己及之前的 token。这是"下一词预测"纪律的架构化——泄露未来会毁掉学习。 - 位置编码(
position_embedding):注意力本身不感知顺序,必须显式注入位置信息。真实模型多用 RoPE 等相对位置编码,见Transformer 架构详解。 generate的自回归:生成一个 token → 拼回去 → 再预测。这就是推理基础里"自回归生成循环"的代码形态。
五、训练循环:看着损失下降
1. 数据加载器
python
import torch
data = torch.tensor(encode(text), dtype=torch.long)
n_data = len(data)
def get_batch(split):
"""从训练/验证数据里随机切一段固定长度序列,构造成 (输入, 目标) 对"""
src = train_data if split == "train" else val_data
src = torch.tensor(encode(src), dtype=torch.long)
ix = torch.randint(len(src) - block_size, (batch_size,))
x = torch.stack([src[i: i + block_size] for i in ix])
y = torch.stack([src[i + 1: i + 1 + block_size] for i in ix]) # 目标=输入右移一位
return x, y目标就是输入右移一位——这行代码是整个 LLM 训练目标最凝练的表述:给定 x[i],让模型预测 x[i+1]。
2. 训练主循环
python
torch.manual_seed(1337)
model = GPT()
optimizer = torch.optim.AdamW(model.parameters(), lr=learning_rate)
@torch.no_grad()
def estimate_loss():
"""在训练/验证数据上各评估一次平均损失"""
out = {}
model.eval()
for split in ["train", "val"]:
losses = torch.zeros(eval_interval)
for k in range(eval_interval):
x, y = get_batch(split)
_, loss = model(x, y)
losses[k] = loss.item()
out[split] = losses.mean().item()
model.train()
return out
for step in range(max_iters):
x, y = get_batch("train")
_, loss = model(x, y)
optimizer.zero_grad(set_to_none=True)
loss.backward()
optimizer.step()
if step % eval_interval == 0 or step == max_iters - 1:
losses = estimate_loss()
print(f"step {step:5d} | train loss {losses['train']:.4f} | val loss {losses['val']:.4f}")3. 怎么读损失曲线
典型输出(示意,具体数值因随机种子而异):
step 0 | train loss 4.2236 | val loss 4.2176
step 300 | train loss 2.4113 | val loss 2.5021
step 600 | train loss 1.8928 | val loss 2.0815
step 900 | train loss 1.6229 | val loss 1.8931
step 1200 | train loss 1.4651 | val loss 1.7893
...
step 3000 | train loss 1.2310 | val loss 1.6492三条必须掌握的读法:
| 现象 | 含义 | 应对 |
|---|---|---|
| train loss 与 val loss 一起下降 | 模型在真正学习,没有明显过拟合 | 继续训 |
| train loss 降到很低、val loss 停滞/回升 | 过拟合:模型在背训练文本 | 提前停(early stop)、加 dropout、加数据 |
| 两者都下不去(损失卡在高位) | 欠拟合或超参不对 | 加大模型/学习率、检查数据 |
| 初始 loss ≈ ln(65) ≈ 4.17 | 模型一开始是"均匀瞎猜" | 这是标准 sanity check,不是 bug |
为什么初始损失约等于 ln(vocab_size)
随机初始化的模型对每个 token 给出近似均匀分布,交叉熵损失就是 -ln(1/65) ≈ 4.17。如果你的初始损失离这个值差很远,说明代码有 bug(比如词表统计错了、目标没右移)。
训练损失能降到 1.2,说明模型"背会"了莎士比亚吗
不完全是。val loss 约 1.6 意味着模型仍然平均"困惑"于约 e^1.6 ≈ 5 个候选 token——它学到的是文本的统计结构(词序、语法、角色台词规律),不是逐字背下原文。真正的"背诵"要发生在参数远多于数据时才明显。
训练优化三件套:让 1M 模型收敛得更稳
本文的最简训练循环没加任何优化技巧,已经能跑通。当你开始"认真训练"(加大模型、加长数据)时,按顺序加上三件套:
python
# 1. 学习率 warmup:前若干步线性升到目标 lr,稳定早期训练
# 2. 权重衰减(weight decay):对非 bias/非 norm 参数施加 L2 正则
# 3. 余弦退火(cosine decay):lr 从峰值平滑降到近 0,末期收敛更稳
import math
def get_lr(step, warmup_iters=200, lr_decay_iters=3000, max_lr=6e-4, min_lr=6e-5):
"""nanoGPT 风格的学习率调度:warmup + cosine 衰减"""
if step < warmup_iters:
return max_lr * (step + 1) / warmup_iters
if step > lr_decay_iters:
return min_lr
decay_ratio = (step - warmup_iters) / (lr_decay_iters - warmup_iters)
coeff = 0.5 * (1.0 + math.cos(math.pi * decay_ratio))
return min_lr + coeff * (max_lr - min_lr)
# 用法:在训练循环里 optimizer.param_groups[0]["lr"] = get_lr(step)这一套与 GPT-2 论文/训练代码中的调度一致,是"从玩具到真模型"的第一批工程化改动(详细原理见预训练的训练动态一节)。
六、采样生成:看它开口说话
python
# 从"换行符"开始(相当于清空上下文),生成 500 个 token
context = torch.tensor([[encode("\n")[0]]], dtype=torch.long)
print(decode(model.generate(context, max_new_tokens=500)[0].tolist()))一段典型的(示意)输出,注意拼写、标点和台词结构已经"像模像样":
HENRY:
If you be to, the soul of my son,
The great and the gracious this sword
Hath brought my body to this gentle king.
GLOUCESTER:
What is he? What were his father's grief,
And let me speak of me to the people.采样即推理策略
这里的 torch.multinomial 是从概率分布里随机抽(温度=1)。如果你想要更确定的输出,可以调温度(temperature)、top-k、top-p 等解码策略——这正是推理基础里采样策略的代码雏形。把 probs 的 logits 除以温度再 softmax,就能控制输出的"随机性 vs 确定性"。
七、从 1M 到 1B:扩展清单
训练完 1M 小模型,你已经理解全部机制。接下来每一步"变大",都要同时解决新问题:
| 规模 | 参数量级 | 需要做的升级 | 对应内容 |
|---|---|---|---|
| 玩具级 | ~1M(本文) | 无,CPU 可跑 | 本文 |
| 入门级 | 10M~50M | 数据换 OpenWebText;加层加宽(如 4 层 256 维);GPU 训练 | 预训练 |
| 进阶级 | 100M~300M | 学习率 warmup + cosine 调度;梯度裁剪;显存优化;用 tiktoken/BPE 分词 | 预训练、分词与词表 |
| 研究级 | 1B 以上 | 多卡数据并行/张量并行;混合精度;更大数据配比 | 规模法则、框架与工具选型 |
按 nanoGPT 官方仓库的对照:config/train_gpt2.py 就是一份**标准 GPT-2(124M 参数)**的完整配置,训练数据为 OpenWebText,使用多 GPU + 混合精度——它是从本文走向"真·预训练"的最佳跳板(参考资料)。
八、常见坑
| 坑 | 症状 | 排查方向 |
|---|---|---|
| 目标没右移 | 模型能"记住"输入、损失降不下去 | 检查 y = x[:, 1:] 这类对齐是否写对 |
| 因果掩码写错 | 训练损失异常低但生成垃圾 | 单独打印 mask 矩阵看是否下三角 |
上下文超过 block_size | 生成时报 shape 错误 | generate 里必须截取最后 block_size 个 |
| 数据没切分 | val loss 几乎等于 train loss | 确认验证文本独立于训练文本 |
| 词表统计错了 | 训练/验证阶段字符集合不一致 | 用 train_data 统计词表,验证文本里新字符会崩 |
| 初始损失不对 | 不是约 ln(vocab_size) | 检查数据加载与 batch 构造 |
想进一步深挖某一环:损失与困惑度的直觉见语言建模,注意力细节见Transformer 架构详解,数据配比与清洗见预训练,训练完想微调成特定风格见微调实战。
延伸阅读
- 语言建模:下一词预测范式 —— 本文模型背后的数学与直觉:困惑度、交叉熵、为什么预测下一个词能学到知识
- Transformer 架构详解 —— 把本文 200 行代码放大成完整架构:多头注意力、RoPE、SwiGLU
- 预训练:数据与目标 —— 从 Shakespeare 到 TB 级语料:清洗、去重、配比
- 分词与词表 —— 从字符级到 BPE 到 SentencePiece 的完整图谱
- 规模法则 —— 本文 1M 模型 vs GPT-3 175B:损失如何随规模幂律下降
- 经典论文精读 —— Attention Is All You Need 与 GPT 系列论文的逐段读法
参考资料
- Karpathy/nanoGPT(GitHub) —— 本文代码的官方来源;仓库内含 Shakespeare 与 GPT-2 两种训练配置
- Let's build GPT: from scratch(Karpathy 视频课程) —— 本文内容的视频版讲解,强烈推荐配合观看
- tiny Shakespeare 数据文件 —— 本文使用的训练语料
- OpenWebTextCorpus(GitHub) —— GPT-2 训练集 WebText 的非官方重制版,扩展阶段的下一份数据
- Attention Is All You Need(arXiv:1706.03762) —— Transformer 原始论文,本文注意力实现的出处
- Language Models are Unsupervised Multitask Learners(GPT-2 论文) —— GPT-2 与 WebText 的原始说明