监督微调(SFT)的效果上限,往往在训练开始前就已经确定了。模型并不会自动修复含糊的指令、错误的答案、混乱的对话结构或不一致的工具调用格式;它只会尽可能学习数据中反复出现的模式。
因此,数据准备不应被看成把几段文本转换成 JSONL 的机械步骤,而应当包括质量检查、统一格式、明确推理与工具调用结构,以及构造有代表性的训练集和评测集。
先检查内容质量,再讨论数据规模
高质量 SFT 数据至少需要回答几个问题:
- 用户请求是否清晰、完整,并且与目标任务相关?
- assistant 的回答是否真正解决了请求,而不是只包含泛泛解释?
- 答案是否事实正确、格式稳定,并符合产品或业务约束?
- 同类问题的回答风格是否一致?
- 数据中是否存在重复样本、相互矛盾的答案或泄露评测集的内容?
数据量大并不等于信号强。如果同一错误答案被复制数千次,它可能比少量高质量样本更强地影响模型。可以先按任务类型、难度、领域和来源统计样本,再对每一类抽样人工检查。自动规则适合发现缺字段、空内容、非法 JSON 和过长样本;事实正确性、回答是否真正满足意图,通常仍需要人工或模型辅助审查。
一个实用的质量记录表可以保存 sample_id、任务类别、来源、人工评分、问题标签和修订原因。这样,后续发现模型行为异常时,可以追溯到具体数据来源,而不是凭感觉重新清洗全部数据。
用 JSONL 表达多轮对话
对话式 SFT 通常将每个训练样本放在 JSONL 的一行中。每条记录包含一个 messages 数组,数组中的消息按顺序使用 role 和 content 表示参与者及其内容:
{"messages":[{"role":"user","content":"把以下句子翻译成英文:数据质量决定微调效果。"},{"role":"assistant","content":"Data quality determines fine-tuning results."}]}
多轮样本可以包含多个 user 和 assistant 消息。系统消息是否允许、字段名称是否必须使用 messages,取决于训练框架或服务接口,因此应在准备阶段确定目标格式,并使用同一套规范生成全部文件。
数据转换时要特别注意:
- 不要把多个独立任务拼进同一条对话。
- 保留必要的上下文,删除与任务无关的历史内容。
- 明确 assistant 的目标回答,避免把草稿、审核意见或内部标记混入训练文本。
- 统一换行、空白、特殊字符和语言编码。
- 对超长对话制定截断策略,并记录被截断的样本。
可以用下面的 Python 脚本做一轮基础校验。示例假设输入文件名为 sft.jsonl,每行是一条包含 messages 的 JSON 记录;它不会判断答案是否事实正确,但能尽早发现结构性问题。
import json
from pathlib import Path
path = Path("sft.jsonl")
allowed_roles = {"system", "user", "assistant", "tool"}
errors = []
seen = set()
for line_no, line in enumerate(path.read_text(encoding="utf-8").splitlines(), 1):
if not line.strip():
continue
try:
item = json.loads(line)
except json.JSONDecodeError as exc:
errors.append(f"line {line_no}: invalid JSON ({exc.msg})")
continue
messages = item.get("messages")
if not isinstance(messages, list) or not messages:
errors.append(f"line {line_no}: messages must be a non-empty list")
continue
normalized = []
for index, message in enumerate(messages):
if not isinstance(message, dict):
errors.append(f"line {line_no}, message {index}: must be an object")
continue
role = message.get("role")
content = message.get("content")
if role not in allowed_roles:
errors.append(f"line {line_no}, message {index}: unsupported role {role!r}")
if not isinstance(content, str) or not content.strip():
errors.append(f"line {line_no}, message {index}: empty content")
normalized.append((role, content))
sample_id = json.dumps(normalized, ensure_ascii=False, sort_keys=True)
if sample_id in seen:
errors.append(f"line {line_no}: duplicate conversation")
seen.add(sample_id)
if errors:
print("FAILED")
print("\\n".join(errors))
raise SystemExit(1)
print(f"OK: {len(seen)} conversations checked")
实际项目还应加入字段长度、语言、敏感信息、重复度和任务特定约束。例如,分类数据可以检查标签是否属于允许集合;结构化输出数据可以解析 assistant 内容,确认它确实是合法 JSON。
推理数据和工具调用要有明确边界
如果训练目标包含推理过程,数据格式需要清楚区分最终答案与可公开的推理内容。可以采用显式字段,例如 reasoning 和 answer,也可以把它们放进约定好的消息内容中。关键不在字段名字,而在于训练与推理阶段使用同一种可预测结构,并确认哪些推理内容可以被暴露给最终用户。
一个概念性的结构可以这样写:
{"messages":[{"role":"user","content":"计算 37 * 24。"},{"role":"assistant","reasoning":"37 * 20 = 740,37 * 4 = 148,合计 888。","content":"888"}]}
工具调用数据则应呈现完整链路:用户请求、assistant 发起的工具调用、工具返回结果,以及 assistant 根据结果生成的最终回答。工具名称、参数结构和返回值类型必须稳定;同一个工具不能在不同样本里一会儿使用字符串参数,一会儿使用对象参数。
可以这样表达一个简化的天气查询流程:
{"messages":[{"role":"user","content":"北京今天的天气怎么样?"},{"role":"assistant","tool_calls":[{"name":"get_weather","arguments":{"city":"北京"}}]},{"role":"tool","name":"get_weather","content":"晴,最高温度 28°C"},{"role":"assistant","content":"北京今天晴,最高温度约 28°C。"}]}
这个示例中的字段必须按照目标训练平台的 schema 调整。不要直接假设所有平台都接受同样的 tool_calls、arguments 或 name 字段。准备数据时,应先用少量样本完成端到端解析和训练试跑,再批量生成文件。
训练集与评测集要代表真实任务
一个代表性的拆分不是简单地随机抽取最后 10% 的数据。评测集需要覆盖上线后真正关心的任务分布,包括常见请求、边界案例、不同难度、不同表达方式和关键业务场景。
拆分时应避免近似重复样本跨越训练集和评测集,否则评测结果会被高估。对于同一用户、同一文档、同一问题模板生成的样本,最好按来源或任务组进行分组后再拆分。评测集还应在训练完成前固定,并限制其进入清洗、调参和提示词迭代流程的频率。
比例可以根据数据规模和任务风险调整。小规模项目可以保留一个足够覆盖关键场景的评测集;数据量较大时,可以使用训练集、开发集和最终评测集三部分。这里的重点是代表性、独立性和可重复,而不是追求某个固定百分比。
一份可执行的准备清单
上线训练前,可以逐项确认:
- 所有记录都能被目标训练工具解析。
- 每条对话都有清晰的用户目标和有效的 assistant 输出。
- 角色、字段、工具名称和参数 schema 保持一致。
- 已处理重复、冲突、空内容、敏感信息和过长样本。
- 推理内容的可见范围已经明确。
- 评测集按任务来源或样本组隔离,且没有明显重复。
- 已保存数据版本、校验结果、修改记录和拆分规则。
SFT 数据准备的产物不只是一个 JSONL 文件,而是一套可检查、可追溯、可复现的数据协议。把质量标准和格式校验前置,通常比训练结束后反复调整超参数更能直接改善结果;同时也应保留原始数据和版本记录,方便定位数据变化对模型行为的影响。