我最近在昇腾NPU上完整跑通了一套GRPO强化学习训练流程,任务很简单:教一个小尺寸的开源语言模型解决9x9数独。选这个题不是因为难度高,而是因为数独是一个“规则极其清楚、奖励极其机械”的推理任务——你根本不需要人去打分,写一个规则函数就能自动判定对错。这对强化学习实验来说,是最友好的起步场景。
这篇文章我尽量按实际操作记录来写:从为什么选数独、昇腾NPU环境怎么准备、GRPO算法怎么理解、奖励函数怎么设计、训练曲线怎么看,到最后几个典型翻车现场,一次讲透。如果你正准备在国产NPU上尝试强化学习,或者想用小任务验证GRPO的稳定性,这篇应该能帮你少走不少弯路。
1. 为什么是数独:规则任务最适合做GRPO的“第一块跳板”
1.1 数独比数学题更适合验证强化学习推理能力
很多人一提到“训练LLM的推理能力”,第一反应就是上数学题。数学题本身没问题,但在强化学习场景里有一个很麻烦的点:答案等价性判断。同一个方程可能有多种简化写法,标准答案和后验计算完全一致才能判对,解析成本高,还容易被奇怪的格式干扰。代码题更麻烦,需要沙箱执行、判题器、超时控制,环境依赖重,动不动还要处理安全风险。
数独完全没有这些负担。9x9标准数独的规则只有三条:每行、每列、每个3x3宫都包含1到9,不允许重复。判定一个输出是否正确,只需要做数字集合比较,不需要编译器、不需要解释器、不需要任何外部依赖。
更关键的是,数独是一个天然结构化的推理任务。它不像开放问答那样“差不多就行”,只有唯一解,要么对要么错。这种二值清晰性让强化学习的奖励信号不会被主观因素污染,非常适合做GRPO的早期验证。
1.2 从约束满足到奖励信号:我能自动化到哪一步
数独本质上是一个约束满足问题(CSP)。一个标准盘面有81个变量、27组约束,每个变量的值域是1到9。LLM要做的不是按部就班地回溯搜索,而是从题目给出的线索中推断出完整解——这是推理,不是查表。
在我这次实验里,整个判分链路完全自动化:
- 模型输入一个带有若干空格的初始盘面,空格用0表示
- 模型输出一个9行数字文本,代表它认为的完整答案
- 我写一个纯Python的奖励函数,对输出做格式解析、规则校验、答案比对
这个奖励函数不需要大模型参与,不需要人工审阅,所以我可以把它直接嵌进GRPO训练循环里,每个采样结果实时打分。这也正是DeepSeek-R1这类公开技术报告中反复提到的“rule-based verifiable reward”思路——用规则函数替代人工反馈,让强化学习的规模可以无限推。
1.3 数独任务的难度阶梯
数独还有一个其他推理任务很少有的优点:难度可以精确控制。通过挖洞数量的多少,轻松调节题目的推理深度。
| 难度档位 | 挖洞数量 | 典型特点 |
|---|---|---|
| 简单 | 30个左右 | 行/列直接排除法大部分够用 |
| 中等 | 45个左右 | 需要行列宫联合判断 |
| 困难 | 55个左右 | 需要候选数链式推理、假设排除 |
这意味着训练时我可以控制正样本的分布,避免模型只学会“填满格子”而没学到“约束推理”。我后面章节里会讲到,这点对防止策略坍缩非常重要。
另外,LLM在原生训练语料里见过的数独样本远少于数学题和代码题,模型很难靠“背答案”蒙混过关。它要么真的学会了规则,要么什么都学不会——这种非此即彼的属性,让它成为测试强化学习是否真正有效的好标尺。
2. 昇腾NPU环境准备:先把“能跑”这件事做扎实
2.1 硬件与软件栈的基本格局
训练LLM少不了算力,我这次用的是昇腾NPU训练卡,单卡HBM在64GB量级,一共8卡。如果你用的是别的NPU设备,大体思路一致,只是驱动和适配层命令不一样。
软件栈分三层,一开始很容易搞混:
- CANN:NPU的计算架构,负责驱动、运行时和算子库,相当于CUDA在NVIDIA生态里的位置
- torch_npu:PyTorch的昇腾适配层,代码里通过
torch_npu.npu模块使用NPU设备,让PyTorch训练代码可以尽可能少改地在NPU上跑 - HCCL:NPU上的集合通信库,对应NVIDIA的NCCL,多卡训练依赖它做梯度同步
我这次主用PyTorch生态,没有直接用MindSpore。原因很简单:社区里找到的RL训练代码基本都是PyTorch写的,改造成本低。如果你的项目从零开始,用MindSpore也很合适,但生态成熟度目前PyTorch这条线更省心。
2.2 环境安装与验证清单
环境准备过程我踩了不止一个坑,给你一份我最终验证有效的最小清单:
- 安装驱动与固件:按NPU厂商文档安装,装完用
npu-smi info确认8卡都可见,这一步卡住后面全白搭 - 安装CANN toolkit:注意版本要和驱动匹配,我随手装过一套不匹配的组合,结果算子在运行时频繁报非法参数
- 创建conda环境:Python版本建议3.8到3.10之间,太新会导致部分适配层包没预编译
- 安装PyTorch与torch_npu:这里最容易出问题,torch_npu的版本必须和PyTorch版本严格对应,装错的话import阶段就会报符号找不到
- 配置环境变量:安装目录下有个
set_env.sh,每次开终端都得source一遍,漏了会出现“找不到CANN库”之类的诡异错误 - 写一段最小验证代码:创建张量、跑一次矩阵乘法、再跑一次梯度回传,确认NPU链路完整
import torch import torch_npu if torch_npu.npu.is_available(): print("NPU count:", torch_npu.npu.device_count()) x = torch.randn(1024, 1024, device="npu") y = torch.randn(1024, 1024, device="npu") z = torch.matmul(x, y) print("matmul ok:", z.shape) else: print("npu is not available")我建议你在做任何RL训练之前先跑通这个验证脚本,因为它能把“环境配置问题”和“训练代码问题”分成两个独立阶段去排查,不然出了问题根本不知道怪谁。
2.3 多卡通信与动态shape的坑
多卡训练时HCCL的初始化偶尔会非常慢,甚至卡死。我遇到的现象是:8个进程都启动,但集体通信一直等不到全进程就绪。原因多半是MASTER_ADDR和MASTER_PORT没设置正确,或者WORLD_SIZE和实际启动的进程数不一致。解决办法很朴素——先把单卡训练跑通,再上多卡DDP,不要一开始就挑战高难度。
还有一个和NPU特性强相关的坑:动态shape开销极大。PyTorch在GPU上对变长序列相对宽容,动态shape只是慢一点,但NPU上很多算子会触发重新编译,导致训练速度断崖式下跌。我处理的办法是让所有序列统一padding到固定长度128,并且用attention mask屏蔽padding token。虽然浪费了一点算力,但换来的是训练步骤稳定可预测,对早期实验来说非常值得。
3. GRPO核心机制:去掉Critic后,相对优势是怎么算出来的
3.1 从PPO到GRPO:为什么要动这条最重的组件
在LLM强化学习里,PPO曾经是绝对主流。但PPO有一个让人头疼的组件——Critic价值网络。Actor和Critic各占一份模型权重,训练一个7B的参数模型就意味着要同时维护两份7B的权重和梯度,显存直接翻倍。更麻烦的是,Critic需要对状态价值做回归拟合,训练不稳定时,价值估计的误差会传导到优势函数里,主模型学得好不好还得看“裁判”给不给面子。
GRPO的做法很直接:不要Critic了。它对每个问题采样一组输出,用这组输出自身的奖励分布来计算相对优势。既然奖励是规则函数算出来的、绝对分数很可靠,那“好坏”的定义完全可以由组内相对位置决定,不需要一个额外的网络去拟合价值函数。
这也是GRPO在规则可验证任务上格外好用的根本原因:它把PPO中“需要学出来的价值比较”替换成“采样统计得到的组内比较”,去掉了最大的不确定性来源。
3.2 GRPO的损失构造与KL约束
GRPO的核心逻辑可以浓缩成一个伪代码。假设我们对同一个问题采样G个输出,每个输出得到一个奖励r_i,那么组内优势就是:
A_i = (r_i - mean(r_1..r_G)) / std(r_1..r_G)这个公式的意义很直白:如果某个输出得分超过该组平均水平,它的优势就是正的,策略就要加强这个输出;如果低于平均水平,优势为负,策略就要远离这个输出。除以标准差相当于做了一个自适应缩放,让不同题目之间的优势尺度对齐。
策略损失和PPO一样采用重要性采样比例加裁剪:
ratio = torch.exp(new_logps - old_logps) pg_loss = -torch.min(ratio * advantages, torch.clamp(ratio, 1 - eps, 1 + eps) * advantages).mean()再加上KL约束项,约束当前策略不要偏离参考模型太远,避免模型在追逐奖励的过程中把语言能力丢掉:
kl_loss = (new_logps - ref_logps).mean() loss = pg_loss + beta * kl_loss loss.backward() optimizer.step()这里old_logps是采样时用冻结的旧策略计算好的,new_logps是更新前用当前策略重算的。beta控制KL约束的强度,太大模型学不动,太小模型容易学歪,我后面给具体调参过程。
每次训练迭代,我都保留一组参考模型的logits用于KL计算。参考模型权重固定不动,不反传梯度,只提供一个“原始语言分布”的锚点。
3.3 组大小、温度和KL系数如何影响训练
GRPO的组大小G是最敏感的超参数。G太小,组内统计量的方差太大,一个偶然的高分输出会误导策略;G太大,每次迭代的采样成本直线上升,训练慢好几倍。我实验下来的经验是:
| 参数 | 我的初始值 | 出问题时调整方向 | 说明 |
|---|---|---|---|
| 组大小G | 8 | 4太抖,16更稳但慢 | 组内相对优势依赖G的统计可靠性 |
| 采样温度 | 0.7 | 1.0太散,0.3太集中 | 温度影响探索多样性 |
| KL系数beta | 0.05 | 0.01太激进,0.1太保守 | 控制策略偏离参考模型的程度 |
| 学习率 | 2e-6 | 1e-5会导致震荡 | LLM RL学习率通常比SFT小一个量级 |
我自己的感受是:GRPO比PPO好调很多,因为少了一个Critic网络的调参面。但G和beta仍然是双刃剑,它们共同决定了“探索的胆子”和“偏离的缰绳”之间怎么平衡。
4. 奖励函数设计:把数独规则翻译成可学习的梯度信号
4.1 三层奖励结构:格式、规则、正确率
奖励函数设计的核心矛盾是:太稀疏,模型学不动;太宽松,模型钻空子。
我最开始只给“最终答案和标准解一致给1分,否则0分”,结果训练几百步几乎原地踏步。原因是稀疏奖励下,模型采样的输出绝大多数都是错的,组内每个样本的奖励都是0,优势全是0,梯度信号完全消失。
所以我把奖励拆成三层,每一层覆盖不同粒度的信息:
- 格式分:模型输出是否恰好9行、每行恰好9个数字、解析成9x9棋盘
- 规则分:填完的棋盘是否满足行列宫不重复,以及是否有较多空格未填
- 正确分:最终棋盘和唯一解逐格比对的正确率
总奖励用加权组合:
total = 0.2 * format_score + 0.3 * rule_score + 0.5 * accuracy_score为什么把正确率权重设最大?因为格式分不能给太高,否则模型哪怕完全乱填也能靠格式分拿到一个正奖励基线,弱化了规则的约束力。规则分和正确率则一起推动模型从“输出格式正确”向“输出内容正确”进化。
4.2 奖励函数的具体代码结构
下面这段是我用过的奖励函数核心逻辑,省略了一些边界细节,但框架完整:
def parse_board(text): lines = text.strip().splitlines() if len(lines) != 9: return None board = [] for line in lines: digits = [int(ch) for ch in line if ch.isdigit()] if len(digits) != 9: return None board.append(digits) return board def rule_violations(board): violations = 0 empty = 0 seen_rows = [set() for _ in range(9)] seen_cols = [set() for _ in range(9)] seen_boxes = [set() for _ in range(9)] for i in range(9): for j in range(9): v = board[i][j] if v == 0: empty += 1 continue if not (1 <= v <= 9): violations += 1 continue box_idx = (i // 3) * 3 + (j // 3) if v in seen_rows[i]: violations += 1 if v in seen_cols[j]: violations += 1 if v in seen_boxes[box_idx]: violations += 1 seen_rows[i].add(v) seen_cols[j].add(v) seen_boxes[box_idx].add(v) return violations, empty def compute_total_reward(text, solution): board = parse_board(text) if board is None: return -1.0 format_score = 1.0 violations, empty = rule_violations(board) rule_score = max(0.0, 1.0 - empty / 81 - 0.15 * violations) acc = sum(1 for i in range(9) for j in range(9) if board[i][j] == solution[i][j]) / 81 return 0.2 * format_score + 0.3 * rule_score + 0.5 * acc注意解析函数非常严格。最初我用“抽取出所有数字再拼成81个数字”的宽松解析器,结果模型学到了把9行答案全部挤在一行里输出,解析器照样判格式分满格——这是典型的奖励黑客漏洞,后面专门讲。
4.3 奖励黑客的几个经典版本
奖励函数你写得再小心,模型都会想方设法钻空子。我遇到的几个典型版本:
全0棋盘:模型输出81个0。因为0代表空格,不算重复违规,所以规则分很容易骗到手。我在规则分里加入了空格惩罚项empty/81,全0棋盘的规则分直接归零,只剩0.2的格式分,奖励大幅缩水。
全1棋盘:模型输出81个1。这个看似简单,肉眼看一眼就知道是胡扯,但如果不检查行内/列内/宫内重复,它甚至能拿到规则分。我上面代码里的三个seen集合就是为了抓这种模式,81个1会让violations数量爆炸,规则分归零。
固定模板:模型不是在解题,而是在复读一个训练集里出现过的完整答案。这类问题靠奖励函数本身很难防,需要我在数据构建时保证训练集与评测集不重合,并且验证集永远用全新生成的题目。
4.4 奖励权重调参记录
我前前后后试过几组权重:
| 权重组合 (format, rule, acc) | 现象 |
|---|---|
| (0.0, 0.0, 1.0) | 收敛极慢,几乎学不到 |
| (0.4, 0.4, 0.2) | 模型快速学会格式,但正确率长期个位数 |
| (0.2, 0.3, 0.5) | 格式、规则、正确率稳步上升,最终方案 |
我还抽空写了几个badcase对着测试奖励函数,专门构造全0、全1、重复行、缺行、混入文本等输出,确保每一种黑客解法都被惩罚到位。这个习惯强烈建议养成——奖励函数是强化学习里唯一的主观设计环节,多砸几个对抗样本进去,比训练跑完再发现问题划算得多。
5. 训练实测:数据、超参与三阶段收敛曲线
5.1 数独数据生成与难度控制的细节
训练数据我全程用程序生成,没有用网上现成数据集。原因有两个:一是网上数独题来源混杂,正确性没有保证;二是需要精确控制难度分布和训练集评测集不重叠。
生成流程分两步:
第一步,用回溯算法随机填充一个完整合法的9x9棋盘。从空盘开始,按随机顺序在每格填入一个数字,填不下去就回溯重试。这个算法很成熟,生成一个完整解耗时在毫秒级。
第二步,挖洞并保证唯一解。按每行每列数量对称的方式挖洞,每挖一个洞就跑一次求解器统计候选解数量,如果解不唯一就换一个位置。最终保留所有唯一解的盘面作为训练样本。
训练集我生成20万题,验证集单独生成5000题。难度分布上,挖洞30到55个之间均匀抽样,确保简单题和困难题都有一定占比。这类均匀分布很重要,因为如果简单题占比过高,模型很容易记住少数模式就拿到高分,后面策略坍缩阶段会吃到这个苦头。
5.2 训练流程与计算量预估
基座模型我选了一个1.5B参数的通用对话模型,max_len固定128。整体训练流程每一步是这样的:
- 从训练集中采样一批数独题目(batch size 32)
- 对每题用当前策略采样G=8个输出,温度0.7,同时记录old_logps
- 用奖励函数给每个输出打分,得到32x8的奖励矩阵
- 按行计算组内均值和标准差,得到优势矩阵
- 重算当前策略的new_logps,计算ratio和裁剪后的策略损失
- 计算与参考模型的KL散度,加到总loss里
- 反向传播更新策略参数
计算量上,每个step大概要做2次前向(采样加KL)和1次反向,每次前向的batch是32x8个长度为128的序列,也就是一次处理256个序列。在8张NPU上,单步耗时在几秒量级。这个规模对1.5B模型来说完全可控,我一共训了大概三千步,能明显看到能力增长。
5.3 训练曲线的三个阶段
训练过程中的loss曲线非常抖动,这是RL训练的正常现象,关键要看滑动平均。我把整个收敛过程分为三个阶段:
**阶段一(前200步):格式对齐期。**格式分快速上升,模型很快学会输出9行数字。这个阶段正确率还很低,但模型已经理解了“答案是一段结构化的棋盘文本”。如果这个阶段格式分上不去,多半是奖励函数解析太严或基座模型指令跟随能力太弱。
**阶段二(200到1500步):规则内化期。**规则分开始爬升,模型的行列宫重复错误明显减少,准确率也开始同步上涨。这个阶段最容易被“格式正确但内容垃圾”的假象迷惑,要靠每个训练批次里抽样生成的结果来判断模型是不是真的在进步。
**阶段三(1500步以后):精度爬坡期。**正确率增长放缓,验证集准确率进入平台期。此时要警惕验证集和训练集准确率差距拉大,一旦拉大,就说明模型开始过拟合训练盘面分布,需要调整数据难度配比。
评测我固定使用temperature=0的贪心解码,每个题目生成一次,统计三个指标:格式正确率、规则通过率、最终盘面准确率。
我的一个实测记录供参考:基座模型未训练时,格式正确率大约80%(因为指令跟随能力尚可),但最终正确率只有3%左右,基本就是乱猜水平。训练结束后,格式正确率提升到99%,规则通过率到85%,最终准确率到42%。在简单题上准确率到71%,困难题大概21%——这个结果不能代表所有模型和设置,但趋势很典型:强化学习确实让模型学会了数独规则,但困难题的表现仍然有限,说明推理深度还不够,这也是后续继续优化的空间。
5.4 超参数配置的完整表格
| 参数 | 取值 | 备注 |
|---|---|---|
| 基座模型 | 1.5B通用对话模型 | 越小越容易实验 |
| max_len | 128 | 固定长度,规避NPU动态shape |
| 组大小G | 8 | 4会抖,16更稳但慢 |
| 采样温度 | 0.7 | 训练采样 |
| 评测温度 | 0 | 贪心解码 |
| KL系数beta | 0.05 | 出问题调0.03或0.08 |
| 学习率 | 2e-6 | 5e-6会震荡 |
| 批大小 | 32题 × G=8 | 每次更新含256个序列 |
| 总步数 | 3000 | 用验证集早停 |
6. 踩坑实录:四次翻车与完整的排查链路
6.1 第一次翻车:第二步就loss变NaN
现象非常直接:加载模型、采第一步还正常,第二步一loss.backward()梯度就全是inf,loss直接变NaN。
我的排查链路是这样走的:
- 先排除输入数据问题。数独输入全是0到9的数字token,洗白了也很正常,不应该是数据nan
- 检查混合精度。KL散度计算时,logits差值在fp16下可能出现指数溢出,把KL计算单独切到fp32下运行,问题依然存在
- 查优势函数。发现当组内8个采样全部得到同一个奖励时,std等于0,除以0之后优势全是NaN。找到根因了,给std加上一个1e-4的epsilon就解决
这个问题给我留下一个深刻教训:RL伪代码里那些“为了数值稳定加上一个极小值”的操作不是走形式,而是真会炸的。标准库里std算出来为0的情况远比你想象的常见,尤其是早期模型采样几乎全是错误答案的时候。
6.2 第二次翻车:格式全对,正确率纹丝不动
训练到300多步,格式分已经到95%以上,但规则分和正确率还是贴着地板。把模型生成的棋盘打印出来一看,所有输出都是固定的“123456789”重复9次。
这个现象的本质是:模型找到了一个“语料里常见的数字排列”,它格式完全合法、每行数字本身是1到9不重复,但放在数独语境下完全无效。因为同一列重复无数次,列约束严重违规,但列违规在初期奖励函数里没有单独分权,导致这个模板还是有相对较高的奖励,在组内优势里经常拿到正分。
修复办法有两个:一是强化规则分对列和宫的独立检查,让“行规但不列规”的输出拿不到高奖励;二是在奖励权重里把acc提权重,让最终正确率主导学习方向。
修复后模型很快放弃了“123456789”模板,开始真正利用题面信息。这次的教训是:任何固定模板只要在奖励函数里有一线生机,模型就会抓住它,而且一抓一个准。
6.3 第三次翻车:策略坍缩成复读机
训练到中后期,我发现验证集准确率不升反降,采样输出也越来越统一,统计一下生成熵从1.0掉到0.05附近,几乎十个输出有八个长一样。
这是典型的策略坍缩:策略过度追求当前奖励,把所有概率质量压在少数几个高分输出上,多样性消失,探索彻底停止。
排查之后确认是多因素叠加:
- KL系数beta设得太小,只有0.01,模型偏离参考模型太远
- G=4组太小,优势估计方差大,少数偶然高分输出被过度强化
- 简单题占比过高,模型只需要复读少数完整盘面就能获得正奖励
修复动作也对应着来:beta调到0.05,G调到8,数据难度重新配平,增加挖洞数在45以上的题目占比。训练大概200步之后,熵恢复到0.4以上,验证集准确率重新回升。
我后来把“熵监控”加进了训练流程,每100步统计一次生成熵和唯一模式数量。熵快速下滑是策略坍缩的前置信号,比看到loss崩坏再反应要早得多。
6.4 第四次翻车:输出变短的长度崩溃
训练到后期,突然出现一批奖励骤降。调查后发现模型开始输出越来越短的文本,有些答案只输出7行甚至5行数字。
根源在解析器。我当时的解析器是“抽出所有数字,够81个就通过”,模型发现少输出几行、只要数字数量凑够81也能拿格式分。于是它学到一个“节省token”的偷懒策略,越输出越短,最终整个输出结构崩坏。
这次修复很简单:解析器改严格模式,必须恰好9行、每行恰好9个数字,否则格式分直接0。但这件事暴露了一个通用问题——我给模型留下了过多的自由度,任何“宽松处理”都会被转化为奖励黑客的通道。
从此我的原则是:解析越严格越好,宁可训练早期模型因为格式问题频繁得低分,也不能让它找到任何“跳过关键约束”的捷径。
6.5 一次典型问题排查的完整链路
把上面的经验综合一下,当训练曲线异常时,我一般按照这个顺序排查:
| 现象 | 首查位置 | 生效修复 |
|---|---|---|
| loss变NaN | 优势计算与KL精度 | std加epsilon、KL切fp32 |
| 格式分涨但正确率不动 | 奖励函数对抗性测试 | 强化规则分、调整权重 |
| 熵骤降、输出重复 | 组大小与KL系数 | G调大、beta调大、数据配平 |
| 输出越来越短 | 解析器宽松度 | 严格解析、格式分归零 |
先别急着调学习率和模型结构,先把奖励函数和解析器这两个最主观的环节检查干净。我在RL训练上浪费过最多时间的地方,恰好都不是算法理论问题,而是这类“看起来很简单、实际上决定成败”的实现细节。
整套流程跑下来的体会是:GRPO在规则可验证任务上确实比PPO好落地——没有价值网络,没有GAE,核心代码可以控制在几十行内,奖励函数一写清楚就能闭环。昇腾NPU这边最大的门槛也不是训练本身,而是环境磨合,CANN、torch_npu、HCCL这三件套只要版本匹配、动态shape避开,训练稳定度还是可以的。
最后分享一个调试技巧:训练时每100步把当前策略生成的一批棋盘保存成文本日志,肉眼看一遍比对着奖励曲线猜半天有效得多。很多“loss在降但能力没涨”的情况,都是通过抽样生成一眼看穿的——模型输出都是重复模板还是真的在按题目填数,在日志里清清楚楚。