Skip to content

从零构建一个大模型

本页速览 沿着 nanoGPT 路线,用最小可运行的 PyTorch 代码走通"数据→分词→模型→训练→采样"的完整闭环:亲手训练一个能说莎士比亚语的小型 GPT,并拿到从 1M 到 1B 参数的扩展清单与四步验收标准。

从零构建一个大模型

读十篇论文,不如亲手训练一个模型。本文带你用 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

逐行读懂这份代码,胜过抄十遍。三个最值得驻足的细节:

  1. 因果掩码torch.tril):保证位置 T 只能看到自己及之前的 token。这是"下一词预测"纪律的架构化——泄露未来会毁掉学习。
  2. 位置编码position_embedding):注意力本身不感知顺序,必须显式注入位置信息。真实模型多用 RoPE 等相对位置编码,见Transformer 架构详解
  3. 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 架构详解,数据配比与清洗见预训练,训练完想微调成特定风格见微调实战

延伸阅读

参考资料