标题:微调评测:别只盯着 loss
digest:loss 不等于能用,三层指标+过拟合+遗忘诊断
帮一个团队 review 微调结果,训练 loss 从 2.1 一路降到 0.28,eval loss 也稳定在 0.35,团队信心满满准备上线。我让他们把测试集里 200 条真实客服 query 跑一遍,格式合格率 61%,拒识率(该拒的没拒)18%,其中有一条把"我要退货"回复成了"感谢您的信任,我们会尽快为您办理开户手续"。
loss 0.28,业务指标不及格。
微调工程里一个反复被踩的坑:把 loss 当作评测终点。loss 只回答了一个问题——"模型在训练分布上,对下一个 token 的预测有多准"。它不回答"模型能不能完成任务",不回答"模型有没有把原来的能力弄丢",更不回答"模型能不能部署"。
这篇聊清楚三件事:怎么搭一套三层评测、怎么发现过拟合和灾难性遗忘、怎么在工程上防住它们。文末给一段可以直接在 CPU 上跑通的 pipeline。
一、微调评测的三个层次
评测不是一个指标,是一套分层的信号系统。
第一层:loss 指标层
train loss、eval loss、perplexity。这层的作用只有一个——告诉你训练过程本身有没有崩。loss 不下降说明学习率、数据格式、mask 策略出了问题;loss 下降太快通常意味着 label 泄漏或者数据太简单。
perplexity 稍微强一点,能反映模型对某个 domain 文本的"熟悉程度",但仍然是 token 级别的概率信号。一个能把训练集背下来的模型 perplexity 会非常低,但业务上可能完全不能用。
这层的定位:训练监控,不是评测。
第二层:自动指标层
ROUGE、BLEU、BERTScore、classification accuracy、F1。这些指标和任务挂钩了,但和人类判断之间还是有 gap。
ROUGE 衡量的是 n-gram overlap,一段答案表达同样意思但用词不同,ROUGE 就会低。BLEU 在翻译任务里尚可,在开放生成任务里几乎没用。分类和抽取任务里 accuracy 和 F1 相对可靠,但要小心类别不平衡——90% 都是"正常"样本的场景下,全预测正常也能拿 90% 准确率。
这层的定位:批量、自动、可回归,用来在 CI 里卡门槛。
第三层:任务指标层
你真正关心的业务数字。客服场景里是格式合格率、拒识准确率、意图分类正确率;代码生成场景里是编译通过率、单测通过率;SQL 生成场景里是执行结果一致率。
这一层往往要写 domain-specific 的评测代码,不能靠 open-source metrics 一把梭。写起来烦,但这是决定"能不能上线"的唯一信号。
三层的权重:任务指标 > 自动指标 > loss。但只看任务指标也不行——任务指标出问题时,你要靠 loss 和自动指标定位到底是训练崩了、还是数据问题、还是评测集问题。
二、过拟合的诊断
过拟合在 LLM 微调里的表现,和传统 ML 有几个不一样的地方。
最早的信号:train loss 和 eval loss 分叉
train loss 持续下降,eval loss 开始抬头,这是教科书上的过拟合。SFT 里通常发生在第 2-3 个 epoch,尤其是数据量小于 10k 的时候。检测方法很直接:每 N 步同时评估 train 和 eval loss,画一条对比曲线。
更隐蔽的信号:eval loss 还在降,但任务指标变差
模型可能学会了训练集的表面 pattern——比如输出总是以"好的,"开头,或者总是用某种句式——eval loss 因为 pattern 匹配而下降,但泛化能力其实在退化。
唯一的检测手段是:在 held-out set 上定期跑任务指标,不只是 loss。频率上,每个 epoch 至少一次,如果训练集小可以每几百步跑一次。
极端表现:边界外输入 hallucinate 加剧
微调数据都是"标准问题-标准回答",模型学到"看到问题就要回答"。真实场景里用户会问奇奇怪怪的东西,一个健康的微调模型应该在无法处理时拒识或转人工,过拟合的模型会硬编一个格式对但内容错的答案出来。
评测集里必须包含 out-of-distribution 样本,专门测拒识率。
三、防过拟合的工程手段
数据侧
验证集比例通常 5%-10%,绝对数量至少 500 条,太少了指标抖动大到没法用。验证集要和训练集独立采样,不能是训练集里随机抽的——如果数据本身有重复或近似样本,随机抽的验证集会严重高估性能。
数据多样性比数据量更重要。10k 条覆盖 50 种问法的数据,比 100k 条只有 5 种问法的数据要好。可以用 embedding 聚类看一下数据分布,聚类数太少就补数据。
训练侧
Early stopping 是最有效的手段,配合"多 checkpoint 选最优"用。具体做法是每个 epoch 存一个 checkpoint,训练结束后在评测集上跑所有 checkpoint,选任务指标最高的那一个——而不是用最后一个 epoch 的模型。
学习率上,warmup + cosine decay 是稳妥选择。SFT 场景常用 2e-5 到 5e-5,warmup ratio 3%-5%,超过这个范围要么学不进要么容易过拟合。gradient clipping 设 1.0 就行,主要是防训练中期出现的 loss spike。
评估侧
评测集要 freeze 下来,版本化管理。每次跑评测用同一份集合、同一份评测脚本,不然指标不可比。评测集要定期扩充,把线上遇到的 badcase 加进去,但不要把这些 badcase 同时加到训练集里——那就成了作弊。
四、灾难性遗忘:比过拟合更隐蔽
过拟合至少有 eval loss 在报警,灾难性遗忘完全无声无息。
具体表现
用 5000 条客服对话 SFT 了一个 7B 模型,客服任务上准确率 92%。上线之后有用户随口问"帮我写个 Python 排序",模型输出的是一段完全跑不通的伪代码——base model 本来能写对的。
原因:SFT 用的是全参微调,训练目标只有客服对话,其他能力对应的参数被"覆盖"了。数据分布越窄、训练步数越长、学习率越大,遗忘越严重。
检测方法
准备一份"通用能力评测集",覆盖代码、数学、常识、多轮对话几个维度,每个维度 50-100 条。这份集合不参与训练,用来对比 base model 和 fine-tuned model 的表现。
关键是"每次微调都跑,形成回归 baseline"。如果微调后通用能力下降超过 5-10%,要重新评估微调方案。
缓解手段
LoRA/QLoRA 本身就是防遗忘的——base weights 冻结不动,只训 adapter,通用能力天然保留。这也是为什么生产环境里 LoRA 用得比全参多。
如果一定要全参 SFT,加 replay 数据:训练集里混入 5%-20% 的通用数据(比如 alpaca、sharegpt 采样),让模型在学新任务的同时"复习"旧能力。replay 数据比例太低没用,太高又稀释了任务信号,具体值要调。
五、完整可运行示例
一段可以在 CPU 上跑通的评测 pipeline,包含 ROUGE-L 计算、任务指标计算、过拟合曲线分析、EarlyStopping 实现。用 mock 数据,不依赖任何外部库。
from dataclasses import dataclass, fieldfrom typing import Callable, Sequenceimport mathimport random@dataclassclass EvalSample: query: str reference: str prediction: str intent: str predicted_intent: str@dataclassclass EvalReport: rouge_l: float perplexity_proxy: float intent_accuracy: float format_pass_rate: float refusal_precision: float n_samples: intdef _lcs_length(a: Sequence[str], b: Sequence[str]) -> int: m, n = len(a), len(b) dp = [[0] * (n + 1) for _ in range(m + 1)] for i in range(1, m + 1): for j in range(1, n + 1): if a[i - 1] == b[j - 1]: dp[i][j] = dp[i - 1][j - 1] + 1 else: dp[i][j] = max(dp[i - 1][j], dp[i][j - 1]) return dp[m][n]def rouge_l(reference: str, prediction: str) -> float: ref_tokens = reference.split() pred_tokens = prediction.split() if not ref_tokens or not pred_tokens: return 0.0 lcs = _lcs_length(ref_tokens, pred_tokens) precision = lcs / len(pred_tokens) recall = lcs / len(ref_tokens) if precision + recall == 0: return 0.0 return 2 * precision * recall / (precision + recall)import reFORMAT_PATTERN = re.compile(r"^\[(意图|拒识)\].+")def format_valid(prediction: str) -> bool: return bool(FORMAT_PATTERN.match(prediction.strip()))def evaluate(samples: list[EvalSample]) -> EvalReport: if not samples: raise ValueError("empty eval set") rouge_scores = [rouge_l(s.reference, s.prediction) for s in samples] intent_hits = [s.intent == s.predicted_intent for s in samples] format_hits = [format_valid(s.prediction) for s in samples] should_refuse = [s for s in samples if s.intent == "REFUSE"] refused_correctly = [s for s in should_refuse if s.predicted_intent == "REFUSE"] refusal_prec = ( len(refused_correctly) / len(should_refuse) if should_refuse else 1.0 ) return EvalReport( rouge_l=sum(rouge_scores) / len(rouge_scores), perplexity_proxy=math.exp(0.42), intent_accuracy=sum(intent_hits) / len(intent_hits), format_pass_rate=sum(format_hits) / len(format_hits), refusal_precision=refusal_prec, n_samples=len(samples), )@dataclassclass TrainStep: step: int train_loss: float eval_loss: float task_metric: float@dataclassclass OverfitDiagnosis: diverged_at: int | None task_metric_peak_step: int task_metric_peak_value: float task_regressed: bool message: strdef diagnose_overfit( history: list[TrainStep], divergence_ratio: float = 1.15, regression_tolerance: float = 0.02,) -> OverfitDiagnosis: diverged_at: int | None = None for step in history: if step.eval_loss > step.train_loss * divergence_ratio: diverged_at = step.step break peak = max(history, key=lambda s: s.task_metric) last = history[-1] regressed = (peak.task_metric - last.task_metric) > regression_tolerance if diverged_at is not None and regressed: msg = "eval loss 分叉且任务指标从峰值回落,典型过拟合" elif diverged_at is not None: msg = "eval loss 已分叉但任务指标还没掉,继续观察下一 checkpoint" elif regressed: msg = "loss 曲线正常但任务指标回落,可能学到表面 pattern" else: msg = "训练健康,可继续" return OverfitDiagnosis( diverged_at=diverged_at, task_metric_peak_step=peak.step, task_metric_peak_value=peak.task_metric, task_regressed=regressed, message=msg, )@dataclassclass EarlyStopping: patience: int min_delta: float = 1e-3 mode: str = "max" best_value: float = field(default=-math.inf) best_step: int = field(default=0) bad_epochs: int = field(default=0) should_stop: bool = field(default=False) def __post_init__(self) -> None: if self.mode == "min": self.best_value = math.inf def update(self, step: int, value: float) -> bool: improved = ( value > self.best_value + self.min_delta if self.mode == "max" else value < self.best_value - self.min_delta ) if improved: self.best_value = value self.best_step = step self.bad_epochs = 0 else: self.bad_epochs += 1 if self.bad_epochs >= self.patience: self.should_stop = True return self.should_stopdef simulate_training(n_steps: int, seed: int = 7) -> list[TrainStep]: rng = random.Random(seed) history: list[TrainStep] = [] for step in range(1, n_steps + 1): train_loss = max(0.15, 2.0 * math.exp(-step / 40) + rng.uniform(-0.02, 0.02)) base_eval = 2.0 * math.exp(-step / 45) overfit_bump = max(0.0, (step - 80) * 0.008) eval_loss = base_eval + overfit_bump + rng.uniform(-0.02, 0.02) peak_step = 90 task = 0.55 + 0.35 * math.exp(-((step - peak_step) ** 2) / 2500) history.append( TrainStep(step=step, train_loss=train_loss, eval_loss=eval_loss, task_metric=task) ) return historydef build_mock_eval_set() -> list[EvalSample]: return [ EvalSample( query="我要退货", reference="[意图] 退货申请", prediction="[意图] 退货申请", intent="RETURN", predicted_intent="RETURN", ), EvalSample( query="订单什么时候到", reference="[意图] 物流查询", prediction="[意图] 物流查询", intent="LOGISTICS", predicted_intent="LOGISTICS", ), EvalSample( query="帮我写首诗", reference="[拒识] 超出服务范围", prediction="[意图] 内容生成", intent="REFUSE", predicted_intent="OTHER", ), EvalSample( query="价格能便宜点吗", reference="[意图] 议价咨询", prediction="[意图] 议价咨询", intent="PRICE", predicted_intent="PRICE", ), EvalSample( query="今天天气怎么样", reference="[拒识] 超出服务范围", prediction="[拒识] 超出服务范围", intent="REFUSE", predicted_intent="REFUSE", ), ]def pick_best_checkpoint(history: list[TrainStep]) -> TrainStep: return max(history, key=lambda s: s.task_metric)if __name__ == "__main__": report = evaluate(build_mock_eval_set()) print("=== 评测报告 ===") print(f"样本数 : {report.n_samples}") print(f"ROUGE-L : {report.rouge_l:.3f}") print(f"意图准确率 : {report.intent_accuracy:.3f}") print(f"格式合格率 : {report.format_pass_rate:.3f}") print(f"拒识精确率 : {report.refusal_precision:.3f}") history = simulate_training(n_steps=160) diag = diagnose_overfit(history) print("\n=== 过拟合诊断 ===") print(f"eval 分叉步数 : {diag.diverged_at}") print(f"任务指标峰值步数 : {diag.task_metric_peak_step}") print(f"任务指标峰值 : {diag.task_metric_peak_value:.3f}") print(f"是否回落 : {diag.task_regressed}") print(f"结论 : {diag.message}") stopper = EarlyStopping(patience=15, min_delta=1e-3, mode="max") for step in history: if stopper.update(step.step, step.task_metric): print(f"\nEarly stop @ step {step.step}, best={stopper.best_value:.3f} @ {stopper.best_step}") break best = pick_best_checkpoint(history) print(f"\n最优 checkpoint : step={best.step}, task_metric={best.task_metric:.3f}")
跑一遍会看到几件事:mock 评测集在拒识任务上失败("帮我写首诗"被判成 OTHER 而不是 REFUSE),拒识精确率降到 0.5;训练历史里 eval loss 在第 80 步左右开始分叉,任务指标在第 90 步达到峰值然后下滑,early stopping 在 patience 用完时触发;最优 checkpoint 不是最后一步,pick_best_checkpoint 会把它挑出来。
这套骨架换成真实模型时,evaluate 的输入换成模型生成结果,simulate_training 换成 trainer 的 log callback,其他部分不用动。
评测告诉你模型有没有"训好",但训好不等于能上线。上线要回答的问题是:这个 adapter 怎么加载?多个版本怎么灰度?推理服务怎么扛住并发?OOM 了怎么办?回滚流程是什么?
这些不是模型问题,是工程问题——下篇聊。
*《Fine-tuning 工程实战》系列每天更新一篇,下篇预告:*
*07 — 生产部署与版本管理:从 adapter 到推理服务*
#AI编程#大模型开发#微调#FineTuning#模型评测#过拟合#LLM