# 旅行助手后训练实战:从产品协议到 LoRA SFT 评测闭环 项目来自我维护的 `helloagents-trip-planner`。这次整理到 Extra-Chapter,是因为这条后训练线刚好把书里很多内容串了起来:产品协议、上下文工程、工具候选、规则评测、SFT 数据审计、LoRA 训练,以及最后的 rerank。 旅行规划这个场景很容易让人误判。第一版 Demo 通常很好看:用户说“我想去北京玩三天,预算 3000”,模型很快就能写出景点、酒店、餐厅和注意事项。但只要把它接到真实前后端,再让用户多试几次,问题就会冒出来。 比如: - 用户说的是整趟预算,模型可能按人均预算理解。 - 酒店价格应该是单间每晚,模型可能写成全程总价。 - 景点门票要乘同行人数,模型有时只算一张票。 - 餐厅看起来很具体,但并不在工具返回的候选里。 - 最后一天明明还在旅行,模型却把晚餐写成“返程无”。 所以这篇文章不是“调个 prompt 就能复现”的短教程,而是一次从产品协议、评测集、prompt 调试、SFT 数据审计、LoRA SFT 到 rerank 的完整闭环。 项目与配套材料: - 旅行助手项目仓库:[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-后训练数据`,网盘链接: 这篇文章会比完整教程更短一些。我会尽量讲清楚主线:为什么不能一上来训练,为什么要先改产品协议,为什么评测集要冻结,为什么 teacher 数据要审计,以及 LoRA SFT 每一轮到底在改什么。 --- ## 目录 - [旅行助手后训练实战:从产品协议到 LoRA SFT 评测闭环](#旅行助手后训练实战从产品协议到-lora-sft-评测闭环) - [目录](#目录) - [第一章:先看一条真实前后对比](#第一章先看一条真实前后对比) - [第二章:为什么不能一上来就训练](#第二章为什么不能一上来就训练) - [第三章:先改产品协议,把模型从猜业务事实里解放出来](#第三章先改产品协议把模型从猜业务事实里解放出来) - [第四章:冻结评测集,把考试卷固定下来](#第四章冻结评测集把考试卷固定下来) - [第五章:Prompt 调试不是求神 prompt,而是找边界](#第五章prompt-调试不是求神-prompt而是找边界) - [第六章:强模型生成 SFT 数据,但 teacher 也要被审计](#第六章强模型生成-sft-数据但-teacher-也要被审计) - [第七章:LoRA SFT 多阶段训练,每一轮只解决一类问题](#第七章lora-sft-多阶段训练每一轮只解决一类问题) - [第八章:Best-of-N Replay,让模型自己产生老师](#第八章best-of-n-replay让模型自己产生老师) - [第九章:评测不要只看一个总分](#第九章评测不要只看一个总分) - [第十章:从这次后训练里抽出来的方法论](#第十章从这次后训练里抽出来的方法论) - [复现资源](#复现资源) --- # 第一章:先看一条真实前后对比 先不讲 LoRA,不讲 hardpass,也不讲 softpass。我们先看一条普通请求: > 一个人去杭州玩 4 天,打车,住经济型酒店,喜欢美食和城市地标,总预算 3500 元左右,而且不能超。 基础模型不是完全不会写。它能写出西湖、灵隐寺、城市阳台,也会给酒店和餐厅。但细看就会发现不太踏实:最后一天住宿没了,肯德基反复出现,预算也报得过低。它看起来像旅行计划,真的拿给用户就有点悬。 后训练后的版本也不是满分。比如餐饮预算还有一个 60 元的小账误差。但它至少开始像一个认真排过的行程:酒店天数稳定,景点密度正常,餐厅不再一路重复,预算也更接近用户给的硬约束。 压缩成一张表,大概是这样: | 观察点 | 基础模型 | 后训练后 | | --- | --- | --- | | 行程密度 | 4 天基本每天 1 个景点,偏空 | 每天 2 到 3 个景点,西湖、断桥、雷峰塔、灵隐寺、西溪、清河坊都覆盖到 | | 酒店 | 前 3 天有酒店,第 4 天写成“无住宿”,但预算里又按 4 晚算 | 前 3 晚稳定使用同一家 250 元经济型酒店,预算按 3 晚算 | | 餐饮 | 肯德基重复较多,第 4 天午餐和晚餐还是同一家 | 餐厅有轮换,包含杭帮菜、海鲜、烤肉、面馆和少量快餐 | | 预算 | 报 1840 元,离 3500 元 hard budget 太远,也有加总关系问题 | 报 2500 元,落在可接受区间下沿 | | 规则评测 | 抓出 9 类错误 | 当前主要剩餐饮小账误差 | 这个例子想说明一件事:后训练不是为了让模型把文案写得更漂亮,而是让它更接近一个能被产品接住的 Planner。住宿晚数、餐饮 grounding、预算关系、日期天气、输出 JSON,这些东西看起来琐碎,但真实产品里就是这些琐碎问题最容易把用户体验打穿。 # 第二章:为什么不能一上来就训练 我一开始也很想直接训练。跑 LoRA 最有“进度感”:数据一准备,脚本一启动,loss 开始往下掉,看起来就像项目在向前走。 后来发现这个顺序是反的。 旅行助手的问题很特殊。用户说“预算 3000”,模型要知道这到底是整趟预算还是人均预算;酒店价格是单间每晚,不是全程总价;景点门票要乘同行人数;餐厅不能凭空编,最好来自工具候选;最后一天如果还在旅行,不能随手写“返程无晚餐”。 如果这些业务事实没有被产品协议固定下来,训练只会把混乱学得更稳定。 所以这条后训练主线不是: ```text 写 prompt -> 造数据 -> 训练 -> 看指标 ``` 而是: ```text 前后端协议改造 -> 冻结 standard / hard 评测集 -> prompt 调试和失败画像 -> 强模型生成 SFT 数据 -> 数据审计与 LLaMA-Factory 导出 -> LoRA SFT 多阶段训练 -> 规则评测、切片对比和 checkpoint 选择 -> 多候选 rerank 和 SFT 阶段收束 ``` 这条路更慢,但每一步都能回答“为什么变好”和“为什么变坏”。 # 第三章:先改产品协议,把模型从猜业务事实里解放出来 刚开始做旅行助手时,很容易把问题都丢给模型:让模型从自然语言里猜人数,猜预算口径,猜住宿晚数,再猜景点门票和餐厅价格。第一版能跑,但后训练会很痛苦,因为训练数据里的“事实”本身就是飘的。 后来我做的第一个决定很朴素:**不要让模型猜业务事实。** 前端不再只提交一段自由文本,而是显式提交: - `party`:成人、儿童、老人、总人数、出行类型; - `budget_constraint`:金额、币种、预算范围、预算档位、约束强度; - `travel_days`、交通方式、住宿偏好、兴趣偏好等结构化字段。 后端也不再把工具结果一股脑塞进 prompt,而是先编译成 `PlannerContext`。这个上下文里会明确告诉模型: - 这次是几个人; - 预算是整趟还是人均; - 酒店按几晚计算; - 每个景点、餐厅、酒店候选来自哪里; - 价格 hint 和预算策略是什么; - 输出必须满足什么 JSON shape。 如果读代码,主线可以按这个顺序看: | 位置 | 看什么 | | --- | --- | | `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 | 有了这层协议,后训练的任务才变窄:模型不再凭感觉写旅行计划,而是在结构化候选里做选择,并输出合法 JSON。 # 第四章:冻结评测集,把考试卷固定下来 很多训练失败不是模型没变好,而是每次评测的题目都变了。 旅行助手尤其容易这样:今天地图候选变了,明天天气变了,后天预算生成逻辑又变了。最后你根本分不清是模型变强了,还是考卷变简单了。 所以第二步不是训练,而是固定评测集。 我把评测拆成两类: | 评测集 | 作用 | | --- | --- | | `standard eval` | 看普通请求下的稳定性,比如常规城市、常规预算、常规偏好 | | `hard eval` | 主动放大难点,比如多人、老人儿童、严格预算、负向偏好、特殊饮食 | 这里有个原则很重要:**同一批评测数据才有可比性。** 前期 prompt 阶段和后期 SFT 阶段,评测指标可能会变,因为我们会不断发现新问题,比如餐饮 grounding、预算关系、伪距离、酒店晚数等。但一旦进入 SFT 阶段,评测口径就要尽量固定。否则每轮训练都换题,指标再漂亮也没有解释力。 后来很多对比都靠这个原则救回来:训练可以换数据,评测不要随便换题。 # 第五章:Prompt 调试不是求神 prompt,而是找边界 前后端协议和评测集稳定后,才进入 prompt 调试。 这里的目标不是写一条“神 prompt”。我更关心的是:哪些问题 prompt 能解决,哪些问题必须交给数据、规则和工程。 第一轮 prompt 主要压输出协议:日期不能乱、餐次不能缺、酒店字段不能飘、JSON 不能半截。第二轮重点修餐饮 grounding:餐厅必须来自候选,不要写“附近小吃”“当地特色餐厅”这种听起来合理但没法落库的名字。第三轮去掉伪精确路线信息:模型很喜欢写“步行 10 分钟”“打车 15 分钟”,但如果工具没有给过这个信息,它其实是在编。 这一步最有价值的不是 prompt 本身,而是失败画像。 当你把失败 case 一条条看完,会发现很多问题可以分层: - schema、日期、餐次缺失,适合 shape validation; - 餐厅和景点不 grounded,适合 prompt + 规则评测一起压; - 预算关系复杂,最好拆成工程重算和模型选择两部分; - 偏好满足度不够,可能需要数据补齐; - 候选质量不好,应该回到检索和上下文构建。 Prompt 调试的终点不是“再写长一点”,而是知道什么时候该停。 # 第六章:强模型生成 SFT 数据,但 teacher 也要被审计 SFT 数据生成是最容易让人放松警惕的一步。 强模型确实能生成很像样的旅行计划,但“像样”不等于“能训练”。如果 teacher 输出里预算口径错、餐厅不 grounded、酒店每天乱换,学生模型会学得更稳定,也会更稳定地错。 所以我把数据生成拆成几步: 1. 先 dry-run 请求分布,看城市、天数、预算、同行类型是否合理。 2. 再 dry-run `PlannerContext`,确认工具候选、价格 hint、天气和预算策略都能编译出来。 3. 小批量生成,先跑 20 到 100 条,不要一上来就生成上千条。 4. 每次强模型调用都记录 usage、manifest 和 run 配置。 5. 生成后先审计,再进入训练集。 审计里最关键的是硬过滤: | 过滤项 | 为什么重要 | | --- | --- | | JSON / schema 合法 | 后端必须能解析 | | 日期和天数一致 | 旅行计划不能少天、多天、错日期 | | 酒店和餐厅 grounded | 不能凭空编候选 | | 餐饮不重复 | 不能连续几顿同一家快餐 | | 预算 hard constraint | 硬预算不能超 | | 预算关系合理 | 酒店晚数、门票人数、餐饮尺度要对 | 还有一个容易忽略的点:旧数据不要舍不得。 项目早期有一批旧 SFT 数据,局部看挺干净,但来自旧预算口径。后来我选择全量归档,不再修修补补继续用。这个决定当时挺痛,但回头看是对的。后训练最怕“新协议 + 旧口径数据”混在一起,模型表面学到了更多样本,实际学到的是互相冲突的规则。 # 第七章:LoRA SFT 多阶段训练,每一轮只解决一类问题 有了数据之后,才真正进入 LoRA 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 | 长上下文训练的基本生存配置 | 真正反复调的是三类东西:数据、学习率、训练轮数。 主线阶段大概是这样: | 阶段 | 起点 | 数据 | 主要参数 | 想解决什么 | | --- | --- | --- | --- | --- | | 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 占比,看是否继续提升 | 这里有个细节很容易混:`adapter_name_or_path` 不是 `resume_from_checkpoint`。它只是拿上一轮导出的 LoRA adapter 做 warm-start,优化器状态不会接着上一轮走。也就是说,每一阶段都会重新使用当前配置里的学习率和调度器。 这反而适合阶段实验。上一轮学到的能力留在 adapter 里,下一轮用更小的学习率继续“雕”。 学习率一路往下降,也是这个原因: ```text main clean: 6e-5 / 8e-5 usage700 mixed: 2e-5 patch700 only: 1e-5 Best-of-N replay: 1e-5 ``` 越往后,数据越像在修局部问题。学习率太高,预算指标可能上去了,餐饮 grounding、住宿连续性或者日期天气又掉下来。 # 第八章:Best-of-N Replay,让模型自己产生老师 SFT 学稳协议后,我没有直接上 DPO,而是先做 Best-of-N replay。 思路很简单:同一个 `PlannerContext`,让当前模型采样多个答案,用规则评估器挑一个更好的,再把 winner 导出成下一轮 SFT 数据。 ```text PlannerContext -> t=0.2 / 0.5 / 0.8 多温度采样 -> 每个候选跑 rule metrics -> 优先选 hardpass 候选 -> 再看预算、餐饮尺度、多样性等软奖励 -> winner 进入下一轮 SFT ``` Best-of-N 的好处是不用人工写每条答案。它能把当前模型已经“偶尔会做对”的能力捞出来,再用 SFT 固化。 代价也很明显:它非常依赖规则设计。如果 reward 过度偏向某个指标,模型也会被带偏。所以每轮 Best-of-N 后都要回到 frozen eval,看全局指标有没有掉。 两次混比也值得保留: | 轮次 | old replay | Best-of-N winner | 过采样 | Best-of-N 有效占比 | | --- | ---: | ---: | ---: | ---: | | 2026-05-12 | 2309 | 540 | 270 | 25.97% | | 2026-05-13 retry | 2309 | 1080 | 0 | 31.87% | 这类 manifest 一定要留。指标变了以后,回头才能解释:到底是 Best-of-N 占比变了,还是学习率变了,还是接了上一轮 adapter 继续训。 # 第九章:评测不要只看一个总分 训练完成后,评测要回答三个问题: 1. 模型还能不能稳定输出合法 `TripPlan`? 2. 它在预算、餐饮、酒店、grounding 上哪里变好或变差? 3. 这个 checkpoint 适合当主线,还是只适合作某个能力的参考节点? 我后来不再用一个混合大指标判断模型,而是拆成: | 指标 | 含义 | | --- | --- | | hardpass | JSON、schema、日期、天气、酒店、餐饮、grounding 等硬协议 | | softpass | 在 hardpass 基础上看景点多样性、餐饮多样性、预算偏好 | | 重算预算软通过 | 用工程重算预算替代模型自报预算后再看 softpass | | 预算关系 | 酒店晚数、门票人数、餐饮尺度等预算语义是否合理 | | 预算算术 | 模型自报 budget 是否精确加总,作为诊断项 | 这样拆以后,模型变化会清楚很多。比如一个 checkpoint hardpass 提升了,但预算偏好下降了,你就知道它适合作硬约束主线,但下一轮要补预算选品。 2026-05-12 那轮 Best-of-N replay 很典型:`final` 的 hardpass、预算关系、餐饮尺度最好,适合作当前主线;但 `replay_usage700` 的预算偏好更好,`lr8e-5` 的重算预算贴合和预算算术更强。 这就是后训练里很真实的一点:没有哪个 checkpoint 在所有指标上都赢。评测不是为了找一个“总分最高”的模型,而是在给每个版本贴角色标签。 到 2026-05-14,Best-of-N 1200 retry 和 rerank 评估跑完后,SFT 阶段就可以收束了。原因不是模型已经完美,而是继续追加 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 | 最后我选择 `final1200 + rerank` 做默认主线,因为它的 softpass 最好;保留 `old600final + rerank` 做预算保守对照,因为它的重算预算 softpass 更强。 # 第十章:从这次后训练里抽出来的方法论 这次旅行助手后训练给我的最大教训是:Agent 后训练不是单纯“多造点数据再训一下”。它更像一套工程闭环。 我最后留下来的方法论大概是这样: 1. **先改产品协议。** 能结构化提交的字段,不要让模型猜。 2. **把上下文编译好。** 模型应该基于候选做选择,而不是凭空写事实。 3. **评测集先冻结。** 训练可以换数据,评测不要每轮换题。 4. **Prompt 调试用来找边界。** prompt 能解决的写进 prompt,解决不了的交给数据、规则或工程。 5. **强模型生成数据,但 teacher 必须被审计。** 像样不等于可训练。 6. **LoRA SFT 分阶段推进。** 每轮尽量只解决一类主要问题。 7. **指标拆开看。** hardpass、softpass、预算关系、重算预算不要揉成一个总分。 8. **SFT 到收益变钝就停。** 后续增益可以交给 rerank、预算重算和偏好数据。 如果只用一句话概括,就是: > 能结构化的交给工程,能规则化的做成评测,必须由模型学习的再进入 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-后训练数据`, 主线 LoRA 复现建议配置是 2 张 40GB 级别 GPU。2 张 24GB 可以做短上下文或 QLoRA 实验,但不建议拿来复现这条长上下文主线,因为 `cutoff_len=24576`、LoRA r32、bf16、activation checkpointing、FSDP2 + CP=2 是这条实验线的一部分。把这些砍掉,也能跑,但就不是同一个实验了。