Appearance
第9章 · TinyGPT 预训练、恢复与生成
前置要求:掌握 第5章 的交叉熵损失、第7章 的优化器更新与训练稳定性,以及 第8章 的 Transformer Block 架构。
前七章造好了全部零件:tokenizer、embedding、Transformer Block、交叉熵、AdamW。本章把它们装成一台真正能训练的模型——TinyGPT,并让它在你的笔记本 CPU 上真实训练、真实保存、真实恢复。这是全书第一个"端到端跑通"的里程碑。
本章目标
学完后你能做到:
- 组装 token/position embedding、Transformer blocks、LM head、batch loader 和训练循环。
- 先过拟合一个 batch 做链路诊断,再在固定微型语料上训练、采样、保存和恢复。
- 解释 learning rate、micro-batch 与梯度累积、一次更新看见的被监督 token 数、context、depth、width、梯度裁剪和 checkpoint 各自的作用;分布式训练只做职责和代价讨论。
- 说明固定算力预算下参数量与 token 数如何一起放大,以及这条关系预测的是预训练损失。
阶段一:把零件装成 TinyGPT
完整模型的数据流,每一层你都认识:
TIP
初学者心智模型:Transformer 是如何把积木拼成能说话的模型的?
许多刚接触大模型的工程师面对各种 shape 变换感到眼花缭乱。建立起下面这套三阶段心智模型,整个架构将一目了然:
- 输入离散符号连续化(翻译阶段):计算机不懂文字,只懂数字。词表里的离散整数通过 Token Embedding 矩阵映射到
维连续几何空间;再加上记录顺序的 Position Embedding,完成输入编码 。 - 深层因果关系抽取(思考阶段):堆叠的
个 Decoder Block 是模型的大脑。每个 Block 里包含两套核心引擎: - 自注意力机制(Self-Attention):负责跨词横向特征融合(“谁跟谁有关系”,如主语与动词照应),因果掩码杜绝偷看未来;
- 前馈网络(FFN / MLP):负责单词纵向非线性概念抽取,充当模型的知识记忆库;
- 残差连接与 LayerNorm:架设高速梯度直连公路,确保数十层深网在反向传播时信号不衰减、数值不溢出。
- 隐层表征映射回词表(说话阶段):经过最终 LayerNorm 稳定尺度后,LM Head 矩阵将隐层维度
逆向投影回巨大的词表大小 ,输出每个候选词的未归一化对数概率(Logits) 。训练时通过错位一格的 Teacher Forcing 计算交叉熵损失;生成时按概率采样输出下一个词!
shape 契约:
| 对象 | shape | 说明 |
|---|---|---|
| input ids | (B, T) | 与 shifted targets 相差一个位置 |
| token/position embeddings | (B, T, D) | 位置索引不可越过 block size |
| logits | (B, T, V) | 与 targets 对齐后算 CE |
| optimizer state | 与可训练参数相关 | resume 不能只加载 weights |
| checkpoint metadata | mapping | 保存 step、config/data hash 和相对路径 |
权重初始化
python/llm_core/gpt.py 的 TinyGPT.__init__ 用 rng.normal(0, 0.02, shape) 初始化所有可学习参数:embedding 表、Linear 权重、attention 的 QKV/proj——全部使用标准差 0.02 的高斯分布;偏置全部初始化为零。python/llm_train/model.py 的 TinyGPTModel._init_weights 行为一致:nn.init.normal_(weight, mean=0.0, std=0.02) 和 nn.init.zeros_(bias)。
这个 0.02 的经验值来自原始 Transformer 论文和 GPT-2 的实践:残差分支的每层输入方差经过多层叠加后大致稳定,0.02 足够小以避免激活饱和(对 ReLU/GELU 都成立),又足够大让不同参数在训练初期的梯度信号不坍缩为零。残差分支的输出投影有时会用更大的缩放(如乘以 python/llm_core/gpt.py 没有做这个额外缩放——本仓库的实现统一使用 0.02,不做分层的 variance rescaling。
阶段二:teacher forcing——一次前向算出所有位置的答案
训练时不让模型自己生成,而是直接把真实文本错位一格喂给它:
text
原文: h e l l o
input: [h e l l] ← 模型看到的
target: [e l l o] ← 每个位置要猜的答案位置 0 看到 h,目标是 e;位置 1 看到 h, e,目标是 l……一次前向就得到 (B, T, V) 的 logits,和所有位置的 target 一起算交叉熵。这就是 teacher forcing——老师全程扶着,每个位置都有标准答案。训练目标就是把第5章的单 token CE 推广到整 batch 所有位置的平均:
target 不 shift 会怎样? 模型学到的任务将退化为“单纯复制当前位置的输入”——训练 loss 虽然能够降得极低,但模型完全丧失了自回归预测下一个词的泛化能力。这是因果模型实现中最易犯的隐蔽错误,也是“未来不可见”契约测试存在的根本原因。
哪些位置进入这个平均
上面的
多篇短文档拼进同一条长度
- 标签:文档 A 的最后一个 token,目标默认是文档 B 的第一个 token。这个位置的
置 0,或把目标写成单独的分隔符,和式就不再要求 A 预测 B 的开头。 - 注意力:整行共用一个下三角掩码时,B 里的 token 能读到 A。按文档把掩码分成互不可见的块之后,B 的上下文只来自 B 内部。只插入分隔符,训练的是见到分隔符之后换一篇;注意力仍然跨过这条缝。
- 位置下标:本章的位置表用窗内下标取向量。下标顺着整窗增加时,B 用到的是 A 末尾之后的位置向量。每篇文档的下标从 0 再计,B 才回到从开头读取时的那一组位置。第10章用旋转角代替这张表,下标仍是同一个量。
补齐和拼接解决的是同一件事:batch 里每一行都是长度
这里的接缝在同一条训练行内部。紧接着的去重与切分也用到文档边界,那是把整篇文档放进训练集或验证集。两处边界不是同一件事。
语料先治理,再切分:去重与污染
真实语料里充满近重复样本:同一篇文档的多个转载、模板化生成的相似页面、同一句话的细微改写。它们对训练有两层实际影响。其一,重复样本会让模型更倾向于记忆(memorization)而不是学习可泛化的规律——loss 下降里有相当一部分来自"背下了重复内容"。其二,hash 切分挡不住近重复泄漏:两条几乎相同的文本按 hash 落进 train 和 val 两侧后,val loss 会因为模型"见过它"而异常漂亮,评估结论随之失真。工程顺序因此固定为:先按规范化文本(小写化、去多余空白、近似哈希/MinHash)去重,再做 train/val 切分。对语言数据,切分必须按时间顺序(或文档边界)划分,不能用随机 shuffle——随机切分会把验证集需要的"未来 token"泄漏进训练集,val loss 虚低、模型看似很好实则不具备泛化能力。第5章从指标定义层面解释了为什么随机 split 在自回归任务上失效;本节从数据工程角度执行同一条规则。
前端类比:构建产物缓存按 content-hash 去重——相同内容只保留一份;跳过去重直接统计缓存命中率,得到的只是虚假的高命中。
Chip Huyen 在《AI工程》第 8 章(Dataset Engineering)把数据质量、覆盖与数量列为独立于建模的工程环节,本节的去重顺序正是其中最基本的一条。同一逻辑放到评估侧叫数据污染(contamination):评估题目混进训练语料后,测出的就不再是泛化能力。第11章的"训练/评估 ID 污染 0/8"指标是这条原则在 fixture 上的落地。
去重决定同一篇文档进入池子几次。配比决定池子里每一类来源被抽到的次数。损失是抽样分布下的平均:按文件体积往下读时,最大的那份来源贡献大部分梯度。给来源指定 token 预算或抽样概率之后,较小的来源会被重复抽到,损失拟合的是这份预算,而不是爬取下来的原始体积。同一文档因上采样被再次读入时,token 计数按读入次数累加。第6章里 DeepSeek-Coder 语料的 87% 源代码、10% 与代码相关的英文、3% 与代码无关的中文,就是一份已经写定的来源配比。质量过滤决定一篇文档能不能进池子,配比决定进池之后被抽中多少次,MinHash 决定近重复还在不在池子里。
阶段三:训练循环与稳定性工具
训练循环就是第3章的五行骨架,外加两件安全装备:
梯度裁剪:
前端类比:像 CSS 的 max-height: 100vh——内容超出时不是砍掉,而是等比缩放让整体可见。
学习率调度:用随时间变化的步长控制更新幅度。梯度裁剪控制的是梯度本身的量级(一次性 rescale 整个梯度向量);学习率调度控制的是参数更新的步长(随时间递减),两者互补但不互相替代。
实际训练中常见的两步式调度:
- warmup(前
步):学习率从 0 线性升到 : - cosine decay(warmup 之后):学习率从
平滑降到 :
为什么 warmup 是必需的:AdamW 的动量估计(
看懂这个由 Karpathy 极力倡导的极简链路诊断代码(纯 CPU 即可在 15 秒内跑完):
python
# 1. 初始化微型 TinyGPT 模型与 AdamW 优化器(纯 CPU 环境)
model = TinyGPTModel(vocab_size=65, block_size=64, n_embd=64, n_head=4, n_layer=2)
optimizer = torch.optim.AdamW(model.parameters(), lr=1e-3, weight_decay=0.01)
# 2. 冻结固定一个微型 batch (B=8, T=64)
x_batch, y_batch = get_batch()
# 3. 连续迭代 100 步
for step in range(100):
logits = model(x_batch) # 前向传播:(B, T, V)
loss = F.cross_entropy(logits.view(-1, 65), y_batch.view(-1)) # 交叉熵损失
optimizer.zero_grad() # 清零历史梯度
loss.backward() # 反向传播计算本步梯度
torch.nn.utils.clip_grad_norm_(model.parameters(), max_norm=1.0) # 梯度范数裁剪防爆炸
optimizer.step() # 优化器沿负梯度更新参数
if step % 20 == 0 or step == 99:
print(f"Step {step:02d} | Loss: {loss.item():.4f}")预期终端输出(在普通笔记本 CPU 上耗时约 10 秒):
text
Step 00 | Loss: 4.1742 <-- 初始 Loss 完美命中 ln(65) 理论基准线!
Step 20 | Loss: 2.1430 <-- 参数稳步下山
Step 40 | Loss: 0.9852
Step 60 | Loss: 0.2814
Step 80 | Loss: 0.0712
Step 99 | Loss: 0.0124 <-- 成功把单一 batch 记牢!证明整条前向与反向流水线完全打通!数一数你的模型有多大
在 python/llm_train/model.py 的 TinyGPTModel 上,用配置 vocab_size=65, block_size=64, n_embd=64, n_head=4, n_layer=2 实测 sum(p.numel() for p in model.parameters()) = 108,352 个参数。解析估算可以逐项拆开,和实测逐项对齐:
| 组件 | 公式 | 本配置值 |
|---|---|---|
| token embedding | ||
| position embedding | ||
| 每 block 的 attention(Q/K/V/O 权重 + 偏置) | ||
| 每 block 的 FFN( | ||
| 每 block 的两个 LayerNorm(各 | ||
| 每 block 合计 | — | |
| 最终 LayerNorm | ||
| LM head(tied embedding) | ||
| 总计 | 108,352 |
tied embedding:上表的 LM head 记 0,因为
TinyGPTModel把输出投影直接复用 token embedding 矩阵,named_parameters()里没有独立的lm_head。若解除绑定,要额外加个参数。第10章会专门对拍 tied 与 untied 的行为差异。
注意:LayerNorm 的可学习参数是
与 两项,所以每个 LayerNorm 记 ;FFN 的偏置是 (中间层 4D,输出层 D),不要漏算。
FLOPs 估算(每个 token 每 batch 元素的前向传播浮点运算量,忽略 attention mask 与 softmax 的常数因子):
对本配置:
大模型训练显存开销解构公式(16Φ 规则)
评估大模型训练的显存开销时,不能仅按模型权重本身估算。例如一个 7B 参数的模型以 16-bit 存放占用 14GB 存储空间,若以此作为训练显存预算,运行时将因显存不足触发 OOM(Out of Memory)异常。
这是因为在全参数训练中,模型的静态训练显存由三大部分共同组成: 设模型参数量为
- 模型权重(Weights):使用 FP16 / BF16 存放,每个参数占 2 字节
字节; - 反向梯度(Gradients):反向传播计算出的参数梯度同样为 FP16 / BF16,每个参数占 2 字节
字节; - AdamW 优化器状态(Optimizer States): 为了防止参数更新时微小步长被浮点舍入吞掉,AdamW 必须维护:
- FP32 主权重副本:每个参数 4 字节;
- 一阶动量矩
(FP32):每个参数 4 字节; - 二阶方差矩
(FP32):每个参数 4 字节; - 优化器状态合计:
字节!
- 对于 7B 模型(
): 即使单卡 80GB 的顶级 A100/H100 显卡,光放静态参数就当场撑爆,更遑论放入上下文! - 此外还有随批大小
和上下文长度 剧增的动态激活值显存(Activation Memory)。为了降低激活值开销,工业界采用激活重计算(Activation Checkpointing / Gradient Checkpointing):前向传播时不保存中间层的激活值,仅在反向传播计算到该层时重新前向计算一次。用约 25%~30% 的前向重算算力,换取激活显存暴降 60%~70%!
一次参数更新看见多少 token
本节前面的循环在每次 backward 之后立刻 step。激活显存随 micro-batch 的 step。每个 micro-batch 的损失若已经是该批内部被监督 token 的平均,全体 token 的平均是按各批被监督个数
各批 backward 相加,得到的是
一次更新里被监督的 token 数就是 step 之后清零,下一次从零开始加。
静态的
算力预算:参数量与 token 数一起放大
Hoffmann 等(2022)把要拟合的量写成预训练损失 optimizer.step 的次数。文中对 Kaplan 等(2020)的转述是:算力增加到 10 倍时,模型大约增大到 5.5 倍,训练 token 大约增加到 1.8 倍。Chinchilla 的结论是
分布式并行四大范式全景(DP, TP, PP, ZeRO-1/2/3 / FSDP)
当单张 GPU 彻底装不下训练状态时,必须拆解并分摊到成百上千张卡上:
| 并行策略 | 英文全称 | 核心分摊逻辑 | 通信通信瓶颈 | 适用物理拓扑 |
|---|---|---|---|---|
| DDP | Distributed Data Parallel | 每卡复制一份完整模型参数与优化器;按 Batch 切分数据,反向时 All-Reduce 同步梯度 | 通信量与模型参数量成正比 | 任意集群(单卡必须装得下 16Φ) |
| TP | Tensor Parallelism (Megatron-LM) | 将单个 Linear 权重矩阵按行或按列切开(如 Attention QKV 与 MLP),卡间协同算一次矩阵乘 | 极高频的前向/反向通信(All-Reduce / All-Gather) | 仅限机内超高速 NVLink(>400GB/s),严禁跨机 |
| PP | Pipeline Parallelism | 按 Transformer Block 层切分(如前 16 层在 GPU 0,后 16 层在 GPU 1) | 跨卡激活值点对点发送,引入流水线气泡(Bubble);用 1F1B(One Forward One Backward)调度降低气泡 | 跨机普通网络 |
| ZeRO-1 | Zero Redundancy Optimizer | 分片优化器状态:各卡只存 | 通信量与标准 DDP 完全相同 | 任意分布式环境 |
| ZeRO-2 | ZeRO Stage 2 | 分片优化器状态 + 梯度:各卡只存 | 通信量与标准 DDP 完全相同 | 任意分布式环境 |
| ZeRO-3 / FSDP | ZeRO Stage 3 / Fully Sharded Data Parallel | 全分片:优化器、梯度、模型参数全部拆分;前向反向时临时 All-Gather 拉取参数,用完立刻释放 | 通信量增加约 50% | 大规模集群,彻底打破单卡显存墙 |
阶段四:checkpoint 与恢复
训练到一半断电怎么办?checkpoint 就是存档点——但它不能只存模型权重:
一份完整 checkpoint 至少包含:
- 模型权重:保存网络所有层的可学习参数张量,这是恢复计算图状态的基础;
- optimizer state——AdamW 的
、 是历史梯度的滑动平均,不恢复它们,resume 后的前几步更新方向会偏; - step 计数——学习率调度依赖它;
- config/data/tokenizer hash——恢复前先比对,不一致就拒绝 resume,防止把权重恢复到错误的配置上。
恢复成功的判定:恢复后下一步的 loss/step 与"没中断过"的轨迹在冻结容差内一致。
自回归生成闭环(Karpathy nanoGPT & Raschka 第4章实现)
训练时模型用 Teacher Forcing 并行计算,但实际推理/生成文本时,模型必须自己当自己的老师:单步生成下一个 token,把它拼接到输入末尾,再送入模型预测下下个词。
看懂这个经典的生成循环:
python
@torch.no_grad()
def generate(model, idx, max_new_tokens, temperature=1.0, top_k=None):
"""
idx: 当前上下文 token ids,形状 (B, T)
max_new_tokens: 最多新生成多少个 token
"""
for _ in range(max_new_tokens):
# 1. 如果上下文超过模型最大窗口 block_size,裁剪保留最近的窗口
idx_cond = idx if idx.size(1) <= model.block_size else idx[:, -model.block_size:]
# 2. 前向传播算出整个序列的 logits: (B, T, V)
logits = model(idx_cond)
# 3. 只取最后一步的 logits: (B, V) —— 我们只关心“接下来这一个词”
logits = logits[:, -1, :] / temperature
# 4. 可选:Top-K 截断(只保留概率最高的 K 个词,将其余词的 logit 设为 -inf)
if top_k is not None:
v, _ = torch.topk(logits, min(top_k, logits.size(-1)))
logits[logits < v[:, [-1]]] = -float('Inf')
# 4a. 可选:Top-P(nucleus)截断——先按概率从大到小排序,累加直到首次超过 P,
# 只保留这最小集合的词,其余设为 -inf。Top-P 比 Top-K 更适应概率分布的长尾。
# (本仓库 generate 函数未内置 top-p;此处只展示概念。)
# 5. Softmax 转化为概率分布并抽样
probs = F.softmax(logits, dim=-1)
idx_next = torch.multinomial(probs, num_samples=1) # (B, 1)
# 6. 将新抽到的 token 拼接到上下文末尾,进入下一轮
idx = torch.cat((idx, idx_next), dim=1)
return idx三条旋钮的职责边界(按操作顺序):
- temperature:先对整条 logits 向量做除法
。 把分布变尖锐(更确定), 把分布变平坦(更多意外)。它改变整个分布的形态。 - top-k:在 temperature 之后,只保留概率最高的
个词,其余概率归零。截断阈值随每次前向动态变化,适合去除明显的"噪声"候选。 - top-p(nucleus):在 temperature 之后,按概率从大到小累加,取最小的集合使其总概率 ≥
,其余截断。当分布是"几个强候选 + 大量近零"时,top-p 自动收缩集合大小,比固定 top-k 更灵活。 数值稳定的 softmax: python/llm_core/attention.py的softmax在指数化前先减去行最大值:x = x - np.max(x, axis=axis, keepdims=True)。这一步不改变概率分布(softmax 是平移不变的),但把指数值从这种溢出范围拉到安全范围。 python/llm_core/train_sample.py::sample_next同样把 logits 先 cast 到float64再做 softmax,进一步提升数值裕度。写自己的实现时不要省略减最大值这一步。
个人电脑实操:从零走完数据收集、清洗、标注到训练全流程
为了彻底掌握数据工程端到端闭环,本节使用纯 Python 标准库完整走通语料收集、清洗、切分到批次加载的大模型数据全生命周期:
1. 数据收集与读取(Data Collection)
无论是本地 Markdown 笔记、业务文档,还是抓取的文本,第一步都是将其读取为原始文本:
python
# 读取原始文本(例如一段技术文档或维基百科段落)
with open("corpus.txt", "r", encoding="utf-8") as f:
raw_text = f.read()2. 数据清洗与质量治理(Data Cleaning)
低质量语料会直接污染模型。前端工程师最熟悉的正则与字符过滤是此时最利落的工具:
python
import re
def clean_text(text: str) -> str:
# 1. 过滤不可见的特殊控制字符(保留换行与Tab)
text = re.sub(r'[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]', '', text)
# 2. 将连续的多个空格压缩为一个空格,统一换行符
text = re.sub(r' +', ' ', text)
text = re.sub(r'\n+', '\n', text)
return text.strip()
cleaned_text = clean_text(raw_text)3. 数据标注机制:自回归预训练的“免费标注”
与传统机器学习需要人工逐条打标签(Label)不同,自回归语言模型的预训练完全是自监督(Self-Supervised)的!
- 核心奥秘在于 Teacher Forcing 错位切分:模型要学习的任务永远是“根据前文预测下一个词”;
- 如果我们的上下文长度是
,那么取长度为 的连续字符切片: - 输入序列
:取前 64 个字符 slice[0:64]; - 目标标签
:取后 64 个字符 slice[1:65](恰好整体向后偏移 1 位)。
- 输入序列
- 每一个词天然就是它前一个词的“标注”!一行代码即可自动生成百万级标注样本:
python
def get_batch(data_tensor, block_size=64, batch_size=4):
# 随机生成 batch_size 个合法的起始位置索引
ix = torch.randint(len(data_tensor) - block_size, (batch_size,))
x = torch.stack([data_tensor[i : i + block_size] for i in ix])
y = torch.stack([data_tensor[i + 1 : i + block_size + 1] for i in ix])
return x, y4. 分词与防泄漏数据集划分
将文本字符去重构建词表,映射为整数 ID,并严格按时间/文档顺序切分训练集与验证集:
python
# 构建字符级词表(更先进的生产实现见第6章 minBPE)
chars = sorted(list(set(cleaned_text)))
vocab_size = len(chars)
stoi = {ch: i for i, ch in enumerate(chars)}
itos = {i: ch for i, ch in enumerate(chars)}
encode = lambda s: [stoi[c] for c in s]
decode = lambda l: ''.join([itos[i] for i in l])
data = torch.tensor(encode(cleaned_text), dtype=torch.long)
# 严格按 9:1 划分,绝不随机乱序(Random Shuffle),杜绝未来数据向验证集泄露
n = int(0.9 * len(data))
train_data = data[:n]
val_data = data[n:]5. 单文件端到端可运行精简脚本(普通笔记本 CPU 30 秒收敛)
将模型定义、数据载入、训练循环与文本生成全部收敛在一个不到 80 行的自包含脚本中,内存占用不到 100MB:
python
import torch
import torch.nn as nn
import torch.nn.functional as F
# 超参数配置(轻量化,专为普通 CPU 调优)
block_size = 32 # 上下文窗口长度
batch_size = 4 # 单批次样本数
n_embd = 64 # 特征隐层维度
n_head = 4 # 注意力头数
n_layer = 2 # Transformer Block 堆叠层数
learning_rate = 1e-3
max_iters = 200
# 极简 Transformer 架构组装
class Head(nn.Module):
def __init__(self, head_size):
super().__init__()
self.key = nn.Linear(n_embd, head_size, bias=False)
self.query = nn.Linear(n_embd, head_size, bias=False)
self.value = nn.Linear(n_embd, head_size, bias=False)
self.register_buffer('tril', torch.tril(torch.ones(block_size, block_size)))
def forward(self, x):
B, T, C = x.shape
k = self.key(x); q = self.query(x)
wei = q @ k.transpose(-2, -1) * (C ** -0.5)
wei = wei.masked_fill(self.tril[:T, :T] == 0, float('-inf'))
wei = F.softmax(wei, dim=-1)
v = self.value(x)
return wei @ v
class Block(nn.Module):
def __init__(self):
super().__init__()
self.sa = nn.ModuleList([Head(n_embd // n_head) for _ in range(n_head)])
self.proj = nn.Linear(n_embd, n_embd)
self.ffn = nn.Sequential(nn.Linear(n_embd, 4 * n_embd), nn.GELU(), nn.Linear(4 * n_embd, n_embd))
self.ln1 = nn.LayerNorm(n_embd)
self.ln2 = nn.LayerNorm(n_embd)
def forward(self, x):
sa_out = torch.cat([h(self.ln1(x)) for h in self.sa], dim=-1)
x = x + self.proj(sa_out) # Pre-LN 与残差连接
x = x + self.ffn(self.ln2(x))
return x
class MinimalGPT(nn.Module):
def __init__(self, vocab_size):
super().__init__()
self.tok_emb = nn.Embedding(vocab_size, n_embd)
self.pos_emb = 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)
def forward(self, idx, targets=None):
B, T = idx.shape
x = self.tok_emb(idx) + self.pos_emb(torch.arange(T, device=idx.device))
x = self.blocks(x)
logits = self.lm_head(self.ln_f(x))
loss = None
if targets is not None:
loss = F.cross_entropy(logits.view(-1, logits.size(-1)), targets.view(-1))
return logits, loss
# 启动训练
model = MinimalGPT(vocab_size)
optimizer = torch.optim.AdamW(model.parameters(), lr=learning_rate)
print("开始在个人电脑 CPU 上训练...")
for step in range(max_iters):
xb, yb = get_batch(train_data, block_size, batch_size)
logits, loss = model(xb, yb)
optimizer.zero_grad()
loss.backward()
# 工业标准:按全局 L2 范数做梯度裁剪(阈值 1.0),拦截偶发梯度尖峰,严格防止权重失控
torch.nn.utils.clip_grad_norm_(model.parameters(), max_norm=1.0)
optimizer.step()
if step % 50 == 0:
print(f"Step {step:03d} | Loss: {loss.item():.4f}")
# 文本生成验证
context = torch.zeros((1, 1), dtype=torch.long)
print("模型自回归生成预览:", decode(generate(model, context, max_new_tokens=50)[0].tolist()))架构师升维:从手搓模型内核到工业级 Agent 落地
为什么说一个不了解模型底层机制的工程师做不好 Agent 开发?手搓过 nanoGPT 后,你将获得前所未有的工程透视力:
- 自回归条件采样 vs 确定性代码的冲突: 业务系统期望确定性的 JSON 输出(如
{ "name": "get_weather", "city": "Beijing" })。但模型底层本质是每一步计算条件概率分布并抽样。一旦前一个 token 产生轻微偏差,根据链式概率法则,后续输出将发生自回归误差累积。因此,在构建 Agent 架构时,系统层需配备强类型的 Schema 校验拦截网(第 15 章)、失败重试机制,或在解码层使用针对语法状态机的 Logits 掩码(Strict JSON Mode)。 - Softmax 熵与 Agent 决策置信度: 在做 Agent 流程分支时,何时应该自主调用工具、何时向人类发起确认(Human-in-the-Loop, HITL),可由模型 Softmax 输出分布的**熵(Entropy)**量化评估。分布极度尖锐(熵接近 0,top-1 概率高)代表高确定性;分布平缓混乱时代表高不确定性,此时盲目直接执行高风险副作用工具容易引发系统故障。
- KV Cache 物理显存限制与上下文截断的原子性纪律: 在多轮对话 Agent 构建中,若直接采用线性追加上下文(
messages.append)的方式,序列长度每翻一倍,Attention 算力开销将呈平方级增长。更重要的是,上下文截断必须保持原子性:若破坏了<tool_call_id>与后续role: "tool"的配对完整性,下游模型端点将直接抛出 HTTP 400 校验异常。第 17 章的 Prompt 记忆架构正是基于此原则实现结构化安全截断。 - 归纳头(Induction Heads)与 Few-Shot Prompt 设计的物理本质: 在 Prompt 中提供高质量输入输出示例(Few-shot)能显著提升模型的工具调用准确率,其底层机制在于模型内部两层注意力构成的**归纳头电路(Induction Heads)**在物理层面上执行了模式检索与复制。理解这一机制后,编写的 Prompt 示例应具备高度的结构一致性,从而更稳定地激活归纳头。
延伸练习:把 GPT-2 官方权重加载到手写模型(外部练习,本章不实现)
重要:本仓库的 numpy
TinyGPT与 OpenAI GPT-2 之间存在架构差异,不能直接”灌入权重就等价”。
- 本仓库
python/llm_core/gpt.py的TinyGPT.forward使用 ReLU FFN(np.maximum(0, h @ W1 + b1))和单序列输入(T,);- GPT-2 使用 GELU FFN(SiLU 的近似形式);
- 本仓库没有 GPT-2 权重加载代码。
因此,手写模型与 GPT-2 官方 124M 权重不是数学等价的 drop-in 目标。要把官方权重映射到手写模型,需要先把模型架构对齐到 GPT-2 的精确规格(pre-LN、GELU、learned positional embeddings、GPT-2 专用的 LayerNorm eps、head count / dims)。
如果需要了解这个练习的完整步骤,Sebastian Raschka 的《从零构建大模型 (LLMs from Scratch)》第 5 章给出了字典键名映射的方法论——仅作为外部阅读指针,不代表本仓库实现了该流程,也不代表本仓库可以运行或验证它。
实际工作(全部在仓库外部完成,本仓库无证据记录):
- 用 PyTorch 重写一个与 GPT-2 架构精确对齐的模型(GELU、pre-LN、correct head dims)。
- GPT-2 发布的 TensorFlow checkpoint 中 conv1D 权重相对于
nn.Linear是转置的——每层需要手动转置再加载。 - GPT-2 使用近似的 tanh GELU,不是精确 erf 形式;加载时要保证激活函数一致。
- 加载后用标准 prompt 做生成质量对比。
本仓库不执行、不验证以上任何步骤,也不声称能够复现 Raschka 书中的结果。
交互观察
交互:Softmax + Temperature
把 logits 看成「未归一化分数」。Temperature 越高分布越平,越低越尖。
LOCAL TRAINING TRACE · NO NETWORK
把一次 TinyGPT 更新拆成可回放事件
batch取固定 seed 的 shifted input/target window
B=4 · T=16当前是教学事件图,不执行 Python、不读取文件,也不代表云训练或学习者掌握。
把 logits 视为生成最后一步的输出,比较温度与 top-k 的职责;真正的生成还必须来自受控训练、checkpoint 和 token 级对拍。
阶段五:动手实验
目标:TinyGPT 从零训练跑通,checkpoint 恢复可验证——loss 单调下降、参数更新范数为正、按相同 config_hash 恢复后指标连续。
环境准备
bash
cd <仓库根>
export PYTHONPATH="$PWD/python"步骤
用固定 byte 数据集做 one-batch overfit,记录初始 loss、最终 loss 和参数范数变化。
加入周期 checkpoint、恢复和 deterministic smoke;恢复后下一步的 loss/step 应与不中断轨迹一致到冻结容差。
跑训练测试:
bashpython -m pip install -r requirements.txt python -m pytest \ python/tests/test_trainable_tinygpt.py \ python/tests/test_checkpoint_recovery.py -q或用可复用 CLI 做真实
backward → AdamW → checkpoint(合成 byte 语料,不联网、不读凭据):bash# smoke:一次快速训练 + 落盘 python -m llm_train.cli smoke \ --config configs/tiny-cpu.json --output-dir .artifacts/llm_train # 查看 checkpoint 元数据 python -m llm_train.cli inspect-checkpoint .artifacts/llm_train/tinygpt.pt # 完整训练并落盘 tinygpt.pt python -m llm_train.cli train \ --config configs/tiny-cpu.json --output-dir .artifacts/llm_train # 从刚写出的 tinygpt.pt 继续训练 python -m llm_train.cli resume \ --resume-from .artifacts/llm_train/tinygpt.pttext判定信号: artifacts 里出现 .artifacts/llm_train/tinygpt.pt final_loss < initial_loss 且 val loss 同步下降 parameter_update_norm > 0(参数确实学到了东西) resume 前后 config_hash 一致,loss 曲线无跳变CLI 输出的
status=ok只表示这次本地训练和 checkpoint 结构通过;不代表云端资格或 clean-room 掌握已通过。缺少 PyTorch 的环境应先完善本地依赖安装,跳过测试不能作为训练成功的验证证据。
概念图:训练 → checkpoint → 恢复闭环
故障注入与预期信号
| 注入 | 预期失败信号 | 修复后证据 |
|---|---|---|
| 只执行 forward 不调用 optimizer | 参数差异为零,loss 不下降 | 参数更新范数大于零且 loss 下降 |
| target 未 shift | 模型学会复制当前位置 | shifted batch contract 失败并修复 |
| 梯度裁剪或 NaN gate 缺失 | 训练出现非有限值仍退出 0 | NaN 使命令失败并保存可诊断结果 |
| 忘记 optimizer zero_grad | 梯度累积导致 loss 抖动、指标不收敛 | 每一步 step 后立即 zero_grad |
| 训练集与验证集切分泄漏 | val loss 异常低,看似过拟合其实数据穿帮 | 严格按 hash 切分并固化随机状态 |
| 近重复样本未去重直接切分 | 近重复分居 train/val 两侧,val loss 虚低、模型偏向记忆 | 切分前先做规范化文本去重,并把重复率记录进 evidence |
| 恢复时只 load 权重不 load optimizer state | 恢复后 loss 跳变、AdamW 动量丢失 | 同时 load 模型和 optimizer 状态 |
| config_hash 与 checkpoint 不一致 | 恢复到错误配置、shape 对不上 | 恢复前先比对 hash,不一致则拒绝 resume |
本章验收
- 自查清单全部能答"是":
- 不看资料,完成这五道闭卷解释题:
- 从
(B, T)的 input/target 写出 teacher forcing 的 shift 和(B, T, V)logits 的 CE 对齐方式;解释 one-batch overfit 为什么是必要诊断而不是泛化结论。 - 从 CE loss 的定义
推导出 。这个平均取被监督位置;每个位置都有真实下一 token 时,分母才是 。再解释 PP=2 意味着模型平均每个 token 处于 2 个等概率选择之间,即每个 token 携带约 1 bit 的信息量(模型的不确定性等价于扔一枚公平硬币)。 - 说明梯度裁剪和 learning rate schedule 分别解决什么问题:梯度裁剪在单步 rescale 整个梯度向量,lr schedule 随时间递减更新步长,两者互补但不互相替代。
- 对比 data parallel、tensor parallel、pipeline parallel 与 ZeRO:各自分摊什么、引入什么通信/显存代价。Chinchilla 拟合的是预训练损失随参数量、token 数和算力的变化:算力预算固定时,参数和 token 一起放大。一次 tiny run 的 loss,或某一个下游指标上升,都不是这条曲线的读数。
- 一条长度为
的训练行怎样变成损失:哪些位置的 为 1;多篇文档拼进同一窗时,标签、注意力掩码、位置下标是否各自跨过接缝;来源配比、质量过滤和 MinHash 各改池子的哪一件事; 个 micro-batch 怎样合成全体被监督 token 的平均。Chinchilla 的 累加的是这些被监督 token,不是优化器步数。
- 通过条件复核:真实 backward/update、loss 下降、无 NaN、checkpoint 恢复和配置 hash 都有记录。缺依赖、跳过测试、无参数更新或无恢复对拍时,本章保持
gate。
论文与延伸
- Scaling Laws for Neural Language Models(Kaplan 等,2020)
- Training Compute-Optimal Large Language Models(Hoffmann 等,2022)
- Language Models are Few-Shot Learners(Brown 等,2020):规模化收益的基线,随后被 Chinchilla 修正"只放大参数不够"。
- 选读:GPT-2 的 Language Models are Unsupervised Multitask Learners。
- 索引:必读论文
实验与参考
前端/Agent 迁移
训练循环是带遥测的事件循环:batch → forward → loss → backward → update → checkpoint。Agent 恢复同样不能只保存最终输出——要保存可重放的输入、状态版本、步数和副作用前后的 checkpoint。DP/TP/PP/ZeRO 在本章讨论各自分摊的职责和引入的通信/显存代价,不启动大规模训练。
资源 / 成本 / 隐私
规范路径要求 PyTorch,优先 Apple Silicon MPS/CPU;本地训练预计 gross cost 为 0,不需要云。数据、checkpoint metadata 和 trace 只使用合成 fixture;云训练另走第21章的资格与授权门。
Evidence
仓库当前机器证据(只读快照)
evidence/module-manifest-v1.json 中 08.evidence 指向当前文件:evidence/08-runtime-v1.json。这是当前 checkout 的脱敏机器运行记录,只覆盖该 JSON 记录的命令、指标、产物和已知失败;它不是学习者提交,也不能推出学习者已完成本章。其 Python 3.11 运行范围、当前工作树边界和云端未核验状态以 JSON 的 known_failures 为准。
学习者提交模板(待填写,不是当前机器证据)
复制下面模板并填写自己的真实运行结果。所有 <...> 都是未填写状态;actual 和 artifacts 尤其不能被当作已运行或已通过。artifacts 必须替换为本次提交中真实存在的仓库相对路径。
yaml
schema: learn-llm.evidence.v1
module: 08-tinygpt
commit: <learner-commit-sha>
verified_at: <iso-date>
environment: <sanitized-python-device-backend>
seed: 6
commands:
- python -m pip install -r requirements.txt
- PYTHONPATH=python python -m pytest python/tests/test_trainable_tinygpt.py python/tests/test_checkpoint_recovery.py -q
metrics:
- name: parameter_update_norm
expected: '>0'
actual: <recorded-value>
- name: final_loss_minus_initial_loss
expected: '<0'
actual: <recorded-value>
- name: checkpoint_resume_max_error
expected: <versioned-tolerance>
actual: <recorded-value>
artifacts:
- <learner-repo-relative-artifact-path>
cost:
gross_usd: 0
credit_usd: 0
licenses:
- source: <source>
version: <version>
license: <license>
attribution: <attribution>
redistribution: <redistribution>
known_failures:
- <sanitized-failure-or-none>下一步
进入 第10章 · 现代推理、KV Cache、RMSNorm/SwiGLU、量化与 PagedKV:给生成路径加上 RoPE、KV Cache 和量化对照。