AI 工程基础体系 · 第 26/100 篇。内容覆盖机器学习、深度学习与生成式 AI;模型、数据、评测、权限和成本会作为同一生产系统处理。

模型微调与适配:SFT、LoRA、数据治理、评测和何时不该微调

1. 先区分“适配”与“训练”

模型适配是让一个已有模型在特定任务、风格、领域或运行约束下表现更符合要求。它不一定意味着更新模型参数,常见适配方式包括:

  • 提示词适配:通过系统提示词、少样本示例和输出格式约束改变输入。
  • 检索增强生成(RAG):在推理时从外部知识库取回相关内容,不把知识写入模型参数。
  • 工具或 Agent 适配:让模型调用搜索、数据库、代码执行器等外部能力。
  • 全参数微调:更新模型的绝大多数或全部参数。
  • 参数高效微调(PEFT):冻结基础模型,只训练少量附加参数,LoRA 是其中最常用的方法之一。
  • 继续预训练:用领域文本继续训练语言建模目标,主要改变模型对领域语言和知识分布的建模能力。
  • 监督微调(SFT):使用输入—目标输出样本,直接训练模型遵循任务指令。

因此,“微调”不是所有模型行为变化的总称。一个生产系统应先判断问题属于:

输入控制问题还是参数能力问题\text{输入控制问题} \quad \text{还是} \quad \text{参数能力问题}

如果只是需要给模型提供最新文档,通常是检索问题;如果需要稳定地输出固定格式,可能是提示词或 SFT 问题;如果模型根本不会使用某个工具协议,则可能需要示例训练、工具封装,甚至更换模型。

一个实用的生产视图如下:

flowchart LR
    A[业务需求] --> B{问题类型}
    B -->|知识新鲜度/私有知识| C[RAG或工具]
    B -->|格式/语气/流程稳定性| D[SFT或LoRA]
    B -->|缺少基础能力| E[更换模型或继续预训练]
    B -->|偶发性行为调整| F[提示词与解码参数]
    C --> G[统一评测]
    D --> G
    E --> G
    F --> G
    G --> H{达到上线门槛?}
    H -->|否| I[诊断数据、目标、推理链路]
    I --> B
    H -->|是| J[权限、成本、版本发布]

这里的“达到上线门槛”不能只看训练损失。微调会同时改变模型版本、数据责任、推理成本、权限边界和回滚方式,因此必须作为一个生产系统处理。


2. SFT:用监督目标训练模型遵循指令

2.1 SFT 的定义

SFT 是 Supervised Fine-Tuning,即监督微调。它使用带有目标输出的样本训练模型,使模型在给定输入条件下提高生成目标输出的概率。

对自回归语言模型而言,一条样本可以表示为:

x=[p,y]x = [p, y]

其中:

  • pp 是提示、指令、上下文或对话历史;
  • y=(y1,,ym)y=(y_1,\ldots,y_m) 是期望的回答 token 序列;
  • 模型根据前面已经出现的 token,预测下一个 token。

标准的监督损失通常是目标 token 的平均交叉熵:

LSFT(θ)=1mt=1mlogPθ(ytp,y<t)\mathcal{L}_{\text{SFT}}(\theta) = -\frac{1}{m} \sum_{t=1}^{m} \log P_\theta(y_t \mid p,y_{<t})

其中 θ\theta 是模型参数,y<ty_{<t} 表示目标序列中第 tt 个 token 之前的 token。

这个公式的含义不是“让模型记住整条字符串”,而是让模型在每个目标位置提高正确 token 的条件概率。训练时通常采用 teacher forcing:预测第 tt 个 token 时,输入使用真实的前置目标 token,而不是模型上一步自己生成的结果。

2.2 为什么要遮蔽提示部分的损失

实际输入往往拼成:

用户:请解释幂等性。
助手:幂等性是指……

模型会接收整段序列,但通常只对“助手”部分计算损失。可定义一个掩码 MtM_t

Mt={1,位置 t 属于目标回答0,位置 t 属于提示或填充M_t = \begin{cases} 1, & \text{位置 }t\text{ 属于目标回答}\\ 0, & \text{位置 }t\text{ 属于提示或填充} \end{cases}

则损失为:

L=tMtlogPθ(ztz<t)tMt\mathcal{L} = -\frac{\sum_t M_t\log P_\theta(z_t\mid z_{<t})} {\sum_t M_t}

其中 z=[p,y]z=[p,y] 是拼接后的完整 token 序列。

如果不遮蔽提示部分,模型也会被训练去预测用户问题、角色标记和格式前缀。这不一定完全错误,但会稀释目标信号,甚至让模型学习到错误的对话边界。

一个简单算例:

提示 token:请 解释
目标 token:幂等性 是

假设模型对两个目标 token 给出的概率分别是 0.80.80.250.25,则只计算目标部分时:

L=log0.8+log0.2520.916\mathcal{L} = -\frac{\log 0.8+\log 0.25}{2} \approx 0.916

如果把提示部分也加入损失,就必须再提供“请”和“解释”的目标概率。此时损失反映的是“复现整段模板”的能力,而不再只反映回答质量。

2.3 SFT 数据格式的关键不是 JSON,而是监督边界

一种常见的数据结构是:

{
  "messages": [
    {"role": "system", "content": "你是一个严谨的技术助手。"},
    {"role": "user", "content": "什么是幂等性?"},
    {"role": "assistant", "content": "幂等性是指同一操作执行一次或多次,结果保持一致。"}
  ],
  "metadata": {
    "source": "internal_manual_v3",
    "owner": "platform-team",
    "license": "internal"
  }
}

这里至少有三个独立问题:

  1. 序列化格式:如何将 messages 转为模型的对话模板。
  2. 监督边界:哪些 token 是训练目标,哪些 token 只是上下文。
  3. 质量与权利:答案是否正确,数据是否允许用于训练。

不同模型的 chat template 可能不同。常见实现会使用 tokenizer 提供的模板函数,但模板名称、参数和行为属于具体模型与库版本能力,不能假定所有模型都相同。上线前应保存最终序列化结果,例如:

<|system|>你是一个严谨的技术助手。<|end|>
<|user|>什么是幂等性?<|end|>
<|assistant|>幂等性是指同一操作执行一次或多次,结果保持一致。<|end|>

并检查:

  • 助手起始标记是否存在;
  • 训练目标是否只覆盖助手内容;
  • EOS token 是否正确;
  • 长度截断是否截掉问题或答案的关键部分;
  • 多轮对话中是否错误地把历史助手回答当成当前目标。

2.4 SFT 能学到什么,不能学到什么

SFT 主要改变的是训练样本分布上的条件生成行为。它适合学习:

  • 输出结构,如 JSON、SQL、固定字段;
  • 任务流程,如先分类再抽取;
  • 领域术语的使用方式;
  • 特定语气、风格和拒答模式;
  • 工具调用的参数格式;
  • 从输入到输出的稳定映射。

SFT 不等于可靠地写入事实数据库。若训练集包含“截至某日的产品价格”,模型可能学会生成这些价格,但不能保证未来价格仍然正确,也不能保证面对训练集之外的实体时不会编造。

可以把这个边界写成:

SFT 学到的是 P(yx) 的参数化近似变化\text{SFT 学到的是 } P(y\mid x)\text{ 的参数化近似变化}

而不是一个具有事务一致性、可更新和可审计查询能力的知识库。

2.5 SFT 的典型失败模式

训练损失下降但业务指标下降,通常有以下原因:

  • 训练集与线上输入格式不同;
  • 训练目标包含大量模板 token,真正回答 token 很少;
  • 数据中存在重复答案,模型学会套话;
  • 训练数据的拒答规则与生产安全策略冲突;
  • 长样本截断,目标只剩下开头;
  • 训练集和评测集有近重复,离线结果虚高;
  • 目标答案本身不一致,模型被迫平均多个风格;
  • 数据量不足却训练过多轮,模型开始记忆具体样本。

SFT 还存在灾难性遗忘:若新数据集中只有少量窄领域样本,更新参数可能损害通用能力。缓解方法包括混入经过筛选的通用数据、降低学习率、减少训练轮数、使用 LoRA、扩大回归集,而不是只追求训练损失继续下降。


3. LoRA:冻结基础模型,只学习低秩更新

3.1 从全参数更新到低秩更新

设一个线性层的权重为:

W0Rdout×dinW_0\in\mathbb{R}^{d_{\text{out}}\times d_{\text{in}}}

全参数微调直接学习一个更新矩阵 ΔW\Delta W,推理时使用:

W=W0+ΔWW = W_0+\Delta W

LoRA(Low-Rank Adaptation)假设有用的更新可以近似为低秩矩阵:

ΔW=αrBA\Delta W = \frac{\alpha}{r}BA

其中:

  • ARr×dinA\in\mathbb{R}^{r\times d_{\text{in}}}
  • BRdout×rB\in\mathbb{R}^{d_{\text{out}}\times r}
  • rr 是低秩维度,通常远小于输入和输出维度;
  • α\alpha 是缩放系数;
  • W0W_0 在训练时冻结,只更新 AABB

前向计算变为:

h=W0x+αrBAxh = W_0x+\frac{\alpha}{r}BAx

直觉是:不允许模型在整个高维参数空间任意移动,而是限制更新落在由 AABB 表示的低维子空间中。

LoRA 常把 AA 随机初始化,把 BB 初始化为零,使得训练刚开始时:

ΔW=0\Delta W=0

因此模型初始行为与基础模型一致,训练变化由优化过程逐渐引入。

3.2 参数量算例

假设某个线性层:

din=4096,dout=11008,r=16d_{\text{in}}=4096,\quad d_{\text{out}}=11008,\quad r=16

全参数更新量为:

4096×11008=45,088,7684096\times11008=45,088,768

LoRA 参数量为:

16×4096+11008×16=241,66416\times4096+11008\times16=241,664

这还没有考虑偏置和其他模块,但数量级差异已经很明显。LoRA 的收益包括:

  • 优化器状态更小;
  • 梯度显存更低;
  • 一个基础模型可以挂载多个任务 adapter;
  • adapter 文件更容易分发和回滚;
  • 多租户可以共享同一个基础权重。

但 LoRA 不是“免费训练”。前向计算仍需要加载基础模型,序列长度、激活值、批大小和量化方式仍会决定显存与吞吐。训练参数少也不意味着训练数据可以不治理。

3.3 LoRA 应该插入哪些层

Transformer 中常见插入位置包括注意力模块的:

  • query projection;
  • key projection;
  • value projection;
  • output projection;

以及 MLP 模块的投影层。

具体模块名称依赖模型实现,不能写死为某一组字符串。错误地只匹配到零个模块,或者把 LoRA 挂在不影响目标任务的层上,可能出现“训练正常、模型不变”的假象。

训练前应验证:

  1. 可训练参数数量大于零;
  2. 基础权重 requires_grad=False
  3. LoRA 参数确实位于预期层;
  4. 一次反向传播后 LoRA 参数梯度非零;
  5. 保存的 adapter 能独立加载并复现结果。

一个常见的 Python 配置形式如下,具体字段和版本行为应以所使用的 PEFT 版本为准:

from peft import LoraConfig, TaskType, get_peft_model

config = LoraConfig(
    task_type=TaskType.CAUSAL_LM,
    r=16,
    lora_alpha=32,
    lora_dropout=0.05,
    target_modules=["q_proj", "v_proj"],
    bias="none",
)

model = get_peft_model(base_model, config)
model.print_trainable_parameters()

这段代码的前置条件是已经正确加载了一个因果语言模型,并且该模型确实存在名为 q_projv_proj 的模块。并非所有架构都使用这些名字。若输出的可训练参数为零,不能继续训练,应先检查模型结构:

for name, module in base_model.named_modules():
    if "proj" in name:
        print(name, type(module).__name__)

3.4 rank、alpha 和 dropout 的取舍

  • rank rr 增大,adapter 表达能力和可训练参数量增加,但显存、训练时间和过拟合风险也增加。
  • alpha α\alpha 控制 LoRA 更新的缩放,不等价于学习率。相同的 rr 下改变 alpha 会改变更新幅度。
  • dropout 可作为正则化手段,但样本很少时不能靠 dropout 解决标签错误、模板错误或数据泄漏。

如果任务是简单格式转换,较小 rank 可能已经足够;如果任务需要复杂的领域推理,过小 rank 可能形成明显的能力瓶颈。但 rank 不是越大越好,正确选择应由验证集和回归集决定。

3.5 LoRA、合并和 QLoRA 的关系

LoRA adapter 可以保持独立:

W0,A,BW_0,\quad A,\quad B

也可以在发布前合并为:

Wmerged=W0+αrBAW_{\text{merged}}=W_0+\frac{\alpha}{r}BA

独立 adapter 便于多任务切换和回滚;合并后的模型部署路径更简单,但失去了直接切换 adapter 的便利。合并前后必须做等价性验证,因为量化、权重类型转换和实现差异可能产生数值误差。

QLoRA 通常指“量化基础模型 + LoRA 训练”,它不是另一种低秩公式。其核心仍是冻结基础权重并训练 LoRA 参数,只是基础模型以低比特形式存储或计算。量化会引入额外的数值和硬件约束,不能把“使用 LoRA”自动等同于“使用 QLoRA”。


4. 数据治理:训练数据是可追责的生产资产

数据治理不是训练前做一次清洗,而是对数据的来源、权利、内容、转换、使用范围和删除能力进行持续管理。

4.1 数据对象和血缘

每条样本至少应能回答:

  • 来源是什么;
  • 谁拥有或批准使用它;
  • 采集时间和有效期是什么;
  • 是否包含个人信息、商业秘密或受限内容;
  • 经历了哪些清洗和改写;
  • 被用于哪个数据集版本和模型版本;
  • 哪些线上请求可能受到它影响。

可以为样本保存如下元数据:

{
  "sample_id": "doc-20250101-00042",
  "source_uri": "s3://training/raw/manual.json",
  "source_version": "manual-v3",
  "owner": "platform-team",
  "license": "internal-use",
  "purpose": "support-assistant",
  "contains_pii": false,
  "retention_until": "2026-01-01",
  "transform_version": "redact-v2",
  "split": "train"
}

sample_id 不只是审计字段。若某个来源后来被撤销授权,系统需要通过它定位受影响的样本、数据集、adapter 和发布版本。

4.2 训练数据的质量维度

训练数据质量至少包括以下不同维度:

  1. 正确性:答案是否事实正确,代码是否可运行。
  2. 一致性:相同问题的答案是否遵循同一规则。
  3. 覆盖性:是否覆盖线上真实输入的主要分布。
  4. 代表性:是否包含难例、边界例和拒答例,而不是只有简单样本。
  5. 去重性:是否有同一文档、模板或答案的多份副本。
  6. 安全性:是否混入提示注入、恶意指令、越权操作样本。
  7. 权利可用性:是否允许用于该用途和该模型。
  8. 可评测性:是否能够定义清晰的目标和验收规则。

数据量增加不一定提升效果。如果新增数据主要是低质量重复样本,经验风险下降可能伴随泛化能力下降。对于监督数据,宁可保留可解释的少量高质量样本,也不能把自动生成数据未经审核地全部加入训练集。

4.3 划分数据集时防止泄漏

随机按行切分经常不够。以下样本不能简单视为独立样本:

  • 同一用户的多条对话;
  • 同一文档的不同切片;
  • 同一问题的改写;
  • 同一代码仓库的不同文件;
  • 同一工单的多轮记录。

如果这些样本被分别放入训练集和测试集,模型可能只是记住了实体、模板或文档内容。

更可靠的划分方式是按实体或时间分组:

D=DtrainDvalidDtestD = D_{\text{train}}\cup D_{\text{valid}}\cup D_{\text{test}}

并满足同一 user_iddocument_idcase_id 不跨集合。对于知识随时间变化的任务,可用较早时间训练、较晚时间验证和测试,从而检验模型面对未来分布的表现。

4.4 一个可运行的数据检查示例

下面的脚本只依赖 Python 标准库,用于检查 JSONL 监督数据的基本结构、角色顺序、空内容、重复样本和明显的个人信息模式。它不能替代人工审核、专业 DLP 或法律审查,但可以作为数据集构建流水线中的硬门槛。

#!/usr/bin/env python3
import hashlib
import json
import re
import sys
from collections import Counter

EMAIL = re.compile(r"\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}\b")
PHONE = re.compile(r"(?<!\d)(?:\+?86[- ]?)?1[3-9]\d{9}(?!\d)")

def normalize(text: str) -> str:
    return re.sub(r"\s+", " ", text).strip()

def main(path: str) -> int:
    errors = []
    hashes = Counter()
    total = 0

    with open(path, "r", encoding="utf-8") as f:
        for line_no, line in enumerate(f, 1):
            if not line.strip():
                continue

            total += 1
            try:
                obj = json.loads(line)
            except json.JSONDecodeError as e:
                errors.append((line_no, f"JSON 无法解析: {e.msg}"))
                continue

            messages = obj.get("messages")
            if not isinstance(messages, list) or not messages:
                errors.append((line_no, "messages 必须是非空数组"))
                continue

            roles = [m.get("role") for m in messages]
            if roles[-1] != "assistant":
                errors.append((line_no, "最后一条消息必须是 assistant"))

            for i, msg in enumerate(messages):
                if msg.get("role") not in {"system", "user", "assistant"}:
                    errors.append((line_no, f"非法 role: {msg.get('role')}"))
                if not isinstance(msg.get("content"), str) or not msg["content"].strip():
                    errors.append((line_no, f"第 {i} 条消息内容为空"))

            text = "\n".join(
                normalize(m.get("content", "")) for m in messages
            )
            digest = hashlib.sha256(text.encode("utf-8")).hexdigest()
            hashes[digest] += 1

            if EMAIL.search(text) or PHONE.search(text):
                errors.append((line_no, "疑似包含邮箱或手机号,需脱敏或豁免审核"))

    duplicate_rows = sum(n - 1 for n in hashes.values() if n > 1)

    print(f"有效读取行数: {total}")
    print(f"重复内容多出的行数: {duplicate_rows}")
    print(f"错误数: {len(errors)}")

    for line_no, message in errors[:20]:
        print(f"[第 {line_no} 行] {message}")

    return 1 if errors else 0

if __name__ == "__main__":
    if len(sys.argv) != 2:
        print(f"用法: {sys.argv[0]} data.jsonl", file=sys.stderr)
        sys.exit(2)
    sys.exit(main(sys.argv[1]))

运行:

python validate_sft.py train.jsonl

可能输出:

有效读取行数: 12000
重复内容多出的行数: 183
错误数: 4
[第 431 行] 最后一条消息必须是 assistant
[第 982 行] 疑似包含邮箱或手机号,需脱敏或豁免审核

脚本返回码为 0 表示未发现这些基础错误,返回码为 1 表示应阻止流水线继续。它不会判断答案是否正确,也不会证明数据拥有训练授权;这些需要额外的标注审核和权限流程。

4.5 隐私、删除和访问权限

训练数据中的个人信息、密钥、内部源代码和客户对话,不能因为“模型是内部部署”就自动合法或安全。至少需要:

  • 在采集前定义用途和保存期限;
  • 对手机号、邮箱、身份证号、访问令牌等进行脱敏;
  • 通过角色权限限制原始数据、脱敏数据和训练产物的访问;
  • 对导出、下载、训练任务和模型发布做审计;
  • 建立从数据来源到模型版本的反向追踪;
  • 在删除请求发生时定位受影响的数据集和发布版本。

删除原始数据不一定等于模型中相关信息立即消失。若数据已经影响模型参数,通常需要重新训练、继续训练以抵消影响,或废弃相关模型版本。这个事实必须在数据使用协议中明确,而不能把“数据库删除”误认为“模型遗忘”。


5. 训练系统:参数、状态、产物和故障路径

一个可复现的训练任务至少包含:

  • 基础模型及其精确版本;
  • tokenizer 和 chat template;
  • 数据集版本和切分规则;
  • LoRA 配置或全参数配置;
  • 优化器、学习率、批大小、梯度累积;
  • 随机种子和硬件环境;
  • 训练日志、验证指标和检查点;
  • 代码提交版本和依赖锁定文件。

训练状态可抽象为:

stateDiagram-v2
    [*] --> Draft
    Draft --> Validated: 数据/权限/配置检查通过
    Validated --> Running: 启动作业
    Running --> Checkpointed: 定期保存
    Checkpointed --> Running: 从检查点恢复
    Running --> Failed: OOM/节点故障/数据错误
    Failed --> Running: 修复后恢复
    Running --> Evaluated: 训练结束
    Evaluated --> Rejected: 未达门槛或存在安全问题
    Evaluated --> Candidate: 评测通过
    Candidate --> Released: 灰度发布
    Released --> RolledBack: 回归或故障
    RolledBack --> Candidate: 修复后重新验证
    Released --> [*]
    Rejected --> [*]

5.1 梯度累积与有效批大小

显存不足时常用梯度累积。若单次设备批大小为 bb,累积步数为 kk,设备数为 nn,则近似有效批大小为:

Beffective=bknB_{\text{effective}}=bkn

每个微批次执行前向和反向,但不立即更新参数;累计 kk 次后再执行一次优化器更新。

如果错误地在每个微批次都调用 optimizer.step(),实际批大小就不是期望值。若损失没有除以累积步数,累计梯度还会放大约 kk 倍。因此训练循环通常要:

loss = loss / accumulation_steps
loss.backward()

if (step + 1) % accumulation_steps == 0:
    optimizer.step()
    optimizer.zero_grad(set_to_none=True)

混合精度、梯度裁剪和分布式训练会进一步影响实现。PyTorch 的自动微分会根据参与计算图的张量记录梯度;冻结基础模型意味着其参数不应被加入可更新参数集合,但前向计算仍然需要它们。

5.2 检查点不是只有模型权重

可靠检查点至少应包含:

  • 模型或 adapter 权重;
  • optimizer 状态;
  • scheduler 状态;
  • 当前 epoch、step;
  • 随机数状态;
  • tokenizer 和配置;
  • 数据迭代位置或可复现的采样状态。

只保存 LoRA 权重后从头开始优化器,虽然可以恢复“模型参数”,但不能严格恢复训练过程。故障恢复时应明确选择:

  • 精确恢复:恢复全部状态,继续原训练轨迹;
  • 参数恢复:只加载模型权重,重新设定优化器,视为新实验。

两者的结果不可混为同一个实验版本。

5.3 常见训练故障及诊断顺序

损失变成 NaN 时,先检查:

  1. 输入是否含有非法 token id;
  2. 学习率是否过大;
  3. 混合精度和 loss scaling 是否异常;
  4. 是否存在极端长样本;
  5. 梯度范数是否爆炸;
  6. 数据中是否有空目标或错误标签。

显存溢出 不应只盲目降低批大小。显存通常由参数、优化器状态、激活值和临时张量共同决定。序列长度对激活显存影响很大;优先检查 padding 是否把一个批次全部补到异常长样本,并考虑按长度分桶、梯度检查点、减少序列长度或使用量化。

训练后输出重复或退化,应同时检查数据重复率、EOS 处理、学习率、训练轮数和解码参数。仅调整 temperature 往往不能修复模型已经学到的重复模式。


6. 评测:测量模型变化,而不是只看训练损失

6.1 评测对象应分层

一个完整的评测系统至少区分:

  1. 基础能力集:确认模型没有明显退化。
  2. 任务效果集:测量目标任务的正确率、召回率或结构有效性。
  3. 边界和安全集:测量越权、隐私、危险请求和拒答行为。
  4. 回归集:固定保存历史线上难例。
  5. 分布外集:测试新用户、不同长度、不同语言或不同数据来源。
  6. 成本与性能集:测量延迟、吞吐、显存和 token 消耗。

如果系统包含 RAG 或 Agent,不能只评估最终回答。还要分别评估:

  • 检索是否取回正确文档;
  • 引用是否能支持结论;
  • 工具选择是否正确;
  • 工具参数是否符合 schema;
  • 失败后是否重试或降级;
  • 是否出现无效循环;
  • 轨迹中的中间动作是否越权。

6.2 任务指标必须匹配目标

分类任务可使用准确率、精确率、召回率和 F1,但类别不平衡时,准确率可能误导。

对于结构化生成,至少分别测:

  • JSON 是否可解析;
  • 必填字段是否存在;
  • 字段类型是否正确;
  • 业务约束是否满足;
  • 字段内容是否正确。

“字符串完全相同”常常过于严格,“Judge 觉得不错”又可能过于宽松。应优先使用可执行规则,例如:

import json

def valid_invoice_output(text: str) -> bool:
    try:
        obj = json.loads(text)
    except json.JSONDecodeError:
        return False

    required = {"invoice_id", "total", "currency"}
    if not required.issubset(obj):
        return False
    if not isinstance(obj["total"], (int, float)):
        return False
    if obj["currency"] not in {"CNY", "USD", "EUR"}:
        return False
    return True

这个规则只能判断结构和部分业务约束,不能判断金额是否真的与发票内容一致;后者需要数据库校验或人工标注。

6.3 Judge 的作用与局限

Judge 是使用另一个模型或规则对生成结果评分。它适合辅助评价开放式回答,但不是客观真理。Judge 可能存在:

  • 偏好更长的回答;
  • 对特定措辞有位置偏差;
  • 被格式或引用风格影响;
  • 与被测模型具有相同错误;
  • 对安全、法律和事实问题判断不稳定。

因此应使用明确评分标准、隐藏模型身份、随机化候选顺序,并定期用人工标注校准 Judge。对“能否解析”“是否越权”“金额是否一致”等问题,优先使用确定性程序,而不是模型评分。

6.4 回归与统计显著性

假设基线模型在 100 个样本中正确 68 个,微调模型正确 72 个。表面提升为 4 个百分点,但这并不自动说明微调有效。

对于单个模型的二项比例,粗略标准误为:

SE=p^(1p^)nSE=\sqrt{\frac{\hat p(1-\hat p)}{n}}

微调模型 p^=0.72,n=100\hat p=0.72,n=100,则:

SE0.72×0.281000.045SE\approx\sqrt{\frac{0.72\times0.28}{100}}\approx0.045

近似 95% 区间约为 0.72±1.96×0.0450.72\pm1.96\times0.045,即约 0.630.630.810.81。样本量很小时,4 个百分点的差异可能只是抽样波动。

如果基线和微调模型在同一批样本上评测,更适合记录配对结果:

  • 两者都正确;
  • 两者都错误;
  • 基线正确、微调错误;
  • 基线错误、微调正确。

重点是比较后两类的数量,而不是把两次独立准确率简单相减。对生成任务还可以使用配对 bootstrap,按样本重采样并计算指标差异分布。

6.5 评测集本身也需要治理

评测集不应被训练过程反复查看到失去独立性。至少应区分:

  • 公开开发集:允许调参;
  • 内部验证集:用于模型选择;
  • 私有测试集:只在候选版本验收时使用;
  • 线上回归集:从真实失败案例中抽取并脱敏。

如果开发集被多轮实验反复优化,最终指标会过拟合到开发集。模型版本应记录评测集版本,否则“提升了多少”无法复现。


7. 一个完整的适配实验应如何组织

以“客服工单分类并输出结构化结果”为例,可以按以下因果链设计实验。

第一步:定义任务契约

输入:

客户描述:订单已经扣款,但页面仍然显示未支付。

目标:

{
  "category": "payment_status",
  "priority": "normal",
  "needs_human": false
}

先规定字段枚举、缺省行为和无法判断时的处理方式。例如:

  • category 必须来自固定集合;
  • priority 只能是 lownormalhigh
  • 信息不足时 needs_human 必须为 true
  • 不允许输出额外自然语言。

这一步先定义业务判定规则,再决定是否微调。否则训练目标本身是不稳定的。

第二步:建立基线

分别测试:

  1. 原始模型 + 清晰系统提示;
  2. 原始模型 + 少样本示例;
  3. 原始模型 + 结构化输出约束;
  4. 原始模型 + RAG 或工具(若任务需要外部信息)。

如果提示词和结构化约束已经达到门槛,微调就没有必要。微调只能在基线不足的维度上提供增量价值。

第三步:构造 SFT 数据

每条数据包括真实输入、正确结构和必要的拒答或转人工样本。应避免把同一工单的不同改写分散到训练和测试集合。

训练集还应包含边界例:

{
  "messages": [
    {"role": "system", "content": "只输出符合 schema 的 JSON。"},
    {"role": "user", "content": "客户说最近扣款有问题,但没有提供订单号。"},
    {"role": "assistant", "content": "{\"category\":\"unknown\",\"priority\":\"normal\",\"needs_human\":true}"}
  ]
}

这个样本训练的不是某个具体订单事实,而是“证据不足时转人工”的行为边界。

第四步:先用 LoRA 做可回滚实验

训练时冻结基础模型,只生成 adapter。不同实验可以使用不同 adapter:

base-model-v1
├── adapter-format-v1
├── adapter-format-v2
└── adapter-safety-v1

这样可以分别比较数据版本、rank、目标层和训练轮数,而不必复制整套基础模型。每个 adapter 必须与明确的基础模型版本绑定,不能把针对 base-model-v1 的 adapter 随意加载到结构或 tokenizer 不兼容的模型上。

第五步:验证结构、业务和回归

对每个候选版本同时记录:

结构化解析率
字段级准确率
拒答/转人工准确率
基础能力回归
安全规则通过率
平均输入 token
平均输出 token
P50/P95 延迟
GPU 显存

例如,一个版本可能把 JSON 解析率从 85% 提升到 98%,但把 needs_human 的召回率从 95% 降到 80%。对客服系统而言,后一个变化可能更危险,不能用前一个指标掩盖。


8. 何时不该微调

8.1 需要最新知识时,不应优先把知识写进参数

以下内容经常变化:

  • 价格、库存、政策;
  • 员工或客户状态;
  • 法规和内部制度;
  • 数据库记录;
  • 实时监控指标。

把这些内容用于 SFT 后,模型可能学会过期答案,而且更新成本高、可追溯性差。更合适的方案是:

查询实时数据组织证据生成回答\text{查询实时数据} \rightarrow \text{组织证据} \rightarrow \text{生成回答}

如果问题是“模型不会调用数据库”,可以训练工具调用格式;但数据库中的具体记录仍应由工具在推理时提供。

8.2 只是输出格式偶尔错误时,不应直接微调

先尝试:

  • 明确 schema;
  • 使用结构化输出接口或语法约束;
  • 给出一个正确示例;
  • 在服务端做解析和重试;
  • 对失败结果走降级路径。

如果模型本身已经能完成任务,只是格式约束没有落实,微调会增加版本和数据治理成本,却不一定比约束解码可靠。

8.3 样本很少且规则不稳定时,不应微调

几十条未经统一标注的样本可能只能表达标注人的偏好,而不能表达任务规则。训练后常见表现是:

  • 记住具体名称;
  • 复制答案措辞;
  • 遇到新输入就退化;
  • 对相互冲突的样本随机选择。

此时应先稳定标签定义,补充困难样本,并建立独立评测集。若无法说明“什么答案算正确”,微调只会把不确定性固化到参数中。

8.4 需要新增基础能力时,不应期待 LoRA 凭空创造能力

LoRA 能改变已有表示的使用方式,但不能保证为模型增加可靠的:

  • 新语言理解能力;
  • 复杂数学能力;
  • 长上下文能力;
  • 工具执行权限;
  • 外部系统访问能力;
  • 事实验证能力。

如果基础模型在目标语言或任务上几乎没有能力,继续调 rank 往往不是正确方向。应考虑更换基础模型、继续预训练、接入工具,或拆分任务。

8.5 训练成本高于推理成本时,先做系统级比较

微调成本不只包括 GPU:

Ctotal=Cdata+Ctrain+Ceval+Cdeploy+CmaintainC_{\text{total}} = C_{\text{data}} + C_{\text{train}} + C_{\text{eval}} + C_{\text{deploy}} + C_{\text{maintain}}

其中维护成本包括重新训练、数据删除、版本回归、权限审计和故障回滚。

如果每天只有少量请求,为一个窄任务训练大模型可能不如提示词、检索或调用小模型划算。反过来,如果任务高频、输出格式极其稳定、提示词很长且推理成本占主导,微调可能通过缩短提示和减少重试降低长期成本。必须以端到端成本比较,而不是只比较训练 GPU 费用。


9. 生产发布、权限与回滚

模型发布对象应明确区分:

  • 基础模型权重;
  • LoRA adapter;
  • tokenizer 和模板;
  • 推理参数;
  • 系统提示词;
  • 检索索引版本;
  • 工具 schema;
  • 评测集版本。

一个模型文件本身不足以复现线上行为。线上响应实际由多部分共同决定:

输出=f(模型,adapter,模板,提示词,检索,工具,解码参数)\text{输出} = f(\text{模型},\text{adapter},\text{模板},\text{提示词},\text{检索},\text{工具},\text{解码参数})

权限也应按组件隔离。能够读取训练原始数据的用户,不应自动拥有生产模型发布权限;能够发布 adapter 的用户,也不应自动能够修改工具调用白名单。

发布时应采用候选版本、灰度流量和回滚指针。回滚必须提前验证:

  1. 旧模型权重和 adapter 仍可加载;
  2. 旧 tokenizer 和模板仍可用;
  3. 依赖镜像和硬件环境仍可调度;
  4. 旧版本的安全规则和工具权限未被新配置覆盖;
  5. 回滚后指标和日志能被区分。

如果 adapter 与基础模型通过文件名松散关联,部署时很容易加载错组合。应在产物中写入基础模型摘要、tokenizer 摘要和配置校验值,加载时进行硬校验。


10. 用失败表现判断问题属于哪里

现象 更可能的原因 优先检查
训练损失下降,线上无提升 数据与线上分布不同,或任务本来是知识问题 线上样本、基线、数据切分
输出格式更稳定,但事实错误增加 SFT 学到了格式,未获得事实来源 RAG、工具、事实校验
只在训练样本附近表现好 重复、泄漏或过拟合 按实体去重、私有测试集
LoRA 训练无效果 target module 不匹配或目标过窄 可训练参数和梯度
基础能力明显退化 灾难性遗忘或数据分布过窄 通用回归集、混合数据、训练轮数
生成频繁重复 EOS、重复数据、学习率或解码设置异常 token 化、数据统计、梯度
Judge 分数提升,用户投诉增加 Judge 偏好与业务标准不一致 人工样本、规则指标、分桶分析
灰度后延迟升高 adapter、量化或序列长度导致计算增加 P50/P95、token、显存、批处理

诊断时不要先改变多个变量。一次实验应尽量只改变数据版本、rank、学习率或目标模块中的一项,否则即使结果变化,也无法解释因果。


11. 最终判断框架

微调值得做,通常需要同时满足以下条件:

  1. 目标行为可以用稳定、可标注的输入—输出样本描述;
  2. 基础模型已有相关能力,只需要改变行为分布或输出协议;
  3. 任务不是主要依赖实时事实;
  4. 有足够的数据权利、质量和可追溯性;
  5. 有独立评测集证明基线存在稳定差距;
  6. 微调收益能够覆盖训练、评测、发布和维护成本;
  7. 失败时可以通过版本化产物和权限控制安全回滚。

其中任一项不成立,都应重新考虑提示词、RAG、工具、结构化解码、更换模型或重新定义任务。

SFT 解决“用监督样本改变模型行为”的问题;LoRA 解决“以较少可训练参数实现这种改变”的问题;数据治理决定“这些改变是否有权利、有质量、可追责”;评测决定“改变是否真的有效且没有破坏其他能力”;生产发布则决定“出了问题能否发现、隔离和恢复”。把这几个部分拆开,微调容易变成一次不可解释的训练实验;把它们作为同一系统,才可能成为可维护的模型适配流程。


系列导航与关联阅读

官方资料

本文依据研究论文、标准组织与主流框架官方文档重新梳理;正文、示例与工程清单由 WR BLOG 编写。