ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

基于昇腾NPU的GRPO强化学习实战:从数独任务到奖励函数设计

基于昇腾NPU的GRPO强化学习实战:从数独任务到奖励函数设计

我最近在昇腾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 环境安装与验证清单

环境准备过程我踩了不止一个坑,给你一份我最终验证有效的最小清单:

  1. 安装驱动与固件:按NPU厂商文档安装,装完用npu-smi info确认8卡都可见,这一步卡住后面全白搭
  2. 安装CANN toolkit:注意版本要和驱动匹配,我随手装过一套不匹配的组合,结果算子在运行时频繁报非法参数
  3. 创建conda环境:Python版本建议3.8到3.10之间,太新会导致部分适配层包没预编译
  4. 安装PyTorch与torch_npu:这里最容易出问题,torch_npu的版本必须和PyTorch版本严格对应,装错的话import阶段就会报符号找不到
  5. 配置环境变量:安装目录下有个set_env.sh,每次开终端都得source一遍,漏了会出现“找不到CANN库”之类的诡异错误
  6. 写一段最小验证代码:创建张量、跑一次矩阵乘法、再跑一次梯度回传,确认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太大,每次迭代的采样成本直线上升,训练慢好几倍。我实验下来的经验是:

参数我的初始值出问题时调整方向说明
组大小G84太抖,16更稳但慢组内相对优势依赖G的统计可靠性
采样温度0.71.0太散,0.3太集中温度影响探索多样性
KL系数beta0.050.01太激进,0.1太保守控制策略偏离参考模型的程度
学习率2e-61e-5会导致震荡LLM RL学习率通常比SFT小一个量级

我自己的感受是:GRPO比PPO好调很多,因为少了一个Critic网络的调参面。但G和beta仍然是双刃剑,它们共同决定了“探索的胆子”和“偏离的缰绳”之间怎么平衡。

4. 奖励函数设计:把数独规则翻译成可学习的梯度信号

4.1 三层奖励结构:格式、规则、正确率

奖励函数设计的核心矛盾是:太稀疏,模型学不动;太宽松,模型钻空子。

我最开始只给“最终答案和标准解一致给1分,否则0分”,结果训练几百步几乎原地踏步。原因是稀疏奖励下,模型采样的输出绝大多数都是错的,组内每个样本的奖励都是0,优势全是0,梯度信号完全消失。

所以我把奖励拆成三层,每一层覆盖不同粒度的信息:

  1. 格式分:模型输出是否恰好9行、每行恰好9个数字、解析成9x9棋盘
  2. 规则分:填完的棋盘是否满足行列宫不重复,以及是否有较多空格未填
  3. 正确分:最终棋盘和唯一解逐格比对的正确率

总奖励用加权组合:

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。整体训练流程每一步是这样的:

  1. 从训练集中采样一批数独题目(batch size 32)
  2. 对每题用当前策略采样G=8个输出,温度0.7,同时记录old_logps
  3. 用奖励函数给每个输出打分,得到32x8的奖励矩阵
  4. 按行计算组内均值和标准差,得到优势矩阵
  5. 重算当前策略的new_logps,计算ratio和裁剪后的策略损失
  6. 计算与参考模型的KL散度,加到总loss里
  7. 反向传播更新策略参数

计算量上,每个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_len128固定长度,规避NPU动态shape
组大小G84会抖,16更稳但慢
采样温度0.7训练采样
评测温度0贪心解码
KL系数beta0.05出问题调0.03或0.08
学习率2e-65e-6会震荡
批大小32题 × G=8每次更新含256个序列
总步数3000用验证集早停

6. 踩坑实录:四次翻车与完整的排查链路

6.1 第一次翻车:第二步就loss变NaN

现象非常直接:加载模型、采第一步还正常,第二步一loss.backward()梯度就全是inf,loss直接变NaN。

我的排查链路是这样走的:

  1. 先排除输入数据问题。数独输入全是0到9的数字token,洗白了也很正常,不应该是数据nan
  2. 检查混合精度。KL散度计算时,logits差值在fp16下可能出现指数溢出,把KL计算单独切到fp32下运行,问题依然存在
  3. 查优势函数。发现当组内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在降但能力没涨”的情况,都是通过抽样生成一眼看穿的——模型输出都是重复模板还是真的在按题目填数,在日志里清清楚楚。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进