面试知识库

12. 训练侧:planner 的 SFT 与 GRPO#

一句话结论:只对 planner 这一个单步任务做 RL,reward 靠真打 Qdrant 而不是 judge;分涨了、靶心没中,线上至今用 API 模型。

速背卡#

#hint展开一句
1只训 planner 一个单步一句话进、结构化字段出,功劳分配问题消失,reward 可确定性算
2reward 45% 真打 Qdrant检索词打进 138 万点库看 top20 命中,不用 BERTScore、不用 judge
3三种弃权:无上文 / 口语区间 / 待审category=None、budget_uncertain、train 样本按 status 降权
4弃权不计分,权重重归一代价是硬编品类也不扣分,抗幻觉这维 reward 看不见
5SFT 学格式: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 分钟版#

  1. 为什么是 planner。 它输出的 category 是整条链的锚点,锚错了后面每步都朝错方向跑;域漂移是提示词治不死的老账,正好是 RL 擅长的「让环境反馈来教」。
  2. 为什么不训整条链。 本地能训的顶天 4B,打不过 API 强模型;15 轮工具调用还有功劳分配问题。planner 是单步任务,这两条同时消失。
  3. S0 造数据。 三源:94 条真实线上锚、1200 条维度矩阵合成、321 条 bad case 对抗,过质量门后产 2154 条逐轮样本;golden 三组字段三种定法(预算纯规则 / 品类域 3 票扰动投票 / 检索词只标语义锚)。
  4. S1 环境。 Qdrant 搬到 GPU 机,先验「本地 bge-m3 和线上 API 是不是同一把尺子」(余弦 1.0),再量 vLLM rollout 成本(prefix caching ×3.71)。
  5. S2 SFT。 只学 schema 与格式,验收线格式正确率 ≥98%,实测到 100%;准不准交给 GRPO。
  6. S3 五轮 GRPO。 r1 lr 1e-6 等于没训;r3(lr 2e-5、beta 0)最好;r4 单变量对照证明起作用的是学习率不是关 KL;r5 改配比想治域漂移,反而更差。
  7. 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 条子集观察。

机制怎么跑#

  1. S0-1 造三源数据:scripts/train/build_planner_anchors.py(真实 user 消息 → 94 条锚)、build_planner_queries.py(247 张品类卡 → 1200 条合成)、build_planner_adversarial.py(8 个 bad case 族 → 321 条)。
  2. S0-1 过质量门:planner_quality_gate.py 七项门,94 条真实锚整体进 dev 当尺子,不进训练集。
  3. 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。
  4. S2 冷启动:build_planner_sft.py 导目标串(prompt 从 2400 token 精简到 675),ms-swift LoRA r16 / lr 1e-4 / 3 epoch,eval_planner_format.py 验收格式正确率。
  5. S1 环境前置:verify_embed_parity.py 验 GPU 机本地 bge-m3 与线上 API 编码是否一致;rollout_profile.py 量 vLLM 前缀缓存收益;gpu_profile.py 量显存墙。
  6. S3 组数据:build_grpo_dataset.py 只给到 user 轮,golden 落 JSON 字符串列(避开 HF struct schema 冲突),只进 reward 的 kwargs,不进 prompt。
  7. S3 跑训练:run_grpo_rollout_server.sh 起独立 vLLM rollout server,run_grpo_planner.sh 起 4 卡 DDP;grpo_planner_plugin.py 只做 ms-swift 适配。
  8. S3 打分:rollout_env.py 解析输出 → 批量查 Qdrant 取 top20 → 调 compute_reward。它不复制 reward 代码到 GPU 机,按文件路径加载仓库里的 app/eval/planner_reward.py,训练与线上评测共用一份实现。
  9. S3 判趋势:analyze_grpo_log.py 读 var/train-logs/m23/output/grpo_r*/*/logging.jsonl,分段均值 + KL + 解析失败率。
  10. 线上(今天):app/agent/llm.py:243 get_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-119a0dd96S0-1 三源数据 + 对 refdocs 三处更正GRPO 而非 GSPO(后者收益在 MoE 路由);本地真调检索不做回放;judge 不进训练回路
08-119b987eaS0-2 golden,2154 条逐轮样本三组字段三种定法;三票必须互相扰动,否则「一致」只证明模型稳定
08-110f28745reward 区分度标定上训练前先确认尺子能分开 strong / copycat / broken
08-111822e96S0-4 90 条种子集基线judge 漏吐 criterion 就整条判 error,改成单条丢掉 + 一次重试
08-115ac7dd2SFT 数据重导字段名写成 term、线上叫 word,1621 条里 0 条带硬排除项
08-118735ab0S1+S2 上卡跑通格式正确率 63.04% → 100%;附 4090D 显存墙实测
08-11d046cf7S1 环境跑通 + S3 起跑embed parity、rollout profile;验收口径改成「reward 可复现 + 生成可复现(观测)」
08-12e659c39r1/r2lr 1e-6 等于没训(202 步权重只动 0.23%)
08-12b08e8e4修 reward 漏洞keywords 为空时整维弃权 = 白送 45% 权重
08-1285437bd修 category 分母 + 权重可覆盖弃权样本不进分母;配比实验不改常量
08-128d75294r3/r4/r5 收尾r3 最优;r5 否定「域漂移是配比问题」;正文写「这一步等你拍板」
09-09e2beb64planner 换 API 模型同一套 dev92 + planner_reward 选型,选 qwen3.8-flash
09-20c869135模型名加 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/247S0 数据三源9a0dd96旧版核对过(提交正文)
2154 条逐轮样本;投票一致率 94.2%;dev/test 人工裁决 29 条S0-2 golden9b987ea旧版核对过(提交正文)
真实锚里 31 条无上文追问碎片改为 category=None弃权语义之一9b987ea旧版核对过(提交正文)
strong 0.842±0.147 / copycat 0.607 / broken 0.145reward 区分度标定(dev 仅 8 条)0f28745旧版核对过;样本太少
train 1621 / val 92;序列长度均值 583SFT 数据集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.522SFT 前后分维8735ab0旧版核对过(提交正文)
不开 grad_ckpt 的 batch 2 比开了快 53%(3692 vs 2414 tok/s);batch 3 即 OOM4090D 显存墙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 线上 APId046cf7旧版核对过(提交正文)
Qdrant 批量查询把打分从 5.57 s 降到 0.30 srollout 打分瓶颈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 sr1–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/90S0-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 看不见硬编,才补了单独的弃权统计。

坑与易混点#

  1. reward 分 ≠ 线上效果。 0.813 是 dev92 上的 reward,S4 端到端 Rubric 验收从没跑过;说成「planner 提升了」是越界。
  2. 「验收全过」不等于问题解决。 三条验收线是防止训坏的下限,不是达成目标的证明——域漂移就是在全过的情况下更差的。
  3. 弃权口径有两套数。 planner_model_eval.json 里的「弃权数」1 / 9 / 12 / 27 是 92 条全体,llm.py docstring 的 1 / 8 / 12 / 26 是 36 条该弃权样本里的正确弃权,口径不同别混用。
  4. dev92 的弃权测的是「上下文缺失时的行为」,线上追问轮有 _render_prior_context() 的上文,那时沿用上一轮品类是正确行为,不能当成线上漂移率。
  5. 文档-代码不一致: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 章。