第十一章 Agentic RL(智能体强化学习)
一句话总结
前面十章都在"用"现成的模型,这一章第一次去"练"模型。Agentic RL(Agentic Reinforcement Learning,智能体强化学习)就是把语言模型当成一个要在环境里真实做事的智能体来训练:让它推理、调工具、看结果、再动手,用"事情办成了没有"当奖励,靠反复试错把"会做事"这件能力练进模型参数里。 它和上一代方法(PBRFT)的区别不是调参多少,而是从"写一段话让人打分"变成"干一整件事看结果"。你不需要现在就去炼大模型,但必须看懂这条流水线——因为奖励函数这一环,应用工程师完全可以亲手写,而且它决定了训练效果的天花板。
本章你能学到什么
- 三个最容易被跳过的"是什么":强化学习怎么套到语言模型头上、为什么预训练之后还必须"后训练"、PBRFT 到底指什么
- Agentic RL 的准确定义,以及只有它才会出现的三个现象:模型自己学会调工具、自己发现错误重来、回答自发变长
- 后训练三段流水线:SFT → 奖励建模(RM) → 强化学习微调,每一段在补预训练留下的哪个缺口;RLHF 和 RLAIF 的差别
- PBRFT 的完整流程走一遍(带具体数字),以及它的三个天花板——这正是 Agentic RL 的切入点
- PBRFT 和 Agentic RL 的根本分野:用马尔可夫决策过程五元组 $(S, A, P, R, \gamma)$ 一格一格对比,并跑一段代码把两者的差异打印出来
- Agentic RL 要练的六大核心能力:推理、工具使用、记忆、规划、自我改进、感知
- 奖励函数怎么设计(本章唯一必须亲手掌握的硬技能):三种内置奖励、稀疏/密集/混合奖励、Reward Hacking 的防法
- GSM8K 的三种数据格式:同一批题,喂给 SFT 和喂给 RL 长得完全不一样
- PPO 四个模型 vs GRPO 两个模型,GRPO 组内相对优势的完整算例,KL 散度怎么读
- LoRA 的数学:ΔW = BA 为什么能把 1677 万个参数压到 6.5 万(有可跑的算术实验)
- 评估指标体系与错误分析:Accuracy@K、格式正确率、错误类型分布、难度分层
- 四个完全离线、零依赖、不用 GPU 的实验,每个都有 ✅ Checkpoint 对照真实输出
- 八个踩坑与解法:显存炸了怎么救、batch_size 为什么必须能被 num_generations 整除、KL 爆炸怎么办
一、先把三个词说清楚:强化学习、Agentic RL、策略
这一节不讲任何算法,只做一件事:把"Agentic RL 是什么"讲到你能用自己的话复述。已经熟悉强化学习的读者可以快速扫过 1.1,但 1.2 和 1.3 建议看完,因为后面所有内容都建立在那张对应表上。
1.1 强化学习一分钟回顾
第二章 4.2 节讲过强化学习,一句话:它不告诉你正确答案,只在你做完之后给一个分数,让你自己琢磨刚才哪一步做对了。 训小狗就是这个套路——你不会给狗讲解"坐"的定义,你只是在它凑巧坐下时给一块肉干。
五个要素(第二章的说法)和它们在语言模型里的对应物:
| 强化学习术语 | 训小狗 | 语言模型 |
|---|---|---|
| 智能体(Agent) | 狗 | 正在被训练的那个语言模型 |
| 环境(Environment) | 客厅 | 提示词、工具、文件系统、网页、代码解释器 |
| 状态(State) | 狗此刻看到的画面 | 模型此刻看到的整段上下文 |
| 动作(Action) | 坐下、握手、扑上来 | 吐出下一个 Token;或者发起一次工具调用 |
| 奖励(Reward) | 一块肉干 | 一个数字,由奖励函数或奖励模型给出 |
| 策略(Policy,记作 π) | 狗养成的习惯 | 模型那几十亿个参数本身 |
最后一行是全章最关键的一次概念对齐,单独强调:
在语言模型的强化学习里,"策略"不是一段代码、不是一份配置,就是模型的权重本身。所谓"训练",就是在改这个策略。 所以你会看到 PPO、GRPO 这些算法被叫做"策略优化(Policy Optimization)"——它们优化的就是模型权重。
1.2 Agentic RL 是什么:一个定义和一个对照
定义:Agentic RL 是把语言模型当成一个在环境中多步行动的智能体来做强化学习训练,奖励来自"它做的事有没有成",而不是"它写的话好不好听"。
光看定义没感觉,看同一道题在两代方法里的样子。题目:一件商品原价 80 元,先打七五折,再用 5 元优惠券,最终付多少钱?
上一代做法(PBRFT,下面第三节详讲):
模型生成一段文本:"打完折是 60 元,减去 5 元优惠券,应付 55 元。"
↓
奖励模型给整段打一个分:0.83
↓
更新参数,这一回合结束。全程一步。
Agentic RL 的做法:
第 0 步 模型想:"先算折扣价,再减券" → 环境没变,奖励 0
第 1 步 模型调用计算器 calc("80*0.75") → 环境返回 60,里程碑奖励 +0.1
第 2 步 模型看到 60,调用 calc("60-5") → 环境返回 55,里程碑奖励 +0.1
第 3 步 模型提交答案 "55" → 判定正确,结果奖励 +0.8
↓
一条 4 步的轨迹,4 个奖励,一起用来更新参数。
差别不在"分高分低",而在中间那两步工具调用根本不存在于 PBRFT 的世界里。PBRFT 的模型没有机会去"试一下、看结果、再决定下一步"。
正因为多了这个闭环,Agentic RL 训出来的模型会出现三个 PBRFT 训不出来的现象——这三条都是可观察的事实,不是宣传语:
- 它自己学会"这里该调工具"。没有人在提示词里写"遇到算术就用计算器";是因为调了工具的那些轨迹拿到了更高的奖励,这个倾向被强化进了参数。
- 它自己学会纠错。工具报错、结果对不上时重来一次的轨迹更容易拿到最终大分,于是"检查—重试"变成了习惯。
- 它的回答会自发变长。奖励只考核结果,而多想几步更容易答对,所以模型自己发现"把思考过程写长一点划算"。训练时看到平均回答长度上涨,通常是好现象,不是 bug。
为什么叫 "Agentic"? 因为训练目标变了。PBRFT 的目标是"生成一段得体的文本",Agentic RL 的目标是"作为一个 agent 达成一个目标"。同样一个模型,前者练的是嘴,后者练的是手。
1.3 本章名词速查表
后面会反复出现这些词,先在这里集中解释,读到卡住时回来查:
| 名词 | 一句话解释 |
|---|---|
| 策略(Policy, π) | 模型权重本身;"给定上下文,下一步做什么"的映射 |
| 轨迹(Trajectory) | 一个回合里"状态-动作-奖励"的完整序列,如上面那 4 步 |
| 回合(Episode) | 从开始到结束的一整次尝试。PBRFT 的回合长度恒为 1 |
| Rollout(采样/推演) | 让当前模型实际跑几遍任务、把轨迹收集下来。RL 训练的时间大头花在这里,不在反向传播 |
| 回报(Return, G) | 一条轨迹里所有奖励的折扣加总,$G=\sum_t \gamma^t r_t$。RL 最大化的是它,不是单步奖励 |
| 优势(Advantage, A) | "这个动作比平均水平好多少"。正的该鼓励,负的该抑制。GRPO 的核心就是用一种极省的方式算它 |
| On-policy | 只用当前模型自己跑出来的数据训练。RL 属于这一类,所以每训一步都得重新采样,这是它比 SFT 慢的根本原因 |
| 奖励函数(Reward Function) | 你写的一段普通 Python 代码,输入模型输出、返回一个分数 |
| 奖励模型(Reward Model, RM) | 一个训出来的打分模型,用在没法写规则判分的场合 |
| SFT(监督微调) | 用"问题→标准答案"成对数据教模型,本质是模仿 |
| KL 散度 | 衡量"现在的模型"离"训练前的模型"跑了多远,用来防止跑偏 |
| LoRA | 冻结原模型、只在旁边训两个小矩阵的省显存微调法 |
二、为什么要后训练:预训练之后还差三样东西
上一节说清了 Agentic RL 是什么,这一节回答为什么需要它。答案要从"预训练留下了什么没做完"讲起。
2.1 预训练到底学了什么
预训练就一个任务:猜下一个词。给模型看"中国的首都是",让它预测"北京";看错了就调参数,让"北京"这个词的概率高一点。几万亿个 Token 这么过一遍,模型学到了语法、事实、代码、翻译、常识——知识确实都在里面了。
但请注意这个训练目标的字面意思:它优化的是"在互联网文本里,接下来最可能出现什么词"。它从来没有被要求"帮用户把事办成"。这两件事的差距,就是后训练存在的全部理由。
一个直观的例子。你拿一个只做过预训练的基座模型(base model),输入:
帮我写一封请假邮件
它很可能给你续写出这样的东西:
帮我写一封请假邮件,谢谢!
帮我写一封辞职邮件怎么写?
帮我写一封感谢信...
它没有理解你在下指令,它在模仿"网上出现这句话的地方,后面通常还跟着什么"——比如一个问答论坛的标题列表。这不是模型笨,是它被训练出来干的活就是这个。
2.2 三个缺口,三段后训练
预训练完成后,还差三样东西,恰好对应后训练的三个阶段:
| 缺口 | 具体表现 | 谁来补 |
|---|---|---|
| 不听指令 | 你下指令,它续写;不知道"该轮到我回答了" | SFT:用几万条"指令→标准回答"教格式和对话习惯 |
| 不知好坏 | 两个回答都语法通顺,它没有任何机制判断哪个更有用、更安全、更诚实 | 奖励建模 + 强化学习(PBRFT/RLHF):让它知道人类偏好哪种 |
| 不会做事 | 多步任务里不知道何时该停下来查资料;算错了不会重算;工具调了失败也不重试 | Agentic RL:在真实任务里试错,按结果给奖励 |
三个缺口的性质完全不同,所以补法也不同,不能互相替代:
- SFT 能让模型"听话、格式对",但它只会模仿见过的答案。数据里没有的解法,它想不出来。
- PBRFT 能让模型"说得让人满意",但它整个回合只有一步,学不到"做事的顺序"。
- Agentic RL 才在练"多步做事"这件事本身。
2.3 那为什么不能只靠提示词?
这是个必须正面回答的问题,因为大多数读者的实际工作里,答案恰恰是"应该只靠提示词"。
三者的区别用一个类比说清:
| 手段 | 类比 | 影响范围 |
|---|---|---|
| 提示词工程 | 考试时给一张小抄 | 只影响这一次调用,换个问题就没了 |
| SFT | 考前反复背标准话术 | 改变模型的默认倾向,但上限是"像训练数据" |
| 强化学习 | 把能力练成肌肉记忆 | 改变模型的能力上限,可能超过任何人教它的东西 |
提示词能让模型"知道"该分步推理,但让它"真的擅长"分步推理,得靠奖励反复塑形。第五节那六大能力,没有一项能靠把提示词写长来解决。
但代价差了几个数量级:提示词改一行、五秒生效、零成本;RL 训练要 GPU、要几天、要评估集、崩了还得从检查点重来。所以本书的顺序永远是:工具 → 提示词 → RAG → 最后才是训练。第十节给出完整的决策清单;9.2 节的错误分析会给你一个真实数据:某次训练后的错误里 42% 是"算术算错",而这类错误挂个计算器工具就能消掉大半,根本不该用训练解决。
2.4 训练全景图:预训练之后的三段路
把训练一个"会做事的模型"想象成培养一名员工:
| 阶段 | 干什么 | 类比 | 产物 |
|---|---|---|---|
| 预训练(Pretrain) | 读几万亿 Token 的互联网文本,玩"猜下一个词" | 十几年通识教育 | 基座模型(博学但不听指挥) |
| SFT(监督微调) | 用几万条"指令→标准回答"教它听话 | 岗前培训,背标准话术 | 指令模型(你日常用的 Chat 版) |
| 奖励建模(RM) | 训练一个"打分器",学会判断哪个回答更好 | 培养一位考核主管 | 奖励模型 |
| 强化学习微调(RLHF/RLAIF) | 让模型追求打分器的高分 | 按考核结果发奖金 | 对齐后的模型 |
| Agentic RL | 在真实任务(解题/用工具)中试错,按结果对错给奖励 | 上一线干活,按业绩发奖金 | 会推理、会用工具的智能体模型 |
预训练之后的三段合称"后训练(Post-training)":先 SFT 教格式和基本能力,再训一个奖励模型当裁判,最后用强化学习去追这个裁判的高分。
名词补课——奖励建模(Reward Modeling, RM):强化学习需要一个能给任意回答打分的函数。但"这个回答好不好"这种主观判断写不出规则,所以干脆再训一个小模型来当裁判。做法是:让人类对同一问题的多个回答排序(A 好于 B 好于 C),用这些排序数据训练打分器。
RLHF(基于人类反馈的强化学习) 里的"H"就是 Human——排序数据来自人。RLAIF 把 H 换成 AI:让一个更强的模型(或同一个模型配上一份"宪法"式的评判准则)代替人类去排序。为什么要换:人工标注慢且贵,一批偏好数据几十万美元;AI 标注可以一夜跑几十万条。代价:AI 裁判自身的偏见会被原封不动地灌进学生模型。
关键分野在最后一步:SFT 是"模仿标准答案"(有人教),Agentic RL 是"自己找解法,只看结果给分"(没人教过程)。当奖励只考核"最终答案对不对"时,模型会自发学会写长长的思考过程、自我检查、发现错误后重来——推理能力是被奖励"逼"出来的,不是人教的。
注意出处:本章讲的 GRPO 算法出自 DeepSeekMath 论文,不是某个具体推理模型的专属发明。业界那些"会思考的模型"用的是这一类方法,但各家配方不公开,别把算法和产品混为一谈。
三、PBRFT 是什么:上一代方法完整走一遍
3.1 名字先拆开
PBRFT = Preference-Based Reinforcement Fine-Tuning,基于偏好的强化微调。 三个词各自的意思:
- Preference-Based(基于偏好):训练信号不是"标准答案",而是人类在两个回答之间的选择。标注员不需要自己写出完美回答,只要回答"A 和 B 哪个更好"——这比写答案便宜得多,也现实得多(什么叫"最好的回答"根本没人说得清,但"这个比那个好"人人都能判断)。
- Reinforcement(强化):用强化学习算法(通常是 PPO)去更新模型,而不是用监督学习。
- Fine-Tuning(微调):在已经预训练+SFT 过的模型上继续训,不是从零开始。
你日常用的所有 Chat 模型都经过这一步。 它是过去几年"对齐(Alignment)"的主力方法,RLHF 就是 PBRFT 最著名的实例。
注意术语的层级关系,面试容易混:PBRFT 是一类训练范式;RLHF 是这类范式里"偏好来自人"的具体做法;PPO 是执行这个做法用的优化算法。它们不是同一层的东西,不要并列着说。
3.2 完整流程,带具体数字
拿"帮我写一封请假邮件"举例,PBRFT 的三步:
第一步:采集偏好数据。 让 SFT 后的模型对同一个提示词生成 4 个回答,让标注员排序:
提示词: 帮我写一封请假邮件
回答 A: 尊敬的领导,因家中有事,我申请于 3 月 5 日请假一天... ← 标注员排第 1
回答 B: 好的,请问您想请几天假? ← 排第 3(反问而不办事)
回答 C: 领导我不来了。 ← 排第 4
回答 D: 尊敬的领导:我因个人原因需请假,具体日期待定... ← 排第 2
这样的数据要几万到几十万条。这是整条流水线最贵的一环——2.4 节的名词补课里提到过,一批高质量偏好数据的标注成本可达几十万美元,这也正是 RLAIF(让 AI 代替人来排序)出现的原因。
第二步:训练奖励模型(RM)。 用上面的排序训一个打分器。它的训练目标很直白:让排名靠前的回答得分更高。训完之后,给它任意一个"提示词+回答",它输出一个分数:
RM("帮我写一封请假邮件", 回答 A) = 0.91
RM("帮我写一封请假邮件", 回答 C) = 0.12
第三步:用 PPO 优化模型去追这个分数。 循环做:模型生成回答 → RM 打分 → 分高的方向被强化 → 同时用 KL 散度拽着它别跑太远(8.3 节详讲)。
一个回合的全貌,注意它只有一步:
提示词 ──→ 模型生成一整段回答 ──→ RM 给一个分 ──→ 更新参数 ──→ 回合结束
3.3 PBRFT 的三个天花板
PBRFT 把模型从"续写机器"变成了"能对话的助手",这个贡献很大。但它有三个结构性的限制,而这三条恰好就是 Agentic RL 的切入点:
| 天花板 | 具体表现 | 根因 |
|---|---|---|
| 只有一步,学不到做事顺序 | 无法学习"先查资料再回答""算错了重算一遍"这类跨步骤的策略 | 回合长度恒为 1,不存在"下一步" |
| 不碰环境,拿不到真实反馈 | 编造的 API 名字和真实的 API 名字,在 RM 眼里都只是"看起来专业的文本" | 没有真实执行,只有文本打分 |
| 奖励是主观偏好,可以被讨好 | 模型学会写得长、写得客气、句式讨喜,因为标注员和 RM 都偏爱这些 | RM 学的是人类偏好,而人类偏好本身有偏 |
第三条有个专门的名字叫 谄媚(Sycophancy):模型学会附和用户而不是纠正用户,因为"被附和"的回答在偏好数据里赢得更多。这不是模型的道德问题,是奖励信号选错了的必然结果。
Agentic RL 换掉的正是奖励的来源:从"人觉得这段话好不好"换成"这件事到底成了没成"。答案对就是对,测试跑过就是跑过,工具调通就是调通——这种奖励没法讨好,只能真做到。
一句话总结两代的关系:PBRFT 教模型怎么说话,Agentic RL 教模型怎么做事。后者不是取代前者——今天的模型仍然要先经过 SFT 和偏好对齐,才有资格进入 Agentic RL 阶段。
四、PBRFT vs Agentic RL:用 MDP 五元组一格一格对
上一节那三个天花板里的前两个("只有一步""不碰环境"),用强化学习的标准语言说出来会更精确。这不是换个术语显得高级,而是因为整个问题的数学形式真的变了。
强化学习用一个五元组 $(S, A, P, R, \gamma)$ 描述问题——这五个字母就是"状态、动作、转移、奖励、折扣":
| 五元组 | PBRFT(传统对齐) | Agentic RL(智能体) |
|---|---|---|
| S 状态(State) | 就是那一条提示词,固定不变 | 环境的当下快照:文件系统、网页内容、工具返回值,每步都在变 |
| A 动作(Action) | 只有一种:生成一段文本 | 两种:生成文本 + 调用工具(动作空间大了一个维度) |
| P 转移(Transition) | 没有转移——生成完就结束了 | 有真实转移:你 rm 了一个文件,世界就真的变了 |
| R 奖励(Reward) | 一次性:整段回答打一个分 | 可以逐步给:每完成一个里程碑给一点 |
| γ 折扣(Discount) | 用不上(只有一步) | 关键参数:决定模型是"急功近利"还是"愿意为长远回报忍耐" |
用大白话说:PBRFT 是闭卷作文考试,交卷即结束,老师给一个总分;Agentic RL 是把人扔进真实工位,他要观察、动手、看结果、再动手,干完一整件事。
名词补课——马尔可夫决策过程(MDP):强化学习的标准问题模板。"马尔可夫"这个词只表达一件事——下一步会变成什么样,只取决于当前状态和你这一步做了什么,跟你是怎么走到这儿的无关。这个假设让问题可解。折扣因子 γ(读作 gamma,取 0 到 1 之间)表示"未来的奖励打几折":γ=0 是只看眼前一步,γ=0.99 是把十几步之后的回报也算进来。
这张表是本章的骨架,但光看表容易"当时懂了、过两天忘了"。7.1 节的实验一会把这五行差异实际跑出来打印在屏幕上——同一道题跑两遍,亲眼看 PBRFT 那条轨迹只有 1 行、状态种类只有 1 种、环境改变 0 次,而 Agentic RL 是 4 行、3 种、2 次。建议读到这里就先跳去把那个实验跑完,再回来看下面的六大能力。
五、Agentic RL 要练什么:六大核心能力
Agentic RL 不是笼统地"变聪明",而是有六个明确的训练靶子:
| 能力 | 要练成什么样 | 奖励信号从哪来 |
|---|---|---|
| 推理(Reasoning) | 会分步拆解、会自我检查、发现错了能重来 | 最终答案对不对 |
| 工具使用(Tool Use) | 知道什么时候该调工具、调哪个、参数怎么填 | 调用是否成功、返回值是否有用 |
| 记忆(Memory) | 该记的记住,该忘的忘掉,长任务里不丢关键信息 | 后续步骤是否用上了前面的信息 |
| 规划(Planning) | 先列计划再动手,而不是想一步走一步 | 整体任务完成度、步数是否经济 |
| 自我改进(Self-improvement) | 看到失败反馈能自己修正,不重复同一个错 | 重试后是否成功 |
| 感知(Perception) | 看懂截图、表格、网页结构等非纯文本输入 | 基于所见的判断是否正确 |
看这张表最重要的一个收获:这六项没有一项能靠"把提示词写长"解决。提示词能让模型"知道"该分步推理,但让它"真的擅长"分步推理,得靠奖励反复塑形。反过来说——如果你的问题能用提示词解决,就不该碰训练(见第十节的决策清单)。
六、数据集与奖励函数:整条流水线的灵魂
6.1 GSM8K:小学数学,大模型的试金石
GSM8K 是小学数学应用题数据集,训练集 7,473 题,测试集 1,319 题,每题需要 2 到 8 个推理步骤才能解出,答案行固定以 #### 数字 结尾:
Natalia sold clips to 48 of her friends in April, and then she sold half
as many clips in May. How many clips did Natalia sell altogether?
Natalia sold 48/2 = <<48/2=24>>24 clips in May.
Natalia sold 48+24 = <<48+24=72>>72 clips altogether.
#### 72
为什么选它入门?答案唯一、可自动判分——这正是强化学习最需要的"客观裁判"。
名词补课——
<<48/2=24>>是什么?这是数据集自带的中间计算标记,方便程序校验每一步算术。它是给数据集工具用的,训练前必须删掉,否则模型会学着输出这些奇怪的尖括号。
6.2 同一批题,三种格式
这是本章最容易被忽略、但实操中最先卡住人的一点:同一道题,喂给 SFT 和喂给 RL,数据长得完全不一样。
| 格式 | 字段 | 装什么 | 谁用 |
|---|---|---|---|
| 原始格式 | question / answer |
原封不动的数据集内容,含 <<>> 标记 |
人看、做转换的输入 |
| SFT 格式 | prompt / completion |
prompt 是对话模板包住的问题;completion 是完整解题过程 + 最终答案 | 监督微调 |
| RL 格式 | prompt / ground_truth |
prompt 一样;但只给最终答案,解题过程要模型自己长出来 | 强化学习 |
prompt 里的 <|im_start|>user / <|im_end|> 是对话模板标记(chat template)。模型在预训练/SFT 阶段就被教会了"看到 <|im_start|>assistant 就该我说话了",所以推理和训练时必须用同一套标记,否则模型不知道自己该接话。
一句话记住区别:SFT 把答案和过程都给你,是"照着抄";RL 只给答案,是"过程自己想,对了才给糖"。这个差别在下面的实验二里会被打印出来。
6.3 奖励函数:你告诉模型什么是"好"
奖励函数(Reward Function)=给模型每次输出打分的规则。它是整个 RL 中最需要人类智慧的一环:奖励设计歪了,模型就会钻空子(术语叫 Reward Hacking,奖励作弊)。经典翻车:只奖励"答案对",模型学会输出一万字把碰巧对的答案埋在里面;只奖励"简短",模型干脆不推理瞎猜。
本章要实现的三种内置奖励是:
| 奖励 | 公式 | 意图 |
|---|---|---|
| 准确率奖励 | $r_{acc} = 1$ 若答案正确,否则 $0$ | 唯一的硬指标,其余两项都建立在它之上 |
| 长度惩罚 | $r_{length} = r_{acc} - \alpha \cdot \max(0,\ l - l_{target})$ | 答对的前提下,越啰嗦扣得越多 |
| 步骤奖励 | $r_{step} = r_{acc} + \beta \cdot s$ | 答对的前提下,推理步骤多一点给一点甜头 |
最关键的一个设计细节,请务必看清:两个公式里都有 $r_{acc}$ 这一项,而且 $r_{acc}$ 答错时是 0。这意味着——
长度惩罚和步骤奖励都只在"答对"时才生效。答错了,啰嗦不额外扣分,堆步骤也不给分。
为什么必须这么设计?如果长度惩罚脱离正确性独立生效,模型会发现一条捷径:啥也不推理,直接瞎猜一个数字——答案错了拿 0 分,但至少长度惩罚也是 0,总分比"认真推理但答错"还高。奖励函数就这样把模型教成了不思考的懒汉。这是一个真实且高频的翻车点。
6.4 稀疏、密集、混合:奖励该多久给一次
| 类型 | 什么时候给分 | 优点 | 缺点 |
|---|---|---|---|
| 稀疏奖励 | 只在任务全部完成时给一次 | 不会误导,模型自己找路 | 长任务里几乎全是 0,学不动(奖励稀疏问题) |
| 密集奖励 | 每一步都给一点 | 学得快,信号足 | 容易被钻空子,人为拆分可能拆错 |
| 混合奖励 | 关键里程碑给小分,最终结果给大分 | 兼顾两者,实战最常用 | 权重要调 |
混合奖励长什么样?以"让智能体在一个 GitHub 仓库里修 bug"为例,把总分 1.0 拆到四个里程碑上:
找到相关仓库 +0.1
定位到出问题的文件 +0.1
给出正确的问题诊断 +0.2
最终提交能通过测试的修复 +0.6
------
1.0
注意权重分配的思路:大头(0.6)永远留给"真的把事办成了",过程分只是路标,防止模型在前几步就完全迷路拿不到任何信号。如果过程分给太多,模型会学会"漂亮地走完前三步然后放弃"。
七、动手实战:四个离线实验
全部离线、零依赖、不装包、不用 GPU、不用密钥。 奖励函数和数据转换就是普通 Python 函数,这是应用工程师离训练最近的一块阵地。
新建目录:
rl_lab/
├── mdp_lab.py # 实验一:PBRFT 单轮 vs Agentic RL 多步 + γ 折扣
├── dataset_lab.py # 实验二:三种数据格式转换
├── reward_lab.py # 实验三:三种奖励 + GRPO 组内优势
└── lora_lab.py # 实验四:LoRA 参数量算术
7.1 实验一:把 PBRFT 和 Agentic RL 的差别打印出来
第四节那张五元组对比表是本章的骨架,但表格看着容易"懂了",过两天就忘。这个实验把两种范式各跑一遍同一道题(80 元打七五折再减 5 元券),让那五格差异变成屏幕上的数字。
新建 mdp_lab.py。
第 1 段:一个绝不用 eval 的迷你计算器
"""实验一:PBRFT 单轮 vs Agentic RL 多步(离线、零依赖、纯 Python)。"""
import re
QUESTION = "一件商品原价 80 元,先打七五折,再用 5 元优惠券。最终付多少钱?"
def safe_eval(expr: str) -> float:
"""只认 '数字 运算符 数字',绝不用 eval(见本章安全提示)。"""
m = re.fullmatch(r"\s*(-?\d+(?:\.\d+)?)\s*([*+\-/])\s*(-?\d+(?:\.\d+)?)\s*", expr)
if not m:
raise ValueError(f"不支持的表达式: {expr}")
a, op, b = float(m.group(1)), m.group(2), float(m.group(3))
return {"*": a * b, "+": a + b, "-": a - b, "/": a / b}[op]
为什么不用
eval("80*0.75")?因为在真实训练里,这个字符串是被训模型生成的,完全不可信。模型完全可能吐出__import__("os").system("rm -rf /")。奖励函数和工具里永远不要eval模型的输出——这条在本章末的安全提示里还会再强调一次。
第 2 段:PBRFT 的一整个回合(注意它只有一行状态)
def run_pbrft(answer_text: str):
"""PBRFT 的一整个回合:状态=那条提示词,动作=生成一段文本,拿一个总分,结束。"""
state = "prompt(固定不变)"
reward = 1.0 if "55" in answer_text else 0.0 # 偏好打分器给的整段总分
return [{"t": 0, "state": state, "action": f"生成文本《{answer_text}》",
"obs": "—(没有环境反馈)", "reward": reward, "done": True}]
这个函数短得有点可疑,但这就是 PBRFT 的全部结构:进去一个提示词,出来一个分数,done=True。没有循环,因为没有"下一步"。
第 3 段:Agentic RL 的环境(世界真的会变)
class ShoppingEnv:
"""极简环境:智能体可以想、可以调 calc 工具、也可以直接交答案。"""
def __init__(self):
self.notes = [] # 环境状态的一部分:工具返回值的历史
self.done = False
def state(self) -> str:
return f"question + 工具结果{self.notes}"
def step(self, action):
kind, payload = action
reward, obs = 0.0, ""
if kind == "think":
obs = "(内心独白,环境没变)"
elif kind == "calc":
value = safe_eval(payload)
self.notes.append(f"{payload}={value:g}") # ← 世界真的变了
obs = f"工具返回 {value:g}"
if abs(value - 60.0) < 1e-6:
reward = 0.1 # 里程碑:算对折扣价
elif abs(value - 55.0) < 1e-6:
reward = 0.1 # 里程碑:用上优惠券
elif kind == "answer":
self.done = True
reward = 0.8 if payload.strip() == "55" else 0.0 # 大头留给"真办成了"
obs = "回合结束"
return self.state(), reward, self.done, obs
step(action)返回(新状态, 奖励, 是否结束, 观察)这个四元组不是我随便定的,它是强化学习的行业标准接口(OpenAI Gym / Gymnasium 的step()就是这个签名),第二章实验四的格子世界也用了同样的形状。认下这个签名,你就能读懂绝大多数 RL 代码。
0.1 / 0.1 / 0.8这三个数就是 6.4 节讲的混合奖励:过程分只是路标,大头 0.8 留给"真的把事办成了"。
第 4 段:跑一条轨迹 + 算折扣回报
def run_agentic(policy):
env, traj = ShoppingEnv(), []
for t, action in enumerate(policy):
s_before = env.state()
_, reward, done, obs = env.step(action)
traj.append({"t": t, "state": s_before, "action": f"{action[0]}({action[1]})",
"obs": obs, "reward": reward, "done": done})
if done:
break
return traj
def discounted_return(rewards, gamma):
return sum(r * gamma ** t for t, r in enumerate(rewards))
policy这里是我预先写好的一串动作,因为我们没有真的在训练模型。在真实的 Agentic RL 里,这串动作是模型自己生成的(这个过程叫 Rollout),而训练要做的事就是:让能拿到高回报的那串动作,以后出现的概率更高。
discounted_return就是 $G=\sum_t \gamma^t r_t$ 这个公式的一行实现:第 t 步的奖励要乘 $\gamma^t$ 打折。
第 5 段:打印四张对照表
def show(title, traj):
print("=" * 72)
print(title)
print("=" * 72)
print("t | 状态 S | 动作 A | 奖励 R | 结束")
print("-" * 72)
for row in traj:
print(f"{row['t']} | {row['state']} | {row['action']} "
f"| {row['reward']:.2f} | {row['done']}")
print(f"→ 共 {len(traj)} 步,奖励序列 {[r['reward'] for r in traj]}")
if __name__ == "__main__":
pbrft = run_pbrft("打完折是 60,减 5 元,应付 55 元。")
show("① PBRFT:交卷即结束,整段回答拿一个总分", pbrft)
policy = [("think", "先算折扣价,再减券"),
("calc", "80*0.75"),
("calc", "60-5"),
("answer", "55")]
agentic = run_agentic(policy)
show("\n② Agentic RL:观察→动手→看结果→再动手", agentic)
print("\n" + "=" * 72)
print("③ 同一件事,五元组的实测差异")
print("=" * 72)
print(f"{'五元组':<10}{'PBRFT':>8}{'Agentic RL':>14}")
print("-" * 40)
rows = [
("S 状态种类", len({r["state"] for r in pbrft}), len({r["state"] for r in agentic})),
("A 动作类别", len({r["action"].split("(")[0].split("《")[0] for r in pbrft}),
len({r["action"].split("(")[0] for r in agentic})),
("P 环境改变", 0, sum(1 for r in agentic if "工具返回" in r["obs"])),
("R 奖励次数", sum(1 for r in pbrft if r["reward"] > 0),
sum(1 for r in agentic if r["reward"] > 0)),
("总步数", len(pbrft), len(agentic)),
]
for name, a, b in rows:
print(f"{name:<10}{a:>8}{b:>14}")
print("\n" + "=" * 72)
print("④ γ 折扣:同一条轨迹,γ 越小,最后那个 0.8 越不值钱")
print("=" * 72)
rewards = [r["reward"] for r in agentic]
final_t = len(rewards) - 1
print(" γ | 回报 G | 最终 0.8 折后 | 占回报比例 | 一句话解读")
print("-" * 72)
for g in (0.0, 0.5, 0.9, 0.99, 1.0):
G = discounted_return(rewards, g)
tail = rewards[final_t] * g ** final_t
share = tail / G * 100 if G else 0.0
note = {0.0: "只看眼前一步,后面全不算",
0.5: "过程分和结果分几乎等重,容易捞分就跑",
0.9: "健康:结果分仍是大头",
0.99: "几乎不打折,愿意为长远忍耐",
1.0: "完全不打折(只适合有限步任务)"}[g]
print(f"{g:>5.2f} | {G:>6.4f} | {tail:>12.4f} | {share:>9.1f}% | {note}")
运行 python mdp_lab.py。
✅ Checkpoint 1 —— 实际输出:
========================================================================
① PBRFT:交卷即结束,整段回答拿一个总分
========================================================================
t | 状态 S | 动作 A | 奖励 R | 结束
------------------------------------------------------------------------
0 | prompt(固定不变) | 生成文本《打完折是 60,减 5 元,应付 55 元。》 | 1.00 | True
→ 共 1 步,奖励序列 [1.0]
========================================================================
② Agentic RL:观察→动手→看结果→再动手
========================================================================
t | 状态 S | 动作 A | 奖励 R | 结束
------------------------------------------------------------------------
0 | question + 工具结果[] | think(先算折扣价,再减券) | 0.00 | False
1 | question + 工具结果[] | calc(80*0.75) | 0.10 | False
2 | question + 工具结果['80*0.75=60'] | calc(60-5) | 0.10 | False
3 | question + 工具结果['80*0.75=60', '60-5=55'] | answer(55) | 0.80 | True
→ 共 4 步,奖励序列 [0.0, 0.1, 0.1, 0.8]
========================================================================
③ 同一件事,五元组的实测差异
========================================================================
五元组 PBRFT Agentic RL
----------------------------------------
S 状态种类 1 3
A 动作类别 1 3
P 环境改变 0 2
R 奖励次数 1 3
总步数 1 4
========================================================================
④ γ 折扣:同一条轨迹,γ 越小,最后那个 0.8 越不值钱
========================================================================
γ | 回报 G | 最终 0.8 折后 | 占回报比例 | 一句话解读
------------------------------------------------------------------------
0.00 | 0.0000 | 0.0000 | 0.0% | 只看眼前一步,后面全不算
0.50 | 0.1750 | 0.1000 | 57.1% | 过程分和结果分几乎等重,容易捞分就跑
0.90 | 0.7542 | 0.5832 | 77.3% | 健康:结果分仍是大头
0.99 | 0.9732 | 0.7762 | 79.8% | 几乎不打折,愿意为长远忍耐
1.00 | 1.0000 | 0.8000 | 80.0% | 完全不打折(只适合有限步任务)
四个观察,每一个都对应第四节那张表里的一行:
- 表 ① 只有一行,表 ② 有四行。这就是"回合长度恒为 1"和"多步"的全部区别。PBRFT 的模型没有第 1、2 步那样的机会——先算一下,看看结果,再决定下一步。
S 状态种类:1 vs 3。PBRFT 的状态从头到尾是那条提示词;Agentic RL 里工具结果[]→['80*0.75=60']→['80*0.75=60', '60-5=55'],状态是被自己的动作改出来的。P 环境改变:0 vs 2。这是 MDP 里的"转移"真正存在的证据:两次calc都往notes里写了东西,世界回不到从前。- γ=0 那一行,回报是
0.0000。这个数很值得盯一会儿:轨迹里明明有 1.0 分的奖励,但 γ=0 意味着"第 0 步之后的都不算",而第 0 步偏偏是那个奖励为 0 的think。一个只看眼前一步的智能体,会认为"思考"这个动作毫无价值——它永远学不会为了后面的收益先花一步做准备。这就是第二章说的信用分配问题,也是 γ 这个参数在 Agentic RL 里突然变关键的原因。
改一改(必做两个):
- 把
policy换成[("answer", "55")]——不用工具直接蒙对。跑出来奖励只有0.8,比老老实实用工具的 1.0 少。这正是过程奖励的用意:它让"好好走流程"比"瞎蒙对了"更划算。再换成[("answer", "60")](蒙错),奖励0.0。 - 在
policy开头多塞两个("think", "..."),让轨迹变成 6 步。γ=0.9 时回报从0.7542掉到0.6109——同样把事办成了,绕的弯路越多,回报越低。这就是 γ 自带的"步数经济性"约束,不需要你另写一条惩罚。
7.2 实验二:三种数据格式转换
新建 dataset_lab.py,逐段敲入:
第 1 段:准备原始数据
"""实验二:GSM8K 三种数据格式转换(离线、零依赖)。"""
import json
import re
# 原始格式:直接来自数据集,给人看的
RAW = [
{
"question": "Natalia sold clips to 48 of her friends in April, and then she "
"sold half as many clips in May. How many clips did Natalia sell "
"altogether in April and May?",
"answer": "Natalia sold 48/2 = <<48/2=24>>24 clips in May.\n"
"Natalia sold 48+24 = <<48+24=72>>72 clips altogether.\n#### 72",
},
{
"question": "Weng earns $12 an hour for babysitting. Yesterday she did 50 "
"minutes of babysitting. How much did she earn?",
"answer": "Weng earns 12/60 = $<<12/60=0.2>>0.2 per minute.\n"
"For 50 minutes she earned 0.2 x 50 = $<<0.2*50=10>>10.\n#### 10",
},
]
第 2 段:两个清洗小工具
def extract_ground_truth(answer: str) -> str:
"""抽出 '#### 数字' 后面的最终答案"""
m = re.search(r"####\s*(-?[\d,]+(?:\.\d+)?)", answer)
return m.group(1).replace(",", "") if m else ""
def strip_calc_marks(answer: str) -> str:
"""去掉 <<48/2=24>> 这种给数据集自己用的中间计算标记"""
return re.sub(r"<<[^>]*>>", "", answer)
re.sub(r"<<[^>]*>>", "", answer)读法:<<开头、>>结尾,中间[^>]*表示"任意个不是>的字符",整段替换成空字符串。这就是删标记。
第 3 段:两个格式转换函数
def to_sft(sample: dict) -> dict:
"""SFT 格式:prompt = 对话模板包住的问题;completion = 完整解题过程 + 答案"""
prompt = (f"<|im_start|>user\n{sample['question']}<|im_end|>\n"
f"<|im_start|>assistant\n")
body = strip_calc_marks(sample["answer"]).split("####")[0].strip()
truth = extract_ground_truth(sample["answer"])
completion = f"Let me solve this step by step.\n\n{body}\n\nFinal Answer: {truth}<|im_end|>"
return {"prompt": prompt, "completion": completion}
def to_rl(sample: dict) -> dict:
"""RL 格式:prompt 一样,但只给最终答案,解题过程要模型自己长出来"""
return {
"question": sample["question"],
"prompt": (f"<|im_start|>user\n{sample['question']}<|im_end|>\n"
f"<|im_start|>assistant\n"),
"ground_truth": extract_ground_truth(sample["answer"]),
}
第 4 段:打印对比
if __name__ == "__main__":
s = RAW[0]
print("=" * 60)
print("① 原始格式(给人看,含 <<>> 中间计算标记)")
print("=" * 60)
print("question:", s["question"][:60], "...")
print("answer :", s["answer"].replace("\n", " | "))
print("\n" + "=" * 60)
print("② SFT 格式(教模型'照着答':prompt + completion)")
print("=" * 60)
sft = to_sft(s)
print(json.dumps(sft, ensure_ascii=False, indent=2))
print("\n" + "=" * 60)
print("③ RL 格式(只给答案,过程自己想:prompt + ground_truth)")
print("=" * 60)
rl = to_rl(s)
print(json.dumps(rl, ensure_ascii=False, indent=2))
print("\n字段对比:")
print(f" SFT 字段: {sorted(sft.keys())}")
print(f" RL 字段: {sorted(rl.keys())}")
print(f" 两者 prompt 完全一致? {sft['prompt'] == rl['prompt']}")
print(f" SFT 的 completion 有 {len(sft['completion'])} 字符(解题过程全给),"
f"RL 的 ground_truth 只有 {len(rl['ground_truth'])} 字符")
运行 python dataset_lab.py。
✅ Checkpoint 2 —— 你应该看到(这是实际运行输出,末尾几行是重点):
② SFT 格式(教模型'照着答':prompt + completion)
{
"prompt": "<|im_start|>user\nNatalia sold clips to 48 of her friends...<|im_end|>\n<|im_start|>assistant\n",
"completion": "Let me solve this step by step.\n\nNatalia sold 48/2 = 24 clips in May.\nNatalia sold 48+24 = 72 clips altogether.\n\nFinal Answer: 72<|im_end|>"
}
③ RL 格式(只给答案,过程自己想:prompt + ground_truth)
{
"question": "Natalia sold clips to 48 of her friends in April, ...",
"prompt": "<|im_start|>user\nNatalia sold clips...<|im_end|>\n<|im_start|>assistant\n",
"ground_truth": "72"
}
字段对比:
SFT 字段: ['completion', 'prompt']
RL 字段: ['ground_truth', 'prompt', 'question']
两者 prompt 完全一致? True
SFT 的 completion 有 139 字符(解题过程全给),RL 的 ground_truth 只有 2 字符
三个观察:
两者 prompt 完全一致? True—— 问模型的方式没变,变的只是"标准答案给到什么程度"。- 139 字符 vs 2 字符 —— SFT 把整个解题过程都交给模型抄;RL 只丢了个
"72"过去。这 137 个字符的差距,就是"模仿"和"探索"的全部差别。 <<48/2=24>>在 SFT 的 completion 里消失了 ——strip_calc_marks干掉了它。这一步漏掉,模型就会学着输出尖括号。
改一改(必做):把 to_sft 里的 strip_calc_marks(...) 换成 sample["answer"],再跑一次,看 completion 里冒出来的 <<48/2=24>>。想象模型学会了输出这个,然后你的正则提取器认不出来——这就是一次典型的脏数据事故。
7.3 实验三:三种奖励 + GRPO 组内优势
新建 reward_lab.py。
第 1 段:答案提取
"""实验三:三种内置奖励函数 + GRPO 组内相对优势(离线、零依赖)。"""
import re
from typing import List
def extract_answer(text: str) -> str:
"""先认 'Final Answer: 数字',认不出就抓最后一个数字"""
m = re.search(r"Final Answer:\s*(-?[\d,]+(?:\.\d+)?)", text)
if m:
return m.group(1).replace(",", "")
nums = re.findall(r"-?\d+(?:\.\d+)?", text)
return nums[-1] if nums else ""
为什么要有"抓最后一个数字"这个兜底?因为模型经常忘了写规定格式。兜底不是纵容,而是防止奖励全 0 导致训练学不动——格式该不该罚,由单独的格式正确率指标去管。
第 2 段:奖励一,准确率(其余两个都建立在它之上)
def accuracy_reward(completion: str, ground_truth: str) -> float:
pred = extract_answer(completion)
if not pred:
return 0.0
try:
return 1.0 if abs(float(pred) - float(ground_truth)) < 1e-6 else 0.0
except ValueError:
return 0.0
abs(a - b) < 1e-6而不是a == b:浮点数在计算机里有微小误差,0.1+0.2不严格等于0.3。所以数值比较永远用"差值足够小"。try/except ValueError是防模型吐出"abc"这种转不成数字的东西把整个训练进程搞崩。
第 3 段:奖励二和三——注意那个 if r_acc == 0.0: return 0.0
def length_reward(completion: str, ground_truth: str,
target_len: int = 200, alpha: float = 0.001) -> float:
r_acc = accuracy_reward(completion, ground_truth)
if r_acc == 0.0: # ← 关键:答错了不谈长度
return 0.0
over = max(0, len(completion) - target_len)
return r_acc - alpha * over
def step_reward(completion: str, ground_truth: str,
beta: float = 0.05, max_steps: int = 5) -> float:
r_acc = accuracy_reward(completion, ground_truth)
if r_acc == 0.0: # ← 同样:答错了堆步骤不给分
return 0.0
steps = len(re.findall(r"(?:Step\s*\d|=)", completion))
return r_acc + beta * min(steps, max_steps)
min(steps, max_steps)是给步骤奖励设上限。没有这个上限,模型会发现"多写等号就能加分",于是输出====。任何"越多越好"的奖励项都必须封顶。
第 4 段:批量接口(训练框架要求的签名)
def batch_accuracy(completions: List[str], **kwargs) -> List[float]:
truths = kwargs.get("ground_truth", [])
return [accuracy_reward(c, t) for c, t in zip(completions, truths)]
这个签名不能改。训练框架(TRL 的
GRPOTrainer)会把一整批生成结果作为completions传进来,数据集里的其他列(比如ground_truth)通过**kwargs一并送到。所以你的函数必须接收列表、返回等长的列表。写成单条的def f(completion, truth)会直接报错。
第 5 段:GRPO 组内相对优势
def grpo_advantages(rewards: List[float]) -> List[float]:
mean = sum(rewards) / len(rewards)
return [round(r - mean, 4) for r in rewards]
就这两行。这就是 GRPO 省掉价值网络的全部秘密——不请裁判估计"这一步值多少分",而是让同一道题的多个答案互相比,超过组内平均分的被强化。
第 6 段:跑起来看
if __name__ == "__main__":
GT = "72"
samples = [
("答对+简洁", "Step 1: 48/2 = 24\nStep 2: 48+24 = 72\nFinal Answer: 72"),
("答对+啰嗦", "Let me think very carefully about this problem. " * 12 +
"\nStep 1: 48/2 = 24\nStep 2: 48+24 = 72\nFinal Answer: 72"),
("答错+啰嗦", "Let me think very carefully about this problem. " * 12 +
"\nStep 1: 48*2 = 96\nFinal Answer: 96"),
("没给答案", "I am not sure how to solve this."),
]
print(f"{'样本':<12}{'长度':>6}{'准确率':>9}{'长度惩罚后':>13}{'步骤奖励后':>13}")
print("-" * 56)
for name, c in samples:
print(f"{name:<12}{len(c):>6}{accuracy_reward(c, GT):>9.2f}"
f"{length_reward(c, GT):>13.4f}{step_reward(c, GT):>13.4f}")
print("\n" + "=" * 56)
print("GRPO 组内相对优势:同一道题采样 4 个答案")
print("=" * 56)
rewards = [1.0, 1.0, 0.0, 0.8]
adv = grpo_advantages(rewards)
print(f"四个答案的奖励: {rewards}")
print(f"组内平均: {sum(rewards) / len(rewards)}")
print(f"相对优势: {adv}")
print("\n批量接口自测:", batch_accuracy(
[c for _, c in samples], ground_truth=[GT] * len(samples)))
运行 python reward_lab.py。
✅ Checkpoint 3 —— 实际输出:
样本 长度 准确率 长度惩罚后 步骤奖励后
--------------------------------------------------------
答对+简洁 53 1.00 1.0000 1.2000
答对+啰嗦 630 1.00 0.5700 1.2000
答错+啰嗦 611 0.00 0.0000 0.0000
没给答案 32 0.00 0.0000 0.0000
========================================================
GRPO 组内相对优势:同一道题采样 4 个答案
========================================================
四个答案的奖励: [1.0, 1.0, 0.0, 0.8]
组内平均: 0.7
相对优势: [0.3, 0.3, -0.7, 0.1]
批量接口自测: [1.0, 1.0, 0.0, 0.0]
四个观察,每一个都对应一条设计原则:
- 第 1 行 vs 第 2 行:两个都答对,简洁的拿 1.0000,啰嗦的(630 字符)被扣到 0.5700。扣掉的正好是 $0.001 \times (630-200) = 0.43$。长度惩罚生效了。
- 第 3 行是本实验的重点:答错的样本啰嗦到 611 字符,长度惩罚仍然是 0.0000。因为惩罚建立在 $r_{acc}$ 之上——答错了根本没有分可扣。这正是 6.3 节强调的那个设计。
- 步骤奖励那一列,第 1、2 行都是 1.2000:因为
min(steps, max_steps)封顶在 5,两个答案的步骤数都超过了上限,所以拿一样的满额加分 $1.0 + 0.05 \times 5$。 - 相对优势
[0.3, 0.3, -0.7, 0.1]:平均分 0.7,所以 1.0 的两个是 +0.3(鼓励),0.0 的那个是 −0.7(强力抑制),0.8 的是 +0.1(轻微鼓励)。全程没有出现任何价值网络。
改一改(必做两个):
- 把
length_reward里的if r_acc == 0.0: return 0.0删掉,重跑。第 3 行会变成 −0.4110,而"没给答案"那行仍是 0.0000。现在模型的最优策略是:啥也别写,直接瞎猜——瞎猜得 0,认真推理答错得 −0.41。你亲手把模型教成了懒汉,这就是 Reward Hacking。 - 把
rewards改成[1.0, 1.0, 1.0, 1.0],看相对优势全变成[0.0, 0.0, 0.0, 0.0]。组内答案全对(或全错)时,GRPO 拿不到任何学习信号——这就是为什么题目难度要分层,全是简单题的数据集训不出东西。
7.4 实验四:LoRA 到底省了多少
新建 lora_lab.py。这个实验一行深度学习代码都没有,纯算术,但它能让你彻底搞懂 LoRA。
先讲原理:微调一个 $d \times k$ 的权重矩阵 $W$,本来要训 $d \times k$ 个参数。LoRA 的想法是——别动 $W$,在旁边挂两个瘦长的小矩阵:$B$ 是 $d \times r$,$A$ 是 $r \times k$,其中 $r$(叫 rank,秩)特别小,比如 8。
$$\Delta W = BA, \qquad W_{new} = W + \frac{\alpha}{r} BA$$
要训的参数从 $d \times k$ 变成 $r(d+k)$。为什么能这么干?因为微调带来的权重改变量 $\Delta W$ 在数学上是"低秩"的——信息量本来就不大,不需要一个满秩矩阵来装。
"""实验四:LoRA 到底省了多少参数(离线、零依赖、纯算术)。"""
def full_finetune_params(d: int, k: int) -> int:
"""全量微调:整个 d×k 权重矩阵都要训"""
return d * k
def lora_params(d: int, k: int, r: int) -> int:
"""LoRA:只训 A(r×k) 和 B(d×r),合起来 r*(d+k)"""
return r * (d + k)
def scaling(alpha: int, r: int) -> float:
"""实际注入强度 ΔW = (alpha / r) * B @ A"""
return alpha / r
if __name__ == "__main__":
d = k = 4096 # 典型 7B 模型的一个注意力投影矩阵
print(f"一个 {d}×{k} 的权重矩阵:")
print("-" * 58)
print(f"{'方案':<16}{'可训练参数':>14}{'相比全量':>14}")
print("-" * 58)
full = full_finetune_params(d, k)
print(f"{'全量微调':<16}{full:>14,}{'1×':>14}")
for r in (4, 8, 16, 64):
p = lora_params(d, k, r)
print(f"{'LoRA r=' + str(r):<16}{p:>14,}{full / p:>13.0f}×")
print("\n" + "=" * 58)
print("alpha 的作用:控制这个'补丁'贴多用力")
print("=" * 58)
for r, alpha in ((8, 8), (8, 16), (8, 32), (16, 32)):
print(f" r={r:<3} alpha={alpha:<3} -> 缩放系数 alpha/r = {scaling(alpha, r):.1f}")
print("\n" + "=" * 58)
print("挂到整个 7B 模型上(32 层,每层 4 个注意力投影)")
print("=" * 58)
layers, per_layer = 32, 4
total_lora = layers * per_layer * lora_params(d, k, 8)
print(f" target_modules = q_proj, k_proj, v_proj, o_proj")
print(f" LoRA 可训练参数 ≈ {total_lora:,} ({total_lora / 1e6:.1f}M)")
print(f" 占 7B 模型的比例 ≈ {total_lora / 7e9 * 100:.3f}%")
✅ Checkpoint 4 —— 实际输出:
一个 4096×4096 的权重矩阵:
----------------------------------------------------------
方案 可训练参数 相比全量
----------------------------------------------------------
全量微调 16,777,216 1×
LoRA r=4 32,768 512×
LoRA r=8 65,536 256×
LoRA r=16 131,072 128×
LoRA r=64 524,288 32×
==========================================================
alpha 的作用:控制这个'补丁'贴多用力
==========================================================
r=8 alpha=8 -> 缩放系数 alpha/r = 1.0
r=8 alpha=16 -> 缩放系数 alpha/r = 2.0
r=8 alpha=32 -> 缩放系数 alpha/r = 4.0
r=16 alpha=32 -> 缩放系数 alpha/r = 2.0
==========================================================
挂到整个 7B 模型上(32 层,每层 4 个注意力投影)
==========================================================
target_modules = q_proj, k_proj, v_proj, o_proj
LoRA 可训练参数 ≈ 8,388,608 (8.4M)
占 7B 模型的比例 ≈ 0.120%
三个必须记住的数:
LoRA r=8那一行:65,536 vs 16,777,216,正好 256 倍。这就是 LoRA 名声的来源。alpha/r = 2.0出现了两次(r=8/alpha=16 和 r=16/alpha=32)。经验做法是 alpha 取 rank 的 2 倍,这样缩放系数固定为 2.0,换 rank 时不用重调学习率。- 0.120% —— 挂满整个 7B 模型,可训练参数只占千分之一点二。训练时 99.88% 的参数冻结不动,所以优化器状态、梯度的显存都省下来了,存下来的"补丁"只有几十 MB。
target_modules 该挂哪几个?起步挂注意力的四个投影 q_proj, k_proj, v_proj, o_proj;效果不够再加上 MLP 层(gate_proj, up_proj, down_proj),代价是参数量和显存跟着涨。
八、SFT 与 GRPO:两步训练法
8.1 先 SFT 热身:别让模型裸考
直接对基座模型上 RL 效果很差——它连"输出 Final Answer:"这个格式都不会,奖励几乎全是 0,无从学起(这就是 6.4 节的奖励稀疏问题)。所以先用几千条标准解题数据做 SFT,让模型"及格":会按格式、会基本推理。
这一步用 LoRA 就够(实验四算过账)。LoRA 原理和 LLaMA-Factory 实操详见 docs/2 第 15 篇。
一份能跑的 SFT 起点配置:
| 参数 | 建议值 | 为什么 |
|---|---|---|
learning_rate |
2e-5 ~ 5e-5 | SFT 的学习率比 RL 高一两个数量级 |
num_train_epochs |
2 ~ 3 | 再多就开始过拟合、背题 |
per_device_train_batch_size |
4(显存不够降到 1) | 配合梯度累积凑等效批量 |
gradient_accumulation_steps |
4 | 等效批量 = 4 × 4 = 16 |
max_length |
512 | GSM8K 的题+解基本够 |
LoRA r / lora_alpha |
8 / 16 | 缩放系数 2.0,实验四验证过 |
target_modules |
q/k/v/o_proj | 起步就挂注意力四件套 |
8.2 再 GRPO 提升:从四个模型减到两个
RL 阶段,经典算法 PPO 要在显存里同时装四个模型:
| PPO 的四个模型 | 干什么 |
|---|---|
| 策略模型(Actor) | 正在被训练的主角,负责生成 |
| 价值模型(Critic) | 裁判,估计"当前状态大概能拿多少分" |
| 奖励模型(RM) | 给最终输出打分 |
| 参考模型(Ref) | 冻结的原始模型,用来算 KL 散度,防止跑偏 |
GRPO(组相对策略优化) 把 Critic 裁掉了,思路极其漂亮:
同一道题让模型生成一组答案(比如 4~8 个),用奖励函数给每个打分,然后组内比较:高于组平均分的被强化,低于的被抑制。
于是 GRPO 只需要两个模型:策略模型 + 参考模型(奖励用你自己写的函数,不需要模型)。用大白话说:不请裁判,让 8 个自己卷,超过平均分的当榜样。实验三最后那两行 grpo_advantages 就是全部算法。
但这里有一个非常容易答错的点:GRPO 比 PPO 省显存,但比 SFT 更费显存。原因很简单——同一道题要生成 4~8 个答案,再对这一大堆生成结果反向传播。所以从 SFT 切到 GRPO 时,显存不降反升,OOM 是常态(见后面"踩坑 3")。
8.3 GRPO 关键超参与 KL 散度怎么读
| 参数 | 建议值 | 说明 |
|---|---|---|
num_generations |
4 ~ 8 | 每题采样几个答案。太少组内比较没意义,太多显存爆 |
learning_rate |
1e-6 ~ 1e-5 | 比 SFT 小一两个数量级,RL 阶段动作要轻 |
kl_coef (beta) |
0.05 ~ 0.1 | KL 惩罚权重,拉住模型别跑太远 |
clip_range |
0.2 | 单步更新幅度上限,PPO 系算法的标配 |
max_new_tokens |
256 ~ 512 | 生成长度,直接决定显存 |
名词补课——KL 散度(KL divergence):衡量"现在的模型"和"训练开始前那个参考模型"差了多远。为什么要管这个?因为强化学习会为了刷高奖励不惜代价,一路跑偏到胡言乱语(术语叫策略崩溃)。KL 惩罚就是一根拴狗的绳子:允许你探索,但别跑出视野。
训练时盯着 KL 这个数看:
| KL 值 | 意味着什么 | 怎么办 |
|---|---|---|
| 0.01 ~ 0.1 | 健康,正在稳步学习 | 什么都别动 |
| > 0.5 | 跑太远了,随时可能崩 | 提高 kl_coef、降低学习率 |
| < 0.001 | 几乎没在学,被绳子拴死了 | 降低 kl_coef、提高学习率 |
搭配看的另外两条曲线:平均奖励应该稳步爬坡;回答长度通常会自发变长(模型学会了多想几步,这是好现象,不是 bug)。
九、评估:光看一个准确率是不够的
9.1 指标体系
| 维度 | 指标 | 看什么 |
|---|---|---|
| 正确性 | Accuracy | 一次答对的比例 |
| Accuracy@K | 采样 K 个答案,只要有一个对就算对 | |
| 数值误差 | 答错时错得离谱还是差一点 | |
| 效率 | 平均长度 | 是否在注水 |
| 推理步骤数 | 步骤是否合理经济 | |
| 推理时间 | 线上能不能接受 | |
| 质量 | 格式正确率 | 有多少输出能被正则解析出来 |
| 推理连贯性 | 步骤之间是否真的有因果关系 | |
| 可解释性 | 人能不能看懂它为什么这么答 |
Accuracy@K 为什么重要:如果 Accuracy 只有 40% 但 Accuracy@8 高达 85%,说明模型其实知道怎么做,只是不稳定。这时候该做的是提高采样稳定性(降 temperature、多数投票),而不是继续训。反过来,如果 Accuracy@8 也只有 45%,那是真的不会,得补训练。这个判断能省掉几周的无效训练。
9.2 错误分析:比总分重要得多
只看"68% 准确率"没法指导下一步。必须抽查答错的题,按类型归档。一次真实的错误分布长这样:
| 错误类型 | 占比 | 说明 | 该怎么修 |
|---|---|---|---|
| 计算错误 | 42.1% | 推理思路对,算术算错了 | 给它一个计算器工具,别指望模型心算 |
| 理解错误 | 28.9% | 题意读歪了 | 补该题型的 SFT 数据 |
| 推理错误 | 23.7% | 思路本身走错 | 加过程奖励,或补思维链数据 |
| 格式错误 | 5.3% | 答案对但提取不出来 | 加格式约束,或放宽提取器 |
看这张表得到的最重要结论:计算错误占了四成多,而它根本不该用训练解决——挂个计算器工具立刻消掉大半。这就是本书反复强调的顺序:工具 → 提示词 → RAG → 最后才是训练。
9.3 难度分层:平均分会骗人
把测试集按需要的推理步骤数分成简单/中等/困难三档,分别看准确率:
| 难度 | 准确率 |
|---|---|
| 简单(2~3 步) | 78.50% |
| 中等(4~5 步) | 58.30% |
| 困难(6~8 步) | 31.60% |
从 78.5% 到 31.6%,步骤一多就崩。这个衰减曲线比那个笼统的平均分有用得多:它告诉你瓶颈在"长链条推理",而不是"知识不够"。对应的动作是加过程奖励、加中间验证,而不是喂更多题。
还要警惕:只在数学上训练,别的能力可能退化(灾难性遗忘),所以每次训完要跑一遍通用能力回归测试(docs/2 第 15 篇讲过这个坑)。
十、该不该自己训?一张决策清单
| 情况 | 建议 |
|---|---|
| 提示词/RAG/工具还没调到头 | 别训。90% 的"效果不好"用第 4~9 章的手段就能解决 |
| 错误分析显示大头是"计算错误"这类工具能解决的问题 | 别训,先挂工具 |
| Accuracy 低但 Accuracy@K 高 | 别训,先做采样稳定性和多数投票 |
| 需要模型学会公司黑话、固定输出风格 | SFT(LoRA)就够,不用 RL |
| 任务有客观可判分的结果(数学/代码/工具调用成败) | Agentic RL 有戏,先从奖励函数设计入手 |
| 任务好坏很主观、没法自动判分 | RL 很难落地,先做评估体系(第十二章) |
踩坑与专家提示
坑 1:长度惩罚脱离正确性独立生效,模型学会瞎猜。 这是本章最重要的一坑,实验三的"改一改"已经让你亲手复现过。规则:任何惩罚项都必须写成 $r_{acc} - \text{penalty}$ 的形式并在 $r_{acc}=0$ 时短路返回 0,不能写成独立的负分。同理,任何"越多越好"的奖励项(步骤数、字数、工具调用次数)都必须用 min(x, upper) 封顶。
坑 2:batch_size 不能被 num_generations 整除,训练直接报错。 GRPO 要把每道题的一组生成结果凑齐才能算组内平均,所以等效批量必须是 num_generations 的整数倍。num_generations=8 就用 8、16、24;写 6 会报维度不匹配,而且报错信息通常不会直说原因。
坑 3:从 SFT 切到 GRPO 就 OOM。 前面说过,GRPO 比 SFT 更费显存。按这个顺序拆:
num_generations8 → 4(最有效,直接砍一半生成量)per_device_train_batch_size4 → 2,用gradient_accumulation_steps补回等效批量max_new_tokens512 → 256- 打开梯度检查点(gradient checkpointing):用重算换显存,慢 20%~30% 但能省很多
- 打开混合精度(bf16 优先,不支持再用 fp16)
坑 4:KL 爆炸,模型开始胡言乱语。 看到 KL > 0.5 就该停。原因通常是学习率太高或 kl_coef 太低。处理顺序:先把学习率砍到 1/3,再把 kl_coef 从 0.05 提到 0.1。别等曲线彻底崩了才救——RL 训练崩了就是废掉,只能从检查点重来,所以要定期保存检查点。
坑 5:奖励看着在涨,实际模型变差了。 这是 Reward Hacking 的标准症状。唯一可靠的检查手段:每隔一段时间人工抽查一批高分样本,亲眼看它是真答对了还是找到了漏洞。奖励曲线是间接指标,不能替代人眼。
坑 6:数据里的 <<48/2=24>> 没洗掉。 实验二的"改一改"演示过。类似的脏东西还有:多余的空白行、HTML 转义(&)、题干里混进的答案。训练前必须打印 3~5 条转换后的样本亲眼看一遍,这是全书性价比最高的一条建议。
坑 7:提取器和奖励函数对不齐。 如果 SFT 时教模型输出 Final Answer: 72,而奖励函数只认 #### 72,那准确率会诡异地接近 0——模型明明答对了但拿不到分。数据格式、SFT 目标、奖励提取器这三处的格式约定必须是同一个,改一处要三处同改。
坑 8:只在一个数据集上训,以为万事大吉。 灾难性遗忘是真的。留一个和训练任务完全无关的小回归集(比如 20 道常识问答 + 10 段代码解释),每个检查点都跑一遍。看到通用能力掉了超过 5 个点,就该降学习率或混入通用数据。
关于安全:训练也要守和前面各章一样的规矩——密钥永远只放 .env,.env 必须写进 .gitignore,训练脚本里用 os.getenv("HF_TOKEN") 之类读取,不要硬编码;一旦泄露,第一件事是去平台吊销并重发。另外两条训练特有的:别把内部数据上传到公开的模型仓库(HuggingFace 默认公开,private=True 要手动加);奖励函数里绝不要 eval() 模型的输出——那是被考模型生成的、完全不可信的文本,数值比较用 float() 加 try/except 就够,需要算表达式请用 ast 白名单(第五章讲过)。
面试视角:本章高频九问
-
"用一句话说清 Agentic RL 是什么。"——把语言模型当成一个在环境里多步行动的智能体来做强化学习,奖励来自"事情办成了没有",而不是"话说得好不好听"。 补一个能证明你懂的细节:它训出来的模型会自发变长回答、自发学会调工具、自发学会失败重试,这三样都不是人教的,是奖励逼出来的。再补一句概念对齐:这里的"策略"就是模型权重本身,所谓训练就是在改这个策略——很多人答到"策略"就含糊过去,这句话能立刻区分开。
-
"为什么预训练之后还要后训练?"——因为预训练的目标只是"猜下一个词",它从没被要求过帮用户把事办成。举那个最有说服力的例子:基座模型收到"帮我写一封请假邮件",会给你续写出一串相似的标题,因为它在模仿"网上出现这句话的地方后面还跟着什么"。三个缺口对应三段后训练:不听指令→SFT;不知好坏→偏好对齐(PBRFT/RLHF);不会做事→Agentic RL。三者不能互相替代。
-
"PBRFT 是什么?它的局限在哪?"——Preference-Based Reinforcement Fine-Tuning,训练信号是人在两个回答之间的选择,而不是标准答案(因为"最好的回答"没人写得出来,但"这个比那个好"人人能判断)。流程三步:采集偏好排序 → 训奖励模型 → 用 PPO 去追高分。三个局限:回合长度恒为 1,学不到做事顺序;不碰真实环境,编的 API 和真的 API 在 RM 眼里一样专业;奖励是主观偏好,所以模型学会讨好——最后这条有个名字叫谄媚(Sycophancy),能说出来是加分项。注意术语层级别答混:PBRFT 是范式,RLHF 是其中偏好来自人的做法,PPO 是执行用的算法。
-
"SFT 和 RL 的本质区别?"——SFT 模仿标准答案(行为克隆,有人教);RL 只看结果给奖励,过程自己探索。能补一句数据格式的差别就更好:SFT 的数据是
prompt + completion(过程全给),RL 的数据是prompt + ground_truth(只给答案)。追问"为什么长推理能力是 RL 练出来的":因为奖励只考核最终答案,长推理是模型自己发现的"涨分策略",没人教过。 -
"PBRFT 和 Agentic RL 的区别?"——用 MDP 五元组答,这是最能体现深度的答法:状态从"一条固定提示词"变成"会变的环境快照";动作从"只能生成文本"多出了"调用工具";转移从"没有"变成"真实存在";奖励从"一次性总分"变成"可以逐步给";折扣因子 γ 从用不上变成关键参数。一句话收尾:从闭卷作文考试,变成扔进真实工位干一整件事。
-
"GRPO 相比 PPO 改进了什么?省了多少显存?"——去掉价值模型(Critic),改用组内相对比较算优势,从四个模型减到两个(策略 + 参考,奖励用普通函数)。但千万别说"显存省一半"就完事——面试官很可能追问:GRPO 比 PPO 省,但比 SFT 更费,因为同一道题要采样 4~8 个答案再反向传播。能主动说出这一点,是有没有真跑过训练的分水岭。
-
"什么是 Reward Hacking?举个你亲手复现过的例子。"——模型钻奖励规则的空子。最好的答法是给一个具体机制:如果长度惩罚不依赖正确性,模型的最优策略就变成"啥也别写直接瞎猜"——瞎猜得 0 分,认真推理答错反而是负分。防法三条:①所有惩罚项建立在准确率之上并在答错时短路;②所有加分项用
min封顶;③定期人工抽查高分样本,因为奖励曲线上涨不等于模型变好。 -
"训练完准确率 68%,下一步做什么?"——先做错误分析再决定,别急着加数据。按类型归档:计算错误占四成多的话,挂个计算器工具就能消掉大半,根本不用再训;格式错误多就修提取器;推理错误多才需要加过程奖励。再按难度分层看衰减(简单 78% / 中等 58% / 困难 32%),定位瓶颈是"长链条推理"还是"知识不够"。最后看 Accuracy@K:@1 低但 @8 高说明模型会做只是不稳,该做采样稳定性而不是继续训。
-
"LoRA 为什么省?具体省多少?顺便说说没有 GPU 集群的应用工程师能做什么?"——前半:$\Delta W = BA$,可训练参数从 $d \times k$ 降到 $r(d+k)$;给数——4096×4096 的矩阵,r=8 时是 65,536 vs 16,777,216,正好 256 倍;实际注入是 $\frac{\alpha}{r}BA$,alpha 通常取 rank 的 2 倍让缩放系数固定为 2.0,换 rank 时不必重调学习率;
target_modules起步挂 q/k/v/o_proj,不够再加 MLP。后半答四样:造数据(从线上日志筛好样本,含三种格式的转换和清洗,见 docs/2 第 15 篇)、写奖励函数(本章核心,就是普通 Python 函数)、建评估集与错误分析体系(第十二章)、判断该不该训(第十节的决策清单)。这四样恰恰是训练效果的天花板所在——奖励函数写歪了,再多卡也是把模型往坑里推。
划重点
-
Agentic RL 是什么:把语言模型当成一个在环境里多步行动的智能体来做强化学习,奖励来自"事情办成了没有"。三个只有它才有的现象:自发调工具、自发失败重试、回答自发变长——都是奖励逼出来的,不是人教的。这里的"策略"就是模型权重本身。
-
为什么必须后训练:预训练只优化"猜下一个词",从没被要求"帮用户把事办成"。三个缺口三段补:不听指令→SFT;不知好坏→偏好对齐;不会做事→Agentic RL,互相不能替代。
-
PBRFT 是什么:Preference-Based Reinforcement Fine-Tuning,训练信号是人在两个回答之间的选择,不是标准答案;流程是"采集偏好排序 → 训奖励模型 → PPO 追高分"。三个天花板:只有一步、不碰环境、奖励可被讨好(谄媚)。术语层级:PBRFT 是范式,RLHF 是做法,PPO 是算法。
-
后训练是三段:SFT 教格式 → 奖励建模(RM)造裁判 → 强化学习追高分。裁判由人排序是 RLHF,由 AI 排序是 RLAIF(快且便宜,代价是把 AI 裁判的偏见一起继承)。
-
PBRFT 和 Agentic RL 的分野在 MDP 五元组:状态会变了、动作多了工具、转移真实存在、奖励可以逐步给、γ 开始起作用。别用"参数多少"回答这个问题。实验一把这五行差异实测成了 1 vs 3、0 vs 2、1 步 vs 4 步。
-
奖励函数是 RL 的灵魂,而且是普通 Python 函数——这是应用工程师离训练最近的阵地。最重要的一条设计铁律:长度惩罚和步骤奖励都只在答对时生效,否则模型学会瞎猜。所有加分项必须封顶。
-
同一批题有三种格式:原始(含
<<>>标记,必须洗掉)、SFT(prompt+completion,过程全给)、RL(prompt+ground_truth,只给答案)。三处格式约定必须对齐,改一处要三处同改。 -
GRPO 的全部算法就是"减去组内平均"两行代码。它从 PPO 的四个模型减到两个,但比 SFT 更费显存——记住这个反直觉的事实,OOM 时先砍
num_generations。 -
batch_size必须能被num_generations整除,否则报一个不会告诉你原因的维度错误。 -
KL 散度是训练的仪表盘:0.01~0.1 健康,>0.5 要救,<0.001 是没在学。定期存检查点,因为 RL 崩了没法原地抢救。
-
LoRA:$\Delta W = BA$,4096×4096 矩阵在 r=8 时省 256 倍,挂满 7B 模型只训千分之一点二的参数。alpha 取 rank 的两倍。
-
评估不能只看一个准确率:加上 Accuracy@K(区分"不会"和"不稳")、错误类型分布(计算错误挂工具解决,别训)、难度分层(78%→58%→32% 的衰减告诉你瓶颈在长链条推理)。
-
训练不是首选,而是最后一招:提示词/RAG/工具没调到头之前别碰;错误分析显示计算错误占大头就去挂计算器;要训也先 LoRA-SFT,最后才是 RL。四个离线实验务必跑通并做完"改一改",那是你离模型训练最近的一次亲手实践。
下一章预告:模型训完了、智能体搭好了,怎么证明它真的行?第十二章讲智能体评估:BFCL 考工具调用,GAIA 考综合能力,以及怎么给自己的智能体建一套考试系统——本章第九节的指标体系,在那里会扩展成一整套方法论。