12. 训练侧:planner 的 SFT 与 GRPO#
一句话结论:只对 planner 这一个单步任务做 RL,reward 靠真打 Qdrant 而不是 judge;分涨了、靶心没中,线上至今用 API 模型。
速背卡#
| # | hint | 展开一句 |
|---|---|---|
| 1 | 只训 planner 一个单步 | 一句话进、结构化字段出,功劳分配问题消失,reward 可确定性算 |
| 2 | reward 45% 真打 Qdrant | 检索词打进 138 万点库看 top20 命中,不用 BERTScore、不用 judge |
| 3 | 三种弃权:无上文 / 口语区间 / 待审 | category=None、budget_uncertain、train 样本按 status 降权 |
| 4 | 弃权不计分,权重重归一 | 代价是硬编品类也不扣分,抗幻觉这维 reward 看不见 |
| 5 | SFT 学格式:63.04% → 100% | 冷启动只修 schema 形态,category 0.533→0.522 基本不动 |
| 6 | 五轮 GRPO,r3 最优 | lr 2e-5、beta 0,dev92 reward 0.790 → 0.813 |
| 7 | 涨检索词,退域判定 | econ .668→.962,但 domains_f1 .775→.654 |
| 8 | 线上没用自训模型 | planner 走 API 的 LLM_PLANNER=qwen3.8-flash,4B 没接进 app |
30 秒版#
M23 把强化学习限定在 planner 单点:Qwen3-4B-Instruct-2507 + LoRA r16,先 SFT 学格式(dev92 格式正确率 63.04% → 100%),再做 GRPO,奖励是「把模型写的检索词真打进 Qdrant,看搜到什么」。五轮下来 dev92 reward 0.790 → 0.813(r3)。但立项要治的域漂移没治好,反而退步。线上 planner 没换成自训模型,2026-09-09 按同一套 dev92 + reward 选型,改用 API 模型 qwen3.8-flash。
3 分钟版#
- 为什么是 planner。 它输出的
category是整条链的锚点,锚错了后面每步都朝错方向跑;域漂移是提示词治不死的老账,正好是 RL 擅长的「让环境反馈来教」。 - 为什么不训整条链。 本地能训的顶天 4B,打不过 API 强模型;15 轮工具调用还有功劳分配问题。planner 是单步任务,这两条同时消失。
- S0 造数据。 三源:94 条真实线上锚、1200 条维度矩阵合成、321 条 bad case 对抗,过质量门后产 2154 条逐轮样本;golden 三组字段三种定法(预算纯规则 / 品类域 3 票扰动投票 / 检索词只标语义锚)。
- S1 环境。 Qdrant 搬到 GPU 机,先验「本地 bge-m3 和线上 API 是不是同一把尺子」(余弦 1.0),再量 vLLM rollout 成本(prefix caching ×3.71)。
- S2 SFT。 只学 schema 与格式,验收线格式正确率 ≥98%,实测到 100%;准不准交给 GRPO。
- S3 五轮 GRPO。 r1 lr 1e-6 等于没训;r3(lr 2e-5、beta 0)最好;r4 单变量对照证明起作用的是学习率不是关 KL;r5 改配比想治域漂移,反而更差。
- S4 没做。 端到端 Rubric 验收、部署推理服务都没做,训练侧就停在这里。
它解决什么问题#
① 奖励信号该怎么给
- 问题:检索词没有唯一答案,字面比对量不出「这组词能不能搜到东西」。
- 坏法:像 refdocs 那样用 BERTScore 比字面,或请 judge 打分。judge 的端到端噪声 ±13.2(M22 实测,旧版核对过),信号比训练增量还大,坏了还看不见——分照涨。
- 修法:R_retrieval 占 0.45,把 keywords 真打进本地 Qdrant,看 top20 的 must_have 命中率和品类纯度;judge 不进训练回路。
- 代价:138 万点的 Qdrant 必须搬到 GPU 机;query 编码要和线上一致,先过
scripts/train/verify_embed_parity.py。
② 无解的题怎么算分
- 问题:有的样本本来就判不出品类(无上文的追问碎片)或预算(「十五六块」)。
- 坏法:一律记 0 分。会给这批样本加系统性偏置、污染 GRPO 的组内优势,等于教模型在无解的题上瞎猜。
- 修法:三种弃权语义——
category=None、budget_uncertain=True、train 待审样本按status降权;compute_reward只在实际参与的维度上按权重重新归一。 - 代价:硬编品类不扣分,reward 看不见「抗幻觉」这一维。2026-09-09 选 API 模型时只能另外单独统计弃权列。
③ SFT 要不要教「判得准」
- 问题:GRPO 里解析失败直接 −1.0,格式不稳会持续拉低组内基线。
- 坏法:SFT 阶段连品类判定一起教。教师产出本身就不准,学了等于把教师的错固化进权重。
- 修法:
build_planner_sft.py里确定字段用 golden 覆盖教师、检索词用教师产出;SFT 只负责格式,验收线 98%。 - 代价:SFT 后 category 基本不动(0.533 → 0.522,旧版核对过),冷启动看起来「没提升」。
④ 配比实验会让旧轮次不可复现
- 问题:r5 要改 reward 权重做对照,直接改常量的话 r1~r4 的分再也复现不出来。
- 坏法:改常量重跑。旧轮的账全废,跨轮比较失去基线。
- 修法:权重支持
PLANNER_REWARD_WEIGHTS环境变量覆盖,默认值不动(85437bd)。 - 代价:多一个环境变量;跨轮比较必须统一用默认配比重打分。
⑤ 涨分了要不要信
- 问题:一步只有 8 个 prompt,单步 reward 抖动和真实提升差不多大。
- 坏法:看逐步曲线、只汇报总分。三条验收线全过但域漂移更差——只报总分就成了「成功的里程碑」。
- 修法:
analyze_grpo_log.py按分段均值判趋势,并把 reward 拆维看;插件每步打印组内 σ,防「分数全一样、优势恒为 0」。 - 代价:评测比训练慢(dev92 每条采 8 个样本),训练中只跑 32 条子集观察。
机制怎么跑#
- S0-1 造三源数据:
scripts/train/build_planner_anchors.py(真实 user 消息 → 94 条锚)、build_planner_queries.py(247 张品类卡 → 1200 条合成)、build_planner_adversarial.py(8 个 bad case 族 → 321 条)。 - S0-1 过质量门:
planner_quality_gate.py七项门,94 条真实锚整体进 dev 当尺子,不进训练集。 - S0-2 标 golden:
build_planner_golden.py—— 预算纯规则零 LLM;category/domains用强模型 3 票互相扰动投票(温度 0 / 0.3 / 0.6 × 域清单顺序 × 是否先写判据),一致率 94.2%;检索词只标 must_have 语义锚。dev/test 的 29 条人工裁决进planner_golden_overrides.py。 - S2 冷启动:
build_planner_sft.py导目标串(prompt 从 2400 token 精简到 675),ms-swift LoRA r16 / lr 1e-4 / 3 epoch,eval_planner_format.py验收格式正确率。 - S1 环境前置:
verify_embed_parity.py验 GPU 机本地 bge-m3 与线上 API 编码是否一致;rollout_profile.py量 vLLM 前缀缓存收益;gpu_profile.py量显存墙。 - S3 组数据:
build_grpo_dataset.py只给到 user 轮,golden 落 JSON 字符串列(避开 HF struct schema 冲突),只进 reward 的 kwargs,不进 prompt。 - S3 跑训练:
run_grpo_rollout_server.sh起独立 vLLM rollout server,run_grpo_planner.sh起 4 卡 DDP;grpo_planner_plugin.py只做 ms-swift 适配。 - S3 打分:
rollout_env.py解析输出 → 批量查 Qdrant 取 top20 → 调compute_reward。它不复制 reward 代码到 GPU 机,按文件路径加载仓库里的app/eval/planner_reward.py,训练与线上评测共用一份实现。 - S3 判趋势:
analyze_grpo_log.py读var/train-logs/m23/output/grpo_r*/*/logging.jsonl,分段均值 + KL + 解析失败率。 - 线上(今天):
app/agent/llm.py:243get_planner_llm()读LLM_PLANNER,不配就回快档;.env.example:41注明 gcjp 线上配的是qwen3.8-flash。没有加载自训 LoRA 的任何代码路径。
reward 结构:R = 0.45×R_retrieval + 0.30×R_field + 0.15×R_format + 0.10×R_econ,解析失败直接 −1.0(旧版核对过)。三条纪律:弃权维度不计分并重新归一;命中判断复用线上 term_hits;照抄原 query 时 R_retrieval 折半。
演进时间线#
| 日期 | 提交 | 改了什么 | 为什么 |
|---|---|---|---|
| 08-11 | 9a0dd96 | S0-1 三源数据 + 对 refdocs 三处更正 | GRPO 而非 GSPO(后者收益在 MoE 路由);本地真调检索不做回放;judge 不进训练回路 |
| 08-11 | 9b987ea | S0-2 golden,2154 条逐轮样本 | 三组字段三种定法;三票必须互相扰动,否则「一致」只证明模型稳定 |
| 08-11 | 0f28745 | reward 区分度标定 | 上训练前先确认尺子能分开 strong / copycat / broken |
| 08-11 | 1822e96 | S0-4 90 条种子集基线 | judge 漏吐 criterion 就整条判 error,改成单条丢掉 + 一次重试 |
| 08-11 | 5ac7dd2 | SFT 数据重导 | 字段名写成 term、线上叫 word,1621 条里 0 条带硬排除项 |
| 08-11 | 8735ab0 | S1+S2 上卡跑通 | 格式正确率 63.04% → 100%;附 4090D 显存墙实测 |
| 08-11 | d046cf7 | S1 环境跑通 + S3 起跑 | embed parity、rollout profile;验收口径改成「reward 可复现 + 生成可复现(观测)」 |
| 08-12 | e659c39 | r1/r2 | lr 1e-6 等于没训(202 步权重只动 0.23%) |
| 08-12 | b08e8e4 | 修 reward 漏洞 | keywords 为空时整维弃权 = 白送 45% 权重 |
| 08-12 | 85437bd | 修 category 分母 + 权重可覆盖 | 弃权样本不进分母;配比实验不改常量 |
| 08-12 | 8d75294 | r3/r4/r5 收尾 | r3 最优;r5 否定「域漂移是配比问题」;正文写「这一步等你拍板」 |
| 09-09 | e2beb64 | planner 换 API 模型 | 同一套 dev92 + planner_reward 选型,选 qwen3.8-flash |
| 09-20 | c869135 | 模型名加 dashscope/ 前缀 | 主 loop 回 deepseek-v4.1、qwen3.8 留 planner;名字写法不一致会被令牌桶开成两个桶 |
2026-09-16 之后这一章的训练代码没有变动;唯一相关的改动是 c869135 的模型名前缀(只动 .env.example、app/harness/token_budget.py、tests/test_token_budget.py)。
数字与证据#
| 数字 | 指什么 | 来源 | 状态 |
|---|---|---|---|
| 锚 94 / 合成 1200 / 对抗 321;品类覆盖 247/247 | S0 数据三源 | 9a0dd96 | 旧版核对过(提交正文) |
| 2154 条逐轮样本;投票一致率 94.2%;dev/test 人工裁决 29 条 | S0-2 golden | 9b987ea | 旧版核对过(提交正文) |
真实锚里 31 条无上文追问碎片改为 category=None | 弃权语义之一 | 9b987ea | 旧版核对过(提交正文) |
| strong 0.842±0.147 / copycat 0.607 / broken 0.145 | reward 区分度标定(dev 仅 8 条) | 0f28745 | 旧版核对过;样本太少 |
| train 1621 / val 92;序列长度均值 583 | SFT 数据集 | var/train-logs/m23/output/sft_r16/*/logging.jsonl | 旧版核对过(训练日志) |
train_runtime 1051.8 s(17m32s)、train_loss 0.1549;可训练参数 33.03M / 4055.5M(0.81%) | SFT,LoRA r16 / lr 1e-4 / 3 epoch | 同上 + args.json | 旧版核对过(训练日志) |
格式正确率 63.04% → 100%;基座违规只有 budget_amount 写成字符串(34/92) | S2 验收,验收线 98% | 8735ab0 | 旧版核对过(提交正文) |
| domains_f1 0.638 → 0.764;budget 0.63 → 0.978;category 0.533 → 0.522 | SFT 前后分维 | 8735ab0 | 旧版核对过(提交正文) |
| 不开 grad_ckpt 的 batch 2 比开了快 53%(3692 vs 2414 tok/s);batch 3 即 OOM | 4090D 显存墙 | 8735ab0 | 旧版核对过(提交正文) |
| 前缀占 prompt 96%(505/526 token);prefix caching ×3.71(14.19 s → 3.82 s);命中率 98.0% | vLLM rollout 成本 | d046cf7 | 旧版核对过(提交正文) |
| 余弦 1.0、top20 重合 97.5%、top1 100% | GPU 机本地 bge-m3 vs 线上 API | d046cf7 | 旧版核对过(提交正文) |
| Qdrant 批量查询把打分从 5.57 s 降到 0.30 s | rollout 打分瓶颈 | d046cf7 | 旧版核对过(提交正文) |
| r1–r5 lr 1e-6 / 5e-6 / 2e-5 / 5e-6 / 2e-5;beta .03 / .01 / 0 / 0 / 0;每组 8 个 rollout | 五轮 GRPO 超参 | var/train-logs/m23/output/grpo_r*/*/args.json | 旧版核对过(训练日志);r5 的 field .40 由环境变量传入,args.json 看不到 |
train_runtime 1597 / 3152 / 2783 / 2825 / 2791 s | r1–r5 耗时 | 同上 logging.jsonl | 旧版核对过(训练日志) |
| dev92 greedy reward .7901(SFT)/ .7903 / .7994 / .8134(r3) / .7936 / .8302(r5) | 五轮结果 | 8d75294 | 旧版核对过(提交正文);原始文件 data/train/gpu_runs/ 本地不存在 |
| domains_f1 .775(SFT)→ .697(r3)→ .654(r5);econ .6678 → .9618;给出 keywords 69/92 → 88/92 | 涨在哪、退在哪 | 8d75294 | 旧版核对过(提交正文) |
| category 0.706(68 条可判样本,SFT 与 r3 相同) | 修分母后 | 85437bd | 旧版核对过(提交正文) |
| 90 条种子集均分 51.74、标准差 37.61、P0 失败 23、overall_pass 67/90 | S0-4 基线 | data/eval/baselines/A_M23_S0_4_90q.json | 旧版核对过;当时环境指纹工作区不干净 |
| 四个 API 候选 reward 0.805 / 0.789 / 0.801 / 0.786;延迟中位 6.8 / 3.0 / 2.1 / 4.9 s;该弃权→真弃权 1 / 8 / 12 / 26(共 36) | 09-09 planner 选型 | app/agent/llm.py:243 docstring、docs/plans/baseline-artifacts/planner_model_eval.json | 代码现读到 ✓ |
追问 10 题#
Q1. reward 为什么让检索占 45%? 这是 planner 这件事的 agentic 核心——138 万点 Qdrant 就在本地、查询确定,可以直接问「这组词到底搜到东西没有」。没有这一维,planner RL 就退化成普通 RLHF。证据:app/eval/planner_reward.py docstring。
Q2 ⚠. 弃权为什么不当 0 分罚? 因为「这题无解」不是「模型答错」。罚 0 会给这批样本加系统性偏置、污染 GRPO 的组内优势。实现是只在参与的维度上按权重重新归一(compute_reward)。副作用:硬编品类不扣分。
Q3 ⚠. 训练中查出的 reward hacking 是什么? keywords 为空时环境传 titles=None,R_retrieval 整维弃权——模型不写检索词就能甩掉 45% 的权重,剩下 field / format 恰好是它最擅长的。改成按 golden 判:有锚不给词记 0,无锚照旧弃权(b08e8e4)。修完「该检索的轮次给出 keywords」69/92 → 88/92。
Q4. SFT 阶段目标是什么?为什么 category 几乎没变? 只学 schema 和格式,验收线 98%。GRPO 里解析失败 −1.0,格式不稳会持续拉低组内基线,这件事用监督信号最便宜。category 0.533 → 0.522 恰好印证「冷启动只学形态」的定位(8735ab0)。
Q5 ⚠. 为什么说起作用的是学习率,不是关掉 KL? r3 和 r4 是单变量对照:同 reward、同 beta=0,只有 lr 不同(2e-5 vs 5e-6),dev92 reward .8134 vs .7936(8d75294)。r2 同时改了 lr 和 beta,正文自己标明是探索性的一组、不是受控消融。
Q6. 训练曲线怎么判断是真涨还是噪声? 用 analyze_grpo_log.py 看分段均值,不看逐步曲线——一步只有 8 个 prompt,单步抖动和真实提升差不多大。三条验收线:reward 均值上升、KL 在 [0.5, 3.0]、格式率不低于 96%。beta=0 的 r3/r5 日志里 KL 记为 0,这条线不适用。
Q7. rollout 为什么快?瓶颈在哪? 公共前缀占 prompt 96%,vLLM prefix caching 让一步 256 条生成从 14.19 s 降到 3.82 s。瓶颈原本在打分侧逐条 HTTP 查 Qdrant,改批量后 5.57 s → 0.30 s(d046cf7)。
Q8 ⚠. 域漂移为什么没治好? domains_f1 从 SFT 的 .775 退到 r3 .697、r5 .654。r5 特意把 field 权重从 .30 提到 .40,结果更差,直接否定了「配比不对」的归因。提交给的两个候选解释:budget 已 .978 饱和把 domains 的边际信号稀释掉,或 LoRA r16 容量下「写检索词」与「判域」互相挤占。建议改成硬约束(域错扣到底),没做。
Q9(压力题). 简历写 0.790 → 0.813 经得起问吗? 数字有出处(8d75294,dev92 greedy、默认配比)。但要主动说三点:① 这是 reward 分不是端到端 Rubric,S4 没做;② 同一张表里 domains_f1 .775 → .697 退步了,涨的只是检索词那几维;③ dev92 评测的原始文件 data/train/gpu_runs/ 本地不存在,只能以提交正文为准,训练日志能复核的是超参、耗时和 train reward 趋势。追问「r5 .8302 更高为什么不写 r5」:r5 改了 reward 配比、是为治域漂移做的实验,而域漂移更差。
Q10(压力题)⚠. 训了 4B planner,线上为什么还用 API 模型? get_planner_llm() 只读 LLM_PLANNER,app 里没有加载自训 LoRA 的代码。M23 停在 S3,没做端到端验收也没部署推理服务。09-09 选型四个 API 候选 reward 在 0.786~0.805 分不出高下,最后按延迟 + 抗硬编选了 qwen3.8-flash。追问「那 M23 的价值在哪」:app/eval/planner_reward.py 成了模型选型的评分器,dev92 真实锚尺子和「弃权不计分」的口径被选型沿用;也正是选型时发现 reward 看不见硬编,才补了单独的弃权统计。
坑与易混点#
- reward 分 ≠ 线上效果。 0.813 是 dev92 上的 reward,S4 端到端 Rubric 验收从没跑过;说成「planner 提升了」是越界。
- 「验收全过」不等于问题解决。 三条验收线是防止训坏的下限,不是达成目标的证明——域漂移就是在全过的情况下更差的。
- 弃权口径有两套数。
planner_model_eval.json里的「弃权数」1 / 9 / 12 / 27 是 92 条全体,llm.pydocstring 的 1 / 8 / 12 / 26 是 36 条该弃权样本里的正确弃权,口径不同别混用。 - dev92 的弃权测的是「上下文缺失时的行为」,线上追问轮有
_render_prior_context()的上文,那时沿用上一轮品类是正确行为,不能当成线上漂移率。 - 文档-代码不一致:
docs/milestones/M23-...md写训出来的 4B 走「双通道 + 一个开关」接进线上,这只是当时的设计打算;app/agent/llm.py里没有任何双通道或本地模型加载路径。以代码为准。
本章和别章的接口#
- planner 在检索管道里的位置、P_t 的品类/预算/词桶 → 第 4 章。
- 90 条种子集的 Rubric 口径与 judge 噪声 → 第 8 章。
- embedding / reranker 的微调(M21 / M22,judge 噪声 ±13.2 的来源)→ 第 11 章。