SFT 数据工程实战:从业务数据到高质量 JSONL 训练集(以“课程推荐 + 学习规划”为例)
大模型微调(SFT)能不能训练出“像样的业务助手”,关键往往不在模型和参数,而在数据工程:你给模型喂的样本是否足够清晰、足够一致、足够可验证,能否逼着模型学会“按规则办事”。本文用一个真实且常见的场景做贯穿:根据用户画像 + 候选课程(candidates),输出课程推荐 + 推荐理由 + 学习规划,并且必须满足强约束
作者:lh
大模型微调(SFT)能不能训练出“像样的业务助手”,关键往往不在模型和参数,而在数据工程:你给模型喂的样本是否足够清晰、足够一致、足够可验证,能否逼着模型学会“按规则办事”。
本文用一个真实且常见的场景做贯穿:根据用户画像 + 候选课程(candidates),输出课程推荐 + 推荐理由 + 学习规划,并且必须满足强约束:
- 只能从 candidates 里选 goods_id
- 不得编造课程名/讲师信息
- 输出必须是严格可解析 JSON
- 候选不足时要能合理拒答/追问
目标是:写完这篇,你就能搭一套稳定的 SFT 数据流水线,并且知道怎么做闭环把模型越训越“听话”。
1. 先定义任务边界:训练模型学什么、不学什么
SFT 最容易翻车的一点是:任务定义太泛。比如“根据画像推荐课程”,模型会倾向于发挥“通用聊天能力”,开始编课程、许诺收益、输出散文式理由。
解决方式是:把任务定义写成可判定、可校验的规则。
以本场景为例,建议你把训练目标约束成四件事:
- 选择:从 candidates 选 0~K 门课(K 固定或可变)
- 解释:理由必须引用画像信息与课程字段(course_name/brief/teacher/direction)
- 规划:学习路线按周拆解(week1~weekN 或 milestones)
- 拒答:当 candidates 不足/信息缺失时,输出拒答并给追问
核心原则:每条输出都要能被程序验证对错(至少结构正确、ID合法、字段齐全)。
2. 数据结构设计:为什么建议“输入里带候选,输出只给 JSON”
2.1 推荐的样本结构(instruction / input / output)
你现在用的 JSONL 三段式非常适合 SFT:
- instruction:固定任务说明 + 规则(强约束)
- input:业务上下文(user_profile、user_query、candidates、constraints)
- output:严格 JSON(只允许某些字段)
这套结构的好处是:
- 规则固定,模型更容易学到一致行为
- 输入包含“可引用证据”(课程字段),减少胡编
- 输出可解析可校验,容易做自动评测与回流
2.2 输出 JSON Schema(建议固定)
例如你可以强制只允许一个顶层字段:selected,里面每门课包含固定子字段:
{
"selected": [
{
"goods_id": "123",
"course_name": "…",
"teacher": "…",
"match_reason": "…",
"learning_plan": [
{"week": 1, "focus": "…", "tasks": ["…","…"]},
{"week": 2, "focus": "…", "tasks": ["…","…"]}
]
}
],
"overall_advice": {
"weekly_hours": 4,
"risk_notes": ["…"],
"next_step": ["…"]
}
}
并在 instruction 明确:
- 不得输出额外字段
- goods_id 必须来自 candidates
- course_name/teacher 必须与 candidates 中完全一致(或只输出 goods_id,名字由系统回填)
工程上更稳的一招:输出只给 goods_id + 理由 + 规划,课程名与讲师由你的后处理从库里回填,直接消灭“编课程名/编老师”的问题。
3. 样本生成主流程:先建“可控候选”,再生成答案
一个稳的 SFT 数据流水线通常分三步:
Step A:构造输入(可控、可验证)
- 从你的课程库里,为每个用户生成 candidates
- candidates 的数量与方向规则要可解释(比如 top_k、同方向优先、老师优先等)
Step B:生成输出(让模型学会“选择 + 解释 + 规划”)
- 可以用更强的模型作为“数据老师”(teacher model)合成回答
- 输出必须满足你的 JSON schema,且可解析
Step C:自动校验 + 回收 bad rows
- 解析 JSON
- 校验 goods_id 是否越界
- 校验字段是否缺失/多余
- 校验理由是否引用了课程字段(可做弱校验:包含 course_name/teacher/direction 关键词之一)
失败样本进入 bad_rows.jsonl,并记录失败原因(后面会讲闭环怎么做)。
关键顺序:先生成候选(输入),再生成回答(输出)。 因为你要保证“只能从 candidates 选”这一条,是输入侧先决定的。
4. 数据质量的“硬指标”:四个必须盯的数
SFT 数据工程一定要量化,不然你不知道训练好没好。
建议至少统计这四项:
- 可解析率:output JSON parse 成功占比
- 越界率:输出 goods_id 不在 candidates 的比例(越低越好)
- 结构合规率:字段缺失/多字段/类型错误比例
- 幻觉率:编课程名/编老师/课程不存在(如果你输出只给 goods_id,这项几乎为 0)
你会发现:
- loss 下降不代表越界率下降
- 越界率下降,业务才真的“能上线”
5. 负样本与拒答样本:让模型“知道什么时候该说不”
很多业务助手失败,是因为训练数据里只有“成功推荐”,没有“失败处理”。模型自然学不会拒答。
你至少要覆盖三类拒答:
5.1 candidates 为空 / 不足
- 输出 selected: []
- 给出原因(候选不足)
- 给追问(需要补充目标/风险偏好/时间等)
5.2 画像信息缺失
- 例如缺少目标、风险偏好、可用时间
- 输出拒答并列出“需要补充的字段清单”
5.3 候选与约束冲突
- 例如 directions 限制,但 candidates 里没有合规方向
- 输出拒答 + 建议放宽约束或重新拉取候选
经验:拒答样本占比别太低。 很多项目里拒答/追问样本做到 10%~30%,模型行为会稳定很多。
6. 模板化不是坏事:让“输出风格一致”反而更好训
SFT 不怕“模板”,怕的是“每条样本输出风格都不一样”。
你可以把理由与规划写成固定结构:
- 推荐理由:画像匹配点 + 课程覆盖点 + 风险提示
- 学习计划:每周目标 + 关键任务 + 产出物
这样模型更容易学会“稳定输出”,而不是写成散文。
7. 闭环:bad_rows 不是垃圾,是下一轮训练的宝藏
建议你把失败样本分桶,比如:
- json_parse_fail:格式炸了
- out_of_candidates:越界
- missing_field:字段不齐
- hallucinated_course:编课程/老师
- weak_reason:理由空泛不引用信息
每一桶失败样本,都能对应一个“补数据策略”:
- 格式炸 → 加更多“严格 JSON”样本 + 加反例警告
- 越界 → 在 instruction 强化 + 给更多“候选列表很长”的样本
- 理由空泛 → 在输入中突出课程 brief + 强制理由引用字段
这套闭环跑 2~3 轮,你会明显看到:
可解析率上升、越界率下降、拒答更合理。
8. 给你一条可直接用的样本(正例 + 拒答例)
正例(示意)
- input:user_profile + candidates(包含课程名/teacher/direction/brief)
- output:只选 candidates 内 goods_id,并引用课程字段写理由,给 4 周计划
拒答例(示意)
- input:candidates 为空或只有 1 门但规则要求 3 门
- output:selected 为空,给追问与下一步(例如“补充学习目标/风险偏好/每周时间”)
你可以把“示意样本”换成你真实课程库字段,文章就更有说服力。
结语:SFT 数据工程的本质,是把“业务规则”变成“可学习的监督信号”
大模型天然会“发挥”,而业务系统需要“可控”。SFT 数据工程做的事情就是:
- 让输入提供足够证据
- 让输出满足严格结构
- 让每条样本都能被程序判对错
- 让失败样本进入闭环持续修正
当你把这件事做扎实,微调只是水到渠成。