← 返回首页

用双卡 5090 训练 Qwen3-0.6B:从数学推理到 Agentic RL 的完整踩坑大集锦

这篇文章是我最近一个月做的一组实验的记录。起因很简单:想看看 0.6B 这种“小参数”模型, 在两张 RTX 5090 上,到底能不能用强化学习训出像样的数学推理和 Agent 能力。 起点是 Datawhale 第 11 章的 hello-agents 案例,之后延伸出了一堆东西。 从 GSM8K 数学推理,到多轮工具调用,再到自己搭一个跨应用的工具基准, 最后还顺手用 QLoRA 挑战了一下 72B 的微调。

想试着讲讲看整个过程踩到的无数的坑。

完整的论文、报告和代码Meredith2328/MathQwen训练曲线wandb

欢迎在评论区参考交流 或者拷打

用到哪些知识

SFT(监督微调) 就是拿一批“问题-标准答案”的数据让模型“照着背”。 它稳定,但只会模仿数据里出现过的行为。

RL(强化学习) 是另一条路:让模型自己生成答案,用奖励函数打分, 再把高分行为强化。最常见的算法是 PPO,但它需要价值网络,显存开销大。

GRPO 是另一种RL方法,是 DeepSeekMath 提出的相比于PPO的简化版:

对同一个问题采样一组回答,用这组回答内部的 均值方差做归一化,代替价值网络。 再加上 PPO 的裁剪和 KL 约束。省掉价值网络之后,我的单机双卡也能跑得动。

公式如下:

\[\mathcal{J}_{GRPO}(\theta) = \mathbb{E}_{q, \{o_i\}_{i=1}^G} \left[ \frac{1}{G} \sum_{i=1}^G \min\left( r_i(\theta) A_i, \operatorname{clip}(r_i(\theta), 1-\epsilon, 1+\epsilon) A_i \right) - \beta \operatorname{KL}(\pi_\theta \| \pi_{ref}) \right]\]
\[r_i(\theta) = \frac{\pi_\theta(o_i|q)}{\pi_{\theta_{old}}(o_i|q)}\]
\[A_i = \frac{r_i - \operatorname{mean}(\mathbf{r})}{\operatorname{std}(\mathbf{r})}, \quad \mathbf{r} = \{r_1, \dots, r_G\}\]

(详细的含义我会随着实验一起讲一讲)

Agentic RL 更进一步:模型不是输出一段文字就结束,而是进入 “思考 → 调工具 → 看工具返回 → 再思考”的循环,奖励来自工具执行的真实结果。

wandb 参考我之前写过的 WanDB基础使用教程总结 · 10PI'S BLOG丨十派的玩具箱

实验一:通过四种训练栈在 GSM8K 上训练Qwen3 0.6B的数学推理能力

问答 -> CoT -> Agentic

一切的开端是推理能力。

在ChatGPT o1横空出世之前,人们对GPT的理解是类似InstructGPT那样的问答。

可o1借助CoT一下子大大提升了能力,DeepSeek R1则把RL训练思想开源了出来。

没有这样强大的推理能力,现在的Agent就总是“差些什么”。

实验设计如下:

使用 openai/gsm8k 数据集,前 500 条用于训练、后 500 条用于测试。

进行GRPO训练,实验的超参数: \(G=8\)\(lr=1e^{-6}\)\(β=0.001\) (KL散度的系数)。

我分别用了四个框架进行GRPO训练,一是操练相关的技术,而是确保我的超参数是跨框架可用的:

hello-agents(起点)、DeepSpeed(TRL+ZeRO-2)、Megatron-LM(全参)、Verl(FSDP+vLLM)。

模型输出通过统一的抽取函数和标准答案比对。结果如下。

配置 GSM8K test
基座 Qwen3-0.6B 42.2%
hello-agents · SFT 57.0%
hello-agents · GRPO(框架默认 lr=5e-5) 2.4%
DeepSpeed GRPO 46.8%
Megatron-LM GRPO 52.6%
Verl GRPO 68.8%

诶?

我相信最显眼的是hello-agents在GRPO的2.4%,这体现了“灾难性遗忘”问题。

我们从理论上早就学到,强化学习对稳定性的要求相当高。我们平时预训练的步长调太高、可能模型loss一直不下降(甚至反而升高);而GRPO则是一定需要特别特别小的步长。HelloAgents默认设计的5e-5这种步长都有些太大了。

\[\mathcal{J}_{GRPO}(\theta) = \mathbb{E}_{q, \{o_i\}_{i=1}^G} \left[ \frac{1}{G} \sum_{i=1}^G \min\left( r_i(\theta) A_i, \operatorname{clip}(r_i(\theta), 1-\epsilon, 1+\epsilon) A_i \right) - \beta \operatorname{KL}(\pi_\theta \| \pi_{ref}) \right]\]

对应到上述GRPO的式子上。我们不妨从左往右读一下它的含义。

  • 我们总是希望在式子最外层看到一个“期望”。这是因为但凡有期望,就可以用“样本均值”进行近似。再重复一遍,用样本均值近似期望。这个真的很重要。所以我们总是会在各种各样的地方看到期望这种设计。
  • 做了组平均,所以是对G求和,然后再除以组数G。组平均的直觉是,同一个模型如果多做几次题目(术语:rollout,其实就是对相同的prompt做多次推理生成多条文本样本)、发现其中有些相对做得更好(比如做对了),那么就矮个子里拔高个、鼓励它做对的这种“分布”。这就是前述用组内平均替代了价值网络的打分。(当然,这种方式有优有劣,那又是后话了。)我在实验中对组数G做了消融实验,发现组数太少太多都不好,这里的 \(G=8\) 是对于我的实验表现比较好的超参数。
  • RL的更新为了稳定,更加倾向于“小步快跑”,所以有clip这样的技巧、使得新的策略 \(\pi_\theta\) 不能离旧的策略 \(\pi_{\theta_{old}}\) 更新得太远。与此同时,我们的GRPO是为了在 \(\pi_{ref}\) 基础上做“强化”(“移动分布”),而不是完全推倒重来,所以又通过loss里添加KL散度的方式,约束 \(\pi_\theta\) 不能离参考策略 \(\pi_{ref}\) 太远。

一个直觉上的例子,我们通过SFT使得模型学会使用了工具(\(\pi_{ref}\)),那么我们就希望用GRPO让它用得得心应手(训出好的 \(\pi_{\theta}\))、却又不希望让它走火入魔、连自己一开始是谁、会的那些知识都忘了(灾难性遗忘,即 \(\pi_{\theta}\) 更新得不合理)。

前述hello-agents框架在GRPO的2.4%就是默认设置的学习率太大、直接把策略搞崩掉了,小模型一步把输出分布就给练没了。

因为这个问题,我还顺手给hello-agents提了一个 Pull Request #771 · datawhalechina/hello-agents 并且已合入。

相比起来,如果训好了,效果就很显著了。例如 Verl 只花了 27 分钟就把小模型 42.2% 提到 68.8%,说明超参和工具都合理。小模型不是不能训,是方法要用对。

ps. 在训练过程中还遇到一些细节问题。例如我参考了 Megatron 官方示例,但是它只认 \boxed{} 结尾的 答案,Qwen3 在还没有训出这种输出格式的情况下奖励几乎恒为 0、梯度为零,RL训练一度停滞。

之后我担心把格式也作为奖励信号、会冲淡我更关注的数学推理能力信号,所以在答案抽取上做了放松。

只要大致符合格式、抽取出的结果正确,我就给它奖励。

做了这样的处理之后,Megatron 才真正训起来,而不是一直停在基座附近。

实验二:Agentic RL 工具链调用能力训练

光做数学推理不过瘾,我们把重点转向“会调用工具”的智能体。设计了两档到六档递进任务: Easy(1 次计算器调用)、Multi-step(2–3 步链)、Hard(4–5 步链)、Long-chain(6–8 步)、 Multi-tool(汇率/单位换算跨工具)、Planning(场景目标自主规划)。数字全是 9–13 位, 心算不可能,模型必须真的调用工具、读取返回、串接下一步。

方法是 SFT 预热(教格式和循环)加过程奖励 GRPO(格式 0.1 + 每步正确 0.6 + 最终答案 0.3)。 SFT 之后六档任务全部达到 97–100%。

但这里发生了一件很值得写的事:我们最初报告“SFT→GRPO 与 SFT 持平(98.3%)”, 后来发现那是假的。

在训练过程中,顺手对远端所有 adapter 做了一次 md5 哈希比对,结果发现: 所有从 SFT 初始化的 GRPO 产物,和 SFT 初始化权重逐字节相同。 也就是说 GRPO 压根没更新权重,那 98.3% 就是 SFT 自己的成绩。

根因是 TRL 1.9.2 的 “ref adapter” 机制:从已有 adapter 续训 GRPO 时, TRL 会在模型里复制一份初始权重当 KL 参照,训练日志一切正常(loss 在降、 梯度范数非零),但梯度没有落到最终保存的 default adapter 上。 修复方法是先把预热权重 merge_and_unload() 合进基座,再新建一个干净的 LoRA。 我还把这个校验写进了训练脚本:每次训练结束打印保存哈希和初始哈希, 两者相同就直接报警,以后不会再犯。

修复后复跑 Hard,真实结果和之前完全不一样:

模型 单调用评测器 多调用评测器
SFT(Hard 数据) 98.3% 66.7%
SFT→GRPO 过程奖励(修复后) 3.3% 2.5%
SFT→GRPO 仅结果奖励(修复后) 0.8%

也就是说,在 SFT 已经饱和的任务上继续做 GRPO,对 0.6B 是破坏性的—— 策略退化成“调用一次工具 + 长篇叙述”。而在后面实验三里,SFT 只有 2.5%(远未饱和), 修复后的 GRPO 反而能把它提到 12.5%。RL 的增益依赖“SFT 未饱和”的分布, 这一点比“GRPO 与 SFT 持平”更接近真相。

顺手摸了一下链长的天花板

实验二做到这里,我其实一直在琢磨一个问题:0.6B 到底能稳定走多长的链? 之前的 Long-chain 也就 6–8 步,那 10 步、16 步、28 步呢?是没见过的长度 直接崩,还是能像写好的循环程序一样一直延伸下去?

于是我做了一组扫长实验。测试集把链长拉到 6/8/10/12/16/20/22/24/26/28, 每档 15 条,再加 6/8/10/12 项的连乘每档 15 条;训练侧准备了四组分布—— 6–8 步、4–14 步、8–20 步、16–28 步——每组 500 条,各自训完再放到 同一套长测试集上比。数字还是 9–13 位的大数,心算不了,必须真调计算器。

训练分布 8 步 12 步 16 步 20 步 24 步 28 步
6–8 步 100% 26.7% 20.0% 0% 0%
4–14 步 100% 100% 26.7% 6.7% 0%
8–20 步 100% 100% 93.3% 93.3% 0%
16–28 步 66.7% 86.7% 100% 100% 86.7% 80.0%

这张表读起来特别有意思。稳定链长的上限,基本就等于训练分布的最长区间: 练 6–8 步,上限就停在 8 步;练 8–20 步,能扛到 20 步;练 16–28 步, 26 步还有 86.7%。但所有模型都在“训练最长步数 + 2~4”的地方断崖式往下掉。 0.6B 学到的是“有限长度的串接模式”,不是一条长度无关的循环程序—— 它更像在背“先算这个、再算那个”的具体剧本,而不是抽象出 “重复上一步直到结束”这个动作本身。

这里得做一次诚实的修正:早期这张曲线图我们标注的是“SFT/GRPO”, 后来审计(就是实验二里那个 md5 哈希比对)发现 GRPO 压根没更新权重, 曲线其实完全就是 SFT 自己跑出来的。“训练分布决定上限”这个结论不受影响, 但“GRPO 在这里起到了作用”这句话得收回去——链长天花板这件事, 是数据分布说了算,不是 RL 算法说了算。

怎么兼顾长链和短链

但这么一搞,一个矛盾马上冒出来了:训练分布右移,长链是稳了,短链却遭殃。 看表里 16–28 步那一行,8 步只有 66.7%——明明更短的链应该更简单, 怎么反而做不对?

想了一下也合理:长链数据里每道题都是十六七步起步,模型从没见过 “三四步就收工”的题,自然不知道什么时候该停。它习惯性地继续调工具、 继续算,把简单的题活活做复杂了。而且 0.900 和 0.890 这种“综合分” 其实挺有迷惑性——长链模型在偏长的测试集上综合分反而更高, 但它在 8 步上有个肉眼可见的大洞,真实用起来谁敢放心。

两条互补的手段,我都试了:

覆盖式平衡训练:别只押一个区间,按长度分层采样——4–28 步每档 20 条, 连乘按 4–16 项每档 20 条,一共 640 条。分布铺开之后,短链完全恢复 (8/12 步回到 100%),长链保持高位(20 步 93.3%、26–28 步 73–80%)。

长度提示:更省事的办法,评测时在问题里写明“本题恰好 N 个计算步骤”, 零训练成本。只练长链的模型,薄弱长度从 0.800 提到 0.960 (8 步从 60% 回到 100%);平衡模型加上提示,全长度直接到 96.7% (6–22 步 100%,24/28 步 86.7%)。

方案 综合正确率
只练长链(16–28 步) 90.0%(8 步只剩 66.7%)
覆盖式平衡训练 89.0%(8/12 步回到 100%)
只练长链 + 长度提示 96.0%*(薄弱长度 0.800 → 0.960)
平衡训练 + 长度提示 96.7%(6–22 步 100%)

要我说,长度提示是性价比最高的“摸高”手段:一分钱训练成本没有, 就是把意图写清楚,模型立刻知道该在什么时候收手。0.6B 不是没有循环能力, 而是要“训练分布覆盖 + 显式长度锚定”两个条件凑齐, 才能把这种能力稳定逼出来。

实验三:自己搭一个跨应用工具基准

真实世界的 Agent 基准(比如 Toolathlon,604 个工具、32 个真实应用)需要 Docker、MCP、真实账号,双卡 5090 环境根本跑不起来。所以我们按同样的方法论 自己搭了一个可控沙箱:9 个模拟应用(文件、数据表、笔记、日历、邮件、数学、文本、 终端、网页)、47 个工具,验证器直接检查最终沙箱状态(文件名、笔记标题、收件人、 表行数、数值),而不是只看模型说了什么。

为了防数据泄露,做了三层保留:web 应用只出现在测试集(训练完全不可见); 测试模板和训练模板没有任何一对应用例复用;每个实例的数值由独立种子生成。 所有专家轨迹都过了自检(重放必须让验证器通过)。

结果:基座 10.8% → GRPO 12.5%(执行错误从 221 降到 12),pass@3 采样三次 16.7%。 但 web 保留应用通过率始终是 0%。失败模式非常典型:模型能选对工具 (基座在没见过的 web 任务上工具选择准确率有 96.5%),却不会把工具返回的内容 串接进下一步——比如把 URL 当正文传给 extract_numbers,而不是读取 get_webpage 返回的“604 tools”。

为了确认 0% 不是“任务太难”的假象,我们做了可学习性正对照: 把 web 样本加入训练再测。SFT 版本即使见过 web 也还是 0%(交互循环里乱调工具); GRPO 版本(修复了保存 bug 之后跑的,adapter 哈希已校验)也还是 0%——但这时 工具选择准确率有 84.5%、web_research 的精确计划率有 65%,模型明明选对了工具、 也按顺序调用了,却总把 URL 当正文传给下一个工具,从不读取 get_webpage 返回的 内容。这个对照实验把结论钉死了一点:web 0% 不是任务太难,也不是零样本迁移的锅, 而是 0.6B 在这个流水线里学不会“把工具返回内容串接成下一步参数”, 也就是工具结果接地(tool-output grounding)这个能力本身缺位。

很可惜,受限于训练资源、以及个人的学习目的,我这里的探索就走到这里。

实验四:顺手挑战一下 72B(QLoRA 参数量摸高)

0.6B 摸完了,顺手往参数量的方向摸一摸高。双卡 5090 一共 64 GB 显存, 全参数训 72B 想都别想,但 QLoRA 可以:把基座权重量化到 4-bit 塞进显存, 只训练插进去的一小撮 LoRA 参数。

具体方案是 Qwen2.5-72B-Instruct,bitsandbytes NF4 4-bit 量化 (基座权重大约压到 36 GB),LoRA r16,DeepSpeed ZeRO-3 加 CPU offload。 ZeRO-3 把优化器状态、梯度和参数全部切到两张卡上分片, CPU offload 再兜底,显存这才压得住。

实际跑通了 40 步 SFT,qlora/models/qlora_70b_sft/ 里的 train_info 记录了峰值显存 17.44 GB/卡——注意是每张卡,离 32 GB 的天花板还有一大截, 说明 batch 或者上下文长度还有往上加的空间。loss 正常收敛,这条路是通的。

我只是探索了一下“72B 能在一台双卡 5090 上训起来”这件事本身,

而没有做 GSM8K 的评估训练。

一些踩坑清单

  • sm_120 软件栈:PyPI 默认 cu126 的 torch 不支持 Blackwell,必须 cu128/cu130; torch 2.7.1 的 NCCL 半精度 all-reduce 有内存损坏 bug,升级torch到 2.9.0 解决。

  • TRL ref-adapter 保存陷阱:见上文,这是本实验最重要的 bug。

  • 评测器要和训练格式对齐:TRL 原生 rollout 支持一条回复多个 <tool_call>, 旧评测器每轮只执行第一个,会系统性低估批量调用模型(3.3% vs 20%)。

  • 批量评估必须左填充:右填充会让短样本解码起点偏移,工具调用率从 78% 掉到 26%。

  • SFT 后熵太低:token 熵约 0.002,温度 0.9 下采样几乎相同、奖励零方差; 要提到温度 1.2、top-p 0.95 才有学习信号。

可观测性落地:怎么把“看起来对”变成“真的对”

前面踩了这么多坑,回过头看,大部分坑的共性只有一个: 日志一切正常,实际什么都没发生。loss 在降、梯度范数非零、 wandb 曲线漂漂亮亮——但保存下来的权重和初始权重逐字节相同。 这让我彻底意识到,这个项目里最值钱的不是哪个框架分数高, 而是一套“怎么确认它真的在动”的办法。

我把这套办法整理成五条,也全部固化进了脚本:

权重级核验。训练脚本结束打印 ADAPTER_HASH_INIT / ADAPTER_HASH_OUT, 两个哈希相同就直接报警。TRL 的 ref-adapter 陷阱就是这么抓出来的—— 如果不是闲着没事跑去比对 md5,我们可能到现在还抱着一个假的 98.3% 在写总结。

数据级核验。数据生成脚本内置 self-check,每一条专家轨迹都要重放一遍、 让执行式验证器真正跑过,才有资格进训练集。标准答案自己都不自洽, 后面的奖励全是空中楼阁。

口径级核验。报数字时必须写清楚评测器是单调用还是多调用、 greedy 还是 sampling、max-new 和 max-rounds 是多少。不同口径的数字 不能混着比,我们最早在 3.3% 和 20% 之间反复横跳, 就是评测器口径没对齐。

结果级核验。论文和 README 里每个数字都能在 results/ 找到对应的 JSON, wandb 有对应的 run id;数字一旦修改,所有文档同步更新。 写报告最怕的就是“我记得好像是 68%”。

负结果照实写。web 保留应用 0%、修复后 GRPO 崩塌到 3.3%, 这种不漂亮的数字全部保留并解释清楚,不因为难看就删。 负结果往往才是真正值钱的信息,它把能力的边界画得明明白白。

这五条做完,至少能保证:当你写下“模型从 42.2% 提到 68.8%”的时候, 这句话经得起别人拿 wandb 和 results 里的 JSON 逐条对账。

结语

我想实验本身是有学习价值的,实验过程中的“可观测性”则更是重中之重。

例如数据生成时写了一些自检、帮助我发现许多问题;

训练结束通过哈希值校验发现了压根没训到的TRL保存陷阱;

还有无论是训练成功、还是训练失败,都要完整记录下来足够完整的信息。

RL 是十分“稀疏”的。

对于人来说,它总是“看起来在动、实际不知道动没动”。

为此,我们需要足够多的提升可观测性的措施,以期在“稀疏”的RL世界求得一隅安身之地。

随本实验附以下资料供参考:

评论