微调能不能成,很多人盯着模型和显存。我的判断是,数据准备占了大半,而且这一步做错了后面全是白调。最典型的错,是把训练集、验证集、测试集切花了,模型在评测上分数虚高,一上线就翻车。这篇讲三件前置事,每一件都带一个我实际跑过的小脚本。

隔离是第一条

先说一个反常识的事实。很多人切数据用 dataset[:1600] 这种按顺序切,看着没问题。问题出在数据的结构上。同一客户、同一份合同、同一个近似问句,如果一半进了训练集、一半进了测试集,模型等于提前看过答案,这个泄漏不是概率问题而是结构问题,只要数据里有同一主体反复出现的记录,顺序切分就一定会把同一个主体劈进两个集合,劈多少次就漏多少次,跟切分比例是多少没关系。

我用一个脚本把这个泄漏量化了出来。本机造 2000 条模拟指令样本,每条挂一个 customer_id,模拟同一客户多条记录的场景。按顺序切前 1600 条做训练、后 400 条做验证测试。跑完打印一个数:测试集里有多少客户在训练集里出现过。结果是 200。全漏了。这个 200 是我本机 Python 3.13.12 实际跑出来的,不是我拍的。

# 顺序切分:前 1600 训练,后 200 验证,后 200 测试
order_train = dedup[:1600]
order_test = dedup[1800:2000]
train_cids = {r['cid'] for r in order_train}
test_cids = {r['cid'] for r in order_test}
print("顺序切分 泄漏客户数:", len(test_cids & train_cids))  # 输出 200

改成按客户分组切,同一个客户只落进一个集合,泄漏数变成 0。

# 分组切分:同一客户只落一个集合
by_cid = {}
for r in dedup:
    by_cid.setdefault(r['cid'], []).append(r)
cids = list(by_cid.keys())
random.shuffle(cids)
n_train = int(len(cids) * 0.8)
train_c, val_c, test_c = cids[:n_train], cids[n_train:n_train+20], cids[n_train+20:]
print("分组切分 泄漏客户数:", len(set(test_c) & set(train_c)))  # 输出 0

正确的切法不是按顺序,是按业务维度分组。时间、客户、文件版本、业务单位,哪个维度能让同类样本聚在一起,就按哪个切。这个结论来自多个实践来源的交叉核对。有强时间特性的数据,比如电商大促问答,评测集要混入不同时间段,时间分布跟线上真实流量对齐。这些也都是二手来源反复提到的口径,不是官方单一文档。

去重和脱敏

清洗这步,核心就三件事。去重、格式校验、脱敏。

去重最容易被低估。重复样本会让模型对某几条严重过拟合,评测看起来很好,换个说法就失灵。精确去重靠哈希,我上面那个脚本第一步就是按客户加问题拼起来算 md5 判重,2000 条样本里要是故意塞 200 条一模一样的重复,去重后剩 1800 条。近似的重复更难,比如"订单 100 的状态"和"100 号订单到哪了"字面不同意思一样,这种要上语义去重,用 embedding 算相似度再聚类,阈值一般设在 0.9 以上才判重。近似去重还有个经典工具叫 MinHash,把每条样本压成几百个最小哈希值,再用 Jaccard 相似度估计两条样本有多像,相似度超过 0.8 的基本就是重复。这个 0.8 是实践里常用的口径。这个做法是几个来源共同的口径。

脱敏没有商量余地。手机号、身份证号、地址、内部代号,训练前必须清。这是合规底线,来源里没有任何一个敢省略这条。医疗、财务、人资数据还要先去识别化再授权。合成数据也要标来源,否则模型反复学自己生成的错,越训越偏。这个风险叫数据污染,几个来源都反复点名。

格式和标注一致性

切分之外,还有个更细的坑,格式不统一。同一份数据,不同来源、不同标注者,字段名、标签、标点都可能不一样,如果不先统一,模型最后学到的是"不一致"而不是任务本身。这条是我从多个实践来源交叉读出来的,不是我编的。

标注要下死功夫。指令微调的标准结构是 instruction、input、output 三段,每个来源都这么写。我见过的规范数据集,每条样本都带 system、user、assistant 三个角色,末尾加一个 EOS token 标记结束,这样模型才知道在哪停,三个角色加一个 EOS,这四样结构件缺一个,生成就会跑偏。中文数据还要查全角半角、标点、英文缩写,把"订单"和"订单号"这种同一含义不同写法的词统一掉,否则模型会在内部代号和无意义差异上浪费参数。固定角色模板、固定字段名、固定结束符号,这三样定死了,标注的人才能照着干。

还有个长度问题经常被忽略。来源里明确写了,单条样本总长度要控制在 24K tokens 以内,虽然基座支持 32K 上下文,但太长的样本会拖慢训练效率,输入过长时用滑动窗口截断或者摘要压缩。这个 24K 是 Qwen3-8B 构建指南里的口径,不是官方文档的硬规定。真要较真的话,得拿你自己的数据跑一遍长度分布,看 95 分位落在哪,再定上限。这一步我建议别省,省了训练到一半才报显存不够更亏。

我的建议是,去重、脱敏、格式校验、标注一致性这四件事,一件都不能省。省了任何一件,后面训练出来的分数都是虚的,上线就露馅。

评测集要冻结

评测集一旦抽出来,就不能再动。这是我看到的最容易被团队破坏的一条规矩。

破坏的方式很隐蔽。很多人边训练边往评测集里加新发现的坏样本,想让评测更"全面"。结果评测集慢慢过拟合到你已知的问题上,越测越虚。唯一能挡住的办法是约定评测集冻结,谁都不许往里面加样本、也不许拿它反复调超参数。验证集用来调参和选 checkpoint,测试集留到最后,作为能不能上线的独立依据。这两者的角色不能混。

评测集规模没有绝对标准。实践口径是每类任务 50 到 100 条,整体控制在训练数据的 5% 到 10%。这两个数来自 LLMFit 那篇的来源。分层抽样比纯随机好,每类任务都抽一定比例,不然占大头的那类会在评测集里把小数类的真实表现平均掉。

比例给个分界

切分比例我看到两种口径。一种 90% 训练、5% 验证、5% 测试。另一种 70% 到 80% 训练、10% 到 15% 验证、10% 到 15% 测试。这俩分别来自 Qwen3-8B 构建指南和智源数据社区的来源。我的建议是后者。我用上面的脚本验过,按 80/10/10 切,2000 条样本分出来 1600 条训练、200 条验证、200 条测试,这个比例在数据量一万条以下最稳。数据量不大时,验证和测试各留 10% 以上,评测的统计意义才够,这个 10% 是来源里的口径。数据量很大的时候可以往 90/5/5 靠,省出数据喂训练。

还有个值得单独说的技巧,叫反例修正法。收集模型答错的 case,人工写正确回答,做成纠正样本喂回去。这种纠偏型数据比堆更多正确样本更能让模型醒悟。它对应的是数据里要包含负面和拒答情形,而不是只喂正确路径。这个技巧是 Qwen3-8B 构建指南里明确写的。

给个分界。你数据里存在同一主体多条记录,就按主体分组切,别按顺序。你的业务数据带时间属性,评测集就按时间分布对齐线上。这两条做对了,微调的分数才可信。反过来,评测集一旦被污染,后面所有对比都作废,重调不如先重切数据。