RLHF Book · 大模型后训练  /  Nathan Lambert
HOMEWORK

配套作业

六个可以在一张消费级显卡上跑完的后训练实验。目的不是训出好模型,是亲眼看到书里描述的那些现象——以及那些书里没写、只有真跑一遍才会撞上的坑。

来源:第 4、5、6、8、9、12 章末尾的 Suggested Experiments 参考实现:rlhf-book/code 实测记录:homework/RESULTS.md

0. 这套作业是从哪来的

《RLHF Book》原本是没有习题的。它最初是一本纯文本的技术手册,讲清楚 Bradley-Terry 怎么推、PPO 的 clip 在语言模型上到底裁掉了什么、DPO 的闭式解怎么来的。习题是后来才加进去的:作者在书的配套仓库里维护了一个 code/ 目录,里面是一组刻意写得很小、很可读的参考实现(SFT、奖励模型、策略梯度、直接对齐、拒绝采样、自蒸馏),然后回到正文,在第 4、5、6、8、9、12 章的末尾各补了一节 Suggested Experiments,把「读到这里你应该去跑什么」写死成具体命令。

这个顺序值得注意:不是先设计习题再配代码,而是先有一套能跑的最小实现,再从中挑出「跑完能看见某个书里讲过的现象」的那几个,写成练习。所以这些练习有一个共同特征——它们的验收标准不是最终指标,而是某个可观察的现象。SFT 那一题要你看的是「base 模型从胡乱续写变成答完就停」,不是 loss 降到多少;策略梯度那一题要你看的是「同一个 prompt 的一组 rollout 里有没有对错混杂」,不是奖励曲线的绝对高度;拒绝采样那一题要你把每个「用奖励模型选样」的配置和它对应的「随机选样」对照组放一起读,而不是看单个数字好不好看。

这六组作业与本站章节的对应

本站的这三组作业页面(以及后续几组)在上游练习的基础上多做了一件事:把它们在一台真实的、显存还被别的进程占着的消费级机器上跑通,并把跑出来的数字原样记下来。记录在仓库里的 homework/RESULTS.md,作业页面里出现的每一个实验数字都出自那里。这件事之所以有意义,是因为上游的默认配置假设你有一台 DGX Spark(约 120 GiB 统一内存),而绝大多数读者手上只有一张 16 GB 甚至 8 GB 的卡——中间要改哪些东西、改完还能不能看到同一个现象,是练习本身没写、但你一定会遇到的部分。

关于「实测」二字的边界

下面所有标了「实测」的数字,都来自本机的一次实际运行,样本量小、只跑了一个种子。它们足以说明某个现象存在(比如 SFT 的 loss 和生成质量脱钩、DPO 对学习率极其敏感),但不足以支持任何定量结论,更不能用来推翻文献。RLHF 是一个复现性很差的领域,这一点在首页和附录 C 里反复强调过。六组作业里有三组在本次会话中没有运行,页面上会明确标出,不会给任何编造的数字。

1. 环境准备

上游代码用 uv 管依赖,要求 Python 3.12+。标准流程只有两条命令:

cd _src/code/
uv sync

Ubuntu / Debian 上如果 uv sync 在编译原生依赖时挂掉,先补一下构建工具:sudo apt install -y build-essential python3-dev。Flash Attention 默认不装(uv sync --extra flash 才装),因为它对硬件和 CUDA 版本挑剔;不装也完全能跑,代码会自动退回 PyTorch 的 SDPA 实现。在 16 GB 级别的小模型实验上,这点速度差异不值得为之折腾编译。

跑之前先做一次三行自检

uv sync 成功、torch.cuda.is_available() 返回 True,并不代表你的 GPU 真的能跑。下面这段是所有实验开始前唯一值得花 10 秒钟做的事:

import torch
print(torch.__version__)                        # 版本 + CUDA 后缀
print(torch.cuda.get_device_capability(0))      # 你的卡的算力,如 (12, 0)
print(torch.cuda.get_arch_list())               # 这个 torch 轮子编译进了哪些架构

关键是把后两行对照着读:如果 get_device_capability(0) 返回 (12, 0),而 get_arch_list() 里没有 sm_120,那么这套环境在你这张卡上是废的,只是它要等到第一次真正启动 CUDA kernel 时才会告诉你。

实测踩到的坑:cu126 轮子跑不了 RTX 50 系

上游 code/pyproject.toml 给 x86_64 Linux 钉的是 cu126 轮子:

torch = [{ index = "pytorch-cu126", marker = "platform_system == 'Linux' and platform_machine == 'x86_64'" }]

uv sync 按这条规则装出来的是 torch 2.13.0+cu126,它的 torch.cuda.get_arch_list() 是:

['sm_50', 'sm_60', 'sm_70', 'sm_75', 'sm_80', 'sm_86', 'sm_90']

不含 sm_120。而本机的 RTX 5080 是 Blackwell 架构,compute capability 为 12.0。于是运行时报:

NVIDIA GeForce RTX 5080 with CUDA capability sm_120 is not compatible with the current PyTorch installation.
torch.AcceleratorError: CUDA error: no kernel image is available for execution on the device
这个坑最坏的地方:is_available() 会骗你

实测确认:在这种「驱动正常、卡也认得出来、只是没编译对应架构的 kernel」的状态下,torch.cuda.is_available() 返回 True,torch.cuda.device_count() 也正常。.to(device) 甚至能成功——因为搬运显存不需要计算 kernel。只有真正做第一次矩阵乘法时才会崩。

结果就是:你以为环境没问题,模型加载、数据处理、tokenizer 全跑完,直到第一个 forward 才炸。定位方法只有一条——比对 get_device_capability(0) 与 get_arch_list(),不要相信 is_available()。

修复:强制重装 cu128 轮子

uv pip install --index-url https://download.pytorch.org/whl/cu128 --reinstall-package torch torch

装完是 torch 2.11.0+cu128,get_arch_list() 变成:

['sm_75', 'sm_80', 'sm_86', 'sm_90', 'sm_100', 'sm_120']

含 sm_120,矩阵乘法通过,后面所有实验正常跑完。注意版本号是降了(2.13.0 → 2.11.0)——cu128 那条索引线上当时的可用版本就是它,这不影响任何一个作业。

--reinstall-package 不能省

如果写成 uv pip install --index-url https://download.pytorch.org/whl/cu128 torch(不带 --reinstall-package),uv 会认为「torch 这个需求已经被满足了」而直接跳过,输出一行 Checked 1 package 就结束,环境纹丝不动。版本约束里没有 CUDA 后缀这一维,uv 无从知道你想换的是构建变体而不是版本。必须显式强制重装。

关掉 W&B,同时留下逐步指标

上游脚本默认往 Weights & Biases 打点。不想联网可以 export WANDB_MODE=disabled,或者用各模块自带的开关(奖励模型是 --no-wandb,SFT / DPO 是在 YAML 里设 wandb_project: null)。

但这里有个副作用:训练脚本把每步指标全交给了 wandb.log,控制台只在阶段末尾打印 loss 和 accuracy。关掉 W&B 就等于把 margins、chosen_rewards、rejected_rewards、逐步 val/* 这些最值得看的曲线全丢了。本仓库为此写了一个不改上游代码的包装器 homework/tools/run_with_metrics.py:它在导入训练模块之前,把 wandb.log 换成一个「先写 JSONL、再原样转发」的版本,于是 WANDB_MODE=disabled 下也能拿到完整的逐步指标。

METRICS_JSONL=logs/metrics_sft_360m.jsonl \
  python homework/tools/run_with_metrics.py instruction_tuning.train \
  --config homework/hw1-sft/sft_smol360m.yaml

效果等价于 python -m instruction_tuning.train --config ...,只是多落一份指标文件。实现上有一个细节值得一提:wandb.init() 会把模块级的 wandb.log 重新绑定到本次 run 上,所以在 init 之前打的补丁会被覆盖掉——包装器因此把 wandb.init 也包了一层,等它返回后再补一次。这类「monkey patch 被框架自己覆盖」的问题在改造别人的训练脚本时非常常见。

2. 六组作业

顺序基本就是后训练流水线的执行顺序:先把 base 模型变成助手(HW1),再造一个能判好坏的奖励函数(HW2),然后是三条用它的路线——在线 RL(HW3)、绕开奖励模型的直接对齐(HW4)、最省事的拒绝采样(HW5),最后是让模型给自己当老师的自蒸馏(HW6)。每张卡上标了对应章节和当前状态。

已跑通 · 有实测记录
已备好配置 · 本次未运行
先跑哪个

如果只有一个下午,跑 HW1 和 HW4。HW1 让你第一次亲眼看到 base→assistant 的相变,这是整条流水线的地基;HW4 让你看到超参敏感性有多离谱,这是后训练实验里最费时间的那类问题。HW2 的价值有点特别——它是一个失败得很有教育意义的实验:两组配置都没能训出一个及格的奖励模型,而恰恰是这个失败暴露了「奖励建模对基座规模的要求比大多数人预期的高」。

3. 实测结果汇总

下表把三组已跑通的实验压成一张表。所有数字都是本机单次运行的实测值,出自 homework/RESULTS.md;未运行的三组明确标注为「未运行」,不给任何数字。

作业基座数据 / 规模关键实测数字状态
HW1 · SFT SmolLM2-360M(base) No Robots 4000 条
2 epoch,496 步
loss:step 1 为 2.1035,step 99 为 1.9849(全程最低),末步 2.2383。
生成:step 0 重复刷屏永不停止;step 450 答对且会停,但仍带 ikipedia 类残留噪声
✓ 已跑通
HW2 · RM(A 组) SmolLM2-135M UltraFeedback 2000 对
1 epoch
最终 val 准确率 0.485,val margin 0.031,demo 判错 ✓ 已跑通
HW2 · RM(B 组) SmolLM2-360M UltraFeedback 5000 对
2 epoch
最终 val 准确率 0.568,val margin 0.235,demo 判对。
epoch 汇总:训练 loss 0.7276→0.6502,val loss 0.7028→0.6963,val 准确率 0.558→0.558
✓ 已跑通
HW4 · DPO(lr 5e-6) SmolLM2-360M-Instruct UltraFeedback 4000 条
1 epoch,250 步,β=0.1
末 25 步:loss 0.6835,accuracy 0.330,margin +0.02832;
全程 loss 范围 0.6353–0.7539,均值贴着 $\ln 2$
✓ 已跑通
HW4 · DPO(lr 2e-5) SmolLM2-360M-Instruct 同上,仅改学习率 末 25 步:loss 0.6222,accuracy 0.537,margin +0.20306;
chosen_rewards +0.20895,rejected_rewards +0.00583
✓ 已跑通
HW3 · 策略梯度 配置与说明已备好,本次会话中未实际运行——上游默认基座 Qwen/Qwen3-1.7B 的全参数训练加 rollout 缓存超出可用显存 ✗ 未运行
HW5 · 拒绝采样 配置与说明已备好,本次会话中未实际运行——需要先批量生成再打分,显存与时间预算都更大 ✗ 未运行
HW6 · 自蒸馏 配置与说明已备好,本次会话中未实际运行——教师与学生双模型驻留,受显存限制 ✗ 未运行

三条能从这张表里直接读出来的结论

一、SFT 的 loss 不是进度指标。HW1 那一行里,loss 从 2.1035 走到 2.2383,几乎没动甚至略升;而同一段时间里,模型从「无限重复 prompt」变成了「回答 Paris 并停下来」。如果你只盯着 loss,会得出「这次训练失败了」的结论。真正的信号在在环采样(in-loop generation)面板里。这也是上游把 sample_every: 50 写进默认配置的原因。

二、奖励建模对基座规模的要求,比「能生成通顺句子」高得多。135M 的模型能写出像样的句子,但在偏好判别上是 0.485——纯随机。同一份代码、同一个数据集,换到 360M 才爬到 0.568,仍然远低于书里提到的合格 RM 的 0.65–0.75。这解释了为什么工业界的奖励模型很少小于策略模型。

三、直接对齐算法的学习率不能照抄。HW4 的两行只差一个数量级的学习率,结果差了一个量级的 margin(+0.028 vs +0.203)。上游 configs/dpo.yaml 给 OLMo-2-1B 用的 5e-6,搬到 360M 上几乎不动。这不是「小模型学不动」,是「小模型需要更大的学习率」。HW1 的配置里把 lr 从上游的 5e-6 提到 2e-5,用的正是这条实测证据。

别把这些数字当基准

0.568 的奖励模型准确率、+0.203 的 DPO margin,都是「在极小规模下勉强看见了学习信号」的水平,不是任何意义上的合格结果。它们的用途是确认现象存在与确认代码跑通了,不是用来和论文里的数字比较。每组作业只跑了一个种子,RLHF 的种子间方差足以吞掉这个量级的差异。

4. 显存预算:为什么全部换成了 SmolLM2

本次实验的硬件

项实际情况
GPU2 × NVIDIA GeForce RTX 5080,各 16303 MiB
驱动 / CUDA580.126.20 / CUDA 13.0
实际可用显存另一个进程(RobotAgent)常驻占用 GPU0 约 8.35 GiB、GPU1 约 11.4 GiB;所有实验只能在 GPU0 剩余的约 6.9 GiB 内完成
CPU 内存125 GiB
磁盘可用约 890 GiB
软件Python 3.12,torch 2.11.0+cu128

「有两张 16 GB 的卡,但只能用其中一张的 6.9 GiB」——这个约束比它看上去更有代表性。共享服务器、跑着别的服务的工作站、云上被别人占了一半的实例,都是这个形状。它也正好落在一个尴尬的区间:比玩具大,比上游默认配置小。

本站没有逐实验的显存实测数字

RESULTS.md 记录的是「所有实验只能在约 6.9 GiB 内完成」这一条硬约束,没有逐个实验记录峰值显存占用,所以本页不会给出「HW1 占用 X GiB」这类数字。下面引用的分档估计来自上游 code/ 的文档,是它给出的经验值,不是本机测量结果。

上游默认配置为什么放不下

上游 code/ 的文档给出的全参数微调显存分档(上游经验值,非本机实测):

模型规模上游给的全参数微调估计6.9 GiB 能否放下
0.6B约 4–6 GB勉强,几乎没有余量
1.7B约 10–15 GB放不下
3B约 20–25 GB放不下

而上游各模块的默认基座恰好都在这条线之上或紧贴着它:instruction_tuning 默认 allenai/OLMo-2-0425-1B,policy_gradients 与 distillation 默认 Qwen/Qwen3-1.7B,reward_models 默认 Qwen/Qwen3-0.6B-Base。这些数字是在作者的 DGX Spark(约 120 GiB 统一内存)上定的,对那台机器完全合理。

更要命的是,后训练的显存开销远不止「模型权重」这一项。以全参数 AdamW 微调为例,同时驻留的至少有:权重、梯度(与权重同大小)、AdamW 的一阶与二阶动量(各与权重同大小,且通常是 fp32)、以及激活值。这就是为什么一个 1B 模型「明明只有 2 GB 权重」却要 10 GB 以上——具体的逐项核算见附录 C。RL 类算法还要再叠一份 rollout 缓存,DPO 类要再叠一个参考模型,自蒸馏要再叠一个教师模型。

做了哪些替换

作业上游默认本站改成其它同步改动
HW1 · SFT allenai/OLMo-2-0425-1B HuggingFaceTB/SmolLM2-360M 序列 2048→1024,batch 4→2,样本数由全量约 9.5K 改为 4000,epoch 3→2,lr 5e-6→2e-5
HW2 · RM Qwen/Qwen3-0.6B-Base SmolLM2-135M / SmolLM2-360M 沿用上游的 batch 2、max_length 512
HW4 · DPO allenai/OLMo-2-0425-1B-SFT SmolLM2-360M-Instruct(policy 与 reference 同源) max_length 512,batch 2 × grad_accum 8,lr 需上调(见 HW4 的实测对照)
HW3 / HW5 / HW6 Qwen/Qwen3-1.7B 等 尚未确定替代方案,本次未运行。需要更小的基座,或等 GPU 空闲

为什么选 SmolLM2 系列而不是继续用 Qwen3-0.6B?三个理由:一是它有连续的规模档位(135M / 360M / 1.7B),做「同一份代码,只改模型大小」的对照实验非常方便——HW2 的 135M vs 360M 对照就是靠这个做出来的;二是它的 base 与 Instruct 版本成对提供,SFT 需要从 Instruct 版借 chat template(base 分词器没有),DPO 需要 policy 与 reference 同源,都省事;三是 360M 这个尺寸在 6.9 GiB 里有足够余量开 gradient_checkpointing 和 bf16,不至于每次调参都在 OOM 边缘试探。

缩小模型会改变什么,不会改变什么

不会改变的:算法的定性行为。SFT 的 base→assistant 相变、DPO 对学习率的敏感、BT 损失的随机基线 $\ln 2 \approx 0.693$、margin 与准确率脱钩——这些在 360M 上照样看得到,这正是这套作业能成立的前提。

会改变的:超参数的最优值(小模型普遍需要更大的学习率,见 HW1 与 HW4)、能力的天花板(0.568 的奖励模型准确率不是算法的问题,是容量的问题)、以及某些需要规模才涌现的现象(HW3 的 spell_backward 若换太小的基座,可能整组 rollout 全错,组相对更新根本没有信号)。换小模型是为了看清机制,不是为了得到可比的结果。

5. 每一页长什么样

六组作业的页面结构一致,按你实际动手的顺序排:

小节内容
任务目标这组实验要看见什么现象,以及它对应书里哪一节的哪个论断
配置讲解逐项说明相对上游默认改了什么、为什么改。改配置的理由比配置本身重要
运行命令可直接复制的完整命令,含指标落盘的包装器用法
实测结果本机跑出来的表和原始输出。未运行的实验这一节会明确写「未运行」
教学点从这些数字里能读出什么。这是整套作业真正的产出
自选消融一次只改一个变量的建议扫参方向,以及每个方向预期能暴露什么

动手之前的三条通用建议

  • 先跑最小的那个命令。用 --max_samples / --samples 或复制一份小 YAML,先确认「学习信号可见」,再开始扫参。在一个根本没跑通的设置上扫超参是最浪费时间的做法。
  • 一次只改一个变量。上游的 Good first sweeps 建议就是这么组织的:SFT 固定其它只扫 lr;策略梯度固定其它只动 num_rollouts / temperature;奖励模型先扫 --samples / --lr / --model-id,再考虑动模型结构;拒绝采样必须保持生成与打分设置完全一致,才能和随机对照组比。
  • 长训练放后台跑,并且盯着它。前台跑很容易在产出有用指标之前就超时;但放后台之后一定要确认日志或指标文件在动,不要留一个静默的后台任务在那里。

准备好了就从 HW1 · 指令微调 开始。它是唯一一个「跑完你会当场明白自己之前哪里理解错了」的实验——大多数人在跑之前都以为该盯着 loss 看。