Skip to content

第9章 · TinyGPT 预训练、恢复与生成 ​

前置要求:掌握 第5章 的交叉熵损失、第7章 的优化器更新与训练稳定性,以及 第8章 的 Transformer Block 架构。

前七章造好了全部零件:tokenizer、embedding、Transformer Block、交叉熵、AdamW。本章把它们装成一台真正能训练的模型——TinyGPT,并让它在你的笔记本 CPU 上真实训练、真实保存、真实恢复。这是全书第一个"端到端跑通"的里程碑。

本章目标 ​

学完后你能做到:

  1. 组装 token/position embedding、Transformer blocks、LM head、batch loader 和训练循环。
  2. 先过拟合一个 batch 做链路诊断,再在固定微型语料上训练、采样、保存和恢复。
  3. 解释 learning rate、micro-batch 与梯度累积、一次更新看见的被监督 token 数、context、depth、width、梯度裁剪和 checkpoint 各自的作用;分布式训练只做职责和代价讨论。
  4. 说明固定算力预算下参数量与 token 数如何一起放大,以及这条关系预测的是预训练损失。

阶段一:把零件装成 TinyGPT ​

完整模型的数据流,每一层你都认识:

TinyGPT 端到端架构与张量流转全景图:从 Token 输入、因果自注意力、前馈网络到 Teacher Forcing 交叉熵损失的张量变换 (B, T, D) → (B, T, V)

TIP

初学者心智模型:Transformer 是如何把积木拼成能说话的模型的?

许多刚接触大模型的工程师面对各种 shape 变换感到眼花缭乱。建立起下面这套三阶段心智模型,整个架构将一目了然:

  1. 输入离散符号连续化(翻译阶段):计算机不懂文字,只懂数字。词表里的离散整数通过 Token Embedding 矩阵映射到 D 维连续几何空间;再加上记录顺序的 Position Embedding,完成输入编码 (B,T)→(B,T,D)。
  2. 深层因果关系抽取(思考阶段):堆叠的 N 个 Decoder Block 是模型的大脑。每个 Block 里包含两套核心引擎:
    • 自注意力机制(Self-Attention):负责跨词横向特征融合(“谁跟谁有关系”,如主语与动词照应),因果掩码杜绝偷看未来;
    • 前馈网络(FFN / MLP):负责单词纵向非线性概念抽取,充当模型的知识记忆库;
    • 残差连接与 LayerNorm:架设高速梯度直连公路,确保数十层深网在反向传播时信号不衰减、数值不溢出。
  3. 隐层表征映射回词表(说话阶段):经过最终 LayerNorm 稳定尺度后,LM Head 矩阵将隐层维度 D 逆向投影回巨大的词表大小 V,输出每个候选词的未归一化对数概率(Logits)(B,T,V)。训练时通过错位一格的 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 metadatamapping保存 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 都成立),又足够大让不同参数在训练初期的梯度信号不坍缩为零。残差分支的输出投影有时会用更大的缩放(如乘以 1/2N,N = block 数)来进一步稳定深层网络的残差流方差,但 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 所有位置的平均:

L(θ)=−1BT∑b,tlog⁡pθ(xb,t+1∣xb,≤t)

target 不 shift 会怎样? 模型学到的任务将退化为“单纯复制当前位置的输入”——训练 loss 虽然能够降得极低,但模型完全丧失了自回归预测下一个词的泛化能力。这是因果模型实现中最易犯的隐蔽错误,也是“未来不可见”契约测试存在的根本原因。

哪些位置进入这个平均 ​

上面的 1BT 默认这一行的每个位置都有真实的下一个 token。短序列补到长度 T 时,补上去的 pad 没有这个目标。pad 若占着词表里的一个 id,把它算进和式,模型就在学习下一步发出 pad。这些项乘 0、分母仍写 BT 时,真实 token 的梯度会跟着 pad 比例变小:分母里有一截没有产生损失的位置。和「每个被监督 token 的平均负对数概率」对齐的分母,是 mb,t=1 的个数。m=0 覆盖 pad,也覆盖这一行里已经没有真实下一 token 的那一格。

多篇短文档拼进同一条长度 T 的行时,省掉的是 pad 上的空转,多出来的是文档接缝。接缝上有三处彼此独立的开关:

  • 标签:文档 A 的最后一个 token,目标默认是文档 B 的第一个 token。这个位置的 m 置 0,或把目标写成单独的分隔符,和式就不再要求 A 预测 B 的开头。
  • 注意力:整行共用一个下三角掩码时,B 里的 token 能读到 A。按文档把掩码分成互不可见的块之后,B 的上下文只来自 B 内部。只插入分隔符,训练的是见到分隔符之后换一篇;注意力仍然跨过这条缝。
  • 位置下标:本章的位置表用窗内下标取向量。下标顺着整窗增加时,B 用到的是 A 末尾之后的位置向量。每篇文档的下标从 0 再计,B 才回到从开头读取时的那一组位置。第10章用旋转角代替这张表,下标仍是同一个量。

补齐和拼接解决的是同一件事:batch 里每一行都是长度 T。补齐把算力花在 m=0 的位置上;拼接把窗口填满真实 token,再分别写明上面三处开关。第11章的 assistant 掩码是加在同一和式上的第二个条件:预训练的 m 问有没有真实的下一 token,SFT 的 m 再问这个 token 是不是 assistant。padding 在两处都是 0。

这里的接缝在同一条训练行内部。紧接着的去重与切分也用到文档边界,那是把整篇文档放进训练集或验证集。两处边界不是同一件事。

语料先治理,再切分:去重与污染 ​

真实语料里充满近重复样本:同一篇文档的多个转载、模板化生成的相似页面、同一句话的细微改写。它们对训练有两层实际影响。其一,重复样本会让模型更倾向于记忆(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章的五行骨架,外加两件安全装备:

梯度裁剪:g←g⋅min(1,c/∥g∥)。当梯度范数超过阈值 c 时,不改变方向、只等比缩小——比逐分量截断合理,因为保持了各维度间的相对比例。

前端类比:像 CSS 的 max-height: 100vh——内容超出时不是砍掉,而是等比缩放让整体可见。

学习率调度:用随时间变化的步长控制更新幅度。梯度裁剪控制的是梯度本身的量级(一次性 rescale 整个梯度向量);学习率调度控制的是参数更新的步长(随时间递减),两者互补但不互相替代。

实际训练中常见的两步式调度:

  • warmup(前 Twarmup 步):学习率从 0 线性升到 ηmax:ηt=ηmax⋅tTwarmup
  • cosine decay(warmup 之后):学习率从 ηmax 平滑降到 ηmin:ηt=ηmin+0.5⋅(ηmax−ηmin)⋅(1+cos⁡(π⋅t−TwarmupT−Twarmup))

为什么 warmup 是必需的:AdamW 的动量估计(m^,v^)在训练最初几步几乎为零,偏差校正尚未稳定。此时如果步长过大,参数会被推到一个损失曲面上很差的区域,后续很难恢复。把前几百步的学习率从零慢慢放大,相当于让优化器先"站稳脚跟"再加速。第一个诊断:one-batch overfit。 正式训练前,先让模型在同一个 batch 上反复训练。如果 loss 不能降到接近 0,说明训练链路有 bug(shift 错了、梯度没回传、优化器没生效)——这跟泛化能力无关,是"电路是否接通"的检查。这个测试通过了,才谈得上在完整语料上训练。

看懂这个由 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 embeddingV⋅D65×64=4,160
position embeddingTmax⋅D64×64=4,096
每 block 的 attention(Q/K/V/O 权重 + 偏置)4D2+4D4×642+256=16,640
每 block 的 FFN(D→4D 与 4D→D 两层,权重 + 偏置)8D2+5D8×642+320=33,088
每 block 的两个 LayerNorm(各 γ,β)2×2D256
每 block 合计—49,984
N 个 blocksN×per-block2×49,984=99,968
最终 LayerNorm2D128
LM head(tied embedding)00
总计108,352

tied embedding:上表的 LM head 记 0,因为 TinyGPTModel 把输出投影直接复用 token embedding 矩阵,named_parameters() 里没有独立的 lm_head。若解除绑定,要额外加 D⋅V=4,160 个参数。第10章会专门对拍 tied 与 untied 的行为差异。

注意:LayerNorm 的可学习参数是 γ 与 β 两项,所以每个 LayerNorm 记 2D;FFN 的偏置是 4D+D=5D(中间层 4D,输出层 D),不要漏算。

FLOPs 估算(每个 token 每 batch 元素的前向传播浮点运算量,忽略 attention mask 与 softmax 的常数因子):

FLOPsforward≈2⋅Nparams⋅T

对本配置:2×108,352×64≈1.39×107 FLOPs 每 forward。反向约为 forward 的 2 倍,因此一次完整训练 step 约 6×Nparams⋅T FLOPs——这是估算硬件需求和比较模型效率时的基准线。把它换成 GPT-2 规模(Nparams≈1.24×108、T=1024):单次 forward 约 2.5×1011 FLOPs,一次训练 step 约 7.7×1011 FLOPs。

大模型训练显存开销解构公式(16Φ 规则) ​

评估大模型训练的显存开销时,不能仅按模型权重本身估算。例如一个 7B 参数的模型以 16-bit 存放占用 14GB 存储空间,若以此作为训练显存预算,运行时将因显存不足触发 OOM(Out of Memory)异常。

这是因为在全参数训练中,模型的静态训练显存由三大部分共同组成: 设模型参数量为 Φ(单位:个):

  1. 模型权重(Weights):使用 FP16 / BF16 存放,每个参数占 2 字节 ⟹2Φ 字节;
  2. 反向梯度(Gradients):反向传播计算出的参数梯度同样为 FP16 / BF16,每个参数占 2 字节 ⟹2Φ 字节;
  3. AdamW 优化器状态(Optimizer States): 为了防止参数更新时微小步长被浮点舍入吞掉,AdamW 必须维护:
    • FP32 主权重副本:每个参数 4 字节;
    • 一阶动量矩 m(FP32):每个参数 4 字节;
    • 二阶方差矩 v(FP32):每个参数 4 字节;
    • 优化器状态合计:4+4+4=12Φ 字节!
静态显存总计=2Φ(权重)+2Φ(梯度)+12Φ(AdamW 状态)=16Φ 字节
  • 对于 7B 模型(Φ=7×109):静态显存=7×16 GB=112 GB即使单卡 80GB 的顶级 A100/H100 显卡,光放静态参数就当场撑爆,更遑论放入上下文!
  • 此外还有随批大小 B 和上下文长度 T 剧增的动态激活值显存(Activation Memory)。为了降低激活值开销,工业界采用激活重计算(Activation Checkpointing / Gradient Checkpointing):前向传播时不保存中间层的激活值,仅在反向传播计算到该层时重新前向计算一次。用约 25%~30% 的前向重算算力,换取激活显存暴降 60%~70%!

一次参数更新看见多少 token ​

本节前面的循环在每次 backward 之后立刻 step。激活显存随 micro-batch 的 B 与 T 增长。单次前向放不下更大的 B 时,把 K 个 micro-batch 的梯度加起来,再做一次 step。每个 micro-batch 的损失若已经是该批内部被监督 token 的平均,全体 token 的平均是按各批被监督个数 ni 加权的:

L=∑i=1KniSLi,S=∑i=1Kni

各批 ni 相同的时候,权重就是 1/K。这时若直接把 K 次 backward 相加,得到的是 K 份平均的和,更新步长被放大到约 K 倍。各批 pad 比例不同时,只除以 K 得到的是「每批平均值的平均」,token 少的批和 token 多的批被当成同样重要。数据并行的 All-Reduce 是卡与卡之间再取一次平均;各卡被监督 token 数相同,它才等于全体 token 的平均。这个平均不代替上面的 ni/S。

一次更新里被监督的 token 数就是 S;每卡数据不同时,再把各卡的 S 相加。故障表里的「忘记 zero_grad」是另一种相加:上一步的梯度还留在参数上。有意累积会在这次 step 之后清零,下一次从零开始加。

静态的 16Φ 不随 K 增大。K 改变的是这次平均覆盖了多少 token。学习率仍是加在这份平均梯度上的步长。

算力预算:参数量与 token 数一起放大 ​

Hoffmann 等(2022)把要拟合的量写成预训练损失 L(N,D),在 FLOPs 预算 C 下选择参数量 N 和 token 数 D。式中的 D 是整个训练里被监督 token 的累计:只数 m=1 的位置,一步参数更新可以含多个 micro-batch,pad 和被掩掉的位置不计入。它不是 optimizer.step 的次数。文中对 Kaplan 等(2020)的转述是:算力增加到 10 倍时,模型大约增大到 5.5 倍,训练 token 大约增加到 1.8 倍。Chinchilla 的结论是 N 与 D 等比例放大:模型大小翻一倍,训练 token 也翻一倍。他们用与 Gopher 相同的计算预算训练了 70B 参数的 Chinchilla,数据量是 Gopher 的 4 倍。下游任务分数是训完之后另测的。一次 tiny run 的 loss,或某一个榜单分数上升,都不是这条损失曲线的读数。

分布式并行四大范式全景(DP, TP, PP, ZeRO-1/2/3 / FSDP) ​

当单张 GPU 彻底装不下训练状态时,必须拆解并分摊到成百上千张卡上:

并行策略英文全称核心分摊逻辑通信通信瓶颈适用物理拓扑
DDPDistributed Data Parallel每卡复制一份完整模型参数与优化器;按 Batch 切分数据,反向时 All-Reduce 同步梯度通信量与模型参数量成正比任意集群(单卡必须装得下 16Φ)
TPTensor Parallelism (Megatron-LM)将单个 Linear 权重矩阵按行或按列切开(如 Attention QKV 与 MLP),卡间协同算一次矩阵乘极高频的前向/反向通信(All-Reduce / All-Gather)仅限机内超高速 NVLink(>400GB/s),严禁跨机
PPPipeline Parallelism按 Transformer Block 层切分(如前 16 层在 GPU 0,后 16 层在 GPU 1)跨卡激活值点对点发送,引入流水线气泡(Bubble);用 1F1B(One Forward One Backward)调度降低气泡跨机普通网络
ZeRO-1Zero Redundancy Optimizer分片优化器状态:各卡只存 1/N 的 AdamW 状态通信量与标准 DDP 完全相同任意分布式环境
ZeRO-2ZeRO Stage 2分片优化器状态 + 梯度:各卡只存 1/N 的优化器状态与梯度通信量与标准 DDP 完全相同任意分布式环境
ZeRO-3 / FSDPZeRO Stage 3 / Fully Sharded Data Parallel全分片:优化器、梯度、模型参数全部拆分;前向反向时临时 All-Gather 拉取参数,用完立刻释放通信量增加约 50%大规模集群,彻底打破单卡显存墙

阶段四:checkpoint 与恢复 ​

训练到一半断电怎么办?checkpoint 就是存档点——但它不能只存模型权重:

一份完整 checkpoint 至少包含:

  • 模型权重:保存网络所有层的可学习参数张量,这是恢复计算图状态的基础;
  • optimizer state——AdamW 的 m^、v^ 是历史梯度的滑动平均,不恢复它们,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 向量做除法 logits/T。T<1 把分布变尖锐(更确定),T>1 把分布变平坦(更多意外)。它改变整个分布的形态。
  • top-k:在 temperature 之后,只保留概率最高的 k 个词,其余概率归零。截断阈值随每次前向动态变化,适合去除明显的"噪声"候选。
  • top-p(nucleus):在 temperature 之后,按概率从大到小累加,取最小的集合使其总概率 ≥ p,其余截断。当分布是"几个强候选 + 大量近零"时,top-p 自动收缩集合大小,比固定 top-k 更灵活。 数值稳定的 softmax:python/llm_core/attention.py 的 softmax 在指数化前先减去行最大值:x = x - np.max(x, axis=axis, keepdims=True)。这一步不改变概率分布(softmax 是平移不变的),但把指数值从 exp⁡(1000) 这种溢出范围拉到安全范围。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 错位切分:模型要学习的任务永远是“根据前文预测下一个词”;
  • 如果我们的上下文长度是 T=64,那么取长度为 65 的连续字符切片:
    • 输入序列 X:取前 64 个字符 slice[0:64];
    • 目标标签 Y:取后 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, y

4. 分词与防泄漏数据集划分 ​

将文本字符去重构建词表,映射为整数 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 后,你将获得前所未有的工程透视力:

  1. 自回归条件采样 vs 确定性代码的冲突: 业务系统期望确定性的 JSON 输出(如 { "name": "get_weather", "city": "Beijing" })。但模型底层本质是每一步计算条件概率分布并抽样。一旦前一个 token 产生轻微偏差,根据链式概率法则,后续输出将发生自回归误差累积。因此,在构建 Agent 架构时,系统层需配备强类型的 Schema 校验拦截网(第 15 章)、失败重试机制,或在解码层使用针对语法状态机的 Logits 掩码(Strict JSON Mode)。
  2. Softmax 熵与 Agent 决策置信度: 在做 Agent 流程分支时,何时应该自主调用工具、何时向人类发起确认(Human-in-the-Loop, HITL),可由模型 Softmax 输出分布的**熵(Entropy)**量化评估。分布极度尖锐(熵接近 0,top-1 概率高)代表高确定性;分布平缓混乱时代表高不确定性,此时盲目直接执行高风险副作用工具容易引发系统故障。
  3. KV Cache 物理显存限制与上下文截断的原子性纪律: 在多轮对话 Agent 构建中,若直接采用线性追加上下文(messages.append)的方式,序列长度每翻一倍,Attention 算力开销将呈平方级增长。更重要的是,上下文截断必须保持原子性:若破坏了 <tool_call_id> 与后续 role: "tool" 的配对完整性,下游模型端点将直接抛出 HTTP 400 校验异常。第 17 章的 Prompt 记忆架构正是基于此原则实现结构化安全截断。
  4. 归纳头(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 章给出了字典键名映射的方法论——仅作为外部阅读指针,不代表本仓库实现了该流程,也不代表本仓库可以运行或验证它。

实际工作(全部在仓库外部完成,本仓库无证据记录):

  1. 用 PyTorch 重写一个与 GPT-2 架构精确对齐的模型(GELU、pre-LN、correct head dims)。
  2. GPT-2 发布的 TensorFlow checkpoint 中 conv1D 权重相对于 nn.Linear 是转置的——每层需要手动转置再加载。
  3. GPT-2 使用近似的 tanh GELU,不是精确 erf 形式;加载时要保证激活函数一致。
  4. 加载后用标准 prompt 做生成质量对比。

本仓库不执行、不验证以上任何步骤,也不声称能够复现 Raschka 书中的结果。

交互观察 ​

交互:Softmax + Temperature

把 logits 看成「未归一化分数」。Temperature 越高分布越平,越低越尖。

tok0
8.5%
tok1
23.2%
tok2
5.2%
tok3
63.1%

LOCAL TRAINING TRACE · NO NETWORK

把一次 TinyGPT 更新拆成可回放事件

0%
batch取固定 seed 的 shifted input/target windowB=4 · T=16

当前是教学事件图,不执行 Python、不读取文件,也不代表云训练或学习者掌握。

把 logits 视为生成最后一步的输出,比较温度与 top-k 的职责;真正的生成还必须来自受控训练、checkpoint 和 token 级对拍。

阶段五:动手实验 ​

目标:TinyGPT 从零训练跑通,checkpoint 恢复可验证——loss 单调下降、参数更新范数为正、按相同 config_hash 恢复后指标连续。

环境准备 ​

bash
cd <仓库根>
export PYTHONPATH="$PWD/python"

步骤 ​

  1. 用固定 byte 数据集做 one-batch overfit,记录初始 loss、最终 loss 和参数范数变化。

  2. 加入周期 checkpoint、恢复和 deterministic smoke;恢复后下一步的 loss/step 应与不中断轨迹一致到冻结容差。

  3. 跑训练测试:

    bash
    python -m pip install -r requirements.txt
    python -m pytest \
      python/tests/test_trainable_tinygpt.py \
      python/tests/test_checkpoint_recovery.py -q
  4. 或用可复用 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.pt
    text
    判定信号:
      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 缺失训练出现非有限值仍退出 0NaN 使命令失败并保存可诊断结果
忘记 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

本章验收 ​

  1. 自查清单全部能答"是":
  1. 不看资料,完成这五道闭卷解释题:
  • 从 (B, T) 的 input/target 写出 teacher forcing 的 shift 和 (B, T, V) logits 的 CE 对齐方式;解释 one-batch overfit 为什么是必要诊断而不是泛化结论。
  • 从 CE loss 的定义 L=−1T∑tlog⁡p(xt∣x<t) 推导出 Perplexity=exp⁡(mean NLL)。这个平均取被监督位置;每个位置都有真实下一 token 时,分母才是 T。再解释 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,或某一个下游指标上升,都不是这条曲线的读数。
  • 一条长度为 T 的训练行怎样变成损失:哪些位置的 m 为 1;多篇文档拼进同一窗时,标签、注意力掩码、位置下标是否各自跨过接缝;来源配比、质量过滤和 MinHash 各改池子的哪一件事;K 个 micro-batch 怎样合成全体被监督 token 的平均。Chinchilla 的 D 累加的是这些被监督 token,不是优化器步数。
  1. 通过条件复核:真实 backward/update、loss 下降、无 NaN、checkpoint 恢复和配置 hash 都有记录。缺依赖、跳过测试、无参数更新或无恢复对拍时,本章保持 gate。

论文与延伸 ​

实验与参考 ​

前端/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 和量化对照。

私有学习站 · 原理从零构建 · 勿提交个人隐私或密钥