【从 0 到 1 构造大模型微调数据】

从 0 到 1 构造大模型微调数据:11 篇法规 → 662 条 QA 全流程

本文是"安全生产法规领域大模型微调"系列第 1 篇

背景

实习时发现通用大模型在安全生产法规场景经常把《安全生产法》的条款安到《消防法》上、编造不存在的条文编号、回答看似流畅实则胡说八道。RAG 能补知识但解决不了"术语对齐"和"表达规范"——你检索到了原文,模型还是可能用自己那套话术乱编。于是决定基于公开法规做一次垂域 QLoRA 微调,顺便把踩坑过程记录下来。

做微调,第一关不是训练,是数据。这玩意儿就是 Garbage in, Garbage out —— prompt 再好,喂垃圾数据进去模型学到的还是垃圾。本文就把数据构造的全流程拆开讲一遍。


一、整体 Pipeline

整个数据流分四步:

11 篇法规 txt → 切分 chunks → 强模型蒸馏生成 QA → 清洗过滤 → 最终训练数据

每一步都有坑,下面逐个说。


二、法规收集与文档切分

2.1 数据来源

从应急管理部官网(mem.gov.cn)下了 11 篇公开法规,包括《安全生产法》《消防法》《特种设备安全法》《危险化学品安全管理条例》等。全部手动整理成纯文本 txt。

这里有个决策点:为什么用公开法规而不是内部文档?两个原因——数据量够用(11 篇蒸馏完能出 600+ 条);公开数据放 GitHub 没合规问题,内部文档碰都不能碰。

2.2 文档切分策略

切分用的是最朴素的滑动窗口:

CHUNK_SIZE=800# 每块 800 字CHUNK_OVERLAP=80# 重叠 80 字,防止条款被拦腰切断

为什么不是"按条切"?法规原文的"第 X 条"格式并不统一,有的用中文数字、有的用阿拉伯数字,正则匹配容易漏。滑动窗口虽然粗暴但省事——800 字一个块足够容纳 2-3 个条款,重叠 80 字保证不会在条款中间断开。

切分脚本见 split_documents.py,核心逻辑就这么几行:

defchunk_text(text:str,size:int=800,overlap:int=80)->list[str]:chunks,start=[],0whilestart<len(text):chunks.append(text[start:start+size])start+=size-overlapreturn[c.strip()forcinchunksiflen(c.strip())>100]

2.3 训练/测试文档级隔离

这是数据构造里最重要的一个坑——不能随机打散所有 QA 对再划分训练集/测试集

同一个文档蒸馏出来的 QA 对,问题和答案都和原文强相关。如果你把同文档的 QA 对一部分分到训练集、一部分分到测试集,等于让模型提前见过测试集的"知识点",评测结果会虚高——你以为是模型学会了,其实是数据泄漏了。

所以按文档级别做隔离:11 篇文档,9 篇归训练侧(158 个 chunk),2 篇归测试侧(37 个 chunk),随机种子固定为 42。隔离清单保存在split_manifest.json里:

训练侧:9 篇法规(消防法、特种设备安全法等),测试侧:2 篇法规(安全生产法、生产安全事故应急条例)

三、强模型蒸馏:让 35B 当"出题老师"

3.1 为什么用蒸馏而不是人工标注

用强模型(我用的 qwen3.6:35b 跑在 Ollama 上)做蒸馏

蒸馏脚本见 distill_generate.py。

3.2 Prompt 设计

这是整个 pipeline 最需要打磨的地方。我试了三版 Prompt,最终版长这样:

你是安全生产领域的资深专家。请基于以下法规原文片段,生成 4 个高质量问答对。 【法规来源】《{法规名}》 【原文片段】 {chunk 文本} 【要求】 1. 问题类型混合:法规知识问答、术语解释、场景案例 2. 答案必须严格依据原文,禁止编造原文没有的条款编号和内容 3. 答案开头注明出处(如"根据《{法规名}》第X条") 4. 问题要具体、有实际业务价值,避免"本文讲了什么"这类空泛问题 5. 以 JSON 数组返回 直接输出 JSON,不要任何其他文字。

几个关键点:

  • 指定"注明出处":这步极其重要。如果不说,35B 会自由发挥,编造条款编号。加上这个约束后,答案会变成"根据《安全生产法》第 21 条……"的格式,可验证性大大提升。
  • 要求"不要任何其他文字":35B 有时候会在 JSON 前后加一段"好的,以下是生成的问答对……",直接导致json.loads()报错。虽然加了这句话还是会偶尔翻车,但概率低了很多。
  • n-per-chunk = 4:试过 3 和 5,3 条太少浪费 chunk 信息量,5 条太多容易重复。

3.3 蒸馏结果

158 个训练侧 chunk × 4 条/chunk + 每 10 个 chunk 额外生成 2 条拒答样本 =662 条训练 QA

37 个测试侧 chunk × 4 = 148 条,加拒答 =154 条测试 QA

四条真实数据长这样(字段脱敏处理):

{"instruction":"根据《X 管理条例》,什么是"X设备设施"?其目录由哪些部门共同制定?","output":"根据《X 管理条例》第二条规定,X 设备设施是指……该设施的目录由……共同制定。","type":"term","source_doc":"X 管理条例"}
{"instruction":"某省属国有企业新购建了一批 X 设备设施并已投入使用,该单位应向哪个部门办理登记?","output":"根据《X 管理条例》第七条和第八条规定,该省属企业……应在 30 日内向负责登记的部门提交材料。","type":"case","source_doc":"X 管理条例"}

四种类型分布:

类型数量说明
domain_qa260法规知识问答(“第 X 条规定了什么”)
case212场景案例(“某企业…应如何处理”)
term160术语解释(“什么是 X”)
reject30拒答样本("伪造审批怎么操作"→ 拒绝)

3.4 踩坑:JSON 解析失败 + 断点续跑

蒸馏过程最大的坑是 35B 偶尔不按规矩输出 JSON。有时多一段废话前缀,有时输出空内容,有时 JSON 结构对不上(外层是个 dict 而不是 list)。

处理方式:指数退避重试 + 兜底逻辑。

defcall_teacher(prompt:str,retries:int=3)->list[dict]:forattemptinrange(retries):try:resp=CLIENT.chat.completions.create(model=MODEL,messages=[{"role":"user","content":prompt}],temperature=0.7,timeout=120,)text=resp.choices[0].message.content data=json.loads(text)ifisinstance(data,dict):data=next(vforvindata.values()ifisinstance(v,list))returndataexceptjson.JSONDecodeErrorase:print(f" JSON 解析失败:{e}")print(f" 模型原始输出(前200字):{text[:200]}")time.sleep(2**attempt)exceptExceptionase:print(f" [{type(e).__name__}]{e}")time.sleep(2**attempt)return[]

另一个重要的功能是断点续跑。158 个 chunk 逐个调 35B,跑一半如果网络抖动或 Ollama 崩了,从头开始太蛋疼。所以每处理完一个 chunk 就往.ckpt文件里记一行索引:

# 每处理完一个 chunk,追加断点ckpt_file.write_text("\n".join(str(x)forxinsorted(done|{i})))

下次重跑时读取 ckpt,跳过已处理的 chunk:

ifckpt_file.exists():done=set(int(l.strip())forlinckpt_file.read_text().splitlines())print(f"断点续跑:跳过{len(done)}个已处理的 chunk")

这个设计在实际使用中救了我至少 5 次——Ollama 时不时就超时或 OOM,有断点续跑可以随时重来。


四、清洗过滤:数据质量最后一道防线

4.1 清洗规则

蒸馏出来的数据不一定都靠谱,需要过三道筛子(clean_filter.py):

① 精确去重:按问题文本归一化(去空格)后去重。本次 662 条蒸馏数据没有重复,因为每个 chunk 内容不同、Prompt 也带了随机性。

defdedup(records):seen,out=set(),[]forrinrecords:key=normalize(r["instruction"])# 去空格ifkeynotinseen:seen.add(key);out.append(r)returnout

② 长度过滤:答案长度在 20~1500 字之间。太短说明 35B 偷懒了(只输出了一句话),太长可能是把原文复述了一遍。

ifnot(20<=len(r["output"])<=1500):returnFalse

③ 低质模式剔除:用正则过滤掉那些一看就是 AI 在划水的回答。

BAD_PATTERNS=re.compile(r"作为(?:一个)?AI|作为语言模型|我无法确定|很抱歉,?我(?:不能|无法)")

如果蒸馏模型说出了"作为 AI,我无法确定……"这种话,说明它对这块内容不够确定,这条数据本身质量就存疑,直接丢掉。

4.2 清洗结果

说实话这次蒸馏质量比我预期的好——662 条全部通过过滤,一条没丢。去重也没发现重复。这说明 Prompt 设计和 35B 的表现都在线。

但这不代表清洗没价值。实际项目中数据量更大、来源更杂时,清洗环节通常会砍掉 15%-30% 的数据。这里的清洗 pipeline 是可复用的。

最终产出的训练数据是标准的 alpaca 格式:

[{"instruction":"问题","input":"","output":"答案"},...]

直接丢进 LLaMA-Factory 的dataset_info.json注册一下就能训练。


五、类型配比为什么是 60/20/10/10

这个配比不是拍脑袋定的:

类型占比实际数量为什么这个比例
domain_qa60%260 条核心任务是回答法规问题,这是主力
term20%160 条术语解释能让模型学会"专业表达",不是每个概念都要查词典
case212 条场景案例的实际比例偏高(32%),因为 35B 天然倾向生成 case 类
reject10%30 条安全合规场景下拒答能力很重要,用户问"怎么伪造审批"必须拒绝

注意:实际蒸馏产出的 case 类偏多(212 条,占 32%),如果后续发现模型过度倾向输出案例式回答,可以在最终导出时做分层抽样压回 10%。但从 662 条的总量来看,数据太少做抽样意义不大,所以保留了全量。


六、11 篇法规够不够?为什么不需要几万条数据

看到这里你可能有个疑问:662 条数据,11 篇原文,是不是太少了?大模型微调不都得上万条吗?

分三层解释这个问题:

第一,法规文本的"密度"远高于对话文本。一篇《安全生产法》全文上万字、上百个条款,每个条款都是一个独立的知识点。相比之下,一条通用对话数据(“今天天气怎么样”“我心情不好”)的信息量几乎为零。所以 11 篇法规 ≈ 上千个知识点,信息的"浓度"完全不同。

第二,蒸馏放大了数据量。11 篇原文 → 195 个 chunk → 每个 chunk 蒸馏 4 条 QA → 662 条训练数据。蒸馏过程不是简单的复制粘贴,35B 模型会对原文做理解、重组、场景化改写,相当于把"法条原文"翻译成了"问答对话"。数据量放大了约 60 倍,多样性也提升了。

第三,这是领域适配,不是从零学说话。Qwen2.5-7B 本身就有扎实的中文理解和推理能力,我要做的是让它学会"法规语言范式"——怎么引用条款、怎么表述罚则、什么场景适用什么法条。这个目标不需要几万条数据,几百条高质量领域数据就够了。类似的例子:医疗领域微调用几千条、法律领域微调用几百条,都有成功案例。

反过来想:如果 662 条不够,那评测结果自然会暴露问题。下一篇的对照实验会给出量化答案——基座模型和微调模型在测试集上的差距到底有多大。如果差距很小,说明数据量确实不足;如果差距显著,就证明数据质量比数量更重要。


七、总结与下一步

数据构造全流程踩坑总结:

  1. 文档级隔离 > 随机划分:评测泄漏比模型效果差更致命,因为你会高估模型能力
  2. Prompt 要强制 JSON + 禁止废话:蒸馏模型很聪明但也很有"主见",不说清楚格式它真的会自由发挥
  3. 断点续跑是必需品:调远程模型蒸馏几十上百次请求,没有断点续跑心态会炸
  4. 清洗规则要根据实际数据调:我的 662 条一条没丢,但你用在不同领域可能不一样

下一步就是把这份数据喂给 Qwen2.5-7B 做 QLoRA 微调,看基座模型的"张冠李戴"毛病能改掉多少。下一篇见。