Skip to content

微调数据从授权清洗到切分与Label完整原理

微调数据不是“把历史聊天导出成 JSONL”。模型会学习样本中重复出现的行为,包括错误承诺、隐私、越权、冗长话术和错误格式。训练代码可以一天跑通,数据授权、定义、清洗、标注、去重、切分和验收通常决定项目上限。

本页从原始业务记录开始,讲到可以交给训练器的 input_idsattention_masklabels,并解释每一步做错会怎样。

学习目标

完成本页后,你应该能够:

  1. 建立数据来源、授权、用途和保留期限清单。
  2. 把业务日志转换为有明确输入、输出和边界的样本。
  3. 识别 PII、密钥、越权内容、错误事实和低质量答案。
  4. 区分精确重复、近似重复、模板重复和实体泄漏。
  5. 解释为什么必须先去重再切分。
  6. 按用户、工单、文档、来源或时间分组切分数据。
  7. 设计正常、澄清、拒答、越权、安全和工具失败样本。
  8. 使用正确 Chat Template,并理解 Assistant-only Label Mask。
  9. 处理截断、Padding、Packing和长尾长度。
  10. 生成可审计的数据质量报告和不可变 Manifest。
  11. 运行一个标准库数据清洗、去重和分组切分 Demo。
  12. 根据训练或线上异常回溯到具体样本和数据版本。

一、数据生命周期

mermaid
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谁对数据正确性和使用负责
sourceCRM、工单、文档还是人工构造
legal_basis是否允许用于模型训练和商业使用
purpose只用于分类还是也允许生成回答
sensitivity公开、内部、敏感、高敏
tenant_scope是否允许跨租户聚合
retention原始数据、处理数据和模型产物保留多久
deletion用户撤回或数据删除后如何追踪到训练版本
export是否允许上传第三方托管训练平台

“员工能在业务系统里查看”不自动等于“允许用于模型训练”。训练会复制、加工并形成衍生产物,需要单独评估用途、保留、跨境、第三方处理和删除能力。

三、先定义一个样本代表什么

以采集故障分类为例:

json
{
  "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 语义冲突

两个近似输入对应不同标签。可能是标签错误,也可能是缺少决定性上下文字段。不能直接删除其中一条,应先确认任务定义。

mermaid
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:

text
<bos><system>...</system><user>...</user><assistant>...</assistant><eos>

真实格式由模型 Tokenizer 的 Chat Template 决定。训练手工拼 user:,推理却使用官方模板,会造成训练—推理格式不一致。

11.2 Assistant-only Loss

text
input_ids: [系统Token][用户Token][助手Token][PAD]
labels:    [-100...][-100...][助手目标ID][-100]

-100 常作为交叉熵忽略值,具体由框架约定。需要验证第一个 Assistant Token、结束 Token 和多轮 Assistant 的 Mask 是否正确。

11.3 可视化抽查

训练前随机打印:

text
原始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;边界错误会让一个样本错误地延续到另一个样本。

text
有效Token利用率 = 参与有效训练的Token / 实际计算Token

Packing 主要提高计算利用率,不会自动改善数据质量。

十三、标注流程和一致性

高风险数据建议:

mermaid
flowchart TD
    A["编写标签定义和正反例"] --> B["标注员A独立标注"]
    A --> C["标注员B独立标注"]
    B --> D["计算一致性并列出冲突"]
    C --> D
    D --> E["领域专家仲裁"]
    E --> F["更新指南和困难样本库"]
    F --> G["抽样复审已通过数据"]

一致性低可能说明标注员能力不足,也可能说明类别定义本身不可操作。不能简单用多数投票掩盖模糊定义。

合成数据可扩展覆盖,但必须:

  • 由真实任务分布和Schema约束。
  • 去除模型生成的错误和重复模板。
  • 与人工/真实数据分开标记来源。
  • 在真实测试集上验证,不能用同一模型生成并评分全部数据。

十四、可运行Demo:清洗、去重和分组切分

下面脚本只使用 Python 标准库。它展示精确去重、敏感信息替换、结构校验、标签冲突检查和按 groupId 切分;近似语义去重仍需额外模型和人工审核。

python
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长度、语言检测、恶意内容、人工审核状态和对象存储不可变版本。

十五、数据质量报告必须包含什么

text
候选、接收和拒绝样本数
各拒绝原因
精确/近似/模板重复率
类别、来源、租户、风险分布
输入输出Token的P50/P90/P95/P99和最大值
Train/Validation/Test的行数与group数
跨集合重复和group泄漏检查
敏感信息扫描结果
JSON Schema合法率
标注一致性和仲裁数量
每个样本的来源与授权覆盖率
数据生成代码和配置版本

不要只保存最终 JSONL。没有报告就无法解释某次训练为何突然变长、某类别为何消失或测试为何虚高。

十六、数据Manifest和不可变版本

yaml
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 被覆盖后无法复现。

十七、失败样本如何进入下一轮

mermaid
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,只提高计算利用率,不会修复数据质量。

二十一、学习验收

不看答案完成:

  1. 为数据来源填写Owner、许可、用途、敏感级别和删除流程。
  2. 设计带sampleId、groupId和labelSource的样本。
  3. 找出精确、近似、模板、实体和Label五种泄漏。
  4. 解释为什么同一工单不能跨Train和Test。
  5. 为类别不平衡报告Macro-F1和关键类Recall。
  6. 使用目标模型Chat Template打印模板化文本。
  7. 可视化Assistant-only Label Mask。
  8. 统计Token长度P50/P95/P99并制定截断策略。
  9. 运行清洗切分Demo并验证group不跨集合。
  10. 人为制造同group标签冲突,验证脚本阻止发布。
  11. 生成数据质量报告和Manifest。
  12. 对线上差评判断是否真的应该进入训练集。

关联知识点