微调数据从授权清洗到切分与Label完整原理
微调数据不是“把历史聊天导出成 JSONL”。模型会学习样本中重复出现的行为,包括错误承诺、隐私、越权、冗长话术和错误格式。训练代码可以一天跑通,数据授权、定义、清洗、标注、去重、切分和验收通常决定项目上限。
本页从原始业务记录开始,讲到可以交给训练器的 input_ids、attention_mask 和 labels,并解释每一步做错会怎样。
学习目标
完成本页后,你应该能够:
- 建立数据来源、授权、用途和保留期限清单。
- 把业务日志转换为有明确输入、输出和边界的样本。
- 识别 PII、密钥、越权内容、错误事实和低质量答案。
- 区分精确重复、近似重复、模板重复和实体泄漏。
- 解释为什么必须先去重再切分。
- 按用户、工单、文档、来源或时间分组切分数据。
- 设计正常、澄清、拒答、越权、安全和工具失败样本。
- 使用正确 Chat Template,并理解 Assistant-only Label Mask。
- 处理截断、Padding、Packing和长尾长度。
- 生成可审计的数据质量报告和不可变 Manifest。
- 运行一个标准库数据清洗、去重和分组切分 Demo。
- 根据训练或线上异常回溯到具体样本和数据版本。
一、数据生命周期
flowchart TD
A["明确任务、用途和禁止用途"] --> B["登记数据来源与授权"]
B --> C["受控抽取原始记录"]
C --> D["字段最小化和脱敏"]
D --> E["规范化与结构校验"]
E --> F["事实纠错和人工标注"]
F --> G["精确、近似和模板去重"]
G --> H["质量与安全审核"]
H --> I["按实体/时间/来源分组切分"]
I --> J["冻结Train/Validation/Test清单"]
J --> K["Chat Template与Tokenize"]
K --> L["截断、Packing和Label Mask"]
L --> M["生成统计报告与Manifest"]
M --> N["训练、评估和失败样本闭环"]顺序很重要。先随机切分再去重,可能把同一工单的改写样本分别留在训练集和测试集;测试时模型只是见过近似答案,分数虚高。
二、数据授权先于数据清洗
每个来源至少登记:
| 字段 | 要回答的问题 |
|---|---|
| owner | 谁对数据正确性和使用负责 |
| source | CRM、工单、文档还是人工构造 |
| legal_basis | 是否允许用于模型训练和商业使用 |
| purpose | 只用于分类还是也允许生成回答 |
| sensitivity | 公开、内部、敏感、高敏 |
| tenant_scope | 是否允许跨租户聚合 |
| retention | 原始数据、处理数据和模型产物保留多久 |
| deletion | 用户撤回或数据删除后如何追踪到训练版本 |
| export | 是否允许上传第三方托管训练平台 |
“员工能在业务系统里查看”不自动等于“允许用于模型训练”。训练会复制、加工并形成衍生产物,需要单独评估用途、保留、跨境、第三方处理和删除能力。
三、先定义一个样本代表什么
以采集故障分类为例:
{
"sampleId": "s-000001",
"groupId": "incident-202607-8891",
"source": "incident_ticket",
"taskType": "collection_error_classification",
"riskLevel": "MEDIUM",
"messages": [
{
"role": "system",
"content": "根据脱敏错误摘要输出指定JSON;证据不足时needHuman必须为true。"
},
{
"role": "user",
"content": "阶段=EXTRACT;数据源=MYSQL;现象=连接30秒后超时;已重试3次。"
},
{
"role": "assistant",
"content": "{\"category\":\"DATABASE\",\"reasonCode\":\"DB_TIMEOUT\",\"severity\":\"P2\",\"needHuman\":false}"
}
],
"labelSource": "two_person_review",
"createdAt": "2026-07-15T10:00:00+08:00"
}辅助元数据通常不直接喂给模型,但用于:
- 分组切分。
- 分层统计。
- 标注追踪。
- 删除和许可追踪。
- 失败样本回溯。
- 评估不同来源和风险等级。
sampleId 必须稳定且唯一。训练文件只有 Messages 而没有来源追踪,出现隐私投诉时很难定位该样本进入过哪些模型。
四、样本覆盖不能只有“正确回答”
| 样本类型 | 教模型什么 |
|---|---|
| 常规成功 | 正常输入到标准输出 |
| 表述改写 | 同一语义的语言变化 |
| 边界值 | 临界长度、相似类别、空字段 |
| 澄清 | 缺少必要事实时主动询问 |
| 拒答 | 无权限、超范围和违规请求 |
| 不确定 | 证据冲突时转人工 |
| 工具失败 | 下游超时、空结果和权限失败 |
| 安全攻击 | 提示注入、套取隐私和绕过规则 |
| 回归保持 | 不希望微调破坏的基础能力 |
只收集客服“满意回答”,模型会学会任何问题都给确定答案;没有越权拒答样本,就不能期待模型仅凭一句 System Prompt 自动建立可靠边界。
五、清洗不是简单正则替换
5.1 结构质量
检查:
- Messages 是否是非空数组。
- Role 顺序是否符合任务协议。
- 是否有且只有目标 Assistant 答案。
- JSON 输出是否符合 Schema 和枚举。
- 必填字段是否存在。
- 文本编码是否正常。
- 输入和答案是否被截断。
5.2 事实质量
历史客服答案不等于标准答案。可能存在过期制度、错误承诺、临时绕过方案和个人习惯。应由业务权威重新确认,而不是把“人工曾经说过”视为 Ground Truth。
5.3 安全质量
需要识别:
- 手机号、身份证、地址、病历号等 PII。
- 密码、Token、Cookie、AK/SK、私钥。
- 数据库连接串和内部主机。
- 跨租户信息。
- 未授权版权或第三方数据。
- 工具返回中的内部成本和隐藏字段。
脱敏必须保持任务语义。例如分类手机号格式时不能把所有号码都替换成相同文本;可以使用合成数据或一致性占位符,但要验证不会引入新的捷径。
5.4 行为质量
删除或修正:
- 无依据的绝对承诺。
- 鼓励绕过权限和流程。
- 输出和输入矛盾。
- 与任务 Schema 不一致。
- 过度冗长或复制模板。
- 把推测写成事实。
六、去重为什么必须在切分之前
6.1 精确重复
规范化后完全相同的样本。重复会让该模式在 Loss 中获得不成比例的权重。
6.2 近似重复
只差订单号、日期、空格或少量措辞。例如同一工单被复制到客服记录、复盘报告和知识库,文本不同但事实相同。
6.3 模板重复
一万个样本都使用同一模板,仅替换 ID。模型可能学会模板捷径,离开模板后性能下降;随机切分会让测试集看起来非常好。
6.4 语义冲突
两个近似输入对应不同标签。可能是标签错误,也可能是缺少决定性上下文字段。不能直接删除其中一条,应先确认任务定义。
flowchart TD
A["汇总全部候选样本"] --> B["规范化并计算精确Hash"]
B --> C["精确去重"]
C --> D["近似/模板/实体聚类"]
D --> E["检查同簇标签冲突"]
E --> F["人工修正或补充上下文"]
F --> G["形成groupId"]
G --> H["按groupId切分"]七、数据泄漏不只是一模一样
| 泄漏类型 | 例子 |
|---|---|
| 精确泄漏 | 同一JSONL行出现在Train和Test |
| 近似泄漏 | 只替换了订单号 |
| 实体泄漏 | 同一患者/用户多个记录跨集合 |
| 事件泄漏 | 同一故障的日志和复盘分别跨集合 |
| 模板泄漏 | 同一自动模板生成的样本跨集合 |
| 时间泄漏 | 使用未来才知道的字段预测过去 |
| 调参泄漏 | 团队反复根据Test结果改训练方案 |
| 来源泄漏 | 标签本身被输入字段或文件名直接暴露 |
例如输入里包含 resolved_reason=DB_TIMEOUT,模型不需要理解错误日志就能得到标签。这叫 Label Leakage。
八、Train、Validation和Test各负责什么
| 集合 | 用途 | 可以做什么 | 不能做什么 |
|---|---|---|---|
| Train | 更新参数 | 采样、增强、训练 | 用于最终宣称效果 |
| Validation | 选Checkpoint和超参数 | Early Stop、比较配置 | 反复当最终验收集 |
| Test | 最终一次独立验收 | 候选冻结后评估 | 根据结果继续调参仍称独立 |
| Safety/Regression | 安全与能力硬门槛 | 每版回归 | 被平均主任务分数掩盖 |
测试集被反复查看后会逐渐变成验证集。应保留真正 Holdout,或周期性引入新鲜盲测集。
九、为什么不能只做随机切分
9.1 按实体分组
同一用户、工单、患者、文档、代码仓库或会话必须进入同一个集合,避免实体记忆。
9.2 按时间切分
训练使用较早数据,测试使用较新数据,更接近“用过去预测未来”。适合制度、流量和错误模式会变化的场景。
9.3 按来源留出
保留一个未见过的医院、系统或渠道作为外部测试,检验跨来源泛化。
9.4 分层
切分后检查类别、风险、来源、长度分布。分组优先于严格比例:不能为了凑 80/10/10 把同一实体拆开。
十、类别不平衡和采样
总体 Accuracy 可能被多数类欺骗:95% 样本是普通网络错误,模型全部预测普通类也有 95%。应看 Macro-F1、每类 Precision/Recall 和高风险类别 Recall。
处理方式:
- 补充少数类真实高质量样本。
- 受控过采样,但避免简单复制过度记忆。
- 对多数类下采样,同时保留分布评估集。
- 使用类别权重或任务特定 Loss,需验证训练框架支持。
- 设置高风险类别独立硬门槛。
训练分布可以为学习而调整,但测试集应尽量反映真实分布,并同时报告分层结果。
十一、Chat Template、Tokenize和Labels
11.1 必须使用模型配套模板
模型可能要求特殊角色 Token:
<bos><system>...</system><user>...</user><assistant>...</assistant><eos>真实格式由模型 Tokenizer 的 Chat Template 决定。训练手工拼 user:,推理却使用官方模板,会造成训练—推理格式不一致。
11.2 Assistant-only Loss
input_ids: [系统Token][用户Token][助手Token][PAD]
labels: [-100...][-100...][助手目标ID][-100]-100 常作为交叉熵忽略值,具体由框架约定。需要验证第一个 Assistant Token、结束 Token 和多轮 Assistant 的 Mask 是否正确。
11.3 可视化抽查
训练前随机打印:
原始messages
模板化文本
Token ID和解码文本
哪些位置参与Loss
截断前后长度若没有这个检查,脚本“能跑”也可能一直训练 PAD、用户输入或空答案。
十二、截断、Padding和Packing
12.1 截断
按最大长度直接从尾部截断,可能删掉 Assistant 答案;从头截断可能删掉 System 规则。策略应按任务决定:过滤超长样本、摘要输入、保留答案、滑窗切分,或使用更长上下文模型。
统计 P50/P90/P95/P99 Token 长度,不要只看平均值。极少超长样本可能显著增加显存和 Padding 浪费。
12.2 Padding
同一 Batch 通常补齐到相同长度,attention_mask 阻止模型把 PAD 当有效上下文,Labels 的 PAD 位置也应忽略。按相近长度分桶可以减少浪费。
12.3 Packing
把多个短样本拼入一条长序列,提高有效 Token 比例。必须正确处理样本边界、EOS、Attention和Loss Mask;边界错误会让一个样本错误地延续到另一个样本。
有效Token利用率 = 参与有效训练的Token / 实际计算TokenPacking 主要提高计算利用率,不会自动改善数据质量。
十三、标注流程和一致性
高风险数据建议:
flowchart TD
A["编写标签定义和正反例"] --> B["标注员A独立标注"]
A --> C["标注员B独立标注"]
B --> D["计算一致性并列出冲突"]
C --> D
D --> E["领域专家仲裁"]
E --> F["更新指南和困难样本库"]
F --> G["抽样复审已通过数据"]一致性低可能说明标注员能力不足,也可能说明类别定义本身不可操作。不能简单用多数投票掩盖模糊定义。
合成数据可扩展覆盖,但必须:
- 由真实任务分布和Schema约束。
- 去除模型生成的错误和重复模板。
- 与人工/真实数据分开标记来源。
- 在真实测试集上验证,不能用同一模型生成并评分全部数据。
十四、可运行Demo:清洗、去重和分组切分
下面脚本只使用 Python 标准库。它展示精确去重、敏感信息替换、结构校验、标签冲突检查和按 groupId 切分;近似语义去重仍需额外模型和人工审核。
from __future__ import annotations
from collections import Counter, defaultdict
from pathlib import Path
import hashlib
import json
import random
import re
from typing import Any
PHONE = re.compile(r"(?<!\d)1[3-9]\d{9}(?!\d)")
ID_CARD = re.compile(r"(?<!\d)\d{17}[\dXx](?!\d)")
SECRET = re.compile(r"(?i)(api[_-]?key|token|password)\s*[:=]\s*[^\s,;]+")
ALLOWED_ROLES = {"system", "user", "assistant"}
def mask_sensitive(text: str) -> str:
text = PHONE.sub("[PHONE]", text)
text = ID_CARD.sub("[ID_CARD]", text)
return SECRET.sub(lambda m: f"{m.group(1)}=[SECRET]", text)
def normalize_text(text: str) -> str:
return " ".join(mask_sensitive(text).strip().split())
def normalize_sample(raw: dict[str, Any]) -> tuple[dict[str, Any] | None, str | None]:
sample_id = raw.get("sampleId")
group_id = raw.get("groupId")
messages = raw.get("messages")
if not isinstance(sample_id, str) or not sample_id:
return None, "MISSING_SAMPLE_ID"
if not isinstance(group_id, str) or not group_id:
return None, "MISSING_GROUP_ID"
if not isinstance(messages, list) or len(messages) < 2:
return None, "INVALID_MESSAGES"
cleaned = []
assistant_count = 0
for message in messages:
role = message.get("role")
content = message.get("content")
if role not in ALLOWED_ROLES or not isinstance(content, str):
return None, "INVALID_MESSAGE"
content = normalize_text(content)
if not content:
return None, "EMPTY_CONTENT"
if role == "assistant":
assistant_count += 1
cleaned.append({"role": role, "content": content})
if assistant_count != 1 or cleaned[-1]["role"] != "assistant":
return None, "EXPECTED_ONE_FINAL_ASSISTANT"
return {
"sampleId": sample_id,
"groupId": group_id,
"taskType": raw.get("taskType", "unknown"),
"label": raw.get("label", "unknown"),
"messages": cleaned,
}, None
def content_hash(sample: dict[str, Any]) -> str:
payload = json.dumps(sample["messages"], ensure_ascii=False, sort_keys=True)
return hashlib.sha256(payload.encode("utf-8")).hexdigest()
def split_groups(samples: list[dict[str, Any]], seed: int = 42) -> dict[str, list[dict[str, Any]]]:
by_group: dict[str, list[dict[str, Any]]] = defaultdict(list)
for sample in samples:
by_group[sample["groupId"]].append(sample)
group_ids = sorted(by_group)
random.Random(seed).shuffle(group_ids)
total = len(group_ids)
train_end = int(total * 0.8)
valid_end = int(total * 0.9)
assignment = {
group_id: (
"train" if index < train_end
else "validation" if index < valid_end
else "test"
)
for index, group_id in enumerate(group_ids)
}
result = {"train": [], "validation": [], "test": []}
for group_id, rows in by_group.items():
result[assignment[group_id]].extend(rows)
return result
def main() -> None:
source = Path("raw_samples.jsonl")
output_dir = Path("dataset-v1")
output_dir.mkdir(exist_ok=True)
rejected = Counter()
seen_hashes: set[str] = set()
accepted: list[dict[str, Any]] = []
labels_by_group: dict[str, set[str]] = defaultdict(set)
for line_number, line in enumerate(source.read_text(encoding="utf-8").splitlines(), 1):
try:
raw = json.loads(line)
except json.JSONDecodeError:
rejected["INVALID_JSON"] += 1
continue
sample, reason = normalize_sample(raw)
if sample is None:
rejected[reason or "UNKNOWN"] += 1
continue
digest = content_hash(sample)
if digest in seen_hashes:
rejected["EXACT_DUPLICATE"] += 1
continue
seen_hashes.add(digest)
labels_by_group[sample["groupId"]].add(sample["label"])
accepted.append(sample)
conflicts = {
group_id: sorted(labels)
for group_id, labels in labels_by_group.items()
if len(labels) > 1
}
if conflicts:
raise ValueError(f"同一group存在标签冲突,请先人工处理:{conflicts}")
splits = split_groups(accepted)
split_by_group: dict[str, str] = {}
for split_name, rows in splits.items():
path = output_dir / f"{split_name}.jsonl"
path.write_text(
"".join(json.dumps(row, ensure_ascii=False) + "\n" for row in rows),
encoding="utf-8",
)
for row in rows:
previous = split_by_group.setdefault(row["groupId"], split_name)
if previous != split_name:
raise AssertionError("group泄漏到多个集合")
report = {
"accepted": len(accepted),
"rejected": dict(rejected),
"splitRows": {name: len(rows) for name, rows in splits.items()},
"splitGroups": {
name: len({row["groupId"] for row in rows})
for name, rows in splits.items()
},
"labels": dict(Counter(row["label"] for row in accepted)),
}
(output_dir / "quality-report.json").write_text(
json.dumps(report, ensure_ascii=False, indent=2),
encoding="utf-8",
)
print(json.dumps(report, ensure_ascii=False, indent=2))
if __name__ == "__main__":
main()生产脚本还应加入近似去重、数据许可、Schema验证、Token长度、语言检测、恶意内容、人工审核状态和对象存储不可变版本。
十五、数据质量报告必须包含什么
候选、接收和拒绝样本数
各拒绝原因
精确/近似/模板重复率
类别、来源、租户、风险分布
输入输出Token的P50/P90/P95/P99和最大值
Train/Validation/Test的行数与group数
跨集合重复和group泄漏检查
敏感信息扫描结果
JSON Schema合法率
标注一致性和仲裁数量
每个样本的来源与授权覆盖率
数据生成代码和配置版本不要只保存最终 JSONL。没有报告就无法解释某次训练为何突然变长、某类别为何消失或测试为何虚高。
十六、数据Manifest和不可变版本
dataset_id: collection-sft-2026-07-v5
task_definition: collection-classification-v3
source_snapshot: sha256:replace-me
cleaning_code_commit: replace-me
cleaning_config: config/clean-v5.yaml
split_strategy: grouped_by_incident_then_stratified_report
split_seed: 42
train_manifest: sha256:replace-me
validation_manifest: sha256:replace-me
test_manifest: sha256:replace-me
license_review: APPROVED
privacy_review: APPROVED
quality_report: reports/quality-v5.json
created_at: 2026-07-15T12:00:00+08:00版本号不能只指文件名。Manifest 应列出每个样本 ID 或文件摘要;否则同名 train.jsonl 被覆盖后无法复现。
十七、失败样本如何进入下一轮
flowchart TD
A["线上失败或人工纠正"] --> B["脱敏并关联模型/Prompt版本"]
B --> C["判断是数据、RAG、工具还是模型问题"]
C --> D{"是否适合成为训练样本"}
D -->|"否"| E["修复Prompt、检索、工具或业务规则"]
D -->|"是"| F["领域专家标注和复核"]
F --> G["进入候选池而非直接Train"]
G --> H["去重、冲突、授权和切分流程"]
H --> I["形成新数据版本"]不要把所有差评直接拿去训练。差评可能来自用户恶意、事实源错误、检索失败或模型服务超时,并不都是 SFT 数据问题。
十八、生产排查Runbook
18.1 Test分数异常高
检查精确/近似重复、groupId跨集合、模板生成器、实体和时间泄漏、标签字段是否出现在输入,以及团队是否已反复根据Test调参。必要时作废该Test并建立新盲测集。
18.2 模型只会固定模板
查看模板重复率、表达多样性、合成数据占比和同一开头比例。减少机械复制,增加真实语义改写和边界样本;不要仅提高Temperature掩盖训练分布狭窄。
18.3 训练后输出空或角色混乱
打印 Chat Template、special tokens、input_ids解码和Label Mask。常见原因是模板与基础模型不匹配、Assistant全部被Mask、EOS缺失或截断删除答案。
18.4 某类召回率很低
检查该类有效样本数、标注冲突、与相似类定义、切分分布和输入是否缺少决定字段。不能只复制同一少数类样本,需补充真实多样性。
18.5 发现训练数据含敏感信息
停止数据分发和后续训练,定位来源、数据版本、训练作业、Checkpoint、日志和已部署模型,按安全流程评估删除、重训、撤回和通知。只修改下一版清洗脚本不能消除已有产物风险。
十九、常见误区
正则脱敏后就安全
错误。自由文本、附件和上下文可间接识别个人,密钥格式也多样。需要数据分类、最小化、扫描、抽查和访问控制。
随机种子固定就没有泄漏
错误。种子保证切分可重复,不解决实体、模板和时间泄漏。
数据切分比例必须严格80/10/10
错误。分组隔离优先于精确比例,小数据还要保证关键风险覆盖。
合成数据可以无限扩充
错误。它会复制生成模型偏差并形成模板化分布,必须用真实盲测评估。
JSON合法就是好样本
错误。字段可能事实错误、越权或互相矛盾,还需要业务校验。
二十、面试标准回答
为什么先去重再切分
如果先随机切分,同一工单、模板或近似改写可能跨Train和Test,模型在测试时实际上见过答案,分数虚高。应先规范化、精确和近似去重、按事件或实体形成groupId,再以group为单位切分并做跨集合泄漏检查。
Train、Validation和Test怎么用
Train更新参数;Validation选择超参数、Epoch和Checkpoint;Test在候选方案冻结后做最终独立验收。若反复根据Test结果修改数据和参数,Test就退化成验证集,需要新的盲测集。
SFT为什么需要Label Mask
Chat序列包含System、User、Assistant和Padding。Assistant-only SFT通常只对目标回答Token计算Loss,其他位置设为忽略值。Mask错误可能让模型学习复述输入,或把答案全部遮掉导致学不到任务。
Packing解决什么问题
Packing把多个短样本放进较长序列,提高有效Token占实际计算Token的比例,减少Padding浪费。它需要正确处理EOS、样本边界、Attention和Loss Mask,只提高计算利用率,不会修复数据质量。
二十一、学习验收
不看答案完成:
- 为数据来源填写Owner、许可、用途、敏感级别和删除流程。
- 设计带sampleId、groupId和labelSource的样本。
- 找出精确、近似、模板、实体和Label五种泄漏。
- 解释为什么同一工单不能跨Train和Test。
- 为类别不平衡报告Macro-F1和关键类Recall。
- 使用目标模型Chat Template打印模板化文本。
- 可视化Assistant-only Label Mask。
- 统计Token长度P50/P95/P99并制定截断策略。
- 运行清洗切分Demo并验证group不跨集合。
- 人为制造同group标签冲突,验证脚本阻止发布。
- 生成数据质量报告和Manifest。
- 对线上差评判断是否真的应该进入训练集。
