Răsfoiți Sursa

Merge pull request #630 from nameless0120/extra12-trip-planner-post-training

docs: 更新旅行助手后训练实战章节
Sizhou Chen 4 săptămâni în urmă
părinte
comite
1a3b2877d5

+ 184 - 81
Extra-Chapter/Extra12-旅行助手后训练实战.md

@@ -1,10 +1,10 @@
 # 旅行助手后训练实战:从产品协议到 SFT、DPO 与 Rerank 收尾
 
-项目来自我维护的 `helloagents-trip-planner`。它不是论文项目,也不是为了刷一个标准榜单。更像是一次完整的工程实验:把一个看起来能聊天的旅行助手,慢慢改成一个能被前后端接住、能被规则评测解释、也能继续迭代的 Planner
+这个项目来自我维护的 `helloagents-trip-planner`。它不是论文项目,也不是为了刷榜。我把它当成一次工程练习:一个看起来会聊天的旅行助手,怎么一步步改到能接前后端、能被规则抓错、还能继续修
 
-旅行规划这个场景很容易让人误判。第一版 Demo 通常很好看:用户说“我想去杭州玩 4 天,预算 3500”,模型很快就能写出景点、酒店、餐厅和注意事项。但接到真实前后端以后,问题会变得很具体:预算到底是整趟还是人均,酒店应该按几晚算,景点门票要不要乘同行人数,餐厅是不是工具候选里真的有,最后一天还要不要安排晚餐。
+旅行规划看起来简单,其实很会骗人。第一版 Demo 通常挺好看:用户说“我想去杭州玩 4 天,预算 3500”,模型很快就能写出景点、酒店、餐厅和注意事项。真接到前后端以后,麻烦就来了:预算是整趟还是人均,酒店按几晚算,门票要不要乘人数,餐厅是不是真的来自工具候选,最后一天到底还要不要安排晚餐。
 
-这篇 Extra-Chapter 写的是这条后训练线的精简复盘。完整教程里有更多命令、配置和归档路径,这里重点讲主线和取舍
+这篇 Extra-Chapter 只写精简版:这条线怎么走过来,哪些尝试有用,哪些后来证明没必要。命令、配置和完整归档放在项目仓库里
 
 项目与配套材料:
 
@@ -12,7 +12,7 @@
 - 完整后训练教程:[旅行助手后训练实战教程](https://github.com/nameless0120/helloagents-trip-planner/blob/main/training/docs/%E6%95%99%E7%A8%8B/%E6%97%85%E8%A1%8C%E5%8A%A9%E6%89%8B%E5%90%8E%E8%AE%AD%E7%BB%83%E5%AE%9E%E6%88%98%E6%95%99%E7%A8%8B.md)
 - 配套数据:`helloagents-后训练数据`,网盘链接:<https://pan.baidu.com/s/5oNsK7pwQnqzQEUg5ykb09Q>
 
-一句话概括这条路线:**Prompt 固定协议,SFT 学会结构,DPO 学偏好,Rerank 在候选里选更稳的答案。**
+跑到最后,我觉得路线其实很朴素:**Prompt 固定协议,SFT 学会结构,DPO 学偏好,Rerank 在候选里选更稳的答案。**
 
 ![后训练主线总览图](./images/Extra12-figures/01-后训练主线总览图.png)
 
@@ -27,10 +27,12 @@
 - [第五章:Prompt 调试是在找边界](#第五章prompt-调试是在找边界)
 - [第六章:SFT 数据生成和审计](#第六章sft-数据生成和审计)
 - [第七章:LoRA SFT 多阶段训练](#第七章lora-sft-多阶段训练)
+- [第七章补充:全参 SFT 为什么没成为主线](#第七章补充全参-sft-为什么没成为主线)
 - [第八章:Best-of-N Replay 和 SFT Rerank](#第八章best-of-n-replay-和-sft-rerank)
 - [第九章:DPO 学偏好,核心指标换成 PlannerSoft](#第九章dpo-学偏好核心指标换成-plannersoft)
 - [第十章:最终多候选 Rerank](#第十章最终多候选-rerank)
 - [第十一章:和 MiMo 外部参考怎么比](#第十一章和-mimo-外部参考怎么比)
+- [Bad Case Gallery:三个最值钱的错误](#bad-case-gallery三个最值钱的错误)
 - [第十二章:这次实验留下来的经验](#第十二章这次实验留下来的经验)
 - [复现资源](#复现资源)
 
@@ -56,7 +58,7 @@
 
 ![开篇 Case 前后对比图](./images/Extra12-figures/02-开篇Case前后对比图.png)
 
-这个例子想说明一件事:后训练不是为了让模型把文案写得更漂亮,而是让它更接近一个能被产品接住的 Planner。住宿晚数、餐饮 grounding、预算关系、日期天气、输出 JSON,这些东西看起来琐碎,但真实产品里就是这些琐碎问题最容易把体验打穿
+我想先把话说清楚:后训练不是为了让模型把文案写漂亮,而是让它更像一个能放进产品流程里的 Planner。住宿晚数、餐饮 grounding、预算关系、日期天气、输出 JSON,这些东西看着碎,但真实产品里最容易翻车的就是它们
 
 ---
 
@@ -68,7 +70,7 @@
 
 如果业务事实没有固定,训练只会把混乱学得更稳定。用户说“预算 3000”,模型要知道这是整趟预算还是人均预算;酒店价格是单间每晚,不是全程总价;景点门票要乘同行人数;餐厅不能凭空编,最好来自工具候选。只靠 prompt 反复提醒,能救一部分,但救不了整条链路。
 
-所以这条后训练主线不是
+所以我后来没有按这条路走
 
 ```text
 写 prompt -> 造数据 -> 训练 -> 看指标
@@ -89,13 +91,13 @@
   -> 多候选 Rerank 收尾
 ```
 
-这条路更慢,但每一步都能回答两个问题:为什么变好,为什么变坏
+慢是慢,但好处很实在:每一轮都知道自己在改什么,也知道哪里又被改坏了
 
 ---
 
 ## 第三章:先改产品协议
 
-刚开始做旅行助手时,很容易把问题都丢给模型:让模型从自然语言里猜人数,猜预算口径,猜住宿晚数,再猜景点门票和餐厅价格。第一版能跑,但后训练会很痛苦,因为训练数据里的“事实”本身就是飘的。
+刚开始做旅行助手时,我也很容易把问题都扔给模型:让它从自然语言里猜人数、猜预算口径、猜住宿晚数,再猜门票和餐厅价格。第一版能跑,但后面训练会很痛苦,因为训练数据里的“事实”本来就是飘的。
 
 后来我做的第一个决定很朴素:**不要让模型猜业务事实。**
 
@@ -109,17 +111,7 @@
 
 ![产品协议改造图](./images/Extra12-figures/03-产品协议改造图.png)
 
-读代码可以从这些文件看起:
-
-| 位置 | 看什么 |
-| --- | --- |
-| `frontend/src/types/index.ts` | 前端 `TripFormData`、`PartyInfo`、`BudgetConstraint` 类型 |
-| `frontend/src/views/Home.vue` | 同行人数、预算档位、总预算、自由文本怎么收集 |
-| `backend/app/models/schemas.py` | 后端 `TripRequest`、`PartyInfo`、`BudgetConstraint`、`TripPlan` schema |
-| `backend/app/planner/policy.py` | 把请求编译成预算、住宿、人数和价格策略 |
-| `backend/app/planner/context.py` | 并行收集景点、天气、酒店等工具快照 |
-| `backend/app/planner/compact.py` | 把完整上下文裁剪成模型真正看到的输入 |
-| `backend/app/planner/output.py` | 提取顶层 `TripPlan JSON`,并做 shape validation |
+如果想顺代码看,可以按这条线索找:前端表单类型、后端请求 schema、PlannerContext 编译、输出解析和校验。具体文件路径放在 `helloagents-trip-planner` 的完整教程里,这里不展开。
 
 有了这层协议,后训练的任务才变窄:模型不再凭感觉写旅行计划,而是在结构化候选里做选择,并输出合法 JSON。
 
@@ -127,11 +119,11 @@
 
 ## 第四章:冻结评测集
 
-很多训练失败不是模型没变好,而是每次评测的题目都变了
+训练结果看不懂,很多时候不是模型的问题,是考卷一直在变
 
 旅行助手尤其容易这样:今天地图候选变了,明天天气变了,后天预算生成逻辑又变了。最后你分不清是模型变强,还是考卷变简单。
 
-所以第二步不是训练,而是固定评测集
+所以我先把评测集固定住
 
 ![评测集冻结图](./images/Extra12-figures/04-评测集冻结图.png)
 
@@ -144,7 +136,7 @@
 
 这里还有一个细节:后面检索策略和上下文修复变了,确实需要重建评测上下文,但不应该重新采样用户请求。我的做法是保持 request signature 不变,只重建工具候选和上下文。这样能保留可比性,又能修掉旧上下文里的脏数据。
 
-这套 frozen eval 后来被反复用于模型选择。严格论文口径下,它更像 validation set,不是 blind test。所以我在文中把它说成“固定评测集上的阶段评估”,不把它包装成独立盲测。
+这套 frozen eval 后来一直用来选模型。严格论文口径下,它更像 validation set,不是 blind test。所以我只把它叫“固定评测集上的阶段评估”,不包装成独立盲测。
 
 最后做 DPO 收尾数据时,我专门检查过签名重叠:`selected_eval_signature_overlap = 0`。也就是说,评测 prompt 没有进入训练数据。
 
@@ -154,7 +146,7 @@
 
 前后端协议和评测集稳定后,才进入 prompt 调试。
 
-这里的目标不是写一条“神 prompt”。我更关心的是:哪些问题 prompt 能解决,哪些问题必须交给数据、规则和工程。
+这里我不是在找一条“神 prompt”。我更关心的是:哪些问题 prompt 能救,哪些问题必须交给数据、规则和工程。
 
 前几轮 prompt 大概解决了三类问题:
 
@@ -164,19 +156,19 @@
 | 餐饮 grounding | 餐厅必须来自候选,不写“附近小吃”“当地特色餐厅” | prompt 里写还不够,评测也必须能抓没 grounded 的输出 |
 | 伪精确路线 | 不写工具没给过的“步行 10 分钟”“打车 15 分钟” | 这类 hallucination 更适合在输出规则里拦掉 |
 
-这一步最有价值的不是 prompt 本身,而是失败画像。看完 bad case 以后,问题自然会分层:schema、日期、餐次缺失适合 shape validation;餐厅和景点不 grounded 适合 prompt 加规则评测一起压;预算关系复杂,最好拆成工程重算和模型选择两部分;偏好满足度不够,可能需要数据补齐
+这一步真正有用的不是 prompt 本身,而是失败画像。bad case 看多了,问题会自己分层:schema、日期、餐次缺失适合 shape validation;餐厅和景点不 grounded,要靠 prompt 和规则一起压;预算关系太复杂,最好拆成工程重算和模型选择两部分;偏好满足度不够,再去补数据
 
-Prompt 调试的终点不是“再写长一点”,而是知道什么时候该停
+Prompt 调到最后,最重要的不是再写长一点,而是知道该停在哪
 
 ---
 
 ## 第六章:SFT 数据生成和审计
 
-SFT 数据生成最容易让人放松警惕的一步
+SFT 数据生成最容易让人放松警惕。
 
 强模型确实能生成很像样的旅行计划,但“像样”不等于“能训练”。如果 teacher 输出里预算口径错、餐厅不 grounded、酒店每天乱换,学生模型会学得更稳定,也会更稳定地错。
 
-所以我把数据生成拆成几步
+所以我把数据生成拆开做
 
 1. 先 dry-run 请求分布,看城市、天数、预算、同行类型是否合理。
 2. 再 dry-run `PlannerContext`,确认工具候选、价格 hint、天气和预算策略都能编译出来。
@@ -186,7 +178,7 @@ SFT 数据生成是最容易让人放松警惕的一步。
 
 ![SFT 数据生成与审计漏斗图](./images/Extra12-figures/05-SFT数据生成与审计漏斗图.png)
 
-审计里最关键的是硬过滤:
+审计时先看硬过滤:
 
 | 过滤项 | 为什么重要 |
 | --- | --- |
@@ -207,31 +199,21 @@ SFT 数据生成是最容易让人放松警惕的一步。
 
 这条线使用 Qwen2.5-7B-Instruct 做 LoRA。训练不是一轮完成,而是多阶段推进。我的原则是:**尽量少同时改变量。**
 
-很多参数一直没动:
-
-| 参数 | 主线设置 | 为什么这样设 |
-| --- | --- | --- |
-| LoRA rank | `r=32` | 长 JSON 协议、候选复制、预算口径都要学,容量不能太小 |
-| `lora_alpha` | `64` | 和 r32 搭配,后面不频繁改 |
-| `lora_dropout` | `0.05` | 防止小数据阶段过拟合 |
-| `target_modules` | `all` | Planner 任务不只是语言风格,还涉及结构化选择 |
-| `cutoff_len` | `24576` | `PlannerContext` 很长,降到 16k 会截掉上下文信号 |
-| batch | `micro_batch_size=1`,`global_batch_size=32` | 单卡放不下大 batch,就用梯度累积 |
-| 精度与显存 | bf16 + activation checkpointing | 长上下文训练的基本生存配置 |
+底层设置基本保持稳定:LoRA r32、长上下文、bf16、梯度累积。这里最重要的不是把参数表背下来,而是明白为什么后面每一轮都只改一个主要变量。`PlannerContext` 里有景点、酒店、餐厅、天气和预算策略,压短上下文会直接截掉信号;rank 太小,长 JSON 协议和候选选择也学不稳。
 
 真正反复调的是三类东西:数据、学习率、训练轮数。
 
 | 阶段 | 起点 | 数据 | 主要参数 | 想解决什么 |
 | --- | --- | --- | --- | --- |
-| main clean lr sweep | base Qwen2.5-7B | `main_clean` | `lr=8e-5 / 6e-5`,`epoch=4` | 先学稳 TripPlan 协议 |
-| usage700 mixed | 从 `lr6e-5` adapter 接着训 | main clean + realbudget usage700 | `lr=2e-5`,`epoch=1` | 补预算使用和真实预算口径 |
-| patch700 only | 从 `lr6e-5` adapter 接着训 | budget utilization patch 700 | `lr=1e-5`,`epoch=2` | 诊断预算利用型补数上限 |
-| Best-of-N 600 replay | 从 usage700 adapter 接着训 | old replay + Best-of-N winner | `lr=1e-5`,半轮保存 | 注入规则筛出来的更好候选 |
-| Best-of-N 1200 retry | 从 Best-of-N 600 final 接着训 | old replay + 更多 Best-of-N winner | `lr=1e-5`,半轮保存 | 增加 winner 占比,看是否继续提升 |
+| 主干 Clean SFT(main clean) | Qwen2.5-7B-Instruct | `main_clean` | `lr=8e-5 / 6e-5`,`epoch=4` | 先学稳 TripPlan 协议 |
+| 真实预算混合补训(usage700) | 从 `lr6e-5` adapter 接着训 | main clean + realbudget usage700 | `lr=2e-5`,`epoch=1` | 补预算使用和真实预算口径 |
+| 预算利用诊断补训(patch700) | 从 `lr6e-5` adapter 接着训 | budget utilization patch 700 | `lr=1e-5`,`epoch=2` | 看预算利用型补数到底能推多远 |
+| SFT Rerank 回放 600(Best-of-N 600) | 从 usage700 adapter 接着训 | old replay + Best-of-N winner | `lr=1e-5`,半轮保存 | 注入规则筛出来的更好候选 |
+| SFT Rerank 回放 1200(Best-of-N 1200) | 从 Best-of-N 600 final 接着训 | old replay + 更多 Best-of-N winner | `lr=1e-5`,半轮保存 | 增加 winner 占比,看是否继续提升 |
 
 ![LoRA 多阶段训练时间线图](./images/Extra12-figures/06-LoRA多阶段训练时间线图.png)
 
-这里有个细节很容易混:`adapter_name_or_path` 不是 `resume_from_checkpoint`。它只是拿上一轮导出的 LoRA adapter 做 warm-start,优化器状态不会接着上一轮走。也就是说,每一阶段都会重新使用当前配置里的学习率和调度器。
+这里有个坑我踩过:`adapter_name_or_path` 不是 `resume_from_checkpoint`。它只是拿上一轮导出的 LoRA adapter 做 warm-start,优化器状态不会接着上一轮走。也就是说,每一阶段都会重新使用当前配置里的学习率和调度器。
 
 这反而适合阶段实验。上一轮学到的能力留在 adapter 里,下一轮用更小的学习率继续修局部问题。
 
@@ -247,6 +229,41 @@ DPO closing:      1e-6 到 1.5e-6 级别
 
 越往后,数据越像在修局部问题。学习率太高,预算指标可能上去了,餐饮 grounding、住宿连续性或者日期天气又掉下来。
 
+### 第七章补充:全参 SFT 为什么没成为主线
+
+LoRA 主线写完以后,我又补了一次全参 SFT。不是想推翻前面的路线,就是想看看上限:如果不只训 adapter,而是动全模型,旅行 Planner 会不会再涨一截?
+
+这次用的是 `Qwen2.5-7B-Instruct` 和第一版 clean SFT 数据,保持长上下文,跑 6 epoch。训练能跑通,但成本马上就上来了:6 张 40GB 卡一炉大约 7 小时,保存出来的模型 28GB。和一个 LoRA adapter 比,这已经不是同一个迭代手感了。
+
+结果挺有意思。全参不是没用,它确实把 planner soft 往上推了:
+
+| 指标 | Full Instruct 全参 | LoRA 同版 | 差值 |
+| --- | ---: | ---: | ---: |
+| 硬通过 | 94.4% | 95.8% | -1.4pp |
+| Planner Soft | 48.0% | 44.7% | +3.3pp |
+| 重算预算 Soft | 33.8% | 30.7% | +3.1pp |
+| 餐饮多样性 | 82.6% | 76.4% | +6.2pp |
+| 预算偏好贴合 | 67.8% | 66.9% | +0.9pp |
+| 预算合计一致 | 68.0% | 72.3% | -4.3pp |
+| 用户预算约束 | 88.8% | 91.6% | -2.8pp |
+
+![全参 SFT 与 LoRA 对比图](./images/Extra12-figures/18-全参SFT与LoRA对比图.png)
+
+standard 集上,全参版本的 Planner Soft 从 48.0% 涨到 57.5%,这个提升很明显。它更愿意把行程写丰富,餐饮也没那么容易重复。换句话说,全参更新确实动到了模型的“规划习惯”,不是只在表面学格式。
+
+但我最后还是没把全参放进主线。原因很简单:这个项目最难的不是让模型多写一点,而是让它在一堆硬约束里别算错。预算合计、用户预算、酒店晚数、景点门票按人数算,这些更像工程规则和数据分布问题。全参会让模型更灵活,但灵活不等于更稳。
+
+对这个项目,我最后还是更愿意用 LoRA:
+
+- 数据量是千级别,不是几十万条指令数据。全参容量太大,容易把局部风格也一起学进去。
+- 任务重心是长上下文复制、schema 输出、候选 grounding 和预算规则。LoRA 已经能把这些压到很高水平。
+- 迭代成本差太多。LoRA 训练、保存、回滚、评估都轻,全参每试一次都要认真排卡和清空间。
+- 后面真正能涨分的地方,多半在数据清洗、bad case 挖掘、DPO pair 和 rerank。全参会拖慢这套节奏。
+
+所以这次全参实验在教程里的位置很清楚:**它说明 Planner Soft 还有上升空间;也说明这个项目不能靠全参硬推。**
+
+全参可以玩,尤其是想做一次漂亮的离线结果。但这个项目要反复补数据、跑评测、回滚对比,我还是会选 LoRA + DPO + Rerank。
+
 ---
 
 ## 第八章:Best-of-N Replay 和 SFT Rerank
@@ -268,15 +285,15 @@ PlannerContext
 
 最终 Rerank 是推理时流程:同一个 prompt 生成多个候选,不再把 winner 写回训练集,而是在线上从候选池里选一个更稳的答案返回给用户。
 
-这两个流程都要回到 frozen eval 看全局指标。原因也简单:规则挑 winner 是有偏的。如果 reward 过度偏向某个指标,模型可能会变保守,也可能牺牲体验。只看单个 winner 的分数,很容易误判
+这两个流程最后都要回到 frozen eval 上看。规则挑 winner 肯定有偏,如果 reward 太偏向某个指标,模型可能会变保守,也可能牺牲体验。只看单个 winner 的分数,很容易高兴早了
 
 SFT 阶段接入多温度候选 + 规则 rerank 后,几个版本整体上了一个台阶:
 
 | 版本 | hardpass | softpass | 重算预算 softpass | 预算算术 | 预算偏好 | 预算关系 | 餐饮尺度 |
 | --- | ---: | ---: | ---: | ---: | ---: | ---: | ---: |
-| ckpt104 + rerank | 98.0 | 65.6 | 54.6 | 81.2 | 77.0 | 86.4 | 88.8 |
-| final1200 + rerank | 98.2 | 66.8 | 54.6 | 78.0 | 78.4 | 85.0 | 88.0 |
-| old600final + rerank | 98.2 | 66.2 | 59.2 | 78.4 | 75.4 | 87.0 | 89.4 |
+| SFT 中期 checkpoint + rerank(ckpt104) | 98.0 | 65.6 | 54.6 | 81.2 | 77.0 | 86.4 | 88.8 |
+| Best-of-N 1200 回放 + rerank(final1200) | 98.2 | 66.8 | 54.6 | 78.0 | 78.4 | 85.0 | 88.0 |
+| Best-of-N 600 回放最终版 + rerank(old600final) | 98.2 | 66.2 | 59.2 | 78.4 | 75.4 | 87.0 | 89.4 |
 
 ![SFT Rerank 对比图](./images/Extra12-figures/09-sft-rerank-comparison.png)
 
@@ -288,9 +305,9 @@ SFT 阶段接入多温度候选 + 规则 rerank 后,几个版本整体上了
 
 SFT 已经能把 TripPlan 的壳子写稳,但合法答案之间也有好坏。两个计划都能过 schema,都能找到酒店和餐厅,一个可能很省但不像用户想要的旅行,另一个预算更贴合、餐饮更少重复、景点也更顺。
 
-SFT 很难从单条 teacher 里稳定学到这种取舍,DPO 更适合做这件事
+这种取舍很难靠单条 teacher 样本学稳,DPO 更顺手
 
-我这里没有把 DPO 当成万能增强。它只做一件事:**在 hardpass 已经过关的候选里,学习哪个更像一个好行程。**
+我没有把 DPO 当万能增强用。它在这里就做一件事:**在 hardpass 已经过关的候选里,学习哪个更像一个好行程。**
 
 ### DPO pair 先过硬门槛
 
@@ -311,13 +328,13 @@ frozen eval signature count = 497
 selected eval signature overlap = 0
 ```
 
-这件事很烦,但必须做。不然分数看起来好,实际上是在背题。
+这一步很烦,但省不了。不然分数看着好,其实是在背题。
 
 ![DPO 样本筛选与防泄漏图](./images/Extra12-figures/12-DPO样本筛选与防泄漏图.png)
 
 ### 主指标换成 PlannerSoft
 
-后来我越来越觉得,普通 softpass 还不够贴近真实体验。旅行助手的输出不是一道选择题,它是一个可以被用户拿去执行的计划。所以核心指标逐步转成 `planner soft`:预算贴合、餐饮重复、景点重复、预算关系这些都要看。
+后来我越来越觉得,普通 softpass 还不够。旅行助手输出的不是一道选择题,而是一份用户可能真的拿去用的计划。所以主指标逐步转成 `planner soft`:预算贴合、餐饮重复、景点重复、预算关系这些都要看。
 
 ![PlannerSoft 指标分解图](./images/Extra12-figures/16-PlannerSoft指标分解图.png)
 
@@ -326,12 +343,12 @@ selected eval signature overlap = 0
 | 阶段 | 目的 | 结论 |
 | --- | --- | --- |
 | 高置信偏好 DPO 试跑 | 先验证长上下文 DPO 能跑通 | 流程跑通,后面开始换指标 |
-| PlannerSoft 规则 DPO | 把优化目标从 hardpass 转向 planner soft | checkpoint-25 成为下一轮起点 |
-| PlannerSoft 扩数据 + Direct 锚定 | 扩大 planner soft 数据,同时保留 direct preference | 形成后续 ckpt126 起点 |
-| PlannerSoft Clean 单生成提升 | 用更大规模 clean 数据继续训 | `checkpoint-138` 成为单生成最佳点 |
-| 预算收尾 DPO | 针对预算偏保守、超支、重复构造 clean pair | 单生成没继续涨,但改变了候选分布 |
+| PlannerSoft 规则 DPO | 把优化目标从 hardpass 转向 planner soft | 选出第一个可继续扩数据的 DPO 起点(ckpt25) |
+| PlannerSoft 扩数据 + Direct 锚定 | 扩大 planner soft 数据,同时保留 direct preference | 得到更稳的扩数据基线(ckpt126) |
+| PlannerSoft Clean 单生成提升 | 用更大规模 clean 数据继续训 | 单生成最好的一版(260519 ckpt138) |
+| 预算收尾 DPO | 针对预算偏保守、超支、重复构造 clean pair | 单生成没继续涨,但候选池更适合 rerank(260520 / 260521) |
 
-DPO loss 也不能跨批次硬比。前几轮 pair 很容易分,loss 低、accuracy 高;预算收尾 pair 更接近,chosen 和 rejected 都是 hardpass 计划,只是在预算使用、重复和偏好上有差别,loss 自然会更高。
+DPO loss 也跨批次硬比。前几轮 pair 很容易分,loss 低、accuracy 高;预算收尾 pair 更接近,chosen 和 rejected 都是 hardpass 计划,只是在预算使用、重复和偏好上有差别,loss 高一点反而正常
 
 ![DPO loss 跨批次不可比图](./images/Extra12-figures/15-DPO-loss跨批次不可比图.png)
 
@@ -341,9 +358,9 @@ DPO loss 也不能跨批次硬比。前几轮 pair 很容易分,loss 低、acc
 
 ## 第十章:最终多候选 Rerank
 
-DPO 后半段最容易误读。单看单生成,`260519 checkpoint-138` 更稳;继续做预算收尾训练后,`ckpt66` 和 `ckpt64` 没有把单生成分数继续推高。
+DPO 后半段最容易误读。单生成最稳的是 PlannerSoft Clean 那版,也就是 260519 ckpt138;后面两轮预算收尾(260520 ckpt66、260521 ckpt64)没有把单生成分数继续推高。
 
-但最终展示版本不是单生成。它是多候选 rerank
+但最终展示不是单生成,而是多候选 rerank。预算收尾的价值主要体现在候选池:它不一定让第一发回答更高分,但更容易采出能被规则选中的好答案
 
 ![单生成与多候选 Rerank 对比图](./images/Extra12-figures/17-单生成与多候选Rerank对比图.png)
 
@@ -351,19 +368,19 @@ DPO 后半段最容易误读。单看单生成,`260519 checkpoint-138` 更稳
 
 | 版本 | hardpass | planner soft | 重算预算 soft |
 | --- | ---: | ---: | ---: |
-| ckpt126 baseline | 98.4% | 66.9% | 48.5% |
-| 260519 ckpt138 single | 98.4% | 71.5% | 50.9% |
-| 260520 ckpt66 single | 99.0% | 70.1% | 48.3% |
-| 260521 ckpt64 single | 98.2% | 69.7% | 47.6% |
-| 260521 ckpt64 rerank n4 | **99.4%** | **80.6%** | **68.2%** |
+| 扩数据基线(ckpt126) | 98.4% | 66.9% | 48.5% |
+| PlannerSoft Clean 单生成(260519 ckpt138) | 98.4% | 71.5% | 50.9% |
+| 预算收尾第 1 轮单生成(260520 ckpt66) | 99.0% | 70.1% | 48.3% |
+| 预算收尾第 2 轮单生成(260521 ckpt64) | 98.2% | 69.7% | 47.6% |
+| 预算收尾第 2 轮 + 4 候选 rerank(260521 ckpt64) | **99.4%** | **80.6%** | **68.2%** |
 
 ![DPO 收尾 Rerank 效果图](./images/Extra12-figures/10-dpo-rerank-closing.png)
 
-这里的结论不是“最后一炉单生成最好”。更准确的说法是
+所以不能简单说“最后一炉单生成最好”。我会这么记
 
-- 单生成最佳:`260519 ps2400clean_plus_direct402 checkpoint-138`
-- 多生成 rerank 最佳:`260521 closing checkpoint-64 rerank n4`
-- 展示主推:`ckpt64_rerank_n4`,500 条 planner soft `80.6%`,hard split planner soft `77.0%`。
+- 单生成最佳:PlannerSoft Clean 单生成版(260519 ckpt138)
+- 多生成 rerank 最佳:预算收尾第 2 轮 + 4 候选 rerank(260521 ckpt64)
+- 展示主推:260521 ckpt64 rerank n4,500 条 planner soft `80.6%`,hard split planner soft `77.0%`。
 
 单生成看的是一次采样的平均质量;rerank 看的是候选池里有没有更好的答案,以及规则能不能把它选出来。两者可以不是同一个 checkpoint。
 
@@ -373,30 +390,116 @@ DPO 后半段最容易误读。单看单生成,`260519 checkpoint-138` 更稳
 
 最后可以加一个外部强模型参照,但这块一定要写清楚口径。MiMo 不是我们这条 LoRA 训练线里的 checkpoint,也不是严格同一套脚本、同一版规则下的 leaderboard。更合适的用法是:看它告诉我们强模型大概会在哪里强,哪里和本地规则不完全合拍。
 
-历史上跑过 `mimo_v2_5_pro_external_mt1p5`,它是 MiMo v2.5 Pro 外部 API,w50,max token 按 1.5x 放大。和最终 `ckpt64_rerank_n4` 放在一起看,大概是这样:
+我还拿 MiMo v2.5 Pro 外部 API 做过参考评测。为了避免输出长度限制影响结论,当时把 max token 放大了一些。和最终本地版放在一起看,大概是这样:
 
 | 模型 | hardpass | planner soft | 重算预算 soft | 预算偏好 | 重算预算贴合 |
 | --- | ---: | ---: | ---: | ---: | ---: |
-| MiMo v2.5 Pro mt1p5 | 98.8% | 78.7% | **76.6%** | 85.5% | **82.4%** |
-| ckpt64_rerank_n4 | **99.4%** | **80.6%** | 68.2% | **86.0%** | 73.4% |
+| MiMo v2.5 Pro(放大 max token) | 98.8% | 78.7% | **76.6%** | 85.5% | **82.4%** |
+| 本地最终版(260521 ckpt64 rerank n4) | **99.4%** | **80.6%** | 68.2% | **86.0%** | 73.4% |
 
 ![MiMo 外部参考对比图](./images/Extra12-figures/11-mimo-reference-comparison.png)
 
-这张表可以这么读
+这张表我会这么看
 
 - 本地最终版在本项目规则口径下,`hardpass` 和 `planner soft` 已经追上并略高于 MiMo 参考。
 - MiMo 的重算预算 soft 和重算预算贴合仍然更强,说明预算总额控制这件事它做得更稳。
 - MiMo 的预算关系、餐饮尺度在早期报告里不算高,主要是它会给出更真实的人均餐费,但这些餐费有时低于我们当前规则档位的下限。
 
-所以这里不把结论写成“全面超过 MiMo”。更准确的说法是:在本项目冻结评测和规则口径下,最终本地模型的 planner soft 已经追平强模型参考;预算贴合仍有差距,后续如果继续做,应该补预算总额控制和预算档位之间的协调。
+所以我不会写成“全面超过 MiMo”。更稳妥的说法是:在本项目这套冻结评测和规则口径下,最终本地模型的 planner soft 已经追平强模型参考;预算贴合还有差距,后面真要继续做,就补预算总额控制和预算档位之间的协调。
+
+
+---
+
+## Bad Case Gallery:三个最值钱的错误
+
+后训练里最有用的东西,往往不是最好看的成功样例,而是那些反复打脸的 bad case。我最后留下三类:预算合计错、餐饮重复或不 grounded、酒店晚数和房间数没算对。
+
+### 1. 预算合计错:看起来像小账,其实会直接打穿 hard budget
+
+第一个例子是济南 3 天,1 人出差顺便玩,硬预算 3200 元。模型输出里的预算字段长这样:
+
+```json
+{
+  "total_attractions": 240,
+  "total_hotels": 2600,
+  "total_meals": 1748,
+  "total_transportation": 400,
+  "total": 5088
+}
+```
+
+问题有两层。
+
+第一层是算术就不对:`240 + 2600 + 1748 + 400 = 4988`,但模型写成了 `5088`。第二层更麻烦,rule 按每天餐费重算后发现,真实餐饮合计应该是 `1928`,不是 `1748`。也就是说,它既写错了分项,又写错了总数,还把 3200 的 hard budget 超了。
+
+rule 抓到的是:
+
+```text
+budget_arithmetic_inconsistent: part_sum=4988, total=5088, diff=-100
+meal_budget_inconsistent: expected_total_meals=1928, reported_total_meals=1748
+budget_hard_constraint_exceeded: requested_budget=3200, total=5088
+```
+
+后来怎么修?我没有继续指望模型自己把所有账算准,而是把预算评测拆成两套:一套看模型声明的 `budget.total`,一套用景点、酒店、餐饮、交通重新算 `recomputed_budget`。训练和 rerank 里也把预算算术、预算关系、预算贴合拆开看。这样模型可以继续负责选项和行程,账本由规则兜底。
+
+### 2. 餐饮重复:不是不能吃同一家,是不能一路偷懒
+
+第二个例子是长沙 4 天,亲子和老人友好。模型确实找到了餐厅,但选得太偷懒:
+
+```text
+2026-03-08 lunch  新长福(世嘉店)
+2026-03-08 dinner 新长福(世嘉店)
+2026-03-10 dinner 新长福(世嘉店)
+2026-03-11 dinner 新长福(世嘉店)
+```
+
+同一天午晚餐同一家,后面又继续重复。对模型来说这可能是“稳妥的本地餐厅”,但对用户来说,4 天里反复吃同一家店就很不像一个认真做过的行程。
+
+rule 抓到的是:
+
+```text
+meal_same_day_lunch_dinner_repeat: 2026-03-08 lunch/dinner 都是新长福(世嘉店)
+meal_repeat_too_many: name_key=新长福, count=4, max_allowed=3
+meal_diversity_ok=False
+```
+
+后来怎么修?餐饮这块不能只写“推荐特色餐厅”。我加了三类约束:同日午晚餐不能重复,同一餐厅族不能超过上限,餐厅最好来自工具候选而不是泛泛写“附近小吃”。Best-of-N Replay 和最终 rerank 里,也会把餐饮重复、多样性和 grounding 放进排序信号。这个改动很实用,因为它不要求模型一次生成完美答案,只要候选池里有一个更不重复的版本,rerank 就能把它捞出来。
+
+### 3. 酒店晚数错:房间数比天数更容易被模型漏掉
+
+第三个例子是北京 3 天,3 个朋友,住高端酒店。行程是 2026-01-07 到 2026-01-09,所以需要 2 晚;3 个成人通常要 2 间房。模型输出里每天酒店是这样:
+
+```text
+2026-01-07 华北宾馆 estimated_cost=1500
+2026-01-08 华北宾馆 estimated_cost=1500
+2026-01-09 无住宿
+budget.total_hotels=3000
+```
+
+乍看没问题:2 晚,每晚 1500,总共 3000。但它漏了房间数。3 个朋友不是 1 间房住 2 晚,而是按策略应该算 2 间房住 2 晚,酒店预算至少应该覆盖 `1500 * 2 rooms * 2 nights = 6000`。
+
+rule 抓到的是:
+
+```text
+hotel_budget_underestimated:
+  lodging_nights=2
+  party_total=3
+  room_count=2
+  expected_min_total_hotels=6000
+  reported_total_hotels=3000
+```
+
+后来怎么修?这类问题不能指望模型在自然语言里“理解一下人数”。前端和后端要显式传 `party.total`,后端策略层要编译出 `room_count` 和 `lodging_nights`,评测里再用 `hotel_budget_covers_nights` 抓出来。训练数据也不能只写单人单房的简单样本,不然模型会默认“酒店价格 = 一间房一晚 * 晚数”,一遇到朋友、亲子、老人同行就漏账。
+
+这三个 bad case 后来基本变成了我的排查顺序:先看账有没有加对,再看餐饮有没有偷懒,最后看酒店有没有按晚数和房间数覆盖。很多看起来很玄的模型问题,拆到这里其实都挺朴素。
 
 ---
 
 ## 第十二章:这次实验留下来的经验
 
-这次旅行助手后训练给我的最大教训是:Agent 后训练不是单纯“多造点数据再训一下”。它更像把产品协议、数据、训练、评测和推理策略一层层对齐。
+这次做下来,我最确定的一件事是:Agent 后训练不是“多造点数据再训一下”这么简单。真正麻烦的是把产品协议、数据、训练、评测和推理策略对上
 
-最后留下来的经验大概是这些:
+最后我记下来的东西不复杂
 
 1. **先改产品协议。** 能结构化提交的字段,不要让模型猜。
 2. **把上下文编译好。** 模型应该基于候选做选择,而不是凭空写事实。
@@ -409,18 +512,18 @@ DPO 后半段最容易误读。单看单生成,`260519 checkpoint-138` 更稳
 9. **训练数据要避开冻结评测集。** 实验项目也没必要背题,重叠检查很便宜。
 10. **指标拆开看。** hardpass、planner soft、预算关系、重算预算不要揉成一个总分。
 
-如果只用一句话概括,就是
+最后我会这么收尾
 
-> 能结构化的交给工程,能规则化的做成评测,必须由模型学习的再进入 SFT 或偏好训练。
+> 能结构化的交给工程,能规则化的做成评测,剩下那些真要模型学的,再放进 SFT 或偏好训练。
 
-这样做出来的模型不会只是“更会说”,而是更接近一个能被产品接住、能被指标解释、也能继续迭代的 Planner
+这样训出来的模型当然会更会说,但重点不在这。更重要的是,它能接进前后端;出了问题,规则能定位;下一轮还能继续修
 
 ## 复现资源
 
-如果你完整复现,可以从这些材料开始:
+想复现的话,从这些材料开始就行
 
 - 旅行助手项目仓库:[https://github.com/nameless0120/helloagents-trip-planner](https://github.com/nameless0120/helloagents-trip-planner)
 - 完整教程:[旅行助手后训练实战教程](https://github.com/nameless0120/helloagents-trip-planner/blob/main/training/docs/%E6%95%99%E7%A8%8B/%E6%97%85%E8%A1%8C%E5%8A%A9%E6%89%8B%E5%90%8E%E8%AE%AD%E7%BB%83%E5%AE%9E%E6%88%98%E6%95%99%E7%A8%8B.md)
 - 配套数据:`helloagents-后训练数据`,<https://pan.baidu.com/s/5oNsK7pwQnqzQEUg5ykb09Q>
 
-主线 LoRA 复现建议配置是 2 张 40GB 级别 GPU。4 张 40GB 会更舒服,尤其是多轮训练和并行评测时。2 张 24GB 可以做短上下文或 QLoRA 实验,但不建议拿来复现这条长上下文主线,因为 `cutoff_len=24576`、LoRA r32、bf16、activation checkpointing、FSDP2 + CP=2 是这条实验线的一部分。把这些砍掉,也能跑,但就不是同一个实验了
+硬件和脚本细节这篇不展开。简单说,这条主线依赖长上下文 LoRA;如果把上下文截短、换成 QLoRA 或改小 rank,也能跑,但就不是文中这条路线了。复现实验时,以 `helloagents-trip-planner` 仓库里的配置和归档为准

BIN
Extra-Chapter/images/Extra12-figures/18-全参SFT与LoRA对比图.png