测试数据合成与用例自动生成
2026 年合成数据已经从“研究花活”成为评测体系的主力来源。Anthropic、OpenAI、Google 内部 70% 以上的评测集是合成的,DeepEval 3.4、distilabel 1.6、Argilla 2.5、LangSmith Synthesis API 把这套能力下放到了每个测试团队。这一篇把合成数据的方法论、五种策略、四道质量门、Failure-Driven 进化、多样性度量讲透,让你能从 200 条 Bad Case 反向扩增出 5000 条高质量评测集。
教学导读
定位: 这一章解决“人工写不动测试用例了”——业务长尾爆炸、模型迭代加速、评测集消耗速度远超人工补充速度。评测科学告诉你“怎么测得准”,本篇告诉你“测试集本身从哪来”。
适用场景: RAG 评测集扩增、Judge 模型训练数据合成、回归集补强、长尾场景补全、对抗样本生成、新业务冷启动评测。
学完产出: 你能用 Claude Opus 4.6 + Evol-Instruct 在 4 小时内合成 1000 条高质量 RAG 评测样本;能搭建 4 道质量门的合成流水线;能从线上 200 条 Bad Case 反向扩增到 5000 条;能写出可解释的多样性自动报告。
第1章:人工写测试用例的极限
1.1 一个真实的故事
2025 年第三季度,我所在的团队接了一个内部任务:为公司新上线的“政务咨询大模型”建立一套准入评测集。要求覆盖“民政 / 税务 / 社保 / 公积金 / 户籍 / 教育”六大领域,每个领域 500 条 QA,共 3000 条。我们安排了 4 个人,预估 3 周完成。结果做了 2 个月还没收尾——并不是写不出来,而是越写越写不动:
-
第 1 周:每人每天能写 30 条,质量很高,覆盖经典场景。
-
第 2 周:每天产出降到 15 条,因为开始反复纠结“这条和上周那条是不是重复了”。
-
第 4 周:每天 5-8 条,团队开始抱怨“想不出新的角度了”。
-
第 6 周:交叉检查时发现 1200 条里有 380 条主题严重重叠(Self-BLEU > 0.6),等于实际有效样本只有 820 条。
-
第 8 周:模型上线,第一周线上就出现了 17 类我们评测集没覆盖的失败模式。
这不是个案。2026 年大量企业在评测集建设上都遇到了同样的瓶颈:人工的“想象力”是有限的,而真实用户的“长尾”是无限的。
1.2 人工写用例的四个硬约束
| 约束 | 表现 | 对评测集的伤害 |
|---|---|---|
| 认知带宽有限 | 一个写手能稳定输出的“思路”约 50-80 种 | 样本主题集中,长尾覆盖差 |
| 人格风格固定 | 每个人的措辞、句式、专业术语偏好稳定 | 合成样本风格高度同质(Self-BLEU 高) |
| 盲点不可见 | “想不到的场景”本身就是想不到的 | 评测集和线上分布脱节,覆盖率虚高 |
| 速度跟不上模型迭代 | 2026 年主流模型 4-8 周一个版本 | 评测集来不及扩展,回归集逐渐过时 |
关键判断。
人工写测试用例不是“不能用了”,而是“性价比拐点过了”。2026 年合理的分工是:人写 10%-20% 的“种子用例”和“判别标准”,剩余 80%-90% 由 LLM 合成 + 人工抽检。这是 OpenAI Evals 团队、Anthropic Alignment 团队、Google DeepMind Eval 团队公开承认的现行做法。
1.3 合成数据 ≠ 偷懒
很多测试老人对“用 LLM 合成测试集”有抵触,理由是“用 LLM 测 LLM 是循环论证”。这种担心不能说错,但太宽泛——它把“合成”和“验证”两件事混在了一起。我们要厘清:
-
合成是数据生产:用 LLM 生成 input 和 reference output。生产环节用 LLM 没问题,因为 LLM 擅长“造样子”。
-
验证是结论判断:用 LLM 给被测模型打分。这个环节用 LLM 才需要小心,因为存在 Judge 偏见和泄露问题。
-
污染是数据流向:合成数据如果污染到训练集,就是循环作弊。
这一篇我们只关心“合成”。验证由 Judge 校准这章管,污染由 Benchmark 防控那章管。
第2章:2026 年合成数据的三个时代
合成测试数据不是 2026 年才出现的,但它的方法论在最近三年发生了两次代际跃迁。理解这条演化线,才能理解为什么 2026 年的“主流方法”是现在这套。
2018-2022 · 启发式时代 → 2023-2024 · LLM 自合成时代 → 2025-2026 · Failure-Driven 进化时代
2.1 启发式时代(2018-2022)
这个阶段以“模板填空”和“规则扩增”为主。典型代表是 NLP 时代的 EDA(Easy Data Augmentation)、Back Translation、TextAttack。它们的共同特点是:
-
不需要 LLM,靠词表替换、回译、句法变换。
-
样本“换汤不换药”,语义同质化严重。
-
对分类、抽取等“短任务”有效,对 LLM 评测基本无效。
2022 年 ChatGPT 出现后,这套方法快速被淘汰——因为它生成的样本对 GPT-3.5 都构不成挑战。
2.2 LLM 自合成时代(2023-2024)
2022 年底 Self-Instruct 论文发布,开启了“用 LLM 生成 LLM 训练数据”的范式。2023 年 WizardLM 把 Evol-Instruct 推到工业级。代表特征:
-
用 GPT-4 / Claude-2 作为“合成器”,从少量 seed examples 扩增到几万条。
-
Evol-Instruct 引入“难度进化”,让样本在质量和难度上更好。
-
Open-Source 阵营(Vicuna、WizardLM、OpenChat)完全靠合成数据训练,性能逼近商业模型。
这个时代的局限是:合成的方向是“随机扩增”或“难度提升”,但不知道被测系统真正在哪儿薄弱。结果就是产出了大量“有难度但不相关”的样本——评测集再大,也漏了真正会出 bug 的角落。
2.3 Failure-Driven 时代(2025-2026)
2025 年是分水岭。Anthropic 在内部 alignment 评测中提出“Failure Mining → Synthesis Loop”流程,OpenAI 在 evals v2 中引入“Adversarial Synthesis”,Google DeepMind 推出 SCALE 框架。这些方法的共同思想是:
核心范式。
不要凭空合成 → 先收集失败 → 抽象失败模式 → 围绕失败模式定向扩增 → 闭环回归。
合成数据的价值不是“多”,而是“打到模型真正会死的点上”。这就把数据合成从“通用任务”变成了“针对被测系统的逆向工程”。
| 时代 | 代表方法 | 合成方向 | 主要痛点 |
|---|---|---|---|
| 启发式(2018-2022) | EDA / Back Translation | 词表/句法扩增 | 语义同质,对 LLM 无效 |
| LLM 自合成(2023-2024) | Self-Instruct / Evol-Instruct / WizardLM | 种子扩增 / 难度进化 | 不知道被测系统真正薄弱在哪 |
| Failure-Driven(2025-2026) | Failure Mining + Adversarial Synthesis + Curriculum | 定向扩增模型弱点 | 需要真实失败语料 + 抽象能力 |
2.4 2026 年工具栈现状
当前主流工具栈如下,按“成熟度 + 在企业场景的实际部署量”排序:
| 工具 | 版本 | 定位 | 场景 |
|---|---|---|---|
| DeepEval Synthesizer | 3.4 (2026-02) | 开箱即用的合成 + Judge 一体化 | RAG / Agent / 通用 QA |
| distilabel | 1.6 (2026-01) | Argilla 出品,pipeline 编排很强 | Self-Instruct / Evol-Instruct / DPO 数据 |
| Argilla | 2.5 (2026-03) | 合成 + 人工标注混合工作流 | 大规模团队协作标注 |
| LangSmith Synthesis API | 2026-01 GA | 从线上 trace 自动反向合成 | 已有 LangSmith 的团队首选 |
| Promptfoo Synthesis | 0.95 (2026-03) | YAML-driven,工程师友好 | CI 集成的合成工作流 |
| Anthropic Workbench Synthesis | 2026-02 beta | Claude 原生合成接口 | 已绑定 Claude 的团队 |
这一篇我们重点用 DeepEval 3.4 + distilabel 1.6 + 自写脚本三套组合。前两者是工具,自写脚本是为了让你理解“工具下面到底在做什么”——脱离原理调工具是要踩坑的。
第3章:Self-Instruct / Evol-Instruct / WizardLM 方法论原理
3.1 Self-Instruct 经典 4 步流程
Self-Instruct(Wang et al., 2022)是合成数据的奠基范式。它解决的问题是:“我手上只有 50 条 seed 用例,怎么扩增到 50000 条?”流程如下:
Step 1 · Seed Pool → Step 2 · Instruction Generation → Step 3 · Instance Generation → Step 4 · Filter & Deduplicate
-
Step 1 · 准备 Seed:人工准备 50-200 条高质量种子样本。每条包含 instruction + input + output。
-
Step 2 · 生成新 Instruction:从种子池采样 6-8 条作为 demo,让 LLM“按照同样的风格再写 8 条新指令”。这一步只生成 instruction,不生成 input/output。
-
Step 3 · 生成 Input/Output:对每条新 instruction,让 LLM 补全对应的 input(如果适用)和 output。
-
Step 4 · 过滤与去重:用 ROUGE-L > 0.7 过滤和已有指令过于相似的;用规则过滤过短/过长/包含敏感词的;最终入池。
下面是 Self-Instruct 的最小可运行实现:
python
"""
self_instruct_min.py
最小化 Self-Instruct 实现,用 Claude Opus 4.6 作为合成器
"""
from anthropic import Anthropic
import json, random, re
from rouge_score import rouge_scorer
client = Anthropic()
scorer = rouge_scorer.RougeScorer(['rougeL'], use_stemmer=False)
GEN_INSTRUCTION_PROMPT = """你是测试用例合成器。下面是 8 条客服领域的种子测试用例:
{demos}
请仿照上面的风格和领域,生成 8 条新的、不重复的测试指令(只写 instruction,不写 input/output)。
要求:
1. 每条与上面 8 条主题不同
2. 难度梯度合理(easy/medium/hard 各 2-3 条)
3. 用编号列表输出:1. ... 2. ... 8. ...
"""
GEN_INSTANCE_PROMPT = """请为下面的客服测试指令生成 1 个具体的 input 和 reference_output:
指令: {instruction}
输出 JSON:{{"input": "...", "reference_output": "..."}}
"""
def gen_new_instructions(seed_pool, n_demos=8):
demos = random.sample(seed_pool, min(n_demos, len(seed_pool)))
demo_text = "\n".join(f"{i+1}. {d['instruction']}" for i, d in enumerate(demos))
resp = client.messages.create(
model="claude-opus-4-6",
max_tokens=2048,
messages=[{"role": "user",
"content": GEN_INSTRUCTION_PROMPT.format(demos=demo_text)}],
)
text = resp.content[0].text
pattern = re.compile(r'^\s*\d+[\.\)]\s+(.*)$', re.MULTILINE)
return [m.strip() for m in pattern.findall(text)]
def gen_instance(instruction):
resp = client.messages.create(
model="claude-opus-4-6",
max_tokens=1024,
messages=[{"role": "user",
"content": GEN_INSTANCE_PROMPT.format(instruction=instruction)}],
)
text = resp.content[0].text
try:
m = re.search(r'\{.*\}', text, re.DOTALL)
return json.loads(m.group(0)) if m else None
except json.JSONDecodeError:
return None
def is_too_similar(new_inst, pool, threshold=0.7):
for p in pool:
score = scorer.score(p["instruction"], new_inst)["rougeL"].fmeasure
if score > threshold:
return True
return False
def self_instruct_loop(seed_pool, target_n=500):
pool = list(seed_pool)
while len(pool) < target_n:
new_insts = gen_new_instructions(pool, n_demos=8)
for inst in new_insts:
if is_too_similar(inst, pool):
continue
instance = gen_instance(inst)
if not instance:
continue
pool.append({"instruction": inst, **instance})
print(f"[{len(pool)}/{target_n}] {inst[:60]}...")
if len(pool) >= target_n:
break
return pool
if __name__ == "__main__":
with open("seed.jsonl") as f:
seeds = [json.loads(l) for l in f]
new_pool = self_instruct_loop(seeds, target_n=500)
with open("synthesized.jsonl", "w") as f:
for s in new_pool:
f.write(json.dumps(s, ensure_ascii=False) + "\n")
3.2 Self-Instruct 的局限
Self-Instruct 的本质是“等量横向扩增”——它能保证主题分布广,但不能保证:
-
难度提升:合成出来的样本难度大致和种子持平,不会自动变难。
-
对抗性:合成不会主动攻击模型的薄弱点。
-
语义多样性:表面措辞可以变,但深层语义可能高度重复。
这就引出了 Evol-Instruct。
3.3 Evol-Instruct 五种进化操作
Evol-Instruct(Xu et al., 2023, WizardLM)提出“指令进化”思想:拿一条现有指令,用 LLM 把它“进化”成更难、更深、更复杂的版本。一共有 5 种进化操作:
(1) Add Constraints · 增加约束
在原指令上叠加额外约束。例如“写一段产品介绍” → “写一段不超过 80 字、必须包含 3 个数据点、不允许使用形容词的产品介绍”。
(2) Deepen · 深化
把原指令的某个概念替换为更具体、更专业的版本。“解释一下机器学习” → “解释一下随机森林中 Gini impurity 的数学定义和优化路径”。
(3) Concretize · 具体化
把抽象指令落到具体场景。“写一封投诉信” → “写一封针对某航空公司因机械故障取消我从北京到旧金山航班 8 小时未给出补偿方案的投诉信”。
(4) Increase Reasoning · 推理加深
把单步任务变成多步推理。“计算 25 × 17” → “如果一辆车每小时跑 25 公里,跑 17 小时后又增速到原来 1.5 倍跑了 8 小时,总共跑了多远?”
(5) Breadth · 广度扩展(横向变种)
不增加难度,而是从原指令派生一个相同难度但完全不同领域的新指令。这是为了避免领域过窄。
实现一个 Evol-Instruct 进化器:
python
"""
evol_instruct.py
Evol-Instruct 5 种进化操作的统一接口
"""
from anthropic import Anthropic
import random, json
client = Anthropic()
EVOL_PROMPTS = {
"add_constraints": """你的任务是改写下面的指令,让它变得更难——通过加入额外约束(字数、格式、风格、必含元素、禁用元素等)。
不要改变核心任务。改写后的指令应该是任何普通人或模型都更难完成的版本。
原指令: #ORIG#
改写后:""",
"deepen": """改写下面的指令,把其中的关键概念替换为更专业、更技术性的版本。
要求:内容方向保持一致,但深度显著增加,需要专业知识才能回答。
原指令: #ORIG#
改写后:""",
"concretize": """改写下面的指令,把它从抽象表述变成具体场景。
加入:具体人物、具体时间、具体数字、具体地点、具体冲突。
原指令: #ORIG#
改写后:""",
"increase_reasoning": """改写下面的指令,让它需要多步推理才能回答(至少 3 步逻辑推断)。
保留原任务方向,但把它变成需要分阶段推理的形式。
原指令: #ORIG#
改写后:""",
"breadth": """请基于下面的指令,生成一条相同难度、但完全不同主题/领域的新指令。
新指令的"任务类型"应与原指令相同(如都是问答、都是总结、都是改写),但话题领域无关。
原指令: #ORIG#
新指令:""",
}
def evolve(instruction, op=None):
op = op or random.choice(list(EVOL_PROMPTS.keys()))
prompt = EVOL_PROMPTS[op].replace("#ORIG#", instruction)
resp = client.messages.create(
model="claude-opus-4-6",
max_tokens=512,
messages=[{"role": "user", "content": prompt}],
)
return {"op": op, "evolved": resp.content[0].text.strip()}
def evolve_pool(seed_pool, rounds=3):
"""对每条种子做 N 轮进化,每轮随机选一种操作"""
evolved = []
for s in seed_pool:
cur = s["instruction"]
for r in range(rounds):
result = evolve(cur)
evolved.append({
"origin": s["instruction"],
"round": r + 1,
"op": result["op"],
"evolved": result["evolved"],
})
cur = result["evolved"]
return evolved
if __name__ == "__main__":
with open("seed.jsonl") as f:
seeds = [json.loads(l) for l in f][:100]
evolved = evolve_pool(seeds, rounds=3)
with open("evolved.jsonl", "w") as f:
for e in evolved:
f.write(json.dumps(e, ensure_ascii=False) + "\n")
print(f"共合成 {len(evolved)} 条进化指令")
3.4 Evol-Instruct 实战经验
三个反直觉的经验。
不要进化太多轮:超过 3 轮后,指令开始“过拟合到模型的进化偏好”——表现为冗长、绕弯、脱离实际场景。建议 max rounds ≤ 3。
每轮换一种操作:连续两轮都用 “add_constraints” 会让指令变成约束堆砌的怪物。应让 5 种操作按 round-robin 或随机轮流。
需要“消化”步骤:进化完后让另一个模型(如 GPT-5)“试着回答”,如果连合成器自己都答不好,这条样本应该剔除——它是合成噪音,不是真正的难题。
3.5 WizardLM 的总流程
WizardLM 把上面两套结合,形成完整流程:
Seed (人工) → Self-Instruct 横向 → Evol-Instruct 纵向 → Elimination Filter → Quality Scoring → 入池
“Elimination Filter”包含三种过滤:长度过短/过长、合成器自己拒答、和已有样本过相似。这套组合在 2023-2024 年被几乎所有开源模型团队采纳。
第4章:Failure Mode Mining 原理
4.1 什么是失败模式挖掘
Failure Mode Mining(失败模式挖掘)是 2025 年起逐渐成熟的方法论。它的核心假设是:真实失败 = 高价值合成种子。一条线上 Bad Case 的信息密度,远超人工想出来的 100 条假设性测试。
它分三步:
-
Collect:从线上日志、用户反馈、Judge 评测低分样本中收集 Bad Cases。
-
Cluster & Abstract:用 embedding 聚类,把 Bad Cases 归纳成 N 个失败模式,每个模式有清晰的“病因描述”。
-
Synthesize Around Modes:围绕每个失败模式做定向合成,生成更多同类型变体。
4.2 失败信号的来源
| 信号源 | 采集方式 | 典型质量 | 规模 |
|---|---|---|---|
| 用户主动反馈(“踩”按钮) | 埋点 + 反馈文本 | 高(用户真正不满) | 每天 0.1%-1% |
| 用户继续追问/纠错 | 对话级行为分析 | 中-高 | 每天 5%-15% |
| 转人工 | 客服系统接管事件 | 中(可能是流程而非质量) | 每天 3%-8% |
| Judge 模型评分 < 阈值 | 线上抽样跑 Judge | 中(受 Judge 准确度限制) | 取决于抽样率 |
| Self-consistency 不一致 | 同一问题多次采样比对 | 低-中(指示不稳定,未必是错) | 大 |
| 规则强校验失败 | JSON Schema、字段必填等 | 高(明确错误) | 小但精 |
4.3 失败模式的抽象层级
抽象的精度直接决定合成效果。三种典型层级:
-
表层(不推荐):“用户问到机票退改签时模型答错了”——太具体,对应的合成只能在“机票退改签”上转圈。
-
中层(推荐):“涉及多步政策判断 + 时间窗口 + 异常情况组合的查询,模型容易遗漏其中一个条件”——可推广到多个业务领域。
-
抽象层(高阶):“模型在面对带有‘隐含的非常规前提’的问题时倾向于走通用路径”——可指导跨业务的对抗合成。
建议团队从中层开始,太抽象会让合成失焦,太具体会让合成失去广度。
4.4 失败模式挖掘的最小实现
python
"""
failure_mining.py
从 bad_cases.jsonl 挖掘失败模式
"""
import json, numpy as np
from sentence_transformers import SentenceTransformer
from sklearn.cluster import KMeans
from anthropic import Anthropic
embedder = SentenceTransformer("BAAI/bge-large-zh-v1.5")
client = Anthropic()
def load_bad_cases(path):
with open(path) as f:
return [json.loads(l) for l in f]
def embed_cases(cases):
texts = [f"Q: {c['user_query']}\nA: {c['model_output']}\nLabel: {c.get('failure_reason','')}"
for c in cases]
return embedder.encode(texts, normalize_embeddings=True)
def cluster_cases(cases, n_clusters=8):
embeddings = embed_cases(cases)
km = KMeans(n_clusters=n_clusters, random_state=42, n_init=10)
labels = km.fit_predict(embeddings)
clusters = {i: [] for i in range(n_clusters)}
for c, l in zip(cases, labels):
clusters[l].append(c)
return clusters
ABSTRACT_PROMPT = """下面是 {n} 条 Bad Cases,它们被聚类算法判定属于同一类失败模式。
请你抽象出"中层失败模式"——既不要太具体(不能只说某个领域),也不要太抽象(要可指导测试合成)。
输出 JSON:
{{
"mode_name": "失败模式短名称",
"description": "中层抽象的病因描述(30-60 字)",
"trigger_pattern": "触发条件 / 用户输入特征",
"model_behavior": "模型典型错误行为",
"synth_hint": "合成更多同类样本时应注意的 2-3 个要点"
}}
Bad Cases:
{cases}
"""
def abstract_failure_mode(cluster_cases, sample_n=5):
samples = cluster_cases[:sample_n]
case_text = "\n\n".join(
f"[case {i+1}]\nQ: {c['user_query']}\nA: {c['model_output']}\nReason: {c.get('failure_reason','')}"
for i, c in enumerate(samples))
prompt = ABSTRACT_PROMPT.format(n=len(samples), cases=case_text)
resp = client.messages.create(
model="claude-opus-4-6",
max_tokens=512,
messages=[{"role": "user", "content": prompt}],
)
text = resp.content[0].text
import re
m = re.search(r'\{.*\}', text, re.DOTALL)
return json.loads(m.group(0)) if m else None
if __name__ == "__main__":
cases = load_bad_cases("bad_cases.jsonl")
clusters = cluster_cases(cases, n_clusters=8)
modes = []
for cid, cluster in clusters.items():
if len(cluster) < 3:
continue
mode = abstract_failure_mode(cluster)
if mode:
mode["cluster_id"] = cid
mode["size"] = len(cluster)
modes.append(mode)
print(f"[Mode {cid}] {mode['mode_name']} | {mode['description']}")
with open("failure_modes.json", "w") as f:
json.dump(modes, f, ensure_ascii=False, indent=2)
第5章:合成数据的污染与多样性度量
5.1 三个核心度量
合成数据的质量评估,2026 年业界形成了三个核心度量:Distinct-N(n-gram 多样性)、Self-BLEU(成对相似度)、Semantic Diversity(语义多样性)。这三个指标互相补充,缺一不可。
5.2 Distinct-N(n-gram 词面多样性)
Distinct-N 定义:所有样本中出现过的 unique n-gram 数量 / 所有 n-gram 总数。Distinct-1 看词级多样性,Distinct-2/3 看短语级多样性。
python
"""
diversity_distinct_n.py
计算 Distinct-1/2/3
"""
import jieba
def ngrams(tokens, n):
return [tuple(tokens[i:i+n]) for i in range(len(tokens) - n + 1)]
def distinct_n(samples, n=1, tokenizer=None):
tokenizer = tokenizer or (lambda x: list(jieba.cut(x)))
all_ngrams = []
for s in samples:
toks = tokenizer(s)
all_ngrams.extend(ngrams(toks, n))
if not all_ngrams:
return 0.0
return len(set(all_ngrams)) / len(all_ngrams)
samples = [s["instruction"] for s in load_synth_samples("synthesized.jsonl")]
for n in [1, 2, 3]:
score = distinct_n(samples, n=n)
print(f"Distinct-{n} = {score:.4f}")
# Distinct-1 = 0.1823 (词级多样性,一般中文 0.10~0.25)
# Distinct-2 = 0.5612 (二元短语多样性)
# Distinct-3 = 0.8341 (三元短语多样性)
建议阈值(中文场景):Distinct-1 ≥ 0.12 / Distinct-2 ≥ 0.45 / Distinct-3 ≥ 0.75。低于这个阈值说明合成器在反复用相似句式。
5.3 Self-BLEU(样本间相似度)
Self-BLEU 把每条样本当作 hypothesis,把其他所有样本当作 reference 集合,计算 BLEU。Self-BLEU 越低 = 样本间越不相似 = 多样性越好。
python
"""
diversity_self_bleu.py
"""
from nltk.translate.bleu_score import sentence_bleu, SmoothingFunction
import jieba, random
def self_bleu(samples, n_grams=4, sample_n=200):
"""采样计算 Self-BLEU 以提速"""
if len(samples) > sample_n:
samples = random.sample(samples, sample_n)
tokens = [list(jieba.cut(s)) for s in samples]
weights = tuple([1.0 / n_grams] * n_grams)
scores = []
sf = SmoothingFunction().method1
for i, hyp in enumerate(tokens):
refs = [tokens[j] for j in range(len(tokens)) if j != i]
score = sentence_bleu(refs, hyp, weights=weights, smoothing_function=sf)
scores.append(score)
return sum(scores) / len(scores)
print(f"Self-BLEU-4 = {self_bleu(samples):.4f}")
# Self-BLEU-4 一般在 0.15-0.45 之间,越低越好
# > 0.5 = 严重同质化(合成器在抄自己)
# < 0.2 = 多样性优秀
5.4 Semantic Diversity(语义多样性 + 簇覆盖率)
Distinct-N 和 Self-BLEU 都是词面指标,对“换皮但同义”无能为力。语义多样性必须用 embedding。
python
"""
diversity_semantic.py
基于 embedding 聚类的簇覆盖率
"""
from sentence_transformers import SentenceTransformer
from sklearn.cluster import KMeans, MiniBatchKMeans
import numpy as np
embedder = SentenceTransformer("BAAI/bge-large-zh-v1.5")
def semantic_cluster_coverage(samples, n_clusters=50):
"""
用 KMeans 把 samples 聚到 n_clusters 个簇,
返回 effective_clusters / n_clusters 作为覆盖率。
effective_cluster = 至少含 1 条样本的簇。
"""
if len(samples) < n_clusters:
n_clusters = max(2, len(samples) // 4)
embeddings = embedder.encode(samples, normalize_embeddings=True, show_progress_bar=False)
km = MiniBatchKMeans(n_clusters=n_clusters, random_state=42, n_init=10)
labels = km.fit_predict(embeddings)
n_effective = len(set(labels))
return {
"n_clusters": n_clusters,
"effective_clusters": n_effective,
"coverage": n_effective / n_clusters,
}
def semantic_pairwise_avg_dist(samples, sample_n=300):
"""采样样本两两余弦距离,距离越大越分散"""
import random
if len(samples) > sample_n:
samples = random.sample(samples, sample_n)
embs = embedder.encode(samples, normalize_embeddings=True, show_progress_bar=False)
# 余弦距离 = 1 - 内积
sims = embs @ embs.T
np.fill_diagonal(sims, 0)
avg_sim = sims.sum() / (len(embs) * (len(embs) - 1))
return 1.0 - avg_sim # 平均余弦距离
print(semantic_cluster_coverage(samples, n_clusters=50))
# {'n_clusters': 50, 'effective_clusters': 48, 'coverage': 0.96}
print(f"Avg pairwise distance = {semantic_pairwise_avg_dist(samples):.4f}")
# 0.50-0.80 是健康区间
5.5 数据污染:合成数据的最大风险
合成数据有一个非常容易踩的雷:合成器(LLM)见过被测系统的训练集。这会导致合成出来的样本和训练集分布过近,等于把训练集“灌”回评测集——结果模型在合成评测集上分数虚高。
三类污染场景。
种子污染:种子取自模型训练数据。例如用 Anthropic HH-RLHF 数据做种子,跑 Self-Instruct,再用 Claude 评测——你测的是 Claude 对自己训练数据的复现能力。
合成器污染:被测模型 = 合成器(如用 Claude 给 Claude 合成)——容易合成出“对自己友好”的样本。
Reference 污染:让合成器同时生成 input 和 reference output——reference 自带合成器的偏好,再让被测模型回答,被测模型分数偏向合成器风格。
5.6 防污染的工程动作
-
合成器与被测分离:用 Claude Opus 4.6 合成 → 测 GPT-5;用 GPT-5 合成 → 测 Claude;用 DeepSeek-R2 合成 → 测多家。
-
Judge 第三方化:合成器、被测、Judge 三者必须是不同模型家族。
-
Reference 由人工或多模型 vote:不能信单个合成器的 reference,必须 2-3 个模型独立生成 + 人工抽检。
-
保留 Held-out 子集:合成集中保留 10% “永不公开”的子集,用于检查模型是否“猜过”评测分布。
-
Canary Hash 检测:参考《Benchmark 污染防控》,给关键样本打入 canary token,监控未来模型是否吐出。
第6章:五种合成策略选择
2026 年成熟的合成策略可以归纳为五种,每种对应不同的测试目标。选错策略会导致大量样本浪费——这一章帮你对症下药。
6.1 五种策略全景
| 策略 | 核心动作 | 主要目标 | 典型工具 |
|---|---|---|---|
| Seed Expansion · 种子扩增 | 从少量 seed 横向扩到大量样本 | 覆盖率、长尾 | Self-Instruct / distilabel |
| Counterfactual · 反事实 | 在敏感变量上做对照变换 | 公平性、鲁棒性 | 自写脚本 |
| Adversarial · 对抗 | 主动构造让模型出错的样本 | 压力测试、红队 | PAIR / GCG / Promptfoo |
| Boundary · 边界 | 构造规则边界、政策边界、模糊边界样本 | 边界识别、拒答门控 | 规则模板 + LLM |
| Combinatorial · 组合 | 多维度变量笛卡尔组合 | 覆盖矩阵、回归集 | Promptfoo / 自写 |
6.2 五种策略的适用矩阵
Seed Expansion · 种子扩增
-
用在何时: 新业务冷启动、评测集不足 500 条、需要快速建立基线。
-
不要用于: 已有大量线上数据时,应优先 Failure-Driven 而非随机扩增。
-
风险: 样本同质,长尾不足。
Counterfactual · 反事实
-
用在何时: 公平性测试、姓名/地域/性别敏感场景、prompt 鲁棒性测试。
-
核心动作: 固定其他所有变量,只改一个敏感变量,看模型输出是否一致。
-
风险: 模板过死会失去自然性。
Adversarial · 对抗
-
用在何时: 红队、安全测试、Jailbreak 防御、Prompt Injection 测试。
-
核心动作: 让一个“红队 LLM”专门构造能让被测出错的 prompt,可结合 PAIR、Tree-of-Attacks 等算法。
-
风险: 过度对抗会脱离真实分布,需配合“代表性测试”才有意义。
Boundary · 边界
-
用在何时: 策略判定、内容审核、拒答门控、合规边界。
-
核心动作: 沿“明确允许 / 灰色地带 / 明确禁止”做梯度构造。
-
风险: 边界定义不清晰会让 Judge 争议大。
Combinatorial · 组合
-
用在何时: 场景矩阵、回归集、有清晰的“多维度变量”可枚举时。
-
核心动作: 列出 N 个维度的取值集合,做笛卡尔积,再用 LLM 渲染成自然语言。
-
风险: 组合爆炸需要做 pairwise 而非全笛卡尔。
组合应用建议
这五种不是互斥的,应按业务阶段叠加:
冷启动 → Seed Expansion + Combinatorial
上线后 → 加 Failure-Driven 反向扩增
合规期 → 加 Counterfactual + Boundary
高风险场景 → 全程加 Adversarial
6.3 一段示例:Combinatorial 策略
组合策略的实现非常工程化。下面以“客服测试集”为例,3 个维度做 pairwise 组合。
python
"""
combinatorial_synth.py
组合式样本合成 (pairwise reduction)
"""
from itertools import product
from anthropic import Anthropic
from allpairspy import AllPairs
client = Anthropic()
DIMENSIONS = {
"用户角色": ["新用户", "老用户", "VIP", "已投诉用户"],
"诉求类型": ["咨询", "投诉", "退款", "售后", "建议"],
"情绪": ["平静", "焦急", "愤怒", "礼貌"],
"渠道": ["APP", "微信", "网页", "电话转写"],
}
PROMPT = """请基于以下要素生成一段自然的客服对话开场用户消息:
- 用户角色: {role}
- 诉求类型: {intent}
- 情绪: {emotion}
- 渠道: {channel}
要求:60-150 字,符合该渠道的真实表达方式,不要在文本中显式提及上述变量名。
直接输出消息内容,不要解释。"""
def render_to_prompt(combo):
return PROMPT.format(role=combo[0], intent=combo[1],
emotion=combo[2], channel=combo[3])
def synth_combinatorial(strategy="pairwise"):
if strategy == "pairwise":
combos = list(AllPairs(list(DIMENSIONS.values())))
else:
combos = list(product(*DIMENSIONS.values()))
samples = []
for c in combos:
prompt = render_to_prompt(c)
resp = client.messages.create(
model="claude-opus-4-6",
max_tokens=300,
messages=[{"role": "user", "content": prompt}],
)
samples.append({
"dimensions": dict(zip(DIMENSIONS.keys(), c)),
"user_message": resp.content[0].text.strip(),
})
return samples
samples = synth_combinatorial("pairwise")
print(f"Pairwise 组合: {len(samples)} 条") # 大幅少于全笛卡尔 (4*5*4*4=320)
第7章:从线上 Bad Case 反向扩增评测集
7.1 反向扩增的核心思想
反向扩增(Failure-Reverse Synthesis)是 2025-2026 年最有价值的合成范式。它的关键在于“反向”两个字:
-
正向合成:从抽象任务定义 → 生成大量样本 → 测模型。
-
反向合成:从模型已知失败 → 抽象失败模式 → 生成同模式的更多样本 → 测模型 → 闭环。
反向扩增的产出价值远高于正向合成,因为每条新样本都“打在模型痛点上”,而不是“打在模型早就会的容易区”。
7.2 反向扩增的标准 4 步
收集 Bad Cases → 聚类 + 抽象失败模式 → 围绕模式合成新样本 → 回测验证 + 入池
7.3 用 LLM 反向生成 hard cases 的 prompt 模板
反向扩增的灵魂在于一个好的 “hard case generation prompt”。下面是经过多次迭代验证、可直接套用的模板:
python
HARD_CASE_PROMPT = """你是一名资深 LLM 测试专家。你的任务是为一个“中文政务咨询大模型”生成 hard cases。
我已经收集到该模型的一个失败模式:
- 模式名称: {mode_name}
- 病因描述: {mode_description}
- 触发条件: {trigger_pattern}
- 错误行为: {model_behavior}
- 合成提示: {synth_hint}
请基于这个失败模式,生成 5 条新的、不重复的、自然的用户咨询测试样本。要求:
1. 每条样本必须能“激活”上述触发条件,但表面措辞、场景、人物完全不同
2. 难度应该等于或略高于原失败案例
3. 必须是真实用户可能问的形式(口语化、有上下文、有具体细节)
4. 不要简单换词,要在场景层面有差异
5. 每条样本同时给出“reference_answer”——一个高质量回答应该具备的核心要素清单(不是完整回答,是评分要点)
输出 JSON 数组:
[
{{
"user_query": "...",
"context": "可选的对话上下文",
"expected_key_points": ["要点1", "要点2", "要点3"],
"trigger_explanation": "解释这条样本如何激活了失败模式"
}},
...
]
只输出 JSON,不要任何解释文字。"""
这个 prompt 的几个关键设计点:
-
显式给出失败模式信息:让模型有的放矢,避免随机生成。
-
“激活” vs “复制”区分:要求样本激活模式而非复制原案例,强制语义层泛化。
-
expected_key_points 而非 reference_answer:避免合成器风格污染评测——只给评分要点,让被测自由生成。
-
trigger_explanation:自我解释能强制模型把“失败逻辑”想清楚,提升合成质量。
7.4 反向扩增脚本
python
"""
reverse_synth.py
基于失败模式做反向扩增
"""
import json
from anthropic import Anthropic
client = Anthropic()
def synth_for_mode(mode, n_per_call=5, total=20):
"""为单个失败模式合成 total 条 hard case"""
samples = []
while len(samples) < total:
prompt = HARD_CASE_PROMPT.format(**mode)
resp = client.messages.create(
model="claude-opus-4-6",
max_tokens=2048,
messages=[{"role": "user", "content": prompt}],
temperature=0.85, # 适度高温以保证多样性
)
text = resp.content[0].text
try:
new = json.loads(text)
for s in new:
s["failure_mode"] = mode["mode_name"]
samples.append(s)
except json.JSONDecodeError:
print(f"JSON 解析失败,跳过一轮")
continue
return samples[:total]
def reverse_expand(modes, total_per_mode=20):
all_samples = []
for mode in modes:
print(f"为模式 [{mode['mode_name']}] 合成 {total_per_mode} 条...")
samples = synth_for_mode(mode, total=total_per_mode)
all_samples.extend(samples)
return all_samples
if __name__ == "__main__":
with open("failure_modes.json") as f:
modes = json.load(f)
expanded = reverse_expand(modes, total_per_mode=25)
with open("expanded_hardcases.jsonl", "w") as f:
for s in expanded:
f.write(json.dumps(s, ensure_ascii=False) + "\n")
print(f"共合成 {len(expanded)} 条 hard cases")
7.5 闭环验证
合成完不能直接入库。必须做“闭环验证”:用被测模型跑一遍合成的 hard case,统计失败率。如果失败率显著高于全集平均,说明合成成功(确实命中了模型痛点);如果失败率和平均持平,说明合成失败(没真正激活痛点)。
| 验证指标 | 合格标准 | 不合格的处理 |
|---|---|---|
| 合成集失败率 / 全集平均失败率 | ≥ 1.5x | 调整 prompt,重新合成 |
| 合成集 Self-BLEU-4 | ≤ 0.40 | 降低合成温度,提高 prompt 多样化 |
| 合成集 vs Bad Case 的 embedding 距离 | 0.3 ~ 0.6 | 太近=复制;太远=失焦 |
| 人工抽检合理率 | ≥ 85% | 检查 prompt 是否引导生成了不真实场景 |
第8章:合成数据的质量校验流水线(4 道质量门)
8.1 4 道质量门设计
门 1 · 去重 → 门 2 · 去污染 → 门 3 · 难度分布 → 门 4 · 多样性
8.2 门 1:去重
去重分三级:完全相同(hash)、近似相同(ROUGE-L > 0.8 或编辑距离)、语义相同(embedding 余弦 > 0.92)。
python
"""
gate1_dedup.py
三级去重
"""
import hashlib
from rouge_score import rouge_scorer
from sentence_transformers import SentenceTransformer
import numpy as np
embedder = SentenceTransformer("BAAI/bge-large-zh-v1.5")
scorer = rouge_scorer.RougeScorer(['rougeL'], use_stemmer=False)
def dedup_exact(samples, key="user_query"):
"""完全相同去重"""
seen, out = set(), []
for s in samples:
h = hashlib.md5(s[key].encode("utf-8")).hexdigest()
if h not in seen:
seen.add(h)
out.append(s)
return out
def dedup_near(samples, key="user_query", thr=0.85):
"""ROUGE-L 近似去重(O(n^2),n 大时改用 MinHash)"""
out = []
texts = []
for s in samples:
if all(scorer.score(t, s[key])["rougeL"].fmeasure < thr for t in texts):
out.append(s)
texts.append(s[key])
return out
def dedup_semantic(samples, key="user_query", thr=0.92):
"""语义去重 (基于 embedding 余弦)"""
if not samples:
return []
texts = [s[key] for s in samples]
embs = embedder.encode(texts, normalize_embeddings=True, show_progress_bar=False)
keep = [True] * len(samples)
for i in range(len(samples)):
if not keep[i]:
continue
for j in range(i + 1, len(samples)):
if not keep[j]:
continue
sim = float(embs[i] @ embs[j])
if sim > thr:
keep[j] = False
return [s for s, k in zip(samples, keep) if k]
def gate_dedup(samples):
n0 = len(samples)
samples = dedup_exact(samples)
n1 = len(samples)
samples = dedup_near(samples)
n2 = len(samples)
samples = dedup_semantic(samples)
n3 = len(samples)
print(f"去重: {n0} → exact:{n1} → near:{n2} → semantic:{n3}")
return samples
8.3 门 2:去污染
去污染检查“合成样本是否和现有评测集(含 benchmark 公开集)有重叠”。这一步保护两件事:
-
不能让合成集污染现有评测集,造成统计不独立。
-
不能让合成集来自被测模型的训练数据(否则等于偷答案)。
python
"""
gate2_decontamination.py
"""
import json, hashlib
from rouge_score import rouge_scorer
scorer = rouge_scorer.RougeScorer(['rougeL'], use_stemmer=False)
def load_existing_eval(paths):
"""加载所有现有评测集(公开 + 内部)"""
all_texts = []
for p in paths:
with open(p) as f:
for l in f:
d = json.loads(l)
all_texts.append(d.get("user_query") or d.get("instruction"))
return [t for t in all_texts if t]
def is_contaminated(text, existing_texts, rouge_thr=0.7, prefix_len=80):
"""如果新样本和任意现有样本 ROUGE-L > thr,或前 80 字完全相同,视为污染"""
pref = text[:prefix_len]
for et in existing_texts:
if pref and pref == et[:prefix_len]:
return True, "prefix_match"
if scorer.score(et, text)["rougeL"].fmeasure > rouge_thr:
return True, "rouge_overlap"
return False, None
def gate_decontaminate(samples, existing_paths):
existing = load_existing_eval(existing_paths)
out, dropped = [], 0
for s in samples:
flag, reason = is_contaminated(s["user_query"], existing)
if flag:
dropped += 1
continue
out.append(s)
print(f"去污染: 丢弃 {dropped} 条")
return out
8.4 门 3:难度分布
合成集如果全是简单题或全是困难题,区分度都不够。需要主动检查并平衡难度分布。
python
"""
gate3_difficulty.py
用 Judge 模型给每条样本打难度标签
"""
from anthropic import Anthropic
client = Anthropic()
DIFFICULTY_PROMPT = """请评估下面这条客服测试样本的难度,1-5 分:
1 = 极易(关键词匹配即可答对)
2 = 容易(单步逻辑)
3 = 中等(需要多步推理或专业知识)
4 = 困难(涉及多政策交叉、异常情况、非标准上下文)
5 = 极困难(边界、对抗、多义、隐性约束)
样本: {q}
只输出一个数字 1-5。"""
def label_difficulty(samples, judge="claude-opus-4-6"):
for s in samples:
resp = client.messages.create(
model=judge, max_tokens=4,
messages=[{"role": "user",
"content": DIFFICULTY_PROMPT.format(q=s["user_query"])}],
)
try:
s["difficulty"] = int(resp.content[0].text.strip()[0])
except (ValueError, IndexError):
s["difficulty"] = 3
return samples
def gate_difficulty(samples, target_dist=None):
target_dist = target_dist or {1: 0.10, 2: 0.20, 3: 0.30, 4: 0.25, 5: 0.15}
samples = label_difficulty(samples)
from collections import Counter
cnt = Counter(s["difficulty"] for s in samples)
total = len(samples)
print("当前难度分布:")
for d in sorted(cnt):
print(f" diff={d}: {cnt[d]} ({cnt[d]/total:.2%}) target {target_dist[d]:.2%}")
return samples
难度分布的“目标分布”应该按业务定。客服场景一般偏中等(3 占 30%),合规场景应加大 4-5 难度比例(合计 ≥ 50%)。
8.5 门 4:多样性
用第 5 章的 Distinct-N、Self-BLEU、Semantic Coverage 三个指标,作为门控。
python
def gate_diversity(samples, key="user_query"):
texts = [s[key] for s in samples]
d1 = distinct_n(texts, 1)
d2 = distinct_n(texts, 2)
d3 = distinct_n(texts, 3)
sb = self_bleu(texts)
cov = semantic_cluster_coverage(texts, n_clusters=min(50, len(texts)//5))
report = {
"distinct_1": d1, "distinct_2": d2, "distinct_3": d3,
"self_bleu_4": sb, "cluster_coverage": cov["coverage"],
}
pass_ = (d1 >= 0.12 and d2 >= 0.45 and sb <= 0.40 and cov["coverage"] >= 0.85)
print(f"多样性报告: {report} | PASS={pass_}")
return samples, report, pass_
8.6 完整流水线
python
"""
synth_pipeline.py
合成数据的 4 道质量门完整流水线
"""
def run_pipeline(raw_synth_path, existing_eval_paths, output_path):
samples = [json.loads(l) for l in open(raw_synth_path)]
print(f"原始合成样本: {len(samples)}")
samples = gate_dedup(samples)
samples = gate_decontaminate(samples, existing_eval_paths)
samples = gate_difficulty(samples)
samples, diversity_report, ok = gate_diversity(samples)
if not ok:
raise ValueError("多样性门控失败,请重新合成")
with open(output_path, "w") as f:
for s in samples:
f.write(json.dumps(s, ensure_ascii=False) + "\n")
print(f"通过 4 道门后: {len(samples)} 条入池")
第9章:用 Claude Opus 4.6 生成 1000 条 RAG 评测集
9.1 任务背景
这一章我们做一个完整的实操:从一份金融知识库(约 200 万字)出发,合成 1000 条 RAG 评测样本。每条样本包含 query、ground_truth_chunks、reference_answer、difficulty。
9.2 合成器选型
| 候选模型 | 优势 | 劣势 | 适用 |
|---|---|---|---|
| Claude Opus 4.6 | 推理深、reference answer 质量高 | 贵 | 主合成器 |
| GPT-5 | 综合质量稳定,长上下文能力强 | 风格容易偏向 OpenAI | 对照合成 / 第二意见 |
| DeepSeek-R2 | 成本低 80%,中文表现好 | 边界场景一致性弱 | 大批量批处理 |
| Gemini 3 Pro | 多模态、超长上下文(2M tokens) | 中文细节稍弱 | 多模态评测合成 |
本案例主用 Claude Opus 4.6,原因:reference answer 必须高质量,而 RAG 任务特别考验“按上下文严格回答”——Claude 在这点上稳定性最好。
9.3 完整合成脚本
python
"""
rag_synth.py
基于知识库 chunk,合成 RAG 评测样本
"""
import json, random, asyncio
from anthropic import AsyncAnthropic
from typing import List, Dict
async_client = AsyncAnthropic()
QA_GEN_PROMPT = """你是 RAG 评测集合成器。下面是一段金融知识库内容(chunk):
---
{chunk}
---
请基于这段内容,生成 3 条 RAG 评测问答样本。要求:
1. 问题必须可以“仅凭这段内容”完整回答(封闭式 RAG 评测)
2. 三条问题难度递增:
- Q1 简单:单点事实查询
- Q2 中等:需综合 chunk 内 2-3 个事实
- Q3 困难:需要推理 / 计算 / 对比,或包含一个干扰性的相似问法
3. reference_answer 必须严格基于 chunk,不能编造内容
4. 不要在问题中暗示答案,不要让问题包含 chunk 原文 5 字以上的连续片段
输出 JSON 数组:
[
{{"question": "...", "reference_answer": "...", "difficulty": 1, "key_points": ["要点1","要点2"]}},
{{"question": "...", "reference_answer": "...", "difficulty": 3, "key_points": [...]}},
{{"question": "...", "reference_answer": "...", "difficulty": 5, "key_points": [...]}}
]
只输出 JSON。"""
async def synth_one_chunk(chunk: Dict, sem: asyncio.Semaphore) -> List[Dict]:
async with sem:
try:
resp = await async_client.messages.create(
model="claude-opus-4-6",
max_tokens=2048,
temperature=0.7,
messages=[{"role": "user",
"content": QA_GEN_PROMPT.format(chunk=chunk["text"])}],
)
text = resp.content[0].text
qa_list = json.loads(text)
for qa in qa_list:
qa["chunk_id"] = chunk["chunk_id"]
qa["ground_truth_chunks"] = [chunk["chunk_id"]]
return qa_list
except Exception as e:
print(f"chunk {chunk['chunk_id']} 失败: {e}")
return []
async def batch_synth(chunks: List[Dict], concurrency: int = 8) -> List[Dict]:
sem = asyncio.Semaphore(concurrency)
tasks = [synth_one_chunk(c, sem) for c in chunks]
results = await asyncio.gather(*tasks)
return [qa for sub in results for qa in sub]
if __name__ == "__main__":
with open("knowledge_chunks.jsonl") as f:
chunks = [json.loads(l) for l in f]
sampled = random.sample(chunks, 350) # 350 chunks * 3 ≈ 1050 QA
qa_pool = asyncio.run(batch_synth(sampled, concurrency=10))
with open("rag_synth_raw.jsonl", "w") as f:
for qa in qa_pool:
f.write(json.dumps(qa, ensure_ascii=False) + "\n")
print(f"原始合成 {len(qa_pool)} 条")
9.4 合成后必跑的两个验证
验证 1:reference_answer 是否真的能从 chunk 答出
合成器有时会“借用”自身知识完成回答,但实际 chunk 里没那条信息。这种样本在评测时会冤枉被测——必须过滤。
python
VERIFY_GROUNDING = """请你判断:下面这条问答的 reference_answer 是否完全可以从 chunk 中得出?
Chunk: {chunk}
Question: {q}
Reference Answer: {a}
输出 JSON: {{"can_be_grounded": true/false, "reason": "..."}}
"""
async def verify_grounding(qa, chunk):
resp = await async_client.messages.create(
model="gpt-5", # 用第三方模型验证以避免 Claude 自我袒护
max_tokens=256,
messages=[{"role": "user",
"content": VERIFY_GROUNDING.format(chunk=chunk, q=qa["question"], a=qa["reference_answer"])}],
)
return json.loads(resp.content[0].text)
验证 2:问题是否对人类合理(合成自然性)
合成 prompt 多了之后,模型容易输出“明显是从 chunk 倒推出来”的怪问题,比如“根据上述描述,请说明...”。这种问题真实用户不会问。
python
NATURALNESS_PROMPT = """请判断这是不是“真实用户会问的”问题。
不真实的特征包括:
- “根据上述...”、“请说明 chunk 中...” 等元描述
- 答案不存在外部上下文时无法理解
- 句子结构是教科书填空题而非自然问句
问题: {q}
输出 JSON: {{"is_natural": true/false, "reason": "..."}}
"""
9.5 成本估算
这一套合成 + 两步验证的真实成本(2026-04 价格):
-
合成 350 chunks × ~2k input + ~1.5k output = 350 × 3.5k tokens × Claude Opus 4.6 价格 ≈ $24-30
-
验证 grounding(GPT-5)≈ $10-12
-
验证 naturalness(GPT-5)≈ $3-5
-
合计:约 $40 / 1000 条 RAG 评测样本。
对比:人工写 1 条专业 RAG 评测样本约需 8-15 分钟,按 ¥150/小时算,1000 条 ≈ ¥30000+。合成方案成本 1/100,质量在通过 4 道门后接近人工水平。
第10章:失败模式挖掘 + 反向扩增脚本
10.1 端到端流程图
线上日志 → Bad Case 抽取 → 聚类(KMeans + bge) → 失败模式抽象 → 反向合成 → 闭环验证
10.2 Bad Case 抽取:多信号融合
python
"""
extract_bad_cases.py
从线上日志提取 bad cases(多信号融合打分)
"""
import json
from typing import List, Dict
WEIGHTS = {
"user_thumb_down": 1.0, # 用户主动反馈
"user_re_ask": 0.6, # 用户立即追问
"transferred_to_human": 0.5, # 转人工
"judge_score_low": 0.7, # Judge 评分低
"schema_violation": 0.8, # 规则校验失败
"self_consistency_diff": 0.3, # 自一致性差
}
def score_session(session: Dict) -> float:
score = 0
for signal, weight in WEIGHTS.items():
if session.get(signal, False):
score += weight
return score
def extract_bad_cases(log_path: str, threshold: float = 0.6) -> List[Dict]:
bad = []
with open(log_path) as f:
for line in f:
session = json.loads(line)
s = score_session(session)
if s >= threshold:
bad.append({
"session_id": session["session_id"],
"user_query": session["user_query"],
"model_output": session["model_output"],
"failure_score": s,
"failure_signals": [k for k in WEIGHTS if session.get(k, False)],
})
return bad
if __name__ == "__main__":
cases = extract_bad_cases("prod_logs.jsonl")
print(f"抽取到 {len(cases)} 条 bad cases")
with open("bad_cases.jsonl", "w") as f:
for c in cases:
f.write(json.dumps(c, ensure_ascii=False) + "\n")
10.3 自动归因到失败原因
原始 Bad Case 没有“failure_reason”字段,需要先让 LLM 自动归因。
python
AUTO_REASON_PROMPT = """下面是一条 LLM 失败案例。请你判断主要失败原因,从下列类别中选 1-2 个最匹配的:
候选类别:
- ["事实错误", "幻觉编造", "拒答过度", "拒答不足", "格式不符",
"条件遗漏", "推理跳步", "过度泛化", "口径不一致", "上下文遗忘",
"工具调用错误", "翻译/语言切换错", "安全策略误判", "情感共情不足"]
User Query: {q}
Model Output: {a}
失败信号: {signals}
输出 JSON: {{"primary": "类别1", "secondary": "类别2 或 null", "explain": "30 字以内"}}
"""
async def auto_reason(case):
resp = await async_client.messages.create(
model="claude-opus-4-6", max_tokens=256,
messages=[{"role": "user", "content": AUTO_REASON_PROMPT.format(
q=case["user_query"], a=case["model_output"],
signals=", ".join(case["failure_signals"]))}],
)
return json.loads(resp.content[0].text)
10.4 反向扩增的完整闭环
python
"""
end_to_end.py
Bad Case → 失败模式 → 反向合成 → 验证 → 入池
"""
async def main():
cases = extract_bad_cases("prod_logs.jsonl")
print(f"[1/5] 抽取 bad cases: {len(cases)}")
cases = [c for c in cases if (r := await auto_reason(c)) and (c.update({"failure_reason": r}) or True)]
print(f"[2/5] 自动归因完成")
clusters = cluster_cases(cases, n_clusters=10)
print(f"[3/5] 聚类完成: {len(clusters)} 簇")
modes = []
for cid, cluster in clusters.items():
if len(cluster) < 3: continue
mode = abstract_failure_mode(cluster)
if mode:
mode["cluster_id"] = cid
modes.append(mode)
print(f"[4/5] 抽象失败模式: {len(modes)} 个")
expanded = []
for m in modes:
new_cases = synth_for_mode(m, total=30)
verified = await batch_verify_failure_rate(new_cases, target_model="gpt-5")
expanded.extend(verified)
print(f"[5/5] 反向合成 + 验证: {len(expanded)} 条入池")
return expanded
if __name__ == "__main__":
asyncio.run(main())
第11章:DeepEval Synthesizer 实操
11.1 DeepEval 3.4 Synthesizer 概览
DeepEval 是 2025-2026 年最流行的开源 LLM 评测框架。3.4 版本(2026-02)的 Synthesizer 模块支持 5 种合成模式、自带 4 道质量门、可直接接 pytest。
bash
pip install deepeval==3.4.0 ragas==0.2.5
11.2 最简 RAG 合成
python
"""
deepeval_rag_synth.py
"""
from deepeval.synthesizer import Synthesizer
from deepeval.synthesizer.config import (
StylingConfig,
EvolutionConfig,
Evolution,
FiltrationConfig,
ContextConstructionConfig,
)
styling = StylingConfig(
expected_output_format="3-5 句中文回答,包含数字时保留原文精度",
input_format="自然中文问句",
task="基于金融知识库的客户问答",
scenario="个人银行客户咨询",
)
evolution = EvolutionConfig(
evolutions={
Evolution.REASONING: 1/4, # 推理加深
Evolution.MULTICONTEXT: 1/4, # 多 chunk 综合
Evolution.CONCRETIZING: 1/4, # 具体化
Evolution.CONSTRAINED: 1/4, # 加约束
},
num_evolutions=2, # 每条样本进化 2 轮
)
filtration = FiltrationConfig(
synthetic_input_quality_threshold=0.7, # 输入质量门控
max_quality_retries=3,
)
synth = Synthesizer(
model="claude-opus-4-6",
async_mode=True,
max_concurrent=10,
styling_config=styling,
evolution_config=evolution,
filtration_config=filtration,
)
goldens = synth.generate_goldens_from_docs(
document_paths=["./kb/finance.pdf", "./kb/products.md"],
include_expected_output=True,
max_goldens_per_context=3,
context_construction_config=ContextConstructionConfig(
chunk_size=1024, chunk_overlap=128,
critic_model="gpt-5", # 第三方 critic 防自袒护
max_contexts_per_document=120,
),
)
synth.save_as("json", directory="./synth_output", file_name="rag_goldens")
print(f"合成 {len(goldens)} 条 golden samples")
11.3 Goldens 转 pytest 测试
python
"""
test_rag_with_synth.py
pytest test_rag_with_synth.py
"""
import pytest, json
from deepeval import assert_test
from deepeval.metrics import (
AnswerRelevancyMetric, FaithfulnessMetric, ContextualRecallMetric
)
from deepeval.test_case import LLMTestCase
with open("./synth_output/rag_goldens.json") as f:
goldens = json.load(f)["goldens"]
@pytest.mark.parametrize("golden", goldens[:200])
def test_rag(golden):
actual = my_rag_pipeline(golden["input"])
case = LLMTestCase(
input=golden["input"],
actual_output=actual["answer"],
expected_output=golden["expected_output"],
retrieval_context=actual["retrieved_chunks"],
context=golden["context"],
)
assert_test(case, [
AnswerRelevancyMetric(threshold=0.7, model="gpt-5"),
FaithfulnessMetric(threshold=0.8, model="gpt-5"),
ContextualRecallMetric(threshold=0.75, model="gpt-5"),
])
11.4 distilabel + Argilla 工作流(标注协同)
如果团队有 5+ 标注员需要协同处理合成样本,建议用 distilabel + Argilla 组合。distilabel 负责合成 pipeline,Argilla 负责把 borderline 样本送给人工评审,人工反馈再回流到下一轮合成。
python
"""
distilabel_pipeline.py
distilabel 1.6 - 合成 + Argilla 推送
"""
from distilabel.pipeline import Pipeline
from distilabel.steps import LoadDataFromHub, KeepColumns, PushToArgilla
from distilabel.steps.tasks import EvolInstruct, GenerateTextRetrievalData
from distilabel.llms import AnthropicLLM, OpenAILLM
llm_synth = AnthropicLLM(model="claude-opus-4-6", api_key="...")
llm_critic = OpenAILLM(model="gpt-5", api_key="...")
with Pipeline(name="rag-synth-pipeline") as pipe:
loader = LoadDataFromHub(repo_id="my-org/seed-finance-qa", split="train")
evol = EvolInstruct(
llm=llm_synth,
num_evolutions=3,
store_evolutions=True,
evolution_strategy="random", # 5 种操作随机
)
rag_gen = GenerateTextRetrievalData(llm=llm_synth, batch_size=8)
keep = KeepColumns(columns=["instruction", "context", "evolved_instruction", "response"])
push = PushToArgilla(
dataset_name="rag-synth-review-2026Q2",
api_url="https://argilla.my-org.com",
api_key="...",
)
loader >> evol >> rag_gen >> keep >> push
if __name__ == "__main__":
pipe.run(parameters={loader.name: {"batch_size": 32}})
第12章:多样性度量自动报告
12.1 报告应包含的内容
一份合格的合成数据“多样性自动报告”应该让阅读者在 30 秒内回答三个问题:
-
这批样本词面多样性够吗?
-
语义层面是否有大块“塌陷”区域?
-
各失败模式 / 难度档 / 主题领域的分布是否均衡?
12.2 自动报告生成器
python
"""
diversity_report.py
合成数据多样性自动报告(输出 HTML)
"""
import json, jieba
from collections import Counter
from sentence_transformers import SentenceTransformer
from sklearn.cluster import MiniBatchKMeans
import numpy as np
def gen_report(samples, key="user_query", out_path="diversity_report.html"):
texts = [s[key] for s in samples]
n = len(texts)
d1 = distinct_n(texts, 1)
d2 = distinct_n(texts, 2)
d3 = distinct_n(texts, 3)
sb = self_bleu(texts, sample_n=200)
embedder = SentenceTransformer("BAAI/bge-large-zh-v1.5")
embs = embedder.encode(texts, normalize_embeddings=True, show_progress_bar=False)
n_cluster = min(50, max(5, n // 20))
km = MiniBatchKMeans(n_clusters=n_cluster, random_state=42, n_init=10)
labels = km.fit_predict(embs)
cluster_sizes = Counter(labels.tolist())
coverage = len(cluster_sizes) / n_cluster
max_cluster_share = max(cluster_sizes.values()) / n
sims = embs @ embs.T
np.fill_diagonal(sims, 0)
avg_dist = 1.0 - sims.sum() / (n * (n - 1))
diff_dist = Counter(s.get("difficulty", "?") for s in samples)
mode_dist = Counter(s.get("failure_mode", "未标注") for s in samples)
health = "GREEN"
if d2 < 0.45 or sb > 0.45 or coverage < 0.85 or max_cluster_share > 0.15:
health = "YELLOW"
if d2 < 0.30 or sb > 0.55 or coverage < 0.70 or max_cluster_share > 0.25:
health = "RED"
html = f"""
<!DOCTYPE html><html><head><meta charset='utf-8'>
<title>合成数据多样性报告</title>
<style>body{{font-family:sans-serif;padding:24px;line-height:1.6}}
.metric{{display:inline-block;margin:8px 16px 8px 0;padding:8px 12px;border-radius:8px}}
.green{{background:#dcfce7}}.yellow{{background:#fef3c7}}.red{{background:#fee2e2}}
table{{border-collapse:collapse}}td,th{{border:1px solid #ccc;padding:6px 12px}}</style>
</head><body>
<h1>合成数据多样性报告</h1>
<p>样本数: {n} | 健康状态: <span class='{health.lower()}'>{health}</span></p>
<h2>词面多样性</h2>
<div class='metric'>Distinct-1: {d1:.4f}</div>
<div class='metric'>Distinct-2: {d2:.4f}</div>
<div class='metric'>Distinct-3: {d3:.4f}</div>
<div class='metric'>Self-BLEU-4: {sb:.4f}</div>
<h2>语义多样性</h2>
<div class='metric'>簇覆盖率: {coverage:.2%}</div>
<div class='metric'>最大簇占比: {max_cluster_share:.2%}</div>
<div class='metric'>平均成对余弦距离: {avg_dist:.4f}</div>
<h2>难度分布</h2><table><tr><th>难度</th><th>数量</th><th>占比</th></tr>
{''.join(f'<tr><td>{d}</td><td>{c}</td><td>{c/n:.2%}</td></tr>' for d, c in sorted(diff_dist.items()))}
</table>
<h2>失败模式分布</h2><table><tr><th>模式</th><th>数量</th></tr>
{''.join(f'<tr><td>{m}</td><td>{c}</td></tr>' for m, c in mode_dist.most_common())}
</table>
</body></html>"""
with open(out_path, "w") as f:
f.write(html)
print(f"报告已写入 {out_path} | 健康度: {health}")
return health
if __name__ == "__main__":
samples = [json.loads(l) for l in open("synth_pool.jsonl")]
gen_report(samples)
12.3 报告的解读规则
| 指标 | GREEN | YELLOW | RED |
|---|---|---|---|
| Distinct-2 | ≥ 0.55 | 0.45 ~ 0.55 | < 0.45 |
| Self-BLEU-4 | ≤ 0.30 | 0.30 ~ 0.45 | > 0.45 |
| 簇覆盖率 | ≥ 0.92 | 0.85 ~ 0.92 | < 0.85 |
| 最大簇占比 | ≤ 0.10 | 0.10 ~ 0.20 | > 0.20 |
| 难度档分布 | 5 档都 ≥ 5% | 1 档不足 5% | ≥ 2 档不足 5% |
RED 状态需要重做合成或改 prompt。YELLOW 可以放行但需在合成下一批时主动加强弱档。GREEN 可直接入池。
第13章:案例:从 200 条 Bad Case 反向扩增到 5000 条评测集
13.1 项目背景
2026 年 Q1,我所在的公司接了一个内部任务:某城商行的“对公贷款 AI 顾问”上线 3 个月后,产品经理收到了 200 多条用户负反馈。需求方希望在 2 周内交付一套覆盖所有失败模式的回归评测集,规模 ≥ 5000 条,可用于后续模型迭代回归测试。
13.2 阶段 1:Bad Case 整理
200 条原始反馈包含格式不一、表述粗糙、有些是产品建议而非 bug。我们做了三步清洗:
-
用规则过滤掉非“模型行为”反馈(如“功能不全”、“不支持企业网银登录”)—— 剩 167 条。
-
对每条反馈,从对话日志中拉取完整对话上下文(前 5 轮)—— 形成“完整 Bad Case”。
-
用第 10 章的 auto_reason 给每条打 1-2 个失败原因标签 —— 形成结构化 Bad Case 池。
13.3 阶段 2:聚类与失败模式抽象
用 bge-large-zh + KMeans 聚类 167 条到 12 簇。手动 review 后合并/拆分得到 9 个失败模式:
| 模式编号 | 模式名称 | 典型病因 | 原始 Bad Case 数 |
|---|---|---|---|
| FM-01 | 多政策交叉判断遗漏 | 同时涉及“小微+新设企业+科技型”时只考虑一个 | 34 |
| FM-02 | 利率口径混淆 | LPR vs 基准 vs 实际执行口径混用 | 28 |
| FM-03 | 额度计算遗漏抵质押系数 | 报“评估值”而非“可贷额” | 22 |
| FM-04 | 过度拒答 | 对合规但敏感的问题(如逾期处置)一律拒答 | 19 |
| FM-05 | 区域差异遗忘 | 套用全国统一答案忽略地方差异 | 17 |
| FM-06 | 时效性失误 | 引用已被替换的旧政策(2024 版条款) | 15 |
| FM-07 | 专有名词误解 | 把“流动资金贷款”和“流贷”误认作不同产品 | 14 |
| FM-08 | 多轮对话上下文遗忘 | 第 4 轮以后忘记用户身份是企业法人 | 10 |
| FM-09 | 计算错误 | 等额本息与等额本金公式混用 | 8 |
13.4 阶段 3:反向合成
用第 7 章的 reverse_synth 脚本,每个失败模式合成 600 条,共 5400 条原始合成样本。合成器 Claude Opus 4.6,温度 0.85,每个 mode 分 30 个 batch、每个 batch 20 条以保证多样性。
python
for mode in failure_modes:
samples = synth_for_mode(mode, n_per_call=20, total=600)
pool.extend(samples)
print(f"原始合成 {len(pool)} 条") # 5400
13.5 阶段 4:4 道质量门
| 步骤 | 输入 | 输出 | 剔除原因 Top 3 |
|---|---|---|---|
| 门 1 · 去重 | 5400 | 4912 | 语义重复 / 模板措辞雷同 |
| 门 2 · 去污染 | 4912 | 4830 | 与现有评测集 ROUGE-L > 0.7 |
| 门 3 · 难度 | 4830 | 4830 (全保留,调整分布) | 难度档不平衡 → 补 200 条 5 难 |
| 门 4 · 多样性 | 5030 | 5030 (PASS GREEN) | — |
13.6 阶段 5:闭环验证
用 GPT-5 跑一遍合成集,对比线上模型在原始 Bad Case 上的失败率:
| 样本集 | 样本数 | 失败率 | 含义 |
|---|---|---|---|
| 原始 Bad Case | 167 | 100% | 全是已知失败 |
| 合成扩增集 | 5030 | 71.4% | 合成成功命中模式 |
| 对照:通用 RAG 集 | 1000 | 12.8% | 正常基线 |
合成扩增集的失败率是通用基线的 5.6 倍——证明合成确实“打到了痛点”,而不是随机扩增。
13.7 项目复盘
三个关键经验。
聚类簇数要适中:第一次设了 20 簇,结果有 7 个簇里只有 1-2 条,无法抽象。降到 12 簇后每簇 12-30 条,抽象稳定。
抽象层级最关键:FM-04 “过度拒答” 第一轮抽象成了“客户问敏感话题被拒”,太具体;调整成“涉及合规边界但属于合法咨询”后,合成多样性显著提升。
合成集失败率不能太高:如果合成集失败率 > 90%,说明合成器把样本做得过于刁钻、脱离真实分布。70% 左右是健康区间。
13.8 项目交付物
-
5030 条 jsonl 评测集(含 difficulty / failure_mode / source 字段)
-
9 份失败模式分析报告(每份含 5-10 条典型样本)
-
多样性自动报告 HTML
-
合成 prompt + 复用脚本(业务可重复使用)
-
下一轮迭代建议(每月增量合成 200 条新样本,跟踪线上新出现的失败模式)
第14章:案例:合成数据训练 Judge 模型的完整流程
14.1 项目背景
2026 年 Q2,团队需要给一个智能客服系统训练一个“专属 Judge 模型”——用 7B 小模型判断对话质量,替代 Claude/GPT-5 在 CI 中跑评测的高成本(每月节省约 8 万元 API 费用)。但训练这个 Judge 模型需要 5 万条带标签的 (query, response, score) 三元组,人工标注 6 个月也做不完。这个任务必须用合成数据。
14.2 数据合成方案
收集 query 池 → 多模型生成多样化 response → 大模型 Judge 双盲打分 → 人工抽检 5% → 分布平衡 + 训练集
14.3 步骤 1:构建多样化 query 池
用第 6 章的“组合策略 + 反向扩增”两路混合,构造 12000 条 query 池。要求覆盖:场景 × 用户类型 × 情绪 × 复杂度 × 是否含敏感话题。
14.4 步骤 2:多模型生成多档质量 response
关键设计:用不同模型 + 不同 prompt 强度生成 5 档质量的 response,确保训练集有清晰的质量梯度。
python
"""
multi_model_response.py
对每条 query 用 5 种配置生成 5 档质量 response
"""
configs = [
{"model": "claude-opus-4-6", "prompt": "你是顶级客服专家,请给出最专业、最贴心、最详细的回答", "expected_score": 5},
{"model": "claude-sonnet-4-6", "prompt": "你是客服,回答用户问题", "expected_score": 4},
{"model": "gpt-5", "prompt": "请简短回答下面的问题", "expected_score": 3},
{"model": "deepseek-r2", "prompt": "Reply briefly", "expected_score": 2}, # 中文场景下英文 prompt 会降质
{"model": "qwen3-7b-instruct", "prompt": "回答下面问题", "expected_score": 1}, # 故意用小模型生成低质回答
]
async def gen_responses_for_query(query):
out = []
for cfg in configs:
resp = await call_model(cfg["model"],
messages=[
{"role": "system", "content": cfg["prompt"]},
{"role": "user", "content": query}
])
out.append({
"query": query,
"response": resp,
"source_model": cfg["model"],
"expected_score": cfg["expected_score"],
})
return out
5 万条 = 10000 query × 5 response。注意 expected_score 只是先验,最终标签由 Judge 重新打分。
14.5 步骤 3:大模型 Judge 双盲打分
用两个独立 Judge 双盲打分,仅保留两者一致的样本(差 ≤ 1 分)。这一步是合成数据质量的关键。
python
"""
double_judge.py
两 Judge 一致性过滤
"""
JUDGE_PROMPT = """请评估下面客服回答的质量,1-5 分:
5 = 极佳(专业、详细、共情、可执行)
4 = 优秀(专业、能解决问题)
3 = 合格(基本回答,无重大缺陷)
2 = 较差(信息不完整或措辞不当)
1 = 很差(敷衍、错误或拒答)
User: {q}
Reply: {a}
输出 JSON:{{"score": 1-5, "explain": "30 字理由"}}
"""
async def double_judge(item, judge_a="claude-opus-4-6", judge_b="gpt-5"):
pa = JUDGE_PROMPT.format(q=item["query"], a=item["response"])
ra = await call_model(judge_a, [{"role":"user","content":pa}])
rb = await call_model(judge_b, [{"role":"user","content":pa}])
sa = json.loads(ra)["score"]
sb = json.loads(rb)["score"]
if abs(sa - sb) > 1:
return None # 两 Judge 分歧大,丢弃
item["judge_a_score"] = sa
item["judge_b_score"] = sb
item["final_score"] = round((sa + sb) / 2)
return item
实测一致性率约 78%——意味着 5 万条原始合成样本,最终保留 3.9 万条作为训练集。
14.6 步骤 4:人工抽检 5%
从 3.9 万条中分层抽样 2000 条(每档约 400 条),让标注员复核 Judge 标签。结果:
-
1837 / 2000 标注员认同 Judge 评分(91.85%)
-
156 条人工分数比 Judge 高 1 分(多为 Judge 偏严)
-
7 条人工认为 Judge 严重错误(涉及合规边界,Judge 误判)
把这 163 条做修正后,最终 3.9 万条训练集准入。
14.7 步骤 5:分布平衡
原始 Judge 评分分布:1 分 7%、2 分 15%、3 分 38%、4 分 28%、5 分 12%。3 分样本太多,用 downsample 把每档拉平到 ~6500 条。最终训练集 32500 条。
14.8 训练结果
用 Qwen3-7B-Instruct 做 base,LoRA 微调 3 epoch,得到内部 Judge 模型。
| 评测集 | Claude Opus 4.6 Judge | 训练后 7B Judge | 差距 |
|---|---|---|---|
| 与人工标签一致率 | 92.1% | 89.4% | -2.7pp |
| 边界争议判定 Kappa | 0.78 | 0.71 | -0.07 |
| 单条平均成本 | $0.012 | $0.0001 | -99.2% |
| QPS(单卡 A100) | ~3 | ~110 | +36.7x |
性能下降在可接受范围(< 3pp),成本和吞吐获得数量级改善。这个项目证明:用合成数据训练专属 Judge,是 2026 年中等规模团队最经济的评测降本方案。
14.9 关键经验
四个值得记的经验。
多模型生成多档质量是关键:如果只用一个模型生成不同 prompt,质量梯度不够清晰。一定要混入“故意用差模型”的低分样本。
双 Judge 一致性过滤比单 Judge 严格质检更便宜:78% 保留率 + 自动化,比让一个人精细复核 100% 要划算。
边界样本要单独抽检:3 分附近最容易争议,人工抽检比例应该提到 10%-20%。
训练后必须用真实数据评测:合成训练 + 合成评测会导致系统性高估,必须保留人工标注的“验证集”作为最终衡量标准。
第15章:练习
-
Self-Instruct 实操:从 50 条客服种子样本出发,用 Claude Opus 4.6 合成 500 条新样本。计算 Distinct-2、Self-BLEU-4、簇覆盖率三项指标,并对比“温度 0.5 / 0.85 / 1.2”三种采样设置下的多样性差异。
思考题:为什么温度 1.2 的 Self-BLEU 反而可能更高? -
Evol-Instruct 5 操作对比:选 30 条简单种子,分别用 5 种进化操作各跑 1 轮,让一个独立模型(GPT-5)回答,并用 Judge 打分。统计哪种进化操作最能“难倒”被测模型。
思考题:如果你的目标是“测试推理能力”,哪两种操作最合适?为什么? -
失败模式抽象:找 10 条你团队真实的 LLM 失败案例(任意业务),手工聚成 2-4 个簇,按“中层抽象”原则写出每簇的 mode_name + description + trigger_pattern + synth_hint。然后让 Claude 基于你的抽象生成 5 条新样本,评估是否真正命中了同一类失败。
-
4 道质量门复现:用第 8 章脚本对自己合成的 500 条样本跑 4 道门,记录每道门剔除的样本数和原因。
思考题:如果“去污染门”剔除了 30% 以上样本,可能的原因是什么?应该怎么调整合成 prompt? -
反向扩增小项目:从你的产品线上挑 30 条用户主动“踩”的对话,用第 10 章的端到端脚本走一遍:抽取 → 聚类 → 抽象 → 合成 → 闭环验证。要求最终合成集失败率 ≥ 全集平均 1.5x。
交付物:失败模式分析表 + 100 条扩增样本 + 多样性 HTML 报告。 -
合成器横评:用同一个 prompt,分别让 Claude Opus 4.6、GPT-5、DeepSeek-R2、Gemini 3 Pro 各合成 100 条客服测试样本。比较:(1) 多样性指标差异 (2) 合成自然度差异 (3) 难度分布差异 (4) 单位成本。给出“哪个模型适合作为你的主合成器”的建议。
-
污染检测演练:故意把现有评测集的 20 条样本“小改”(替换 1-2 个词)后混入合成池,跑去污染门,看能不能全部识别出来。如果识别率 < 95%,说明你的去污染门阈值太松——找出最佳阈值。
本章小结。
2026 年合成测试数据的核心范式是 “Failure-Driven 合成 + 4 道质量门 + 多样性度量闭环”。它不是替代人工写用例,而是把人从“写”中解放出来,让人专注于“判定标准”和“模式抽象”——这两件事 LLM 暂时还做不好。
如果你只能记住一句话,请记住:“合成数据的价值不是数量,而是它必须打到模型真正会死的地方。” 没有这个判断,再多样本都是空气。带着这个判断,200 条 Bad Case 也能撬动 5000 条高价值评测集。
AtomGit 是由开放原子开源基金会联合 CSDN 等生态伙伴共同推出的新一代开源与人工智能协作平台。平台坚持“开放、中立、公益”的理念,把代码托管、模型共享、数据集托管、智能体开发体验和算力服务整合在一起,为开发者提供从开发、训练到部署的一站式体验。
更多推荐



所有评论(0)