跟着LAP啥都学 跟着LAP啥都学

第 13 章 推理模型:CoT、投票与 GRPO 强化学习

      +

      本章导读:实现四类推理模型构建方法——推理时扩展、纯 SFT、 纯强化学习(GRPO)与 SFT+RL 流水线,并以统一评测对比方法与规模的影响。 本章是全书最后一章,任务从「调用外部工具」转向「生成内部思考」。

      学完本章你将能够:

      1. 准确定义推理模型,区分「处理层面」与「呈现层面」两个层次;

      2. 实现规则奖励函数与 GRPO 策略梯度训练循环;

      3. 理解 DeepSeek-R1 的冷启动、奖励设计与蒸馏路线;

      4. 通过双规模对照实验解释规模、解码策略与 RL 调参的关系。

      产出与代码:chapters/chapter13_reasoning/(core/rl_train.h、core/reward.h、train/、 serve/src/reasoning_chat.cpp、eval/src/eval_math.cpp)。

      13.1 推理模型的定义:判定标准与两个层次

      模型发布会常说自家模型「会推理了」。判断这句话是否成立,需要一个可操作 的定义。本教程采用的定义是:以多步骤生成、包含中间过程的方式解答问题的模型。 关键在「多步骤 + 中间过程」,不在「难」。 「法国的首都是哪里」是事实检索(❌);「火车 60 英里/小时开 3 小时走了 多远」需要先识别「距离 = 速度 × 时间」,再代入计算(✅)。

      这个判据有时效性:几年前火车题还能当正例,今天大多数 LLM 已经能答对它。 现在能区分推理模型的,是更复杂的任务:解谜题、数学推导、数学证明、 复杂编程。判据会随模型能力的普遍提升而失效——这是全书唯一一个会随时间 过期的正例,引用时需要先核对。

      「推理」一词实际上用在两个不同的层次上,需要先拆开:

      层次 含义 对用户可见?

      处理层面

      模型在内部通过多个中间步骤生成结果

      不一定

      呈现层面

      最终答案中包含某种推理过程

      可见

      中间步骤可以显式(思维链直接写在回答里),也可以隐式(内部多轮迭代 但只返回答案,如 o1 的做法)。这个区分的意义在于:「会写思考过程」≠ 「是推理模型」。一个模型输出了「让我想想…」,可能只是模仿格式。 反过来,许多并非为推理设计的 LLM,答案里同样会出现中间步骤。 设想一份 SFT 数据全部带「首先…然后…」,训出来的模型个个都会写 「首先…然后…」——它内部多做了一步计算吗?没有。输出格式可以零成本 伪装,处理过程不能。至于模型是否真的在「思考」,工程事实与哲学断言 要分开:生成了中间步骤、性能提升是事实;「有意识地在推理」是另一个 问题。

      什么时候该用推理模型?按任务类型选择,同时考虑它的三个代价:

      适用(需多步推理) 不适用(一步到位) 推理模型的三个弊端

      解谜题

      总结

      成本更高——生成更多词元

      高级数学推导

      翻译

      输出更冗长——中间步骤全吐给用户

      复杂编程问题

      基于知识的问答

      过度思考反而出错——最反直觉

      第三条最反直觉:推理能力不是单调增益。简单问题上强行推理会引入 错误——一步能答对的题,每多绕一步都是新的出错机会。一条实用的 启发式规则:如果一个问题本身不涉及推理,针对它优化推理模型没有必要。 给「法国的首都」做思维链,不会提升正确性,只会制造错误。工程上的 对应做法是任务分类路由:简单查询分流给普通模型,只把复杂推理交给 推理模型。路由层直接决定 LLM 应用的成本结构——推理模型的高价词元, 只应花在需要多步推理的任务上。

      四类方法各有位置:

      Diagram

      第一类方法不改动任何权重,只改解码方式。三种手段的机制如下:

      • CoT 提示:在 prompt 里给出(或暗示)「请一步一步思考」,让模型把 中间推导写出来。机制上,每个已生成的词元都进入下一步的上下文—— 写出来的中间步骤等于给模型增加了额外的「计算步数」,原本要在一次 forward 里完成的推理,摊到了多个 forward 上。Transformer 是固定深度的 网络,一次 forward 能做的串行计算步数上限就是层数——这是不碰权重就能给模型 「加算力」的唯一手段;

      • 多数投票(自一致性,self-consistency):同一问题采样 N 次 (温度 > 0),对最终答案取多数。它依赖一个假设:错误答案彼此分散, 正确答案趋于收敛——所以投票只在「准确率过半」的基座上稳定有效, 13.7 节的实验(SFT-small vote(5) 仅 2%)就是这个假设失效的情形;

      • 束搜索(beam search):每步保留概率最高的 b 个前缀并行扩展, 最终取总概率最高的序列。它与采样的取舍在于:采样提供多样性, 束搜索提供确定性的最优——对数学这类有唯一答案的任务合适, 但对开放生成会产出千篇一律的文本,且推理开销是 b 倍。

      零训练就有提升,为什么还要后三类方法?因为推理时扩展的上限由基座 决定。投票只能收敛于「模型已经会的东西」——基座答不对的题,采样一万次 也投不出正确答案;束搜索找的是概率最高的序列,不是正确答案。解码层 能放大已有的能力,不能创造新能力。要突破这个上限,就得改权重—— 13.2 起的三节做这件事。

      13.2 数据转换:特殊词元装不进 GPT-2 词表

      本章的数据分两路:SFT 用 Sky-T1-17k(带思维链的推理数据),RL 与评测用 GSM8K(小学数学应用题,标准答案是整数)。前者教「怎么思考」,后者判 「答没答对」。

      第一道障碍出现在数据转换阶段:Sky-T1 的特殊词元(如 <|begin_of_thought|>)在 GPT-2 的 BPE 词表里不存在。GPT-2 的 50257 个词元是第 2 章就定死的,加新词元意味着重训词表、重训嵌入, 等于推倒重来。convert_data.py 的处理是做字符串替换:

      # scripts/convert_data.py
      s = s.replace("<|begin_of_thought|>", "think")
      s = s.replace("<|end_of_thought|>",   "think")     # 开头结尾同词!
      s = s.replace("<|start_of_final_answer|>", "<answer>")
      s = s.replace("<|end_of_final_answer|>",  "</answer>")
      # 缺标签的样本兜底:think{推理}think\n<answer>{答案}</answer>

      转换后的标签格式定型为 think 推理过程 think\n<answer>答案</answer>。 「开头结尾都用 think」是一个务实选择:GPT-2 词表装不下自定义特殊词元, 而一个普通词加 <answer> 标签,足以让模型学会「先想后答」的结构。

      注意这个妥协不对称:转换把思考段的开头结尾压成了同一个词,答案边界 却保留了成对标签。原因是两边的消费者不同。思考段的边界给模型看, 结构对称与否不影响「先想后答」的学习;答案的边界给程序看,奖励函数 抽取答案、评测脚本判分,全靠 <answer> 这个稳定锚点做解析。 格式妥协可以选,解析锚点不能丢。做工程妥协时,应当选不伤机制的 那一种。

      13.3 奖励函数:规则奖励的构成与权重

      GRPO 不需要训练奖励模型——数学任务的正确性可以自动判定,用规则奖励 就足够(core/reward.h)。RL 与评测共用同一实现,保证口径一致: 训练时优化的奖励与评测时考核的准确率,必须是同一把尺子,否则就是在 优化一个东西、考核另一个东西。

      // 答案抽取优先级:<answer>...</answer> → \boxed{...} → "#### " → "The answer is"
      inline std::string extract_answer(const std::string& text);
      // 归一化:去空白/逗号/$/%/单位/句号,可比 72 与 "72." 与 "$72"
      inline std::string normalize_for_compare(const std::string& raw);
      
      double composite_reward(text, gold, w_fmt, w_acc);  // 默认 w_fmt=w_acc=0.5
      //   format_reward:    输出符合 think...think + <answer> 标签结构
      //   accuracy_reward:  extract_answer 后与 gold 匹配

      w_fmt = w_acc = 0.5 的组合对应「先教会格式,再追求答对」的平滑起步: 格式分保证 RL 早期 reward 信号不全是 0(策略梯度需要非零方差才能更新, 13.4 节解释原因),准确率分把模型往答对的方向拉。复合奖励就是一条 加权求和:

      \[r = w_{\mathrm{acc}} \cdot r_{\mathrm{acc}} + w_{\mathrm{fmt}} \cdot r_{\mathrm{fmt}}, \qquad w_{\mathrm{acc}} = w_{\mathrm{fmt}} = 0.5\]

      为什么准确率分不能单独承担奖励?因为 RL 早期模型几乎全错:同一道题 采出的回答全错,奖励全组相同、方差为零,参数不会更新。格式分在这个 阶段提供信号——格式有对有错,奖励就有差异。两个分不是简单加权, 是时间上的接力:早期靠格式分维持信号,格式熟练后准确率分接管 主要梯度。

      规则奖励还有两个好处:不训练奖励模型,省一份算力;也不像 LLM 评委 那样有评判者混淆风险——本教程的实测中,裁判曾把待评回复当成自己的输出 打分。规则没有这个问题,它只认字符串。

      13.4 GRPO:advantage 与策略梯度训练循环(rl_train.h)

      DeepSeek-R1-Zero / TinyZero 路线在 core/rl_train.h 的 grpo_step 中 实现:

      // 1) 每个 prompt 采 G 条 rollout(no-grad,temperature 0.9 + top_k 50)
      for (int64_t g = 0; g < cfg.group_size; ++g) {
          auto r = sample_rollout(model, prompt_ids, cfg.max_new_tokens, ...);
          gen_texts.push_back(prompt + decode(r.gen_tokens));
      }
      // 2) 组内归一化 advantage——GRPO 的全部创新就在这一行:
      advantage = (reward - mean_group) / (std_group + 1e-8);
      // 3) 重新 forward 整个序列(带梯度),取生成段的 log-prob
      auto lp = compute_completion_log_probs(model, full_seq, r.prompt_len);
      // 4) 策略梯度:loss = -mean(advantage × log_prob)
      auto term = -lp * adv_t;

      第 4 行是整个 RL 的引擎,值得从零推一遍它为什么能学习。先回忆第 5 章 建立的心智模型:优化器只会做一件事——调整参数让 loss 变小。那么 「想让模型学会什么」,就把它写成 loss 的下降方向。

      这里的 log_prob 是模型给这条回答打出的对数概率:log_prob 越大, 模型越「倾向」生成这条回答。看这个 loss 在两种情况下的走向:

      好回答(advantage > 0):
          loss = -(正数) × log_prob
          → 想让 loss 变小,就要让 log_prob 变大
          → 提高这条好回答的生成概率 ✓
      
      差回答(advantage < 0):
          loss = -(负数) × log_prob = 正数 × log_prob
          → 想让 loss 变小,就要让 log_prob 变小
          → 压低这条差回答的生成概率 ✓

      对比第 7 章的监督微调:SFT 的 loss 把「生成结果」往标准答案上拉 (每个词元都有正确标签);这里的 loss 没有任何标签,只有「这条回答 好不好」的分数——训练信号从标签换成了奖励。这就是强化学习与 监督学习的本质分界,也是为什么第 12 章工具调用可以用 SFT (有标准对话可抄),而「学会答对数学题」要更进一步就得靠 RL 或蒸馏(对没见过的 题,没有可抄的标准思考过程,只有对错可判)。

      顺带解释 13.3 节埋下的伏笔——「策略梯度需要非零方差才能更新」: 如果一组 G 条回答的奖励全部相同(全对或全错),减去组均值后 advantage 全为 0,loss 恒为 0,没有任何梯度信号,参数原地踏步。 这就是奖励函数要「格式分 + 准确率分」混合的原因——RL 早期模型 几乎全错,准确率分全组相同,但格式分仍有差异,信号不中断; 等格式熟练后,准确率分再接管主要梯度。

      第 2 步和第 4 步合起来就是 GRPO 的全部数学。组内归一化 advantage (G 条同题回答互为基线):

      \[A_i = \frac{r_i - \mathrm{mean}\left(\{r_1, \dots, r_G\}\right)} {\mathrm{std}\left(\{r_1, \dots, r_G\}\right) + \varepsilon}\]

      策略梯度损失——advantage 为正就抬高那条回答的对数概率,为负就压低:

      \[\mathcal{L} = -\frac{1}{G}\sum_{i=1}^{G} A_i \, \log \pi_\theta\!\left(a_i \mid s_i\right)\]

      为什么用「组内相对比较」而不是 value network(PPO 的做法)?同一个 prompt 采 G 条回答,好于组均值的 advantage 为正、差于均值为负—— 不需要额外训练一个打分网络,显存与实现复杂度同时减半。对 124M/355M 这样的小规模模型,这是让 RL 跑起来的最小配置。

      这个归一化公式还有两层设计意图:减组均值等于用「同一道题的其他 回答」当基线——题目本身的难度差异(简单题全对、难题全错)在整组减法中 消掉,advantage 只衡量「这条回答比同题的其他回答好多少」;除组标准差则把 不同题目奖励的幅度拉到同一尺度,学习率不需要随奖励的绝对大小调整。 一行减法除法,同时解决了基线选择与尺度对齐两个经典 RL 问题。

      一个 G=8 的采样组从奖励到 advantage 的完整过程:

      13 grpo advantage
      Figure 1. 公式图像:GRPO 的组内归一化 advantage(上:G 条奖励与组均值;下:归一化后的 A_i)

      13.5 监督微调:105 秒学会格式

      train/sft_train 与第 7 章同源(-100 掩码 + Alpaca 模板 + 全参微调), 数据换成 think/answer 标签格式。实测训练成本(RTX 4090 级,1 epoch):

      small  (124M): 1550 步, 1.6M tokens, 105 秒, val_loss 1.221
      medium (355M): 3150 步, 1.4M tokens, 335 秒, val_loss 1.100   # batch 减半

      SFT 后格式合规率已达 96–100%——「think → think → <answer>」的骨架 学得很稳。一个自然的问题是:SFT 成本这样低(105 秒),能否只用 SFT? SFT 教的是「抄标准思考过程的格式」,不是「自己想对」;它的定位是为 RL 铺路,让后续 RL 只需专注「把答案想对」。13.7 节的实验给出结论: 格式全会、答案全错,就是纯 SFT 的上限。

      13.6 DeepSeek-R1 案例研究:四个关键概念

      本项目的 RL/SFT+RL 路线直接复刻 DeepSeek-R1 的方法论。四个关键概念 每个都对应一种流行的误读:

      1. R1-Zero 的「纯 RL」跳过的是 SFT 与奖励模型,不是预训练。标准 RLHF 流程是 SFT → 奖励模型 → RL;R1-Zero 直接在完整预训练过的 DeepSeek-V3 上做 RL,所以叫「纯 RL」。它没有跳过预训练:6710 亿参数的底子是预训练 给的,RL 只负责在其上训练推理能力;

      2. 两种奖励机制:准确性奖励(代码用编译器验证、数学用确定性系统 评估,客观可自动判定)与格式奖励(确保输出遵循 <think> 标签格式)。 注意格式奖励「依赖一个大语言模型来判定」——这与第 7 章的 LLM-as-judge 是完全相同的手法,也因此继承了同样的问题: 本教程实测中,裁判曾把待评回复当成自己的输出("I’d rate my own response as 95"),R1-Zero 的格式奖励同样面临评判者混淆风险;

      3. 「Aha 时刻」:研究人员发现模型在 RL 过程中自发生成了带推理过程的 回答——尽管没有任何显式训练教它这么做。这与第 1 章的「涌现」是 同一现象:只优化答案正确性,却学会了思考。R1-Zero 的价值是 证明路径可行,不是性能最优——R1 本身后来还是走了完整的多阶段 流水线;

      4. R1-Distill 的「蒸馏」不是传统知识蒸馏:教师不给 logits 软标签, 而是生成监督微调数据集让学生做指令微调(标准交叉熵)—— 工程上与第 7 章的流程完全一样,只是数据换成了强推理模型生成的样本, 更准确的叫法是「数据蒸馏」。

      第 3 条末尾的「多阶段流水线」,入口是冷启动(cold start)。R1-Zero 的 纯 RL 有两个肉眼可见的毛病:语言中英混杂、输出可读性差。R1 的补救发生在 RL 之前——人工筛选数千条带完整思维链的问答样本,对 DeepSeek-V3-Base 做一轮 SFT,给 RL 一个「会好好写字」的起点。之后依次是:面向推理的 RL → 拒绝采样生成约 80 万条 SFT 数据再训一轮 → 覆盖全部场景的最终 RL。 对照本章实现,这条流水线的分工一目了然:13.5 节的 SFT 是微型冷启动, 先把 think/answer 格式教会;13.4 节的 GRPO 才能专心优化「答案对不对」。

      还有一条对小规模实践最重要的实验结论:DeepSeek 把 R1-Zero 的纯 RL 直接应用到 Qwen-32B 上,结论是「对于较小的模型,蒸馏远比纯强化学习 有效」。R1-Zero 是 6710 亿参数,Qwen-32B 是 320 亿,相差 21 倍; 纯 RL 的涌现可能需要足够大的模型容量作为前提,小模型上高质量推理 数据的 SFT 是更有效的策略。这解释了本项目 355M 模型上 GRPO 「跑通了但收益有限」的结果。方法论上还有一条可复用的做法:对比实验里 应补上「SFT+RL 对照组」——要证明某个组件的贡献,必须做「去掉它」的 消融实验,与第 8 章的消融思想一致。

      13.7 对比实验:规模、投票与 RL 的实测结果

      GSM8K test 50 题的实测结果(docs/compare-report.md):

      模型 × 策略 准确率 格式合规 平均耗时 结论

      SFT-small greedy

      0%

      96%

      0.54 s

      格式对、答案错

      SFT-small vote(5)

      2%

      98%

      2.6 s

      投票略有提升

      SFT-medium greedy

      10%

      96%

      1.2 s

      规模才是下限

      SFT-medium vote(5)

      4%

      100%

      5.6 s

      反而更差

      RL-medium greedy

      8%

      98%

      1.0 s

      待调参

      RL-medium vote(5)

      0%

      100%

      4.8 s

      退化

      这张表有三个反直觉的观察:

      1. 推理时扩展不是万能药:vote 在 medium 上 4% < greedy 的 10%—— 「多数样本都答错时,投票只会把错误答案选得更稳」。自一致性方法的经典结论 「majority@N 只在高准确率基座上稳定提升」在 50 题小样本上得到了 直接验证。另外注意:50 题的 1 题之差就是 2%,小样本的方差足以 淹没方法差异——读实验数字,先看样本量;

      2. 规模决定下限:medium 10% vs small 0%——同样的数据、同样的训练, 124M 装不下小学算术,355M 勉强入门;

      3. RL 跑通了,但收益还没出来:100 步 GRPO(batch=1, group=2, lr=1e-6) 208 秒,reward 稳定在 0.5(格式分拿满、准确率分没有起色)。 报告给出的调参建议:lr → 1e-5、group → 4–8、步数 → 500+、加 KL 惩罚 防策略漂移——这组参数对应习题 1 的实验。

      如何理解「投票反而更差」:多花 4 倍时间,准确率还降了 6 个百分点。 回到 13.1 节埋的假设:投票依赖「正确答案趋于收敛」,而 medium 的 greedy 基座准确率只有 10%,五次采样里错误答案分散在多种错误上, 多数票选中的未必是正确的那个。这不推翻自一致性方法本身,只说明 它的前提在低准确率基座上不成立——方法没有错,是使用条件不满足。

      13.8 编译运行与预期输出

      本章完整流程如下(数据下载 → 编译 → 数据转换 → SFT → RL → 推理 → 评测):

      cd chapters/chapter13_reasoning
      ./scripts/download-data.sh            # Sky-T1-17k + GSM8K parquet → data/downloads/,转换出 data/*.json
      ./scripts/build.sh
      # 数据转换已由下载脚本自动完成:Sky-T1-17k → data/sft-train.json(13.2 的标签替换)、
      # GSM8K → data/rl-train.json 与 data/eval-test.json
      # SFT(small 示例;medium 换 --size medium + batch 2)
      ./scripts/run.sh sft_train --data data/sft-train.json \
          --size small --epochs 1 --batch 4 --max-length 768 --lr 5e-5 \
          --out data/reasoning_small_sft_gsm.pt
      # RL(从 SFT 初始化,DeepSeek-R1 蓝图;去掉 --init 即纯 RL)
      ./scripts/run.sh rl_train --data data/rl-train.json \
          --size medium --init data/reasoning_medium_sft_gsm.pt \
          --max_steps 100 --batch 1 --group 2 --lr 1e-6 \
          --w_acc 0.5 --w_fmt 0.5 --out data/reasoning_medium_rl_gsm.pt
      # 推理:CoT + 多数投票
      ./scripts/run.sh reasoning_chat --model data/reasoning_small_sft_gsm.pt --size small \
          --strategy vote --samples 5
      # 统一评测 + 报告
      ./scripts/run-compare.sh              # 产出 docs/compare-report.md

      RL 训练日志每步一行:

      [best sample @ step 95] ... ### Response: think Holly takes 3 x 2 = 6 ...
      [rl_train] step=96 mean_reward=0.5 loss=0
      [rl_train] step=99 mean_reward=0.5 loss=0     ← reward 平了?去读 reward 分解

      日志中的 loss=0 不是异常,而是 13.4 节解释过的现象:组内方差为零 → advantage 全零 → 无梯度。遇到它,先读 reward 分解定位原因,而不是重跑。 排错时可以先对照公式:日志里的每个反常数字,都应能在 13.4 节的公式中 找到对应的位置。

      本章小结

      • 推理模型的判定标准是「多步骤生成并包含中间过程」;处理层面与呈现层面是两回事,「会写思考过程」不等于「是推理模型」。

      • 四类构建方法:推理时扩展(零训练,上限由基座决定)、纯 SFT、纯强化学习(GRPO)、SFT + RL 流水线;简单任务上强行推理反而出错,应做任务分类路由。

      • GRPO 以组内相对优势替代价值网络,一行减法除法同时解决基线选择与尺度对齐;奖励全组相同则 advantage 全零、无梯度,格式分是早期的信号来源。

      • 实验结论:模型规模决定推理能力下限;多数投票只在高准确率基座上有效;小模型上蒸馏优于纯强化学习;loss=0 要去读 reward 分解。

      自测清单

      能不回看就答出这些,本章就算过关:

      1. 数据转换后标签为 think…​think\n<answer>…​</answer>,无残留特殊词元

      2. SFT 后格式合规率 ≥ 95%(骨架学会)

      3. medium 的 greedy 准确率显著高于 small(规模效应直接确认)

      4. GRPO 全链路跑通:rollout → 奖励 → advantage → 策略梯度 → checkpoint

      5. 能解释 vote 何时有益何时有害(基座准确率 + 样本量)

      6. 能复述 \(\mathrm{loss} = -A \cdot \log \mathrm{prob}\) 在 advantage 正负两种情况下各自的优化方向

      7. eval_math 对 base/sft/rl 各 checkpoint 统一口径评测

      习题

      1. 按报告给出的参数调 RL:lr=1e-5, group=8, max_steps=500,画出 reward 曲线与 greedy 准确率,看 RL 在本项目的规模上能带来多少提升。

      2. 把 w_fmt 从 0.5 降到 0.1,观察格式合规率与准确率的变化。

      3. 给 eval_math 增加 majority@32 与束搜索策略,对比「多采样」与 「精细搜索」在同预算下的表现。

      4. 用第 12 章的方式给推理模型套一层 dispatcher:<answer> 出现才停。

      5. 给 13.7 节的对比实验补上「SFT + RL」与「RL(无 SFT 初始化)」两个 对照组——消融 SFT 环节,观察格式信号需要多少步才能自行出现。

      6. (思考)格式奖励与准确率奖励都是「结果奖励」。如果想奖励 思考过程的质量(步骤正确性),规则还能写出来吗? 这正是过程奖励模型(PRM)要解决的问题。

      拓展阅读