一、合成数据的方法论骨架:它不是"人工数据的廉价替代"
1.1 这篇文章要回答的问题
几乎每个做后训练的团队都会在某个月份遇到同一个场景:线上真实数据突然不够用了。要么是采集成本太高,要么是用户行为分布太窄,要么是新产品形态根本没有历史数据可采。于是最顺手的动作就是"让模型自己造一批",把生成结果丢进 SFT。这一步通常有效,但几乎所有团队都会在三个月后遇到同一组症状:合成数据加得越多,模型在内部评测集上越强,一上线到真实场景就掉得更狠;反复迭代两三轮之后,模型开始稳定地犯同一种新错误,而且这种错误在它自己的输出里完全看不出来。
这些症状不是工程事故,而是方法论缺口。合成数据真正的问题从来不是"质量不够好",而是当你把模型生成的内容当作训练信号时,你同时把它对世界的错误理解也当成了金标。模型只能生成它认为可能的东西,它无法生成它不知道自己不知道的东西。这个结构性限制决定了一件事:合成数据的合法性不来自它由谁生成,而来自它被什么信号判定。
所以本文不讨论"如何写好 prompt 让模型多生成点数据",而是讨论一条完整链路:生成、验证、混合、监控。这个框架的价值在于它把合成数据从一次性技巧变成了可持续的工程系统——每一步都有明确的失败模式、明确的度量指标和明确的退出条件。
1.2 四个环节,以及为什么顺序不能反
把这条链路拆开,只有四个环节,但它们的先后顺序是硬约束:
- 生成(Generate):用某个模型从一个 prompt 池出发产出候选样本。关键变量是 prompt 池的分布,而不是生成模型的聪明程度。
- 验证(Verify):用独立于生成过程的信号判定每个候选的对错与质量。关键变量是验证信号的外部性——它是否真的独立于生成器。
- 混合(Mix):把验证后的合成数据与真实数据按某种配比、某种分桶策略放在一起。关键变量是三条独立的轴被混淆了。
- 监控(Monitor):在真实 held-out 集上持续观测,检测分布塌缩、能力遗忘、验证器漂移。关键变量是停止条件必须由真实集而非合成集定义。
顺序之所以是硬约束,是因为后面每一环的度量都依赖前面一环的产物。生成层的截断率、重复率只有在有验证层做筛选时才有意义;验证层的误判率只有在有真实抽检集时才能标定;混合层的分桶策略只有在有分桶指标时才能优化;监控层的停止条件只有在漏斗前四层数据都齐备时才拿得到完整解释。跳掉任何一环,后面几层的"改进"往往只是把噪声往前推。
本文按这个顺序展开。重点不在某一步的技术细节,而在于每一步的失效形态——因为合成数据流水线里,绝大多数"效果变差"的事件都能追溯到某一层的具体失效模式,而不是玄学。
二、自蒸馏与拒绝采样的闭环
2.1 闭环的四个要素
自蒸馏(self-distillation)的字面含义是"用模型自己产出的数据训练模型自己"。在实践中,它从来不裸奔,永远是一个闭环:
- Prompt 源:一批种子任务。它的分布决定了整条流水线的天花板——模型不会生成 prompt 池里不存在的任务类型。
- 采样策略:温度、top-p、每题采样数、是否多次独立采样。它决定了候选集覆盖了多大的概率质量。
- 验证信号:规则检查、单元测试、执行结果、参考答案比对、过程级打分模型、外部工具返回。这是整个闭环里唯一有资格当"金标"的东西。
- 回灌策略:哪些通过的样本进入训练集、去重规则、按什么分桶比例混入、是否保留多次迭代的历史数据。
拒绝采样(rejection sampling / best-of-N)是这个闭环里最朴素也最有效的形态:生成 N 个候选,用验证信号筛出通过的那些,丢掉其余的,作为 SFT 数据。它的价值不在于"造出正确答案"——正确答案往往本来就有——而在于让训练分布集中到模型能力边界附近的那片区域。这一点是下一节要展开的核心,也是整套方法论里最值钱的一条经验。
拒绝采样的本质是重新加权:把梯度预算从模型已经百分之百掌握的简单样本上挪走,挪到成功率为 10% 到 50% 的区域。在有限训练预算下,这个挪动带来的收益通常远大于生成更多同分布数据。
2.2 STaR、ReST、SPIN、RAFT 各自补上了哪一块
自蒸馏这条线上有几种被反复引用的变体,它们不是同一件事的不同说法,而是各自修补了基线做法的不同漏洞。理解它们的差异,比记住名字有用得多。
STaR(Self-Taught Reasoner)处理的是"正确答案有了但推理过程缺了"这个问题。它的做法是让模型先直接给答案,如果答案正确,就要求它倒推出一条能到达该答案的推理链;把这条链当作监督信号。它的隐含假设是:模型虽然不会正面解出这道题,但它至少能识别正确解法长什么样,因此可以通过"逆向生成"来逼近。这个假设在简单推理任务上比较站得住,在需要多步搜索的任务上很脆弱。
ReST(Reinforced Self-Training)处理的是"数据只来自当前模型,分布永远不扩展"这个问题。它在两阶段之间交替:先做若干轮拒绝采样并全量重训(期望最大化步),再重新采样——但新一轮采样可以产生上一轮训练后模型才能解出的题,也就是自举(bootstrapping)。这个"EM 视角"很贴切:期望步把好经验固化,最大化步扩大可解集。
SPIN(Self-Play)处理的是"正例监督已经榨干"这个问题。它用当前策略输出的较差结果当作对手,生成一个"更好的"伪标签,构造自我对弈的偏好数据。它的隐含风险很直接:如果"更好"的判断来自模型自己,那么这个偏好方向本身就带有模型的偏见,只是把偏见固化成了偏好信号。SPIN 在通用文本流畅度上有收益,在有客观对错的任务上收益远不如拒绝采样。
RAFT(Reward rAnked FineTuning)是拒绝采样的工程化版本:生成多个采样,用奖励模型打分,保留奖励最高的一部分做 SFT。它的关键贡献是把"二值通过"变成了"连续排序",在验证信号稀疏(无法二分判定但可以比较优劣)的场景下尤其有用。
把这四个放在一起看会发现一个规律:自蒸馏的每一次演进,都是在修补"监督信号来自模型自己"这个根本矛盾。STaR 用逆向构造绕开,ReST 用迭代扩展绕开,SPIN 用相对比较绕开,RAFT 用排序绕开。绕不开的地方,就是接下来两节要正面处理的问题。
2.3 可学习前沿:为什么成功率中段最值钱
如果本文只能留下一条可执行的经验,就是这一条:训练样本的价值与它被模型做对的概率负相关到一个拐点为止。
设想你把 prompt 池按模型的成功率从 0% 排到 100%,然后看梯度贡献。成功率接近 0 的样本,模型完全不知道该往哪走,正例监督提供的其实是它猜出来的那条偶然正确路径,噪声极大;成功率接近 100% 的样本,模型早已掌握,梯度接近 0,训练它纯粹浪费预算。真正产生学习增益的,是成功率落在中间区间的样本——模型有一定概率做对,因此验证信号能可靠地筛出正例;又没有完全掌握,因此梯度依然显著。
这个直觉可以形式化成一句话:对于验证通过的概率 p,一个正例带来的期望信息量大致随 p * (1 - log p) 之类的量变化,在 p 很低时快速上升,在 p 很高时饱和。所以很多团队在实践中得到一个粗略但稳定的经验区间:把 p 控制在 0.1 到 0.5 之间,是合成数据投入产出比最高的位置。这个区间不是普适常数,会随任务难度分布、模型规模、优化器配置漂移,但它的形状是稳定的。
推论很直接:你的 prompt 池不该是"尽可能难的任务列表",也不该是"标准教程里的话题列表",而应该是相对于当前 checkpoint 的成功率中段的那批任务。这意味着 prompt 池必须随模型能力滚动更新——一个在 v1 模型上处于前沿的题,在 v3 模型上已经毕业,需要被更难的题替换下去。同时,已经被掌握的旧题不能直接丢,要以降低采样比例的方式继续以低频重放,用来对抗遗忘。
这一点也是为什么"课程编排"在合成数据里不是锦上添花而是主干设计:它把一个静态的 prompt 池变成了一个相对于模型能力自适应的动态系统。
2.4 2026 年的变化:可验证奖励把合成和强化学习合并成了一条线
过去几年这两件事是分开的:合成数据用于 SFT,强化学习用 reward model 或人工偏好做打分。2025 到 2026 年最重要的结构性变化,是可验证奖励(RLVR, Reinforcement Learning with Verifiable Rewards)范式的普及把两者合并成了同一条流水线:合成数据不再是"训练完就固化"的一次性产物,而是变成强化学习 rollout 的采样池;验证信号不再只是过滤器,而是直接充当奖励函数。
这个合并带来三个实质变化,值得每个团队重新评估自己流水线的设计:
- 正例不再是唯一的数据形态。在 RL 框架下,失败样本带负梯度信息,同样有价值。只保留通过样本的拒绝采样丢掉了"模型在哪些地方系统性犯错"这一半信号,而这一半恰恰是构造对抗样本最直接的原料。
- 过程级验证的价值上升。结果级验证(答案对不对)在多步任务上信用分配极差,模型不知道自己是在第几步走偏的。过程奖励模型(PRM)以及 2025 年后出现的生成式验证器(用一个模型写出判分理由再给分)试图补这块,它们的成本明显高于规则检查,但能在数学推导、多跳问答、代码调试这类长链任务上显著改善信用分配。
- 验证器从"筛选器"升级为"环境"。strong>代码领域把"生成一个测试用例"变成了可执行环境,模型写错就运行、运行失败就得到确定性信号;工具调用领域把合成数据做成了完整的 trace 序列,包括工具返回值。这类"可执行的合成数据"本质上是在合成一个训练环境,而不是合成一份数据集,这个转变让数据生成的想象空间大了一整档。
但合并也有代价。RL 阶段对奖励黑客的敏感度远高于 SFT 阶段:模型会主动寻找验证器的漏洞,而不是努力把题做对。因此 RLVR 流水线必须比纯 SFT 流水线更早、更密集地做验证器审计——这一点在第九节还会回到。
2.5 闭环的收敛性:自证与奖励黑客
现在把上面这些拼起来,会看到一个不太舒服的结论:一个用自己生成数据训练自己、且用自己或近亲模型做验证的闭环,是不保证收敛的。
直觉是这样的。每轮迭代,模型分布向"验证器认可的样本分布"收缩。收缩本身不是问题——那正是我们想要的定向能力提升。问题在于当生成器和验证器共享足够多的训练来源、架构偏好和语言风格时,验证器的判据会随生成器一起漂移。漂移的终点是:模型开始生成"验证器愿意给高分"的东西,而不再生成"真正正确的"东西。这时的闭环在指标上依然很漂亮(通过率上升,训练损失下降),但已经和真实目标脱钩。
打破收敛的常规手段有四种,可以叠加使用:
- 外部锚点:每轮固定掺入一部分从未参与过合成流程的真实数据,且这部分数据不参与任何筛选。它的作用是提供一个不会漂移的坐标系。
- 验证器轮换:周期性用新一批数据重训验证器,或者采用多验证器投票并强制要求至少一个"异源"验证器同意。异源指的是不同架构、不同训练数据、不同目标体系。
- prompt 池轮换:定期引入人工撰写的高价值任务,特别是那些模型系统性失败的领域。人工样本量不需要大,作用是"把天花板抬回去"。
- 收敛检测与熔断:见第九节的停止条件。关键是把熔断信号定义在真实 held-out 集上,且要求这个信号连续出现若干轮才触发,避免被训练噪声误导。
2.6 一段可复用的闭环实现
下面这段代码把上述闭环的核心逻辑写出来。它不依赖任何具体框架,可以直接接到任意生成后端和验证后端上。注意几个刻意的设计选择:验证结果保留失败样本(带标注原因)、入池前做语义去重、以及一个显式的可学习前沿过滤。
import json, hashlib, random
from collections import defaultdict
from dataclasses import dataclass, field
from typing import Callable, Iterable
@dataclass
class Candidate:
task_id: str
prompt: str
output: str
passed: bool
verifier_score: float = 0.0
failure_type: str = "" # 失败原因分类,仅在 passed=False 时有意义
difficulty_bucket: str = "" # 由 seed taxonomy 继承
domain: str = ""
fingerprint: str = field(default="")
def semantic_fingerprint(text: str) -> str:
"""归一化后取哈希,作为精确去重的第一道闸门。"""
norm = "".join(text.lower().split())
return hashlib.sha1(norm.encode("utf-8")).hexdigest()[:16]
def rejection_sampling_loop(
prompts: Iterable[str],
generate: Callable[[str, int, float], list[str]],
verify: Callable[[str, str], tuple[bool, float, str]],
n_samples: int = 8,
temperature: float = 1.0,
frontier_low: float = 0.10,
frontier_high: float = 0.55,
) -> list[Candidate]:
"""生成 -> 验证 -> 统计成功率 -> 只保留可学习前沿的题目产出。"""
kept: list[Candidate] = []
seen: set[str] = set()
for prompt in prompts:
domain = prompt.split("|", 1)[0].strip()
task_id = semantic_fingerprint(prompt)
outs = generate(prompt, n_samples, temperature)
results = []
for out in outs:
ok, score, ftype = verify(prompt, out)
results.append((out, ok, score, ftype))
pass_rate = sum(1 for r in results if r[1]) / max(1, len(results))
# 能力边界之外:全对说明已掌握,全错说明无梯度可用
if pass_rate < frontier_low or pass_rate > frontier_high:
# 失败的极端仍然有用,标注后进入对抗样本池,而不是直接丢弃
if pass_rate < frontier_low:
for out, ok, score, ftype in results:
if not ok:
kept.append(Candidate(task_id, prompt, out, False,
score, ftype, "very_hard", domain,
semantic_fingerprint(out)))
continue
bucket = f"p={round(pass_rate, 1)}"
for out, ok, score, ftype in results:
if not ok:
continue
fp = semantic_fingerprint(out)
if fp in seen: # 精确去重
continue
seen.add(fp)
kept.append(Candidate(task_id, prompt, out, True, score, "",
bucket, domain, fp))
return kept
def diagnose_pipeline(cands: list[Candidate]) -> dict:
"""用少量诊断指标替代单一通过率,用于早期发现闭环自证。"""
by_domain = defaultdict(list)
for c in cands:
by_domain[c.domain].append(c)
report = {
"total": len(cands),
"pass_rate_by_domain": {},
"failure_type_hist": defaultdict(int),
"verifier_score_mean": 0.0,
}
if cands:
report["verifier_score_mean"] = sum(c.verifier_score for c in cands) / len(cands)
for c in cands:
if not c.passed and c.failure_type:
report["failure_type_hist"][c.failure_type] += 1
for dom, group in by_domain.items():
ok = sum(1 for c in group if c.passed)
report["pass_rate_by_domain"][dom] = ok / len(group)
# 告警:某个领域的失败类型高度集中,通常意味着验证器有系统性缺陷
for dom, group in by_domain.items():
if len(group) < 30:
continue
hist = defaultdict(int)
for c in group:
if not c.passed and c.failure_type:
hist[c.failure_type] += 1
if hist:
top, cnt = max(hist.items(), key=lambda kv: kv[1])
if cnt / max(1, len(group)) > 0.8:
report.setdefault("alerts", []).append(
f"{dom}: 80% 以上失败集中于 {top},疑似验证器缺陷而非模型缺陷")
return report
这段代码里有两个容易在评审时被挑出来、但值得保留的细节。第一,frontier_high 上界之外的成功样本被丢掉,而不是留在训练集里"巩固一下"——这是反直觉但正确的,因为高成功率样本的梯度接近零,留在里面会稀释 batch 质量。第二,极高难度的失败样本没有被丢弃,而是带着失败类型标注进入另一个池子;它们是第七节对抗样本的主要来源。绝大多数失败数据之所以没有价值,不是因为它错,而是因为没人记录它错在哪里。
三、为什么模型生成的数据不能直接当金标
3.1 分布塌缩是概率质量问题,不是正确性问题
合成数据流水线最容易被误诊的问题是分布塌缩。团队看到通过率 90%、损失曲线漂亮、离线评测涨点,于是认为数据没问题。但塌缩从来不是以"数据变错"的形式出现,而是以"数据变窄"的形式出现:每一条样本单独看都是正确的,整个数据集合起来看却只覆盖了真实分布很小的一块。
这个区别极其重要,因为它决定了诊断方式。正确性问题用逐条检查能发现;分布问题必须用统计量才能发现。典型的塌缩统计信号有:
- 合成集与真实集在嵌入空间的最近邻距离分布显著右偏——意思是合成样本都"扎堆"在真实分布的少数区域。
- 合成集的 token 类型-搭配(TTR)曲线下降异常快,说明表层词汇多样性在快速耗尽。
- 新一批合成数据与上一批的自我相似度显著上升,而与上一批真实数据的相似度反而下降。
- 长度分布收缩。模型倾向于在学到某种模式后,生成长度越来越均匀的输出。
这些信号的共同点是:它们在单条样本的正确率上完全不可见。所以如果你只盯着通过率做监控,你会一直到线上出问题才发现塌缩。
3.2 递归自消费:为什么迭代会加速丢失
关于递归训练导致模型退化,已经有相当扎实的研究给出了机制解释。把它们放在一起,可以得到一个更精确的图景,而不只是"别用模型自己的数据"这种粗糙结论。
分布尾部被逐步删除。递归生成等价于对原分布做反复重采样与筛选。每一次筛选都会删掉低概率样本——恰恰是那些最独特、最有信息量的样本。第 n 轮之后,分布尾部被削掉了 n 层,模型的能力边界向内收缩。这就是所谓"模型崩塌(model collapse)"的经典机制:递归训练在近似意义下让模型收敛到一个收缩的分布,而不是原分布。
自消费悖论("Go MAD"现象)。更严重的一类情况是"以模型数据为输入、训练新模型、其输出又被用作下一轮输入"这种交替式递归。实验显示这类训练会快速产生分布异常,模型开始输出与真实数据几乎无关的内容。这里的关键机制是:塌缩不是缓慢的,是加速的。一旦模型丢失了某个模式,后续自采样就再也无法重建它,因为重建需要从该模式出发产生具有足够概率质量的样本,而这恰恰是不可能发生的。
对"拒绝采样"的推论很重要。拒绝采样看起来天然免疫这个问题——它只用通过验证的样本。但它只在一个前提下免疫:验证器的覆盖范围足够宽,宽到能不断把低概率但正确的样本捞上来。如果验证器只对模型熟悉的任务形式有效,那么它筛出来的就是分布的核心而不是边缘,塌缩照常发生,只是慢一些。所以验证器的覆盖率是合成数据质量的真正瓶颈,不是生成规模。
3.3 错误放大:为什么"筛出对的"仍然可能学不到东西
这一节讨论的问题最容易被忽视,也是收益最高的部分。团队的常见困惑是:我的数据每一条都通过了验证,为什么模型还是学会了某个错误?
答案在于,验证器的判据和训练目标不是同一件事。验证器判定"这条推理链的结论正确",而训练优化的是"这条推理链的每个 token"。这两者之间有一个巨大的自由度差:一条结论正确的链,中间可以有无数种错误推理——跳步、错误类比、错误归因、甚至几个互相抵消的错误。这些错误在验证器的判据下完全隐形。
这导致一个反直觉但可观测的现象:模型会稳定地习得"如何用一条错误的路径得到正确的结论"。这类错误极其顽固,因为它在验证器、离线评测、乃至人工抽查的结论层面都看不到。唯一的暴露方式,是看推理链本身。
更麻烦的是第二类问题:系统性偏差在筛选中存活率更高。随机错误会被验证器大量拦下,因为随机错误里有相当比例会导致结论错误。但系统性错误不同——它是模型认知里的一个稳定偏差,会一致地作用于它生成的所有链。这类错误导致的结论错误比例往往低于随机错误,反而更容易通过验证。
举例来说,如果模型对某种单位换算有稳定的 10% 误差,它生成的 10 条链里约有 1 条会错,9 条会通过验证——而那 9 条"侥幸正确"的链里,量纲处理方式仍然是混乱的,模型从中提取到的规律是混乱的。这种错误在通过率曲线上完全不可见,只能靠对通过样本的推理链做抽检来发现。
相应的工程对策有三条,都不复杂但经常被省略:
- 过程级验证优于结果级验证。至少对关键任务做推理链检查,而不只看结论。成本换准确率。
- 对通过样本做"错误富集"抽检。不是随机抽检,而是专门抽检那些推理链结构可疑的样本:跳步过多、关键中间量未显式出现、结论与前提的距离过大。
- 用第二种独立方法交叉验证。例如用不同的验证器、或者用反向构造的等价问题重新问一遍,看答案是否自洽。这类"自洽性检查"抓不到的错误,坦白说不值得投入更多成本。
3.4 验证器同化:闭环退化为自证
前面讨论的是数据层的风险,这一节是更危险的一层:当验证器本身被污染,闭环就退化为自证循环。
同化的路径有三条,都很常见:
- 共享训练语料。验证器和生成器在相似的预训练语料上训练,会共享语言偏好、事实盲区、以及对"什么看起来像正确答案"的直觉。两者越同源,分数相关性越高,而分数相关性高恰恰是塌缩的信号。
- 共享标注来源。如果验证器的训练标签部分来自生成器的输出(同源蒸馏),它学到的就是生成器的判断标准,而非客观标准。
- 同构诱导偏置。同架构、同训练配方、甚至同一种对齐数据,会让模型系统性地高估"自己风格"的输出。这解释了为什么自蒸馏在流畅度任务上收益明显,在需要精确性的任务上收益微弱。
诊断同化的方法很直接:计算验证器分数与生成器自身置信度(logprob 或自评概率)之间的相关性。如果这个相关性极高——比如 spearman 相关超过 0.8——那么验证器提供的信息量接近于零,闭环实质上是模型在给自己打分。健康的验证器应当与生成器置信度存在中等相关(因为难题确实更难),但不能是近乎完全一致。对抗性验证器要刻意制造这种不一致:它应该在模型自信的地方偶尔判错,在模型犹豫的地方偶尔判对。
一个实用的判据:如果换掉生成模型、保留验证器,分数排序几乎不变,那么验证器是真正独立的;如果换掉生成模型后分数大幅变化,说明验证器主要在测"像不像这个生成器"。
3.5 金标的合法性:来自判定信号,不来自生成者
把 3.1 到 3.4 收拢成一条原则:
一条样本能否作为训练金标,取决于它被什么信号判定,而不是它由什么模型生成。
这条原则有很强的实践推论。同一条"模型生成的链",如果它通过了单元测试,它就是高价值金标;如果它只是"看起来很合理",它就是有害噪声。生成模型的身份只影响先验质量(即通过的难度分布),不影响金标的合法性。混淆这两点,会导致两种典型的资源错配:要么在"用更大的模型生成"上过度投入而忽视了验证器的质量,要么在"用更弱的验证器筛选"上妥协而把噪声灌进了训练集。
在预算约束下,几乎总是应该优先投资验证器,而不是生成器。生成能力通常是最容易采购的(换模型、加推理预算就行),验证能力才是真正稀缺、真正决定成败的。
四、质量与多样性的平衡:可量化、可调节
4.1 三个质量维度,不是一个分数
实践中最常见的错误,是用单一的质量分数来筛选数据——比如验证器打分从高到低取 top 10%。这个策略在直觉上合理,实际上会系统性地砍掉多样性:高质量分数高度集中在"最典型、最常见"的那类样本上,按分数排序本质上是在做分布核心的反复采样。
正确的做法是把质量拆成至少三个独立维度,分别度量、分别设阈值:
- 正确性(correctness):结论是否通过独立信号验证。硬约束,不通过即丢弃,没有妥协空间。这一维对应"对不对"。
- 完整性(completeness):是否满足全部约束——格式、长度边界、必含要素、引用可解析、代码可运行且无未定义变量。大量"看起来通过"的数据死在这一维上,尤其是格式约束多的场景。
- 信息量(informativeness):这条样本是否携带了模型没见过的东西。这一维是多样性的守门员,也是最容易被忽略的一维。
三者的关系不是并列的。正确性是准入门槛,完整性是过滤网,信息量是排序与配额调节的依据。把三者混成一个加权分数,门槛的硬性就丢了——加权分数低但仍可接受的数据会被误杀,而为了提高分数又会不自觉地把采样推向分布核心。
4.2 三个多样性度量与它们的失效模式
多样性必须被度量,否则"提升多样性"只是一句口号。常用的三类度量各有用处,也各有陷阱。
4.2.1 词面多样性:便宜,但容易骗人
最基础的度量是 distinct n-gram 比:统计 n 元组(如 bigram、trigram)在全语料中的唯一比例。实现成本极低,适合放在流水线上做实时监控。
但它的失效模式必须知道:它会惩罚必要的重复。在代码、数学、格式化输出、领域术语密集的场景中,高质量数据本来就应该重复大量固定的 token 序列(函数名、量纲单位、模板结构)。此时 distinct n-gram 下降可能完全不表示退化。因此正确的用法是:按桶分别计算,再看桶间差异,而不是看全语料的单一数字。同一个指标在代码桶下降 5% 而在自然语言桶上升 20%,整体可能持平,但两者的含义完全不同。
4.2.2 语义多样性:主力度量,但需要校准
把样本嵌入空间,做聚类,得到簇数量、簇大小分布、以及每簇内的紧致度。这是目前最实用的多样性度量,因为它直接对应"覆盖了多少不同的问题"。
它的失效模式是:嵌入模型对表层改写过度敏感,对实质性差异不敏感。用同一道题换了个问法,可能被判为"新内容";而同一类问题换个领域背景,可能被判为"重复"。缓解办法有两条:一是使用经过领域适配的嵌入模型(通用嵌入模型在代码、中文、法律等领域上的表现差异很大);二是用"同义改写判定"做小规模人工校准——让人判断聚类结果里的相邻簇是否真的不同,据此调整相似度阈值。阈值是最需要校准的超参,固定阈值是最常见的偷懒。
4.2.3 结构性多样性:最容易被忽略,收益最高
结构多样性指的是任务形态上的差异:不同的推理步数、不同的工具调用链长度、不同的约束数量、不同的输入模态组合、不同的输出格式。指标包括:工具调用轮数的分布、约束条款数的分布、是否需要多轮澄清、输入中干扰信息的比例。
为什么这一维收益最高?因为模型的能力边界主要由结构复杂度决定,而不是由话题决定。一个模型可能在"地理问答"上很强但"需要三轮工具调用的信息整合"上很弱,而后者与前者的话题毫无关系。只按领域分桶的采样策略会让后者永远得不到训练,因为它在自然分布里占比很低。
一个经验性的检查方法:把训练集按任务形态(而非领域)重新分桶,看每个桶的样本量和 pass rate。如果某个桶的 pass rate 长期接近 0 而样本量也接近 0,那不是模型学会了,是你的采样策略把这个能力排除了。
4.3 从加权求和到约束优化:为什么后者更可控
多样性最常见的工程实现是"质量分减去多样性惩罚",类似 Self-BLEU 惩罚或最大边际相关(MMR)式的重排。这条路能用,但它有一个根本缺陷:惩罚项的权重没有可解释的量纲。你把权重从 0.3 调到 0.5,效果是平滑变化的,没有一个点告诉你"多样性已经达标"。这导致调参变成盲试,且对数据集变化不鲁棒。
更可控的框架是把它写成约束优化问题:在满足多样性硬约束的前提下,最大化质量。约束的形式通常是这样:
maximize sum_i quality(s_i) * w_i
subject to cluster_k: n_samples(k) <= cap_k # 每簇上限
ngram: distinct_3gram >= tau_3 # 表层多样性下限
structure: P(struct_bin=j) <= rho_j # 结构分布上界
domain: coverage(domain_d) >= 1 - eps_d # 领域覆盖下限
novelty: nns_dist(s_i) >= delta_i # 近邻距离下限
这个写法的好处非常实际:每一个约束都对应一个可以独立监控的报警信号。cluster 上限这个约束,对应的报警是"单一簇占比超过阈值";novelty 约束对应的报警是"新样本到已有集合的中位近邻距离持续下降"。报警可以直接挂在训练流水线上,而不依赖任何综合评分。
需要强调的是"每簇上限"这个约束为什么重要。默认的采样行为是向高密度区域倾斜——因为那里质量分高、验证通过率高、生成成本低。结果就是数据分布向核心坍缩。显式的每簇上限是对抗这种倾向的唯一有效手段,因为它作用在选择环节,而所有质量导向的机制都作用在评分环节。评分环节天然偏向下游,需要一个独立的约束来平衡。
4.4 六个可以实时调节的旋钮
把上面所有讨论压缩成六个可以在训练循环中动态调节的旋钮。它们的价值在于可以做成一个根据实时指标自动反馈的控制系统,而不需要每轮人工干预。
- 采样温度:最基础但最被低估。温度提高直接扩大覆盖的分布范围,代价是通过率下降。正确用法是按难度桶分温度:对已经在中段的桶用低温保证通过率,对接近边界的桶用高温去捞出更少见的解法。全局统一温度做不到这种分层的调节。
- 每簇采样上限:直接控制分布塌缩速度,见上文的约束。
- 去重阈值:语义去重的相似度阈值。太松会误杀同源但必要的变体,太松的代价在长尾桶上尤其大;太紧则形同没有。应该按桶设置,并单独统计被去重杀掉的比例——如果某个桶被杀掉了 60%,说明这个桶的生成多样性已经出问题了。
- 验证阈值:随迭代轮次提高。这是最有效的抗塌缩手段之一,因为随着模型变强,原先的通过样本会越来越多地用更宽松的判据通过。逐步收紧阈值,强迫新数据必须比旧数据更好,等价于给闭环加了一道斜坡。
- 历史数据保留比例:每轮回灌时保留上一轮数据的比例。太低会遗忘,太高会累积噪声导致优化困难。经验上从 30% 到 50% 起步,并配合下面的课程重放机制。
- 异源数据掺入比例:每轮固定掺入一部分不参与筛选的真实数据。它的作用是坐标系锚定,比例可以很小(5% 到 10% 常常就够),但必须固定掺入,不能因为"这一轮真实数据表现不好"就临时下调——那正是最需要它的时刻。
4.5 一个失衡检测的实用工具
下面这段代码不生成数据,只做诊断。它的设计目的是在塌缩变得不可逆之前发出信号——具体来说是识别"质量在涨、多样性在跌"这个特征组合,因为它是塌缩最典型的早期形态。
import math
from collections import Counter, defaultdict
from dataclasses import dataclass
@dataclass
class DiversitySnapshot:
round_id: int
pass_rate: float
ngram_3: float # distinct 3-gram 比
n_clusters: int
top_cluster_share: float # 最大簇占比
median_nns: float # 到最近邻的中位距离
struct_entropy: float # 结构桶分布的香农熵
def shannon_entropy(counts) -> float:
total = sum(counts)
if total == 0:
return 0.0
h = 0.0
for c in counts:
if c <= 0:
continue
p = c / total
h -= p * math.log(p)
return h
def detect_collapse(history: list[DiversitySnapshot],
ngram_floor: float = 0.35,
cluster_floor: int = 40,
entropy_floor: float = 1.10) -> list[str]:
"""在塌缩不可逆之前报警。判据是组合式的,避免单指标误报。"""
alerts: list[str] = []
if len(history) < 3:
return alerts
cur, prev = history[-1], history[-2]
# 模式一:质量上升的同时多样性下降 —— 塌缩最典型的形态
if cur.pass_rate > prev.pass_rate + 0.02:
if cur.ngram_3 < prev.ngram_3 or cur.median_nns < prev.median_nns:
alerts.append(
f"round {cur.round_id}: 通过率上升而近邻距离下降,"
f"疑似向高密度区过度集中")
# 模式二:表层多样性触底
if cur.ngram_3 < ngram_floor:
alerts.append(f"round {cur.round_id}: 3-gram 多样性 {cur.ngram_3:.3f} 低于下限")
# 模式三:覆盖范围收缩
if cur.n_clusters < cluster_floor:
alerts.append(f"round {cur.round_id}: 语义簇数 {cur.n_clusters} 低于下限")
if cur.top_cluster_share > 0.45:
alerts.append(
f"round {cur.round_id}: 单簇占比 {cur.top_cluster_share:.2f} 过高")
# 模式四:任务形态分布变窄 —— 最容易被忽略但最严重
if cur.struct_entropy < entropy_floor:
alerts.append(
f"round {cur.round_id}: 结构熵 {cur.struct_entropy:.2f} 过低,"
f"复杂任务形态正在被采样策略排除")
# 模式五:多样性已经连续三轮单调下降
tail = history[-3:]
if len(tail) == 3 and all(tail[i].n_clusters > tail[i + 1].n_clusters
for i in range(2)):
alerts.append(
f"round {cur.round_id}: 语义簇数连续三轮下降,处于不可逆塌缩风险区")
return alerts
def suggested_next_params(snap: DiversitySnapshot) -> dict:
"""根据快照给出下一轮的旋钮建议。这是一个粗粒度的规则控制器。"""
advice = {"temperature_delta": 0.0, "cluster_cap_delta": 0,
"verify_threshold_delta": 0.0, "real_data_inject": 0.08}
if snap.top_cluster_share > 0.40:
advice["temperature_delta"] += 0.15
advice["cluster_cap_delta"] -= 0.10
if snap.struct_entropy < 1.20:
# 结构熵低说明高难度复杂形态没被采到,需要提高温度并定向补采
advice["temperature_delta"] += 0.10
advice["real_data_inject"] += 0.04
if snap.ngram_3 < 0.40:
# 表层多样性低但通过率可能仍高,此时应收紧质量阈值逼出更难的题
advice["verify_threshold_delta"] += 0.02
if snap.pass_rate > 0.80:
# 整体太轻松,学习前沿已经上移,需要更激进的边界探测
advice["temperature_delta"] += 0.10
advice["verify_threshold_delta"] += 0.03
advice["temperature_delta"] = min(0.4, max(-0.2, advice["temperature_delta"]))
advice["cluster_cap_delta"] = min(-0.05, max(-0.30, advice["cluster_cap_delta"]))
advice["verify_threshold_delta"] = min(0.10, max(-0.05,
advice["verify_threshold_delta"]))
advice["real_data_inject"] = min(0.20, advice["real_data_inject"])
return advice
这个诊断层应该作为流水线的常驻组件,而不是在出问题时才临时搭。它的价值在于把"塌缩"从一个需要事后解释的模糊现象,变成了一个每轮都有数值、可以看趋势、可以配对报警的工程指标。上面几条报警规则里,"结构熵"和"近邻距离"这两条最值得优先实现——它们对塌缩的敏感度明显高于通过率和 n-gram 比,原因是它们直接测量"覆盖了什么",而不是"覆盖了多少种说法"。
五、难度课程编排:让数据相对于模型自适应
5.1 为什么固定难度集必然失效
第二节给出的"可学习前沿"原则有一个直接的推论:难度不是任务的固有属性,而是任务相对于当前模型的能力属性。同一道题在 v1 模型上处于前沿,在 v3 模型上已经毕业。这意味着任何固定难度的 prompt 池都会在几轮迭代后失去作用——不是因为它变差了,而是因为模型追上它了。
更隐蔽的失效是"梯度稀释"。当一个已经被完全掌握的领域继续占据训练预算时,它不仅不产生有用梯度,还会挤占 batch 中其他样本的份额。实际表现是训练损失下降变慢、遗忘变严重、最终收敛点低于本应达到的水平。这类问题在按"自然难度分布"采样的流水线里特别常见,因为自然分布中大量简单样本会持续大量涌入。
5.2 四种难度估计方法及其成本
给任务打难度分需要测量,但不同场景能承受的测量成本差别很大。按成本从低到高排列:
5.2.1 静态代理指标:几乎零成本,但只能做粗排
用不跑模型就能算出来的特征做初筛:token 长度、嵌套层级数、约束条款数、涉及的实体数量、所需推理步数的估计(用一次贪心解码数步数)、工具调用链的静态分析结果。
这个方法的价值不在于精度,而在于它能在生成之前就把 prompt 分层,让昂贵的验证预算集中在看起来有价值的区间。实践中合理的工作流是:静态指标粗分成 3 到 4 档,对每一档只做小批量探测,再决定后续投入。它单独使用是不够的,因为静态指标与真实难度只有中等相关——一句很短的话可能是一个极难的问题。
5.2.2 主动探测:把测量成本集中到信息量最大的地方
对每道题做 k 次采样(k 取 4 到 8),统计通过率,得到经验难度。这是性价比最高的方案,也是第二节代码里采用的方案。k 太小会让难度估计方差过大(k=4 时通过率的置信区间很宽),太大则成本线性上升。折中做法是两阶段探测:先用 k=2 快速排除掉明显两端(全对/全错的题),剩下的题再补采样到 k=8。这样在难度中段聚集了测量精度,边缘只做粗判,而边缘恰好是"现在已经不需要花力气"的区域。
5.2.3 交叉模型探测:用外部难度基准锚定
用一个不同的模型(不同架构或不同代际)测同一批题,得到难度排序。如果题在两个模型上难度差异很大,说明它依赖了某个模型特有的能力模式——这类题对单模型的训练价值通常不高,但可以标记出来单独观察。反之,两个模型都难的题往往是真正有难度的通用难题,值得重点投入。
这个方法的额外好处是它天然过滤掉了"过拟合到某个模型特定弱点"的题目。这类题目在单模型探测下看起来很有价值(正好卡在边界上),但实际上训练它只能强化该模型已有的特定偏差,对泛化帮助有限。
5.2.4 学习曲线外推:精度最高,成本最高
对一小部分高价值题追踪它们在多轮训练后的通过率变化,拟合学习曲线,用外推判断"再训多少轮会毕业"。这能给出精确的预算分配——提前把即将毕业的题降权,把新晋升的题提权。代价是需要长期追踪一批固定题,且拟合方法本身有噪声。适合在流水线已经稳定运行、准备做精细化调优的阶段引入,不适合早期。
5.3 课程编排的三条设计原则
把难度测量和训练顺序结合起来,有三条原则值得明确:
原则一:课程的主要收益不是"由易到难",而是"持续把预算集中在前沿"。传统课程学习强调顺序——先易后难能提升收敛速度。但在合成数据的场景里,更重要的收益来源是预算的持续重分配。线性上升的课程只能提供一次性的顺序收益,而动态前沿跟踪提供的是每轮都正确的预算分配。实际实现上后者更容易:不需要一个预设的难度阶梯,只需要每轮重新分桶并按分桶结果调采样比例。
原则二:难度必须分层,不能只有一维。把复杂度拆成几个正交维度分别跟踪:推理深度(步数)、约束密度(需要同时满足多少条件)、知识广度(涉及多少个不同领域)、工具链长度(交互轮数)、干扰强度(输入中有多少无关信息)。一个模型可能推理深度够了但工具链完全不行。只用一个综合难度分做课程,会让后一种情况被平均掉而永远得不到针对性训练。
原则三:毕业不等于丢弃,必须降权重放。一个已经掌握的领域,样本量应该降到一个很低但非零的水平(通常是总量的 2% 到 5%),并保持在这个水平上。降到零会导致灾难性遗忘——合成数据的生成-训练循环本身会加剧遗忘,因为数据分布本来就被模型能力圈限了,遗忘的部分根本没有自然来源会补充。降到非零的低位,作用是持续刷新而不消耗预算。
5.4 课程实现中的三个常见坑
- 难度标签的滞后性。难度是相对于上一轮的 checkpoint 测的,本轮训练后所有标签都偏低了。修正方式是把分桶阈值按经验系数上移(比如把"已掌握"的通过率阈值从 0.95 降到 0.90),或者在每轮开始时先做一次快速重测。忽略这件事会导致课程实际节奏比预期快很多,模型在还没准备好的时候就已经换了难度档。
- 难度分层的泄漏。如果同一个任务的不同变体被分到训练集和验证集,难度统计会被污染,训练指标虚高。分桶应当在变体生成之前完成,按任务 ID 而不是按样本做分桶。
- 过度追求前沿导致训练不稳。把绝大多数预算放在通过率 10% 的区域,梯度噪声会非常大,训练可能不收敛。合理的配置是让"前沿桶"占据主要但非压倒性的份额(比如 40% 到 50%),并保留 30% 到 40% 在已经掌握的桶(这些样本的梯度小但方差也小,起到稳定器作用),剩下给边界外的探索。稳定器的作用经常被低估——高噪声梯度过半的训练后期会持续掉点。
六、按领域与难度分桶采样:把配比从拍脑袋变成可计算
6.1 分桶的两个维度:领域与难度不能混为一谈
领域是任务的主题(法律、医疗、代码、客服),难度是任务的复杂度。两者是正交的,但在实践中经常被合并处理——最常见的是"按领域分桶,每桶内按通过率排序取 top"。这种做法的问题在于:某个领域如果整体太难,取 top 就等于全取;某个领域如果整体太简单,top 里全是无信息量的样本。领域内的难度差异被桶的边界吃掉了。
正确做法是二维分桶:先按领域切,再在领域内按难度切。桶的粒度要足够细,让"领域 × 难度"组合的样本量足以做统计判断。一个粗略的规模参考是每个桶至少 200 到 500 条有效样本,否则桶内通过率估计的置信区间会宽到无法指导采样决策。
6.2 温度化重采样:为什么要"不自然"的分布
最直接的配比策略是"按真实业务分布采样"。这个策略在第一轮是对的——它让模型先适应真实分布。但它有一个致命问题:真实分布里高价值区域的样本量往往不足以支撑学习。一个真实业务里,只有 3% 的工单需要三轮以上工具调用,模型永远学不会;而 60% 是一句话的简单查询,模型学 100 遍也基本不涨。
有效做法是温度化重采样。设桶 b 的真实占比为 p_b,我们实际采样比例为 q_b,则:
q_b proportional to p_b ** (1 / tau) # tau < 1 时更均匀,tau > 1 时更集中
# tau = 1.0 时,采样分布等于真实分布
# tau = 0.5 时,q_b proportional to p_b^2,长尾桶被进一步压缩
# tau = 2.0 时,q_b proportional to sqrt(p_b),长尾桶被显著放大
# tau -> inf 时,趋于均匀分布,所有桶被平均对待
import math
def tempered_sampling_weights(bucket_stats, tau: float) -> dict:
"""bucket_stats: {bucket: {"prior": 真实占比, "value": 训练价值分}}"""
num = {}
for b, s in bucket_stats.items():
prior = max(s["prior"], 1e-9)
num[b] = prior ** (1.0 / tau)
total = sum(num.values())
return {b: v / total for b, v in num.items()}
def value_adjusted_weights(bucket_stats, tau: float,
kl_cap: float = 0.35) -> dict:
"""在温度化之后再按训练价值调整,并用 KL 约束限制偏离真实分布的程度。
纯价值驱动的分配会产出不自然的分布(某些桶被完全排除),
部署时出现 OOD 崩塌。KL cap 是这个问题的安全阀。
"""
base = tempered_sampling_weights(bucket_stats, tau)
# 价值分归一化到 [0.5, 1.5],避免价值项完全压过先验
vals = [s["value"] for s in bucket_stats.values()]
lo, hi = min(vals), max(vals)
span = (hi - lo) or 1.0
raw = {}
for b, w in base.items():
v = bucket_stats[b]["value"]
v_norm = 0.5 + (v - lo) / span # [0.5, 1.5]
raw[b] = w * v_norm
total = sum(raw.values())
adjusted = {b: v / total for b, v in raw.items()}
# 逐步向真实分布回拉,直到 KL 低于上限
for b in list(adjusted):
adjusted[b] = 0.5 * adjusted[b] + 0.5 * bucket_stats[b]["prior"]
t = sum(adjusted.values())
adjusted = {k: v / t for k, v in adjusted.items()}
kl = sum(p * math.log(p / q)
for p, q in zip(adjusted.values(), base.values()) if p > 0)
if kl <= kl_cap:
break
return adjusted
这里的 KL 约束值得专门说明。纯价值驱动的配比会产生一个看起来合理、实际上很危险的分布:它会自然地把低价值桶压到接近零。而低价值桶正是模型在真实业务中一定会遇到、但因为"训练时没见过所以表现差"的部分。这在评测上不会暴露(因为评测也来自训练分布的近似),只会在部署时暴露。KL 上限强制分配结果不能离真实分布太远,把这部分风险显式地管理起来。
6.3 分层批次:比全局配比更有效的做法
全局配比(决定整个数据集的构成)是一个正确的层次,但它不够。原因是梯度估计的方差:即使全局比例是 30% 对某个桶,这个桶的样本在每个 mini-batch 里的出现仍然是随机的,高方差会让该桶的学习信号被噪声淹没。
分层批次(stratified batching)的做法是让每个 batch 内部就满足配比:比如每 256 条的 batch,固定包含 40 条来自"代码 × 高难度"、30 条来自"客服 × 中难度",等等。剩余的名额留给按采样随机抽取的部分。
这个做法的收益是双重的:它降低了梯度方差,同时它还消除了一个隐蔽的问题——batch 级的分布偏移。在随机成批的设置下,模型会在某些 batch 上看到偏斜的分布并做出对应的适应,这本身是一种噪声来源。分层批次把这种噪声移除了。
分层批次的代价是它需要预先确定桶的枚举,并对每个 batch 做构造。这个实现成本通常被高估了——本质上就是按配额切分再打乱,一个 batch 的构造开销在现代数据加载器里可以忽略。真正需要注意的是桶的数量不能太多,否则单个桶在 batch 中的样本量不足,统计上失去意义。经验上控制在 10 到 30 个活跃桶。
6.4 配比的动态调整:从静态表到控制器
静态的配比表在第一轮之后就开始失效,因为它没有反映模型能力的变化。合理的闭环应该是每轮根据以下信号调整桶权重:
- 通过率。通过率跌到接近 0 的桶说明当前难度超纲,应降权并安排重新探测;通过率长期接近 1 的桶应降权到重放水平。
- 样本量与边际收益。同一个桶在连续几轮里加入更多样本但真实 held-out 分数不再提升,说明这个桶已经饱和,应把预算转移走。这个"边际收益"信号比通过率更能指导预算分配,因为它直接测量的是投入产出比而不是状态。
- 结构覆盖缺口。即使某个桶的边际收益为零,只要它对应的任务形态在整体能力画像里是空白,也应该保留最低样本量继续采样。因为能力画像上的空白往往是离线的,测不出来。
把这三条写成调整规则时,有一个原则值得强调:降权比升权容易做,瓶颈在升权。系统天然会向已经表现好的桶倾斜(因为那里收益可见),结果高价值但难做的桶长期拿不到预算。解决办法是给"待突破桶"一个保底配额——按历史的最差表现而不是当前的表现来决定保底,从而避免陷入局部最优。这本质上是一个带探索项的多臂老虎机问题,合成数据流水线本质上就在做 bandit 决策,只是大多数实现退化成了贪心。
七、错误注入与对抗样本:主动制造模型的盲区
7.1 为什么必须自己造错
前面几节的隐含假设是"验证器能筛出正确的"。这一节处理相反的方向:把模型的错误系统性地收集、分类、放大,变成训练资产。
被动地收集错误(等模型在验证中失败,然后看失败样本)有一个根本缺陷:它只能收集到模型当前能力范围内的错误,也就是它已经知道自己不会的那些。真正危险的错误是模型不知道自己不会的——它会自信地、流畅地、错误地完成。这类错误在验证失败列表里是看不见的,因为验证器可能也无法判定它是错的(错误本身就是合理的),或者它恰好通过了某个宽松的检查。
所以对抗样本必须是主动构造的:先想清楚"模型可能在哪里错、为什么会错、错误长什么样",然后针对性地造。这本质上是把安全领域和鲁棒性领域积累多年的红队方法论,搬到通用能力训练上。
7.2 构造原则:最小编辑距离 + 单一受控变量
构造对抗样本时最重要的一条工程原则是:每次只改一个受控变量。一次编辑引入多个变化(换措辞 + 换数字 + 换约束),你会得到一个"模型答错"的样本,但你不知道它错在哪个变化上,训练信号因此是模糊的。
具体到做法:
- 保持原题不变,只改答案。生成一个自信但错误的解答,用于训练识别能力。
- 保持原题不变,只加一个干扰条件。比如加一个"如果 X 不成立怎么办",或者把一个已给出的条件改成矛盾的。
- 保持答案不变,改问题使其答案失效。这类样本特别有价值,因为它训练的是模型对输入条件的敏感性。
- 只改一个数值或实体。用"扰动测试"的思路:把题面里的某个数字改掉,看模型的答案是否随之改变。不随之改变的(哪怕它原本是对的)就是答案锚定在错误位置,这是一个非常隐蔽的失效模式。
在代码领域,最小编辑距离这一条尤为关键且尤为容易被破坏。生成"有 bug 的代码"时,如果让模型自由发挥,它会写出一段风格完全不同、结构完全不同的错误代码——这种样本几乎不提供信息,因为它和正确代码没有任何共享结构。正确做法是从正确代码出发,做单点修改(改一个运算符、删一个边界检查、引入一个 off-by-one),这样模型必须精确定位到那一处。
7.3 五类构造手法及其适用场景
7.3.1 变异型:受控扰动,测稳定性
手法:保持语义不变,改变表述、语序、冗余信息、格式。用途是训练模型对表面变化的鲁棒性,防止它学到表面特征而非语义。
这个方向的合成数据有一个隐蔽的负面作用需要注意:过度训练会导致模型对输入变化过度不敏感,这在需要精确性的任务上是有害的(比如把"仅限 A 类"改成"仅限 B 类"时,模型学会了忽略这些修饰语)。所以变异型数据的比例需要谨慎控制,并且变异的层次要区分——句式变异是好的,条件变异是坏的。
7.3.2 矛盾注入:测冲突检测
手法:给出一道本身自洽的题,然后在题面中植入一个与前提冲突的条件、或者两个互相矛盾的要求。用途是训练模型识别不可满足的输入并说明原因,而不是硬着头皮给出一个答案。
这类数据在真实业务里的价值极高,因为用户输入本来就充满矛盾和自相冲突的要求。一个能够识别"这两个要求没法同时满足"的助手,比一个总是选一个条件硬做的助手有用得多。而且这类能力很难从正向数据中学到——正向数据里几乎没有"检测到矛盾"这个行为模式,因为正向样本都是可解的。
7.3.3 缺陷注入:测错误检测
手法:注入特定类型的缺陷,让模型定位并修正。用途是训练 critique 能力和错误定位能力。适用领域:代码(日志、计算、逻辑缺陷)、数据(一致性、异常值)、文档(事实错误、逻辑断裂、引用失效)、方案(步骤缺失、约束违背)。
这一类是可以做到高度可验证的:代码缺陷可以用测试用例判定修没修好,数据缺陷可以用校验规则判定,文档缺陷可以用事实核验判定。相比正向生成,缺陷注入的验证成本通常更低,因为它把问题转化成了一个二分的判定。这是它比其他类别更容易工业化的原因。
7.3.4 分布外构造:测泛化边界
手法:把已知领域的问题跨域迁移(法律条文用在生物医药上、数学结构用在语言学问题上),或者用新出现的概念/术语/格式。用途是训练模型在遇到陌生输入时不要套用错误的先验。
关键要求是:跨域迁移必须保持任务的内在结构,只换表面领域。如果连结构也变了,那测的是完全不同的能力。这类构造的判定标准是"人类专家会认为这是个合理的问题",因此它对质量的要求高于其他类别,也更难大规模生成。
7.3.5 越界与拒答:测边界识别
手法:构造应当被拒绝处理的请求——超出能力范围、涉及有害内容、要求编造不存在的引用、要求处理机密。用途是训练正确的拒答和转介行为。
这类数据有个特殊的要求:它必须有极高的精度。拒答数据的误标代价是双向的——把该答的标成拒答,会让模型变得无能且招人烦;把该拒的标成应答,则直接是安全问题。而且这两类错误在自动验证中都不容易发现。实践上的处理是:这类数据的生成可以不规模化(几千到几万条通常已经足够),但人工审核的比例必须接近 100%。这是合成数据里最不适合走全自动流水线的一类。
7.4 负样本的用法:别把它们当垃圾扔掉
负样本在训练中的用法比正样本更需要设计,因为它有多种互不兼容的使用方式,混用会直接有害:
- 不要用负样本做 SFT 负标签。把错误答案喂给 SFT 并打上"这是错误"的标签,在多数实现里会污染梯度。SFT 没有"负标签"这个概念。
- 负样本的正确用途是构造偏好对。同题的正负配对用于偏好优化,或者用 critiques 构造修正目标。
- 用于训练验证器/判别器。这是被严重低估的用途。合成流水线里的验证器需要训练数据,而你手头正有大量带失败类型标注的负样本——这是你独有的、别人拿不到的数据优势。用它训练判别器,比用通用偏好数据训练有效得多。
- 用于训练 critique 能力。在 RLVR 框架下,负样本是奖励信号的主要载体,而不是被丢弃的废料。
经验上,负样本的长期价值往往高于同源的少量正样本。正样本可以让模型做对某道题,负样本可以让模型知道"这类错误是我会犯的"。后者对泛化的贡献更大。
7.5 一段对抗样本构造与配对的实现
下面这段代码实现的是"最小编辑 + 单一受控变量"的构造思路,并做正负配对。它刻意把变更操作做成显式的、可追溯的记录,因为对抗样本的可解释性直接决定了它能不能被人工审核——而这类数据恰恰是最需要人工审核的。
import difflib
import random
from dataclasses import dataclass, field
# 受控编辑操作。每一个操作只改一个语义维度,便于归因。
EDIT_OPS = {
"negate_condition": "翻转题目中的一个条件约束",
"swap_entity": "把关键实体替换为同类但不同的实体",
"perturb_number": "对某个数值做局部扰动",
"add_contradiction": "加入与既有前提冲突的条件",
"weaken_constraint": "把一个硬约束改成软约束",
"remove_prerequisite": "删除推理所需的前置条件",
}
@dataclass
class AdversarialPair:
task_id: str
domain: str
good_question: str
good_answer: str
bad_question: str
bad_answer: str
injected_defect: str = "" # 注入的缺陷类型
edit_distance: float = 0.0 # 编辑距离占原文长度的比例
control_ops: list = field(default_factory=list) # 对照组操作
def make_adversarial(task_id, domain, question, answer, defect_type,
rng: random.Random) -> AdversarialPair:
"""从一条正确样本派生一条对抗样本。
关键约束:只做一次受控编辑,且编辑后的样本必须仍然
"看起来像一个合理的问题" —— 否则模型只需识别题面异常,
训练信号就浪费了。
"""
if defect_type not in EDIT_OPS:
raise ValueError(f"unknown defect type: {defect_type}")
# 这一步需要真实的编辑算子实现;此处保留接口与约束校验
bad_q, bad_a = _apply_edit(question, answer, defect_type, rng)
dist = difflib.SequenceMatcher(None, question, bad_q).ratio()
edit_distance = 1.0 - dist
pair = AdversarialPair(
task_id=task_id,
domain=domain,
good_question=question,
good_answer=answer,
bad_question=bad_q,
bad_answer=bad_a,
injected_defect=defect_type,
edit_distance=edit_distance,
)
# 编辑距离过大说明改得太狠,模型只需靠表面异常识别,不提供信息量
if edit_distance > 0.35:
pair.injected_defect = "TOO_LARGE_EDIT"
return pair
def audit_pairs(pairs: list[AdversarialPair],
human_label_fn=None) -> dict:
"""上线前的质量闸门。人工标注函数存在时用它做二次过滤。"""
stats = {
"total": len(pairs),
"too_large_edit": 0,
"defect_hist": {},
"domain_hist": {},
}
good_pairs = []
for p in pairs:
stats["defect_hist"][p.injected_defect] = \
stats["defect_hist"].get(p.injected_defect, 0) + 1
stats["domain_hist"][p.domain] = stats["domain_hist"].get(p.domain, 0) + 1
if p.injected_defect == "TOO_LARGE_EDIT":
stats["too_large_edit"] += 1
continue
if human_label_fn is not None:
verdict = human_label_fn(p)
# verdict: "valid" | "defect_not_real" | "question_broken"
if verdict != "valid":
p.injected_defect = f"REJECTED_{verdict}"
continue
good_pairs.append(p)
# 缺陷类型分布检查:任何单一缺陷占比过高说明生成有偏
if good_pairs:
hist = {}
for p in good_pairs:
hist[p.injected_defect] = hist.get(p.injected_defect, 0) + 1
n = len(good_pairs)
top_defect, top_cnt = max(hist.items(), key=lambda kv: kv[1])
if top_cnt / n > 0.5:
print(f"[WARN] 单一缺陷类型 {top_defect} 占比 {top_cnt/n:.0%},"
f"对抗数据存在构造偏差")
return stats, good_pairs
这段代码里最值得强调的是 audit_pairs 中的两道闸门。第一道是编辑距离闸门:它拦住"改得太狠"的样本。这类样本的危害在于它们教会模型一个捷径——识别题面异常,而不是识别推理缺陷。模型会因此在对抗测试上表现优异,在真实对抗上毫无抵抗力。第二道是人工标注闸门:defect_not_real 意味着注入的缺陷实际上不构成缺陷(比如原答案在新条件下依然成立),这类样本如果留在训练集里,就是明确的错误标签。
需要承认的是,这两道闸门都很贵。所以对抗样本的构造不适合走全自动化大规模生产的路线,它的正确形态是小批量、强审核、按缺陷类型定向补齐——用一个真实失败案例去定义需要哪一类缺陷,然后集中造这一类。这种"按需构造"的效率远高于盲目大规模生成。
八、合成数据与真实数据的混合配比
8.1 三个阶段,配比完全不同
不存在一个通用的最优配比,因为配比的最优值随训练阶段剧烈变化。把它当常量是实践中最常见的错误之一。
冷启动阶段(SFT 冷启)。目标不是能力上限,而是基本行为对齐。此时真实数据应当占绝对多数,建议不低于 50%,常见做法是 70% 以上。理由是:冷启动阶段的失败模式往往不是"不会某个任务",而是"不会按你要求的方式回应"——格式、语气、结构、拒绝的方式。合成数据在这个阶段几乎无法提供帮助,因为它的行为分布是从模型自己的倾向里采样出来的,本质上是在固化模型当前的偏好,包括它不该有的那些。
能力扩张阶段。这是合成数据价值最高的窗口,模型需要大量在能力边界上的练习。此时合成数据可以占到 60% 到 80%。但这个比例是分领域差异极大的:验证信号强、真实数据稀缺的领域(数学推导、代码执行验证、结构化任务)可以更高;依赖真实世界知识和长尾表达的领域(开放问答、客服对话、创意写作)合成比例应该显著更低,因为这类任务没有可靠的验证器,合成数据的质量上限被验证器锁死。
收敛与校准阶段。合成数据比例应该回落到 30% 以下,剩下的用真实数据做分布校准。这一步经常被跳过,代价是模型在真实场景上的分布偏移持续存在——每加一轮合成数据,模型就更贴近合成数据的分布,离真实分布更远一点。这个漂移是累积的、单调的,不做校准不会自己消失。
一个反复被验证的经验:合成数据适合用来"学会一个新能力"或"把一个能力练到稳定",不适合用来"保持已经具备的能力"。保持能力这件事,真实数据的效率显著更高,因为真实数据携带了模型自己无法生成的那些分布特性。
8.2 混合不是拼接:三个必须分开的操作
配置配比时最常见的混淆是把三个独立的决策压成一个数。它们需要分别设定:
- 采样比例:数据加载器里各来源出现的频率。影响的是梯度估计的构成。
- 样本权重:每条样本对损失的贡献倍率。影响的是相同信号被放大的程度。采样比例和权重必须同时使用才能精确控制:
实际贡献 = 采样比例 × 权重。当你想微调某个桶的贡献但不希望改变 batch 的构成时,单独调权重是正确做法。 - 清洗与保留率:真实数据在进入混合前被保留的比例,以及是否被同样过滤。这一项经常被忽略,但它决定了真实数据里那些"看起来像噪声"的样本(口语化、拼写错误、非常规表达)是被保留还是被清掉。这些样本恰恰是模型在真实场景中最需要处理的。
特别提醒第三项。合成数据通常来自 prompt 池,天然是"干净"的;而真实数据里有大量"不干净"但完全有效的样本。流水线如果为了统一而对真实数据施加与合成数据相同的清洗标准(长度截断、格式规范化、去重阈值),会系统性地削掉真实分布的长尾。结果是模型在"标准化"输入上表现好,在真实输入上表现差,而后者才是线上情况。
8.3 配比的验证:必须在真实 held-out 上做
配比调优的一个常见错误是用"合成分布上的评测集"来做验证。如果评测集也来自同一个生成过程和 prompt 池,那么不同配比的方案在它上面的差异会严重失真——因为增加合成数据比例会同时增加训练和评测对同一批分布的拟合。
正确的验证协议:
- 评测集必须来自真实分布,且在流水线开始前就固定下来,永不参与任何生成或筛选。
- 评测集要分层,不能只看总分。总分掩盖了结构短板:综合分可能持平,但工具调用轮次长尾的分数掉了 10 分。合成数据配比调整最容易引发的就是这类局部退化。
- 保留一个纯粹由人工撰写、规模很小(比如 200 到 500 条)、但覆盖结构多样性的评测集。它的作用是检测分布漂移——自动流程很难自己发现这种漂移,因为它用的还是同一套假设。
最后一条对应一个常被低估的机制:合成流水线的评测集会随流水线一起漂移。每轮从 prompt 池生成的题目都在强化流水线的固有偏好,评测集如果从 prompt 池抽样,也会逐渐向这个偏好对齐。这个过程很慢,通常要 4 到 6 轮才显现,但一旦显现,团队会误认为"指标在稳步上升,一切正常"。真正的信号只有真实 held-out 上的分数,而它通常已经下降了好几轮了。
九、流水线的评测与防退化:让合成数据可观测、可停止
9.1 三个环节各自需要独立的评测集
合成数据流水线的评测不能只有一套指标。按环节拆开来看,至少需要三类评测集,各自承担不同的职责:
- 生成评测集:衡量"生成出来的样本本身好不好",不涉及训练。它用来评估 prompt 池的质量、采样参数的合理性、以及生成模型是否需要更换。指标包括格式合规率、截断率、与 prompt 的语义一致度。
- 验证评测集:衡量"验证器判得准不准"。这一类最需要建设,也最常被省略。做法是人工标注一批(1000 到 5000 条量级即可)的验证结果作为标准答案,测量验证器的准确率、假阴性率(正确被判错,这会浪费好数据)、假阳性率(错误被判对,这会污染训练集)。其中假阳性率是致命指标,因为它直接决定金标的可靠性,而假阴性只是效率损失。
- 训练评测集:真实分布上的 held-out,衡量最终效果。这一类的要求在 8.3 已经说过——必须分层、必须固定、必须含人工小集。
这三者的关系是:验证评测集决定了生成评测集的可信度,生成评测集决定了训练评测集的归因清晰度。跳过第二类,等于在一个未校准的过滤器上构建整条流水线,出了问题无法归因——你分不清是生成得差还是验证判错了。
9.2 防退化的五个具体机制
9.2.1 验证器的独立审计
验证器同化(3.4 节)是闭环最危险的失效模式,因为它不会触发任何常规告警。防御手段有三个层次:
- 持续监控验证器与生成器的分数相关性。相关性上升是同化的早期信号。相关性突然下降也值得注意——通常意味着验证器出了 bug。
- 周期性换源。用外部来源的真实标注(哪怕是人工标注的少量高质量数据)持续更新验证器。注意不是重新训练,而是在验证器打分上加一个基于外部锚点的校正项,效果往往就够用。
- 保留一个"对抗验证器"。它的训练目标专门是识别主验证器会放过的问题(比如过于流畅但逻辑断裂的输出)。它的作用不是替代主验证器,而是持续给它提供负反馈。
9.2.2 数据指纹与重复检测
合成数据的一个特有风险是跨轮次的隐性重复。同一批 prompt 在多轮迭代中被反复使用,模型最终会被训练在这些题目上——而因为每轮采样有随机性,表面上看起来是"新数据",实际是同一批题的重采样。这会造成训练指标虚高、真实泛化停滞,而且很难被发现,因为它不违反任何已有的监控项。
检测方法是维护一个跨轮次的指纹库:每条入池样本记录精确指纹(归一化文本哈希)、语义指纹(嵌入向量的粗量化)、以及来源指纹(prompt 池 ID + 变体 ID)。每轮入库前做三级查重。来源指纹这一级尤其有用,它能直接告诉你"这一轮有多少比例是上一轮题目的重采样"——这个数字如果超过 50%,基本可以判定这一轮没有产生新的信息。
9.2.3 版本与谱系管理
合成数据必须像代码一样管理。具体要求:
- 每条样本记录完整的生成谱系:prompt 池版本、生成模型版本、采样参数、验证器版本、轮次 ID。
- 数据集本身版本化,每个版本冻结一个不可变的样本 ID 集合。
- 训练运行记录它使用的精确数据集版本和 commit hash。
- 任何指标异常都能在几分钟内回溯到"是哪批数据、哪个验证器、哪组参数"导致的。
这一条看起来像工程洁癖,实际是防退化的核心能力。合成流水线的迭代速度很快,没有谱系追踪时,指标异常几乎无法归因,团队最终只能靠试错,而试错在多变量同时变化的流水线里成功率很低。
9.2.4 人工注入:对抗漂移的固定锚点
每轮必须注入一定量的人工撰写样本,来源是:线上真实失败案例、系统性失败的领域、以及领域专家指出的知识盲区。这部分数据的价值不在于规模(通常占比 5% 到 10% 就够),而在于它们是唯一不可能被流水线自身偏好同化的分布。它们是整个系统的免疫系统。
实践建议是把人工样本做成一个长期维护的集合,每轮只增补、不删除,并单独追踪它们在评测中的表现。如果连人工样本集合上的分数都在下降,那说明模型出现了基础能力退化,必须立即熔断。
9.2.5 熔断规则与停止条件
流水线的停止条件必须在开始前就定义好,否则每到该停的时候都会有"再试一轮"的诱惑。三个层次的停止条件:
- 单指标熔断:真实 held-out 分数连续两轮下降且降幅超过预设阈值(如 0.5 个点),暂停流水线,做归因分析。这条门槛可以设得比较敏感,误报成本低——只是多花一次分析时间。
- 多样性熔断:第四节代码里的任何一条塌缩报警触发,且在下一轮未恢复,暂停流水线。这类熔断比单指标熔断更早触发,因为塌缩通常在指标掉下来之前 2 到 3 轮就开始了。
- 边际收益熔断:连续三轮,同一桶的样本量翻倍但真实 held-out 分数变化在噪声范围内。这说明瓶颈不再是数据量,而在验证信号强度或基座能力上,继续加合成数据是浪费。
最后一条是三种熔断里最有价值的,因为它回答了"该不该继续"这个根本问题。很多团队在流水线进入平台期后仍在持续投入,因为"上一轮还有提升"。边际收益监控能把这条曲线显式化,让停止成为一个基于数据的判断而不是一个情绪判断。
9.3 一张熔断决策表
| 观测现象 | 最可能的原因 | 优先排查项 | 处置 |
|---|---|---|---|
| 通过率上升、真实 held-out 下降 | 分布塌缩,模型在拟合合成数据的分布 | 语义簇数、近邻距离分布、top 簇占比 | 提高温度、收紧簇上限、临时提高真实数据掺入比 |
| 通过率上升、真实 held-out 不变 | 验证器同化,闭环在自证 | 验证器分数与生成器置信度的相关性 | 引入异源验证器或外部校正项 |
| 通过率不变、真实 held-out 下降 | 难度前沿整体上移,采样分布没有跟随 | 各桶通过率分布、难度标签的滞后性 | 按 5.4 的方法上移分桶阈值并重新探测 |
| 某类任务分数突降、其余正常 | 结构覆盖缺口,分桶策略排除了该形态 | 结构熵分布、该结构的样本量与轮次趋势 | 为该形态单设保底配额,绕过价值驱动的配比 |
| 人工样本集合分数下降 | 基础能力退化,不是分布问题 | 真实数据比例、遗忘曲线、训练轮次 | 立即熔断,检查配比与重放机制 |
| 验证器准确率随轮次下降 | 任务分布漂移,验证器覆盖不足 | 验证评测集的分层得分、新出现的失败类型 | 扩充分层评测集,重训或轮换验证器 |
| 人工审核拒绝率高 | 合成质量下滑或构造偏差 | 各缺陷类型占比、编辑距离分布 | 按缺陷类型定向补齐,暂停有偏类型 |
9.4 什么情况下应该放弃合成数据这条路
有三种情况是合成数据方法论解决不了的,应该直接换路径:
- 任务没有可构造的验证信号。如果一个任务的正确性本质上是主观的、或者需要真实世界交互才能确认的,那么合成数据的质量上限被锁死在了验证器的上限。硬做只会产出一个"看起来对但系统性偏移"的模型。这类任务的正确做法是投入真实标注,用合成数据做数据增强(提供变体和近邻样本)而不是替代。
- 基座能力本身不足。如果模型在所有形式的合成数据上都无法超过某个水平,继续投合成数据是浪费。判断依据是边际收益熔断的曲线形状:早期上升、中期趋平、后期下降——下降段说明模型容量或基座质量是瓶颈。
- 分布长期右偏且无法约束。如果合成数据与真实数据的分布距离在增加干预下依然持续扩大,说明这个差距不是靠采样策略能弥合的,它来自生成模型的能力边界之外。此时合成数据只能用于任务形态的变体扩展,不能用于内容本身。
十、收束:一条可执行的主线
把整篇文章压缩成一条主线,它是这样的:
用合成数据造一个自我强化的闭环,用验证信号保证这个闭环的每一次迭代都真的在变好,用多样性约束和课程编排保证它不会走偏,用真实数据作为永不漂移的坐标系,用严格的停止条件保证它在无收益时及时停手。
如果只能记住六条,按重要性排序是:
- 金标的合法性来自判定信号,不来自生成者。预算紧张时,优先投资验证器而非生成器。
- 训练价值集中在能力边界上。通过率 0.1 到 0.5 的区间是合成数据投入产出比最高的位置,prompt 池必须随模型能力滚动更新。
- 正确不等于有用。通过验证的推理链中间可以藏着系统性错误,只看结论的验证器看不见它们。
- 塌缩是分布问题,不是正确性问题。必须监控语义覆盖和任务形态覆盖,只监控通过率会漏掉它。
- 配比是阶段性的,且三个轴要分开调。采样比例、样本权重、清洗保留率是三件不同的事。
- 停止条件必须由真实 held-out 定义,并前置决定。多样性塌缩通常比指标下降早 2 到 3 轮出现。
最后一句实践上的提醒:合成数据流水线是一个有状态的系统,它的退化是渐进的、沉默的、多轮的,而团队的注意力是集中的、片段的、被最新指标锚定的。这两者的错配是绝大多数合成数据项目最终失败的真正原因——不是因为哪一步做错了,而是因为没有人持续看着那些需要被持续看着的指标。因此,把诊断层做成常驻组件、而不是出问题时才搭的工具,是这条流水线上最容易被跳过、也最不该被跳过的一件事。