微调 job 几乎所有失败都在文件验证阶段,六大原因:JSON 格式、字段缺失/错误、角色错误、空消息、超 token 上限、样本数不足。
一个微调任务失败了,几乎总是在文件验证阶段就失败了,在还没跑哪怕一个梯度步之前。错误信息会指出行号和字段,出错的原因通常就那么六种:某行 JSON 格式不对、缺少字段或字段名写错、角色不合法、assistant 消息为空、单个示例超出了 per-example token 限制、或者示例数量太少。
验证报错通常是具体的,但如果只是粗略扫一眼,这些具体信息就白瞎了。不同服务商的措辞不一样,但反复出现的格式是这样的:
报出行号和字段名。行号从 1 开始计数,指的是上传后的文件。如果你上传之后又编辑过,那看的行号就是错的了。
第 N 行 JSON 格式不对。几乎都是因为尾逗号、字符串内出现了真实的换行符、未转义的引号,或者是一个被格式化成了多行的漂亮对象。JSONL 要求每行是一个完整的 JSON 对象;如果格式化工具把文件弄成了多行格式,那文件就已经被毁掉了。
字段缺失或出现意外字段。对话格式要求一个 messages 数组;有些服务商也接受 prompt-completion 格式。两种格式混在一个文件里会失败,用了文档已经废弃的格式也会失败。
角色不合法。角色只能从一小部分允许的值中选取。bot、ai、human、大写 Assistant 都会失败。
示例超过了 token 限制。每个训练示例有一个最大 token 数,独立于模型的上下文窗口上限;超限的示例要么被拒绝,要么被静默截断——截断的后果更糟糕,因为这是在一个截断的答案上训练的。
示例数量太少。服务商有最低数量要求,通常在 10 条左右。低于这个数量任务根本不会启动。
最低数量、per-example token 限制和文件大小上限因服务商而异,而且会变化。请阅读你所使用的服务的最新文档;下面的 linter 把这些作为参数传入,而不是硬编码一个会过时的值。
下面的每一项检查都是托管验证器会做的,在本地运行这些检查能把一个排队后被拒绝变成一个五秒钟就失败的情况,而且会一次性列出所有问题,而不是只报第一个。
#!/usr/bin/env python3
"""Lint a chat-format JSONL fine-tuning file. Reports everything, not
the first fault. Pass a real tokeniser for accurate token counts."""
import json, sys
from collections import Counter
ROLES = {"system", "user", "assistant", "tool"}
MAX_TOKENS = 16384 # per example; check YOUR provider's current limit
MIN_EXAMPLES = 10
def lint(path, count=lambda s: len(s) // 4): # crude default; replace it
problems, labels, lengths, seen = [], Counter(), [], set()
n = 0
with open(path, encoding="utf-8") as f:
for i, line in enumerate(f, 1):
if not line.strip():
problems.append(f"{i}: blank line"); continue
if i == 1 and line.startswith("\ufeff"):
problems.append("1: file starts with a UTF-8 BOM")
try:
ex = json.loads(line)
except json.JSONDecodeError as e:
problems.append(f"{i}: invalid JSON: {e.msg} at col {e.colno}")
continue
n += 1
msgs = ex.get("messages")
if not isinstance(msgs, list) or not msgs:
problems.append(f"{i}: missing or empty 'messages'"); continue
if extra := set(ex) - {"messages", "tools", "parallel_tool_calls"}:
problems.append(f"{i}: unexpected top-level keys: {sorted(extra)}")
roles = [m.get("role") for m in msgs]
for r in roles:
if r not in ROLES:
problems.append(f"{i}: invalid role {r!r}")
if roles[-1] != "assistant":
problems.append(f"{i}: last message is {roles[-1]!r}, "
f"not 'assistant' — nothing to learn from")
if not any(r == "assistant" for r in roles):
problems.append(f"{i}: no assistant message")
for j, m in enumerate(msgs):
c = m.get("content")
if m.get("role") == "assistant" and not (c or "").strip() \
and not m.get("tool_calls"):
problems.append(f"{i}: assistant message {j} is empty")
if c is not None and not isinstance(c, (str, list)):
problems.append(f"{i}: message {j} content is {type(c).__name__}")
text = " ".join(m.get("content") or "" for m in msgs
if isinstance(m.get("content"), str))
t = count(text)
lengths.append(t)
if t > MAX_TOKENS:
problems.append(f"{i}: ~{t} tokens, over the {MAX_TOKENS} limit")
key = json.dumps(msgs, sort_keys=True)
if key in seen:
problems.append(f"{i}: exact duplicate of an earlier example")
seen.add(key)
last = msgs[-1].get("content")
if isinstance(last, str):
labels[last.strip()[:60]] += 1
if n < MIN_EXAMPLES:
problems.append(f"only {n} examples; the minimum is {MIN_EXAMPLES}")
print(f"{n} examples, {len(problems)} problems")
for p in problems[:50]:
print(" ", p)
if lengths:
lengths.sort()
print(f"tokens: min {lengths[0]} median {lengths[len(lengths)//2]} "
f"max {lengths[-1]}")
print("most frequent final messages (label balance):")
for label, c in labels.most_common(5):
print(f" {c:>6} ({c/max(n,1):5.1%}) {label!r}")
return 1 if problems else 0
if __name__ == "__main__":
sys.exit(lint(sys.argv[1]))
其中两项检查值得单独说一下,因为没有托管验证器会做这两项。重复检查能捕获那种会把某个示例静默加重权重的复制粘贴。而最后打印的标签分布则是下一节的主题。
一个通过验证的文件仍然可能在教一些你并非本意的东西,出现这种情况有两种方式,都跟 loss 在哪些 token 上计算有关。
多轮对话示例。 在一个包含四轮 assistant 回复的对话中,大多数服务默认计算每条 assistant 消息的 loss。如果你从生产记录构建文件,而只审核了最后一个答案,那你同时在用三个未经审核的答案和那一个好答案一起训练。如果某个服务支持按消息设置权重,就把前面几轮 assistant 消息的权重设为 0。如果不支持,就把这个对话拆成多个示例,让每个示例都在你真正想学习的轮次处结束。
# 一个对话,四轮 assistant,只有最后一轮被审核了。
# 选项 A — 权重(如果支持的话):
{"messages": [
{"role": "user", "content": "..."},
{"role": "assistant", "content": "...", "weight": 0},
{"role": "user", "content": "..."},
{"role": "assistant", "content": "the reviewed answer", "weight": 1}
]}
# 选项 B — 截断对话,让它在好的那轮结束。
# 字段名及其支持情况因服务商而异;依赖此字段前先确认,
# 如果它被静默忽略了就回退到选项 B。
系统提示词。 训练示例里放的是什么,模型就是在什么样的分布上做适配。三个常见错误:文件里没有系统消息但生产环境有、每个示例里的系统消息不一样、或者一个很长的系统消息在每个示例里重复——后者会虚增你为训练支付的 token 数量,有时候比实际内容还多。如果系统提示词是固定的,那么包含它的理由是匹配线上环境;反对的理由是成本。选择其一并保持文件和线上环境一致。
两种错误在训练后有一个共同的特征:模型在和文件完全一致的输入上表现良好,在其他任何输入上都表现糟糕——这看起来像过拟合,但实际上是格式不匹配。
一个文件可以通过所有验证器,但这个任务本身根本不值得跑。类别不平衡是常见原因:如果 88% 的示例以同一个标签结尾,那么一个总是预测那个标签的模型得分就是 88%,根本没有学到你想要的东西。它在聚合准确率数字上看起来可以,但在你做这个模型要处理的任务上毫无用处。
训练前打印分布,不要等到训练后。linter 做了这件事。如果多数类占比超过 70% 左右,先处理它——类别不平衡包括重平衡,数据集平衡包括在不丢弃数据的情况下做这件事。
检查是否有泄露到验证集。近似重复被分散到训练集和验证集会导致验证 loss 看起来非常好但毫无意义。先去重再分割,不要分割后再去重。
检查格式一致性。如果一半的 assistant 回复以句号结尾、另一半没有,或者一半是 JSON 另一半是纯文本,那你就是把不一致性也训练进去了。
检查系统提示词是否和线上环境一致。一个用一套系统提示词微调、却用另一套系统提示词上线的模型,训练的是一个它永远见不到的分布。
任务成功了,loss 曲线看起来正常,但模型在你的任务上比基座模型还差。这种情况很常见,应该有心理准备,通常是以下四种原因之一:要教的行为示例太少、小数据集上跑了太多 epoch 导致模型在背而不是泛化、学习率太高、或者任务本身就不是微调能解决的——微调教格式和风格比教事实容易得多。
两个习惯可以让这种情况变得可挽回。留出一个测试集,训练任务从未见过它,然后用同一个提示词在这个测试集上对比微调模型和基座模型的表现。还有就是看验证 loss 曲线是否在训练 loss 还在下降时就停止下降了——这个 gap 就是过拟合,修复方法是减少 epoch 或增加数据。"Fine-tuning failures and evaluating a fine-tuned model" 讲的是这两件事,而"到底要不要微调"如果答案一直是否,也值得重新读一遍。