【案例】Qwen 微调实战 · 任务定义与数据工程

动手训练之前必须钉死四件事:任务边界、基座选型、数据格式、评价指标。钉不死就别点火——烧掉的算力不会退回来。

30″30 秒看懂这个项目

把一次大模型微调想成开一家餐厅。你不会一上来就点火炒菜——真正决定这家店活不活得下去的,是开炉之前那场立项会

立项会上要钉死四件事:做哪一类菜(是不是非开这家店不可)、选哪口灶(铺面多大、灶多少火力)、菜谱怎么写(后厨每个人照着同一张卡出餐)、怎么算好吃(合格线画在哪,谁说了算)。四件事定不下来就开火,等于拿真金白银试错——而在微调里,试错烧的是显卡时间,烧完不会退回来。

图① 30 秒看懂:开炉之前先定死的四件事
图① 30 秒看懂:开炉之前先定死的四件事
立项会上的事对应的技术动作定不下来会怎样
做哪一类菜任务边界把本该用提示词或检索解决的问题拿去微调,花几天训完,效果还不如改一句提示词
选哪口灶基座选型(Qwen 的代次与参数量)灶太小做不动大席面,灶太大天天空烧——显存不够直接训不起来,显存过剩则白付成本
菜谱怎么写数据格式(instruction / input / output每个标注员写法不一样,模型学到的是「随便怎么答都行」
怎么算好吃评价指标(F1、ROUGE-L)训完只能靠「我看着还行」拍脑袋,没法判断这版比上版好还是坏
后厨备料间数据来源、清洗标注与增强食材不新鲜,菜谱写得再好也白搭
菜单登记册data/dataset_info.json菜做好了但没上菜单,训练框架根本找不到你的数据
⛔ 整讲只有一条铁律 四件事全部钉死之后,才允许点火开训。其中任何一件留到训练中途再改——换了基座、改了指令句式、改了短语分隔符——前面烧掉的算力全部作废,必须从头再来。这一讲通篇都在讲怎么把这四颗钉子钉进去,一行训练代码都不会跑。
这一页和下一页的分工 这一页管「立项会」:定任务、挑灶、写菜谱、备料、上菜单。真正点火炒菜——LoRA、QLoRA、DeepSpeed 怎么配、显存怎么压下来——在下一页。所以本页出现的训练参数只作为数据侧的约束(例如 cutoff_len 决定了样本能有多长),不展开讲训练本身。

01概念:一个微调项目的立项会开什么

先判断该不该微调,再把微调和它的两个邻居分清楚,最后把四件事逐条钉死

1.1 第一个问题永远是:这活到底该不该用微调

微调不是万能钥匙,它只解决一类问题:让模型稳定地按照某种格式、某种风格、某种专业习惯去输出。它教的是「怎么做」,不是「知道什么」。

换回餐厅的说法:菜谱能教会厨师刀工、火候、摆盘顺序,让他每次出餐都长一个样;但菜谱教不会他今天菜市场的西红柿多少钱一斤——那是实时信息,得去问;也教不会他隔壁老板昨天新定的内部规矩——那是私有知识,得去查册子。

你的诉求该用什么为什么
输出格式总是飘,要求固定成某种结构微调格式是一种「行为习惯」,用几千条样本能压得很稳,提示词压不住
领域术语用不对、行话不地道微调行业语感要靠大量同领域语料喂出来
回答要基于公司内部几万份文档RAG(检索增强)文档会更新,靠训练把知识焊进权重,改一份文档就要重训一次
要最新股价、今天天气、库存数量Function Call / 工具调用实时数据,任何训练方式都追不上
只是想让它说话客气点、分点作答提示词工程一句系统提示词就能解决,微调是杀鸡用牛刀
最常见的立项失误 把「模型不知道我们公司的报销标准」当成微调需求。知识型缺口交给检索,行为型缺口才交给微调。判断方法很简单:把答案直接贴进提示词,模型能不能答对?能答对,说明它缺的是知识,上 RAG;贴了答案它还是不按你要的格式输出,那才是微调的活。

1.2 微调、提示词、RAG 的分工

三者不是竞争关系,在同一个项目里经常同时存在。本项目最终就是「微调出稳定的抽取格式」+「上线后靠提示词微调语气」的组合。

维度提示词工程RAG微调
改的是什么输入输入(多塞了检索到的资料)模型权重本身
生效速度立刻建好索引即可小时到天级,要算力
知识更新改提示词改文档即可,最灵活要重训,最笨重
格式稳定性弱,长对话后容易跑偏最强,这是它的主场
推理成本提示词越长越贵每次都要多塞检索结果,最贵提示词可以写得极短,长期最省
对数据的要求文档齐全即可成对的输入输出标注,最贵的一项

最后一行是很多项目低估的地方:微调真正的成本不在显卡,在标注。几千条高质量成对样本的整理工作量,通常远超训练本身。这也是为什么立项会要先把菜谱格式定死——格式定错了,标注返工的代价是成倍的。

1.3 立项四件事逐条钉死

1任务边界

写清楚模型输入什么、输出什么、不负责什么。本项目的边界是:输入一段中文原文,输出顿号分隔的核心短语;不负责判断真伪、不负责补充原文没有的信息、不负责翻译。边界越窄,样本越好标,效果越稳。

2基座选型

定下哪一代、哪个参数量,并且从此不再更换。换基座意味着模板(template)、显存预算、数据长度上限全部要重算,前面的训练成果一律作废。选型判断链见 2.1。

3数据格式

三个字段各自的写法约束要写成一页纸发给每个标注员:指令用哪一句、原文保留到什么程度、成品用什么分隔符、平均给几个短语。这一页纸就是后厨的菜谱卡。

4评价指标

训练开始之前就把合格线画出来,并留出一份不参与训练的评测集。指标选型见 2.5。没有评测集的项目,最后只能靠「我看着还行」验收。

四件事的顺序不能乱 必须是 任务边界 → 基座选型 → 数据格式 → 评价指标。边界决定了任务难度,难度决定了要多大的灶;灶的上下文长度上限反过来卡住原文能写多长;而指标必须贴着输出格式来定——输出是顿号短语,指标才可能是抽取 F1;如果输出是整段摘要,指标就得换成 ROUGE-L。倒过来定,一定会互相打架。

02原理:怎么挑灶、怎么写菜谱、怎么定合格线

选型的判断链、Qwen 三代的演进、参数量与显存的对应关系、三字段的写法约束、指标选型

2.1 基座选型的判断链

选型最容易犯的错,是看着一张「各尺寸模型对比表」从上往下挑,挑到一个「看起来不错」的。表是死的,判断链是活的——它是一串有先后顺序的问题,每回答一个就砍掉一半选项。

图② 基座选型的判断链:从设备到任务再到质量要求
图② 基座选型的判断链:从设备到任务再到质量要求

这条链一共三问,顺序不能换:

问1跑在什么设备上

这一问决定上限,且是硬约束。要塞进手机或 IoT 设备,那就只能在 0.5B1.8B 里选,后面两问都不用问了。跑在服务器上,才有往下挑的余地。

问2任务属于哪一类

这一问决定下限。摘要、抽取、分类这类有明确输入输出的转换型任务7B 就是性价比甜点;法律医疗这类需要长链条推理、术语密集的,才值得上 14B

问3还要不要更高的质量

这一问决定要不要付多卡的钱72B 的质量提升是真的,但代价是多卡并行;单卡又想要大模型的效果,路只有一条:MoE 稀疏激活

本项目的走法:问 1 答「服务器」,问 2 答「摘要 + 抽取」,问 3 答「不需要」——落在 Qwen-7B 上。这个结论不是拍的,是这三问一路砍下来的唯一剩余项。

⛔ 选型铁律:不是越大越好转换型任务上,模型尺寸的边际收益掉得极快。把 7B 换成 14B,抽取 F1 可能只涨一两个点,而显存翻倍、吞吐减半、成本翻几倍。真正能把 F1 往上拉的,几乎永远是数据质量而不是模型尺寸。灶再大,食材不新鲜也做不出好菜。
先小后大,不要反过来 正确的推进节奏是:拿最小的尺寸把数据管线和训练流程整条跑通(几百条样本、一轮,只为了验证格式没写错、注册没写错、能跑完不报错),确认无误后再换到目标尺寸做正式训练。反过来先上大模型,一个字段名写错就要浪费几小时。

2.2 Qwen 三代的演进

选「哪一代」和选「多大」是两件事。代次决定了上下文长度、多模态能力、量化生态这些能力边界,参数量决定了算力账

特性Qwen 1.0(2023)Qwen 2.0(2024)Qwen 3.0(2025 年中)
参数量范围1.8B~72B1.5B~72B0.5B~72B + MoE 架构
上下文长度4k~32k tokens8k~128k tokens128k~1M tokens(实验版)
多模态支持基础图文问答(Qwen-VL)增强图文 / 音频理解全模态(图 / 音 / 视频 / 3D)
训练数据规模3 万亿 tokens5 万亿 tokens8 万亿 tokens + 合成数据
推理效率优化GPTQ 4-bit 量化AWQ 4-bit + 动态量化动态稀疏计算(MoE)
垂直领域适配通用场景金融 / 医疗专用版行业工具链深度集成
开源协议Apache 2.0Apache 2.0商用友好 + 开发者激励计划

把这张表压成三句能用的判断:

  • 看上下文长度这一行选代次。 本项目要处理法律文书、科研文献这类长文本,原文动辄数千字,4k 显然不够——这一行直接把 1.0 排除掉了。
  • 看推理效率这一行选量化路线。 打算走 GPTQ 的,2.0 一代的生态最成熟;想吃 MoE 稀疏激活红利的,只能上 3.0。
  • 看开源协议这一行做商用合规。 要交付给客户的项目,这一行是法务要问的第一个问题,不是技术细节。
Qwen3 多出来的两个东西 一是思考模式与非思考模式可切换:复杂问题让它逐步推理,简单问题直接快答,等于给「思考预算」加了一个旋钮。二是原生支持 MCP 协议与工具调用,配套的 Qwen-Agent 框架把工具调用模板和解析器都封装好了。这两点对本项目的抽取任务用不上(抽取是典型的转换型任务,不需要长推理),但如果任务里有多步工具编排,代次选择的权重就要往 3.0 偏。

2.3 参数量与场景的对照

这张表的正确读法是从右往左读:先看手上有什么卡,再倒推能上多大的模型。

模型参数量适用场景硬件需求(FP16 推理)
Qwen3-0.5B0.5B边缘设备(手机 / IoT)、实时轻量任务显存 ≥ 2GB(量化后支持 1GB)
Qwen-1.8B1.8B轻量级任务(如对话、文本分类)、边缘设备部署显存 ≥ 4GB(如 RTX 3060)
Qwen-7B7B通用场景(摘要、信息抽取)、企业级服务显存 ≥ 10GB(如 RTX 3090)
Qwen-14B14B复杂语义理解(法律 / 医疗文本)、多模态任务显存 ≥ 24GB(如 A10/A100)
Qwen-72B72B高精度生成(创意写作、代码生成)多卡并行(如 8×A100 80GB)
Qwen3-MoE混合专家超高效率推理(稀疏激活,激活参数量 1.8B,等效 14B)显存 ≥ 8GB(RTX 4080)
⚠️ 这张表写的是「推理」显存,不是「训练」显存 表里 7B 写的 ≥10GB 指的是加载模型做推理。训练要贵得多——同一个 7B 模型,全量微调实测吃掉 42.18GB,LoRA 20.17GB,QLoRA 10.97GB。按推理显存去准备训练机器,一开训就会撞上 CUDA out of memory。这三个数字怎么来的、怎么压下去,是下一页的内容。
MoE 那一行怎么读 「激活参数量 1.8B,等效 14B」的意思是:模型总参数很大,但每次前向只激活其中一小部分专家,所以算得像 1.8B 那么快,答得像 14B 那么好。代价是总参数仍然要装进显存——省的是算力,不是全部的存储。还是餐厅那套说法:后厨挂了二十口锅,但每道菜只点着两口。

2.4 菜谱卡:三字段数据格式

本项目采用标准的指令微调(Instruction Tuning)格式,每个样本就三个字段。别看只有三个,每一个都有自己的写法约束,任何一条松了,模型学到的东西就会跟着松。

图③ 一条训练样本的三栏结构:指令 / 原文 / 成品
图③ 一条训练样本的三栏结构:指令 / 原文 / 成品
一条完整样本长什么样数据格式
[
  {
    "instruction": "请提取以下内容中的摘要信息",
    "input": "保持身体健康的五个方法:\n\n1. 每天至少饮用8杯水,促进新陈代谢\n2. 每周进行150分钟中等强度运动,如快走或游泳\n3. 保证7-9小时高质量睡眠,避免熬夜\n4. 饮食中增加蔬菜水果比例,减少油炸食品\n5. 定期体检,监测血压、血糖等指标",
    "output": "多喝水、规律运动、充足睡眠、均衡饮食、定期体检"
  },
  {
    "instruction": "请提取以下内容中的摘要信息",
    "input": "提高学习效率的三个技巧:\n\n1. 使用番茄工作法,每25分钟专注后休息5分钟\n2. 建立思维导图整理知识框架\n3. 睡前复习重点内容加强记忆",
    "output": "番茄工作法、思维导图、睡前复习"
  },
  {
    "instruction": "请提取以下内容中的摘要信息",
    "input": "旅行必备物品清单:\n1. 护照/身份证原件及复印件\n2. 便携充电宝和转换插头\n3. 常用药品(退烧药、创可贴)\n4. 轻便折叠雨伞\n5. 分装洗漱用品",
    "output": "证件、充电设备、药品、雨具、洗漱包"
  },
  {
    "instruction": "请提取以下内容中的摘要信息",
    "input": "职场沟通四大原则:\n① 明确沟通目标\n② 使用金字塔表达结构\n③ 注意非语言信号(眼神/姿态)\n④ 及时确认信息理解度",
    "output": "目标明确、结构化表达、非语言交流、信息确认"
  },
  {
    "instruction": "请提取以下内容中的摘要信息",
    "input": "TODO 把待处理的原文贴到这里。要求:\n1. 保留原文的换行与编号,不要压成一行\n2. 每条内容是动宾短语,带细节描述\n3. 长度控制在 cutoff_len 之内,中文按 1 字 ≈ 1 token 估",
    "output": "TODO、顿号分隔的核心短语、必须 100% 覆盖 input 条目、不加原文没有的信息"
  }
]

字段一:instruction —— 这道菜怎么做

作用是明确告知模型需要执行的任务类型。本项目全程只用一句:

约束为什么
固定句式,全数据集不变帮模型建立稳定的任务映射关系。映射一旦建稳,实际应用时即使用户换了说法(「请总结以下要点」「帮我提炼一下」),模型仍能正确响应;反之,训练时指令写法就有十几种,模型学到的是「指令这个位置随便写什么都行」,映射反而散了。
使用祈使句明确任务目标祈使句的信息密度最高、歧义最小。「请提取以下内容中的摘要信息」比「摘要」或者「下面这段话你看看」都好得多。
与后续 input 形成完整语义指令以「以下内容」结尾,正好承接下一字段。读起来是一句完整的话,而不是两段拼在一起的碎片。

字段二:input —— 给了哪些食材

作用是提供待处理的原始文本内容。核心约束只有一条:它是原文,不是你改写过的原文。

  • 保留换行和编号。 「1. 2. 3.」「① ② ③」这些结构信号是模型判断「原文列了几条」的依据,压成一行会直接削弱条目对齐能力。
  • 每条内容是动宾短语(动词 + 名词),并包含细节描述。「每天至少饮用 8 杯水,促进新陈代谢」这种带数字、带目的的写法,比干巴巴的「喝水」更能训练模型的提炼能力——它要学的正是「把细节压掉、把动作留下」。
  • 长度受 cutoff_len 约束。 训练配置里 cutoff_len: 1024,超出部分会被截断,而被截掉的往往正好是排在最后的 output——样本看着在,其实已经废了。所以构造数据时就要做长度过滤,这一步写在 build_dataset.py 里。

字段三:output —— 端出来该长什么样

作用是给出符合指令要求的预期结果。这是三个字段里约束最多的一个,因为模型最终学的就是它。

约束具体要求违反了会怎样
内容形态input 中提炼核心名词 / 动宾短语写成整句话,模型就会学着输出长句,抽取任务变成了摘要任务
分隔符中文顿号「、」一半样本用顿号一半用逗号,上线后输出的分隔符就会随机漂移,下游解析全炸
顺序无顺序要求(除非原文有优先级)强行要求顺序会逼模型学一个它无从判断的规则,反而增加噪声
数量平均 3~5 个短语数量分布过散,模型无法形成「该给几个」的稳定预期
覆盖率100% 覆盖 input 条目原文列了 5 条只标了 3 条,等于在教模型「漏掉两条也算对」
不编造不添加原文未提及的信息这是抽取类任务的底线。标注里出现一次编造,模型就学会了幻觉
⛔ 覆盖率是条目级的,不是字面级的 「100% 覆盖 input 条目」指的是原文列了几条,成品就要给几个短语,不是要求短语的字必须出现在原文里。原文写「每天至少饮用 8 杯水」,标成「多喝水」是正确的概括,尽管两者字面几乎不重合。把覆盖率理解成字面包含,做出来的校验脚本会把正确标注全判成错——这一点在 check_dataset.py 里踩过,见 4.5。

2.5 合格线画在哪:评价指标怎么定

指标必须贴着输出格式来定。本项目的输出是「顿号分隔的短语列表」,同时又归在「文本摘要」这个大类下,所以两套指标都要看。

指标量什么怎么算本项目里的角色
F1关键信息抽取的准不准、全不全把预测短语集合与标注短语集合求交集,精确率与召回率的调和平均主指标。因为输出本来就是一个短语集合,天然适合用集合指标去量
ROUGE-L生成摘要与参考摘要的最长公共子序列重合度按 LCS 算召回与精确,再调和辅指标。用来盯住「会不会越写越长、越写越飘」
人工抽检有没有编造随机抽 100 条,逐条看有没有原文没有的信息兜底。编造在 F1 上可能只掉一两个点,但在业务上是致命的
⚠️ 评测集必须从一开始就切出来,而且不参与训练 训练配置里的 val_size: 0.1 划出来的那 10% 是验证集,用来在训练过程中盯 loss、判断有没有过拟合,它每一轮都被看到。验收用的评测集要另外切一份,全程不进训练。拿验证集当评测集报出来的分,只能说明模型记住了这批数据,不能说明它在新数据上行。

03最小代码:把一条样本变成合法的训练数据

十几行看懂三字段是怎么落成文件的,以及为什么必须在这一步就做长度过滤

数据工程听起来很重,但剥掉清洗、增强、统计之后,最核心的动作只有一个:把「原文 + 标注」拼成一个三字段字典,攒成一个 JSON 数组落盘。下面这十几行就是全部:

最小可运行版本
import json

INSTRUCTION = "请提取以下内容中的摘要信息"   # 全数据集固定这一句

raw = [
    ("提高学习效率的三个技巧:\n1. 番茄工作法\n2. 思维导图\n3. 睡前复习",
     ["番茄工作法", "思维导图", "睡前复习"]),
]

samples = []
for text, phrases in raw:
    if not (20 <= len(text) <= 900):       # 长度过滤,挡住会被截断的样本
        continue
    samples.append({
        "instruction": INSTRUCTION,
        "input": text.strip(),              # 原文,保留换行与编号
        "output": "、".join(phrases),       # 顿号分隔,不是逗号
    })

with open("qwen_dataset.json", "w", encoding="utf-8") as f:
    json.dump(samples, f, ensure_ascii=False, indent=2)   # 必须关掉 ASCII 转义

这段代码里有四处是写错了当时不报错、训完才发现的地方,逐个说清楚:

代码位置写错的样子后果
INSTRUCTION 提到循环外在循环里根据内容拼不同的指令任务映射被打散。模型学到「指令这一栏爱写什么写什么」,上线后换个说法就失效
长度过滤不过滤,全都收超长样本的尾部被 cutoff_len 截掉,截掉的正好是排在最后的 output。样本还在,但等于喂了一条没有答案的题
"、".join(...)", ".join(...)分隔符不统一,模型输出的分隔符会随机漂移,下游按顿号切分的代码全部失效
ensure_ascii=False用默认值 True中文全部变成 \u5b66\u4e60 这样的转义串。训练框架能读,但人没法复查,标注错误从此无法用肉眼发现
⚠️ 落盘格式:JSON 数组还是 JSON Lines 两种都行,但在一个项目里只能选一种。JSON 数组([{...}, {...}])整体读入,适合几千到几万条;JSON Lines(一行一个对象)可以流式读、可以用 wc -l 直接数条数,适合更大的量。注册时 file_name 指向哪个后缀,框架就按哪种解析——文件内容是数组但文件名写成 .jsonl,会直接解析失败
为什么 inputstrip() 而不是整体清洗 strip() 只去掉首尾空白,属于「摆盘」;正文里的换行、编号、全角符号一个都不能动,因为它们是模型判断条目边界的依据。真正的清洗(去广告、去乱码、去重复段落)应该发生在更早的一步——原始语料入库的时候,而不是在构造训练样本的时候顺手做。两件事混在一起,出了问题你分不清是清洗规则错了还是构造逻辑错了。

04完整案例:这个项目的数据工程怎么落地

从任务定义到语料入库、增强、构造、体检、注册,最后串起整条技术路线

4.1 落地任务与应用场景

前面讲的四件事,到这里开始填具体内容。本项目的任务边界是两件事:文本摘要关键信息抽取。它要解决的现实问题是信息过载——人每天面对大量文本,把关键信息自动提出来,能直接省掉阅读时间。

01新闻媒体

自动生成新闻要点,提升内容分发效率。特点是量大、时效强,对吞吐的要求高于对精度的要求。

02金融分析

从财报、研报中提取关键指标与风险事件。特点是不许编造,一个凭空多出来的数字就可能引出决策事故。

03医疗健康

解析病历、文献,生成患者病情摘要。特点是术语密集,通用模型不微调几乎用不了。

04法律文书

快速抽取案件核心信息,辅助法律检索。特点是原文极长,这是选长上下文代次的直接理由。

05企业知识库

自动化归档会议记录、技术文档。特点是私有数据,数据出不了内网,这是选开源模型私有化部署的直接理由。

06共同点

五个场景形态各异,但输入输出的形状完全一样:给一段原文,要一串核心短语。这就是把它们合成一个任务来训的底气。

回到 1.1 的判断题:这五个场景为什么不用提示词或检索解决?因为它们要的不是「知道更多」,而是每一次都按同样的格式吐出同样颗粒度的短语——这是典型的行为型缺口,正是微调的主场。

为什么选 Qwen 作为基座 三条理由,每一条都对应上面某个场景的硬需求:支持长文本输入(最高 32k tokens),适配法律文书、科研文献这类复杂语义场景;多语言能力与领域泛化性强,覆盖中英文混合文本;开源生态完善,便于二次开发与优化,也意味着企业知识库场景下数据可以完全不出内网。

4.2 数据来源与清洗标注

备料间分两条进货渠道,用途完全不同:

来源具体作用与注意
公开数据集CN-DBpedia(中文实体识别)、LCSTS(摘要生成)、领域定制语料(如金融 / 医疗文本)用来打底,把通用的中文语感和抽取习惯先喂上。量大、免费,但颗粒度不一定贴合你的业务
企业私有数据脱敏后的内部文档、用户日志等用来贴合业务,决定了模型上线后好不好用。必须脱敏——姓名、手机号、身份证、账号一律替换掉,否则这些信息会被写进权重,之后再也删不掉
⛔ 脱敏必须在入库前做,不能在训练前做 权重一旦训进去,没有任何办法把某一条信息从模型里删掉,只能整个重训。所以脱敏这一刀要砍在语料进备料间的那一刻,而不是等到构造样本时才想起来。这一条和「不加原文没有的信息」是本项目数据侧仅有的两条不可退让的规矩。

清洗与标注的做法是人工 + 弱监督结合

  • 清洗去除噪声数据——广告尾巴、重复段落、编码乱码、空壳文档。这一步在语料入库时做,不要拖到构造样本时。
  • 标注实体、关系及摘要标签。弱监督负责把量铺开(用规则或大模型先批量打一版),人工负责把质量兜住(抽检并修正,重点修「漏标」和「编造」这两类)。

4.3 数据增强的两把刀

标注贵,所以要用增强把有限的标注撑开。本项目用两种,它们撑的是不同的维度

手法怎么做撑开了什么要当心什么
回译
Back Translation
中文原文 → 译成英文 → 再译回中文,得到一条说法不同、意思相同的新原文句式与措辞的多样性。让模型明白「同一个意思换个说法,该抽的短语还是那几个」回译会改写原文的用词。原文里的专有名词、数字、单位要锁住不译,否则「8 杯水」可能变成「八杯水」甚至「一些水」,标注就对不上了
实体替换
Entity Swapping
把原文里的实体换成同类型的另一个(公司 A 换成公司 B,北京换成上海)实体的覆盖面。防止模型把某个具体实体名和某种输出模式绑死标注要跟着一起换。原文换了实体而 output 里还留着旧实体,就制造了一条「编造」样本,比不增强还糟
⚠️ 增强出来的样本不能进评测集 评测集必须全部是真实的、没被改写过的数据。拿回译样本去评测,等于自己出题自己判卷——分数会虚高,因为增强样本和训练样本本来就同源。增强只服务训练集,这条界线要在切分数据的那一步就划好。

4.4 构造数据集:build_dataset.py

把 03 节那十几行扩成能用的版本,它多做了四件事:字段规范化、长度与数量过滤、可选打乱、落盘统计。

build_dataset.py —— 把原始语料构造成三字段数据集可直接运行
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""build_dataset.py —— 把原始文本构造成 instruction / input / output 三字段指令微调数据集。

输入:一份 JSON Lines 原始语料,每行至少包含正文字段(默认 `text`),
      可选地包含已标注好的关键短语字段(默认 `phrases`,列表或顿号分隔字符串)。
输出:LLaMA-Factory 可直接注册的 JSON 数组文件(一个 list,元素是三字段字典)。

运行(不需要 GPU,纯标准库):
    python3 build_dataset.py --raw raw.jsonl --out qwen_dataset.json
    python3 build_dataset.py --raw raw.jsonl --out qwen_dataset.json --demo

设计上只做三件事:规范化字段、按长度过滤、落盘并打印统计。
标注质量本身不是脚本能保证的,它只保证"格式是对的、长度是可训的"。
"""

import argparse
import json
import os
import random
import re
import sys
from collections import Counter

# ---------------------------------------------------------------------------
# 1. 固定指令:同一个任务只用同一句祈使句
# ---------------------------------------------------------------------------
# 指令固定不变,模型才能建立稳定的任务映射;换着花样写指令会让映射变松散。
INSTRUCTION = "请提取以下内容中的摘要信息"

# output 里多个短语之间的分隔符,全站统一为中文顿号
SEP = "、"

# 长度过滤阈值(按字符计,粗略对应 token 的 0.6~1.0 倍,中文偏 1.0)
MIN_INPUT_CHARS = 20
MAX_INPUT_CHARS = 900        # 与训练配置里的 cutoff_len: 1024 留出余量
MIN_OUTPUT_PHRASES = 2
MAX_OUTPUT_PHRASES = 12


# ---------------------------------------------------------------------------
# 2. 字段规范化
# ---------------------------------------------------------------------------
def normalize_input(text):
    """把正文规整成干净的多行文本。

    做三件事:统一换行、压掉连续空行、去掉首尾空白。
    不改写正文内容本身 —— input 就是"待处理的原文",改了它标注就对不上了。
    """
    if not isinstance(text, str):
        return ""
    text = text.replace("\r\n", "\n").replace("\r", "\n")
    text = re.sub(r"\n{3,}", "\n\n", text)
    text = re.sub(r"[ \t]+\n", "\n", text)
    return text.strip()


def normalize_output(phrases):
    """把关键短语规整成顿号分隔的一行字符串。

    接受三种输入形态:列表、顿号/逗号分隔的字符串、None。
    顺带去重并保持原顺序 —— 重复短语会让模型学到"可以啰嗦"。
    """
    if phrases is None:
        return ""
    if isinstance(phrases, str):
        parts = re.split(r"[、,,;;]", phrases)
    elif isinstance(phrases, (list, tuple)):
        parts = list(phrases)
    else:
        return ""

    seen, kept = set(), []
    for p in parts:
        p = str(p).strip().strip("。.,,、")
        if p and p not in seen:
            seen.add(p)
            kept.append(p)
    return SEP.join(kept)


# ---------------------------------------------------------------------------
# 3. 单条样本的构造与过滤
# ---------------------------------------------------------------------------
def build_sample(record, text_key="text", phrase_key="phrases"):
    """把一条原始记录变成三字段样本;不合格返回 (None, 原因)。"""
    raw_input = normalize_input(record.get(text_key))
    raw_output = normalize_output(record.get(phrase_key))

    if not raw_input:
        return None, "input 为空"
    if not raw_output:
        return None, "output 为空(未标注)"

    n = len(raw_input)
    if n < MIN_INPUT_CHARS:
        return None, "input 过短"
    if n > MAX_INPUT_CHARS:
        return None, "input 过长"

    phrases = raw_output.split(SEP)
    if len(phrases) < MIN_OUTPUT_PHRASES:
        return None, "短语数过少"
    if len(phrases) > MAX_OUTPUT_PHRASES:
        return None, "短语数过多"

    sample = {
        "instruction": INSTRUCTION,
        "input": raw_input,
        "output": raw_output,
    }
    return sample, None


# ---------------------------------------------------------------------------
# 4. 读入 / 写出
# ---------------------------------------------------------------------------
def read_jsonl(path):
    """逐行读 JSON Lines;坏行跳过并计数,不让一行脏数据毁掉整个构建。"""
    rows, bad = [], 0
    with open(path, "r", encoding="utf-8") as f:
        for line in f:
            line = line.strip()
            if not line:
                continue
            try:
                rows.append(json.loads(line))
            except json.JSONDecodeError:
                bad += 1
    if bad:
        print("[warn] 跳过无法解析的行:%d 行" % bad, file=sys.stderr)
    return rows


def demo_records():
    """内置样例,方便没有原始语料时先把流程跑通一遍。"""
    return [
        {
            "text": "保持身体健康的五个方法:\n"
                    "1. 每天至少饮用8杯水,促进新陈代谢\n"
                    "2. 每周进行150分钟中等强度运动,如快走或游泳\n"
                    "3. 保证7-9小时高质量睡眠,避免熬夜\n"
                    "4. 饮食中增加蔬菜水果比例,减少油炸食品\n"
                    "5. 定期体检,监测血压、血糖等指标",
            "phrases": ["多喝水", "规律运动", "充足睡眠", "均衡饮食", "定期体检"],
        },
        {
            "text": "提高学习效率的三个技巧:\n"
                    "1. 使用番茄工作法,每25分钟专注后休息5分钟\n"
                    "2. 建立思维导图整理知识框架\n"
                    "3. 睡前复习重点内容加强记忆",
            "phrases": "番茄工作法、思维导图、睡前复习",
        },
        {
            "text": "旅行必备物品清单:\n"
                    "1. 护照/身份证原件及复印件\n"
                    "2. 便携充电宝和转换插头\n"
                    "3. 常用药品(退烧药、创可贴)\n"
                    "4. 轻便折叠雨伞\n"
                    "5. 分装洗漱用品",
            "phrases": ["证件", "充电设备", "药品", "雨具", "洗漱包"],
        },
        {
            "text": "职场沟通四大原则:\n"
                    "① 明确沟通目标\n"
                    "② 使用金字塔表达结构\n"
                    "③ 注意非语言信号(眼神/姿态)\n"
                    "④ 及时确认信息理解度",
            "phrases": ["目标明确", "结构化表达", "非语言交流", "信息确认"],
        },
        {
            "text": "太短",   # 故意留一条会被长度过滤掉的
            "phrases": ["甲", "乙"],
        },
    ]


def summarize(samples):
    """打印一份可以贴进日志的统计,用来判断数据能不能开训。"""
    if not samples:
        print("没有产出任何样本。")
        return

    in_lens = sorted(len(s["input"]) for s in samples)
    out_cnt = sorted(len(s["output"].split(SEP)) for s in samples)

    def pct(arr, p):
        return arr[min(len(arr) - 1, int(len(arr) * p))]

    print("\n样本总数:%d" % len(samples))
    print("input  长度  min/中位/p95/max = %d / %d / %d / %d"
          % (in_lens[0], pct(in_lens, 0.5), pct(in_lens, 0.95), in_lens[-1]))
    print("output 短语数 min/中位/p95/max = %d / %d / %d / %d"
          % (out_cnt[0], pct(out_cnt, 0.5), pct(out_cnt, 0.95), out_cnt[-1]))

    dist = Counter(out_cnt)
    print("短语数分布:" + "  ".join("%d个:%d条" % (k, dist[k]) for k in sorted(dist)))

    inst = Counter(s["instruction"] for s in samples)
    print("instruction 种类数:%d(应为 1,多于 1 说明任务映射被写散了)" % len(inst))


def main():
    ap = argparse.ArgumentParser(description="构造 instruction/input/output 三字段微调数据集")
    ap.add_argument("--raw", help="原始语料 JSON Lines 路径")
    ap.add_argument("--out", default="qwen_dataset.json", help="输出的数据集 JSON 路径")
    ap.add_argument("--text-key", default="text", help="原始记录里正文字段名")
    ap.add_argument("--phrase-key", default="phrases", help="原始记录里关键短语字段名")
    ap.add_argument("--shuffle", action="store_true", help="落盘前打乱顺序")
    ap.add_argument("--seed", type=int, default=42, help="打乱用的随机种子")
    ap.add_argument("--demo", action="store_true", help="不读文件,用内置样例跑一遍")
    args = ap.parse_args()

    if args.demo:
        records = demo_records()
    elif args.raw:
        records = read_jsonl(args.raw)
    else:
        ap.error("要么给 --raw,要么加 --demo")
        return

    samples, rejects = [], Counter()
    for rec in records:
        sample, why = build_sample(rec, args.text_key, args.phrase_key)
        if sample is None:
            rejects[why] += 1
        else:
            samples.append(sample)

    if args.shuffle:
        random.Random(args.seed).shuffle(samples)

    out_dir = os.path.dirname(os.path.abspath(args.out))
    if out_dir:
        os.makedirs(out_dir, exist_ok=True)
    with open(args.out, "w", encoding="utf-8") as f:
        json.dump(samples, f, ensure_ascii=False, indent=2)

    print("已写出:%s" % args.out)
    if rejects:
        print("被过滤:" + "  ".join("%s=%d" % (k, v) for k, v in rejects.items()))
    summarize(samples)


if __name__ == "__main__":
    main()

不带原始语料也能先跑通一遍,用内置样例:

跑一遍看看输出
$ python3 build_dataset.py --demo --out qwen_dataset.json
已写出:qwen_dataset.json
被过滤:input 过短=1

样本总数:4
input  长度  min/中位/p95/max = 59 / 76 / 120 / 120
output 短语数 min/中位/p95/max = 3 / 5 / 5 / 5
短语数分布:3个:1条  4个:1条  5个:2条
instruction 种类数:1(应为 1,多于 1 说明任务映射被写散了)

这份输出里,最后一行才是最该盯的。前面几行长度统计只是让你确认没人写出超长样本,而「instruction 种类数」只要不是 1,就说明有人在标注时自己改了指令句式——这件事在训练日志里看不出来,在这里一眼就能发现。

脚本里的关键决定为什么这么定
MAX_INPUT_CHARS = 900训练配置 cutoff_len: 1024,中文按 1 字符 ≈ 1 token 保守估,留出 100 多的余量给指令和输出。不是拍脑袋定的,是从训练参数倒推的
MIN_OUTPUT_PHRASES = 2只给一个短语的样本没有「列表」的形态,训多了模型会倾向只答一个
normalize_output 里去重重复短语会教模型「可以啰嗦」。顺序保持原样,因为标注规范里写明无顺序要求
--shuffle 带固定 --seed打乱是为了避免同类样本扎堆影响训练;固定种子是为了这次和下次构造出来的文件完全一致,方便定位问题
坏行跳过并计数一行脏数据不该毁掉整次构建,但也不能静默吞掉——所以打到 stderr 上让人看见

4.5 开训前体检:check_dataset.py

构造完不等于能训。开炉前还要过一遍体检,四项检查各自对应一类真实会翻车的情况:

检查项查什么不查会怎样
A 字段完整性缺字段、类型不对、output 空串、instruction 不统一output 是在直接教模型「可以什么都不答」
B 条目级覆盖率原文列了几条,成品给了几个短语系统性漏标,模型学会「漏两条也算对」
C 长度分布三字段合计长度的直方图与超限条数cutoff_len 截断的样本混在里面,看不出来
D 重复与冲突完全重复的样本、同一原文对应不同成品重复导致局部过拟合;标注冲突让模型学得摇摆
check_dataset.py —— 开训前把数据集体检一遍可直接运行
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""check_dataset.py —— 开训之前把数据集体检一遍。

检查四件事,每一件都对应一个真实会翻车的点:
  A. 字段完整性     —— 缺字段 / 字段类型不对 / instruction 不统一
  B. output 覆盖率  —— input 里列了几条,output 就该给几个短语(条目级覆盖)
  C. 长度分布       —— input 会不会被 cutoff_len 截断
  D. 重复与泄漏     —— 完全重复的样本、input 相同 output 不同的冲突标注

运行(纯标准库,不需要 GPU):
    python3 check_dataset.py qwen_dataset.json
    python3 check_dataset.py qwen_dataset.json --cutoff 1024 --strict
退出码:0 = 通过;1 = 有致命问题(--strict 下警告也算不通过)。
"""

import argparse
import json
import re
import sys
from collections import Counter, defaultdict

SEP = "、"
REQUIRED_KEYS = ("instruction", "input", "output")


# ---------------------------------------------------------------------------
# A. 字段完整性
# ---------------------------------------------------------------------------
def check_fields(data):
    errors, warns = [], []

    if not isinstance(data, list):
        errors.append("顶层结构不是 list —— LLaMA-Factory 的 alpaca 风格数据集要求一个 JSON 数组")
        return errors, warns

    for i, item in enumerate(data):
        if not isinstance(item, dict):
            errors.append("第 %d 条不是对象" % i)
            continue
        for k in REQUIRED_KEYS:
            if k not in item:
                errors.append("第 %d 条缺字段 %s" % (i, k))
            elif not isinstance(item[k], str):
                errors.append("第 %d 条字段 %s 不是字符串(是 %s)" % (i, k, type(item[k]).__name__))
        if isinstance(item.get("input"), str) and not item["input"].strip():
            warns.append("第 %d 条 input 为空串" % i)
        if isinstance(item.get("output"), str) and not item["output"].strip():
            errors.append("第 %d 条 output 为空串 —— 空标注会直接教坏模型" % i)

    insts = Counter(x.get("instruction") for x in data if isinstance(x, dict))
    if len(insts) > 1:
        warns.append("instruction 有 %d 种写法,建议同一任务只用一句固定祈使句:%s"
                     % (len(insts), list(insts)[:3]))
    return errors, warns


# ---------------------------------------------------------------------------
# B. output 覆盖率
# ---------------------------------------------------------------------------
# input 里一个"条目"的开头长什么样:1. / 2、/ ① / - / • / (3)
ITEM_HEAD = re.compile(
    r"^\s*(?:\d+\s*[\.、)\)]|[((]\s*\d+\s*[))]|[①-⑳]|[-*•·])\s*\S"
)


def count_items(input_text):
    """数一数 input 里列了几个条目。

    标注规范要求 output 100% 覆盖 input 条目,所以"条目数"才是覆盖率的分母。
    注意不能用字面包含去判断 output 短语 —— 标注本来就是概括,
    「每天至少饮用8杯水」概括成「多喝水」,字面一个都对不上,那是正确标注。
    识别不出条目(整段散文)时返回 0,此时跳过覆盖率判断。
    """
    return sum(1 for line in input_text.split("\n") if ITEM_HEAD.match(line))


def _charset(s):
    """取中文字符与字母数字,用于"零交集"这一条最保守的编造信号。"""
    return set(re.findall(r"[\u4e00-\u9fff0-9A-Za-z]", s))


def check_coverage(data, sample_limit=20):
    warns, mismatched, orphan = [], [], []
    ratios = []

    for i, item in enumerate(data):
        if not isinstance(item, dict):
            continue
        inp = item.get("input") or ""
        out = item.get("output") or ""
        if not isinstance(inp, str) or not isinstance(out, str) or not out:
            continue

        phrases = [p for p in out.split(SEP) if p.strip()]
        if not phrases:
            continue

        # B-1 条目级覆盖:列了几条就该给几个短语
        n_items = count_items(inp)
        if n_items:
            ratio = min(1.0, len(phrases) / n_items)
            ratios.append(ratio)
            if len(phrases) != n_items and len(mismatched) < sample_limit:
                mismatched.append((i, n_items, len(phrases)))

        # B-2 最保守的编造信号:短语与原文一个字符都不沾
        inp_chars = _charset(inp)
        for p in phrases:
            cs = _charset(p)
            if cs and not (cs & inp_chars) and len(orphan) < sample_limit:
                orphan.append((i, p))

    if ratios:
        avg = sum(ratios) / len(ratios)
        full = sum(1 for r in ratios if r >= 0.999)
        print("条目级覆盖率:平均 %.1f%%,完全覆盖的样本 %d/%d%.1f%%)"
              % (avg * 100, full, len(ratios), full / len(ratios) * 100))
        if avg < 0.95:
            warns.append("平均条目覆盖率 %.1f%% 偏低,说明有 input 条目在 output 里没被提到" % (avg * 100))
    else:
        print("条目级覆盖率:input 未使用编号/项目符号,跳过该项检查")

    if mismatched:
        warns.append("以下样本的条目数与短语数对不上(前 %d 条):" % len(mismatched))
        for i, a, b in mismatched:
            warns.append("    第 %d 条 → input %d 条目 / output %d 短语" % (i, a, b))
    if orphan:
        warns.append("以下短语与原文没有任何共同字符,疑似凭空添加(前 %d 条):" % len(orphan))
        for i, p in orphan:
            warns.append("    第 %d 条 → 「%s」" % (i, p))
    return warns


# ---------------------------------------------------------------------------
# C. 长度分布直方图
# ---------------------------------------------------------------------------
def histogram(values, bins=10, width=46, title="分布"):
    """纯文本直方图,方便直接贴进终端日志。"""
    if not values:
        return
    lo, hi = min(values), max(values)
    if lo == hi:
        print("%s:全部等于 %d" % (title, lo))
        return
    step = (hi - lo) / bins
    counts = [0] * bins
    for v in values:
        idx = min(bins - 1, int((v - lo) / step))
        counts[idx] += 1
    peak = max(counts) or 1
    print("\n%s(共 %d 条)" % (title, len(values)))
    for b in range(bins):
        left = lo + step * b
        right = lo + step * (b + 1)
        bar = "#" * int(counts[b] / peak * width)
        print("  %7.0f ~ %7.0f | %-*s %d" % (left, right, width, bar, counts[b]))


def check_length(data, cutoff):
    """按字符估算长度;中文场景下 1 字符 ≈ 1 token 是个偏保守的估计。"""
    warns = []
    lens = []
    over = 0
    for item in data:
        if not isinstance(item, dict):
            continue
        total = len(item.get("instruction") or "") + len(item.get("input") or "") \
            + len(item.get("output") or "")
        lens.append(total)
        if total > cutoff:
            over += 1
    if lens:
        lens_sorted = sorted(lens)
        p95 = lens_sorted[min(len(lens_sorted) - 1, int(len(lens_sorted) * 0.95))]
        print("\n三字段合计长度:中位 %d,p95 %d,最长 %d(字符)"
              % (lens_sorted[len(lens_sorted) // 2], p95, lens_sorted[-1]))
        histogram(lens, title="长度直方图")
        if over:
            warns.append("有 %d 条(%.1f%%)超过 cutoff_len=%d,训练时尾部会被截断,"
                         "被截掉的正好是 output —— 要么调大 cutoff_len,要么把长样本拆开"
                         % (over, over / len(lens) * 100, cutoff))
    return warns


# ---------------------------------------------------------------------------
# D. 重复与冲突
# ---------------------------------------------------------------------------
def check_duplicate(data):
    warns = []
    whole = Counter()
    by_input = defaultdict(set)
    for item in data:
        if not isinstance(item, dict):
            continue
        key = (item.get("instruction"), item.get("input"), item.get("output"))
        whole[key] += 1
        by_input[item.get("input")].add(item.get("output"))

    dup = sum(c - 1 for c in whole.values() if c > 1)
    if dup:
        warns.append("完全重复的样本 %d 条 —— 重复会让模型对这部分过拟合" % dup)

    conflict = [k for k, v in by_input.items() if len(v) > 1]
    if conflict:
        warns.append("同一 input 对应多个不同 output 的有 %d 组 —— 标注冲突,模型会学得摇摆" % len(conflict))
    return warns


def main():
    ap = argparse.ArgumentParser(description="指令微调数据集体检")
    ap.add_argument("path", help="数据集 JSON 路径")
    ap.add_argument("--cutoff", type=int, default=1024, help="训练配置里的 cutoff_len")
    ap.add_argument("--strict", action="store_true", help="把警告也当作不通过")
    args = ap.parse_args()

    with open(args.path, "r", encoding="utf-8") as f:
        data = json.load(f)

    print("=" * 62)
    print("数据集:%s" % args.path)
    print("=" * 62)

    errors, warns = check_fields(data)
    if not errors:
        warns += check_coverage(data)
        warns += check_length(data, args.cutoff)
        warns += check_duplicate(data)

    print("\n" + "-" * 62)
    if errors:
        print("致命问题 %d 项:" % len(errors))
        for e in errors[:40]:
            print("  [x] " + e)
    if warns:
        print("警告 %d 项:" % len(warns))
        for w in warns[:60]:
            print("  [!] " + w)
    if not errors and not warns:
        print("全部通过,可以开训。")

    bad = bool(errors) or (args.strict and bool(warns))
    sys.exit(1 if bad else 0)


if __name__ == "__main__":
    main()
体检输出
$ python3 check_dataset.py qwen_dataset.json --cutoff 1024
==============================================================
数据集:qwen_dataset.json
==============================================================
条目级覆盖率:平均 100.0%,完全覆盖的样本 4/4(100.0%)

三字段合计长度:中位 106,p95 156,最长 156(字符)

长度直方图(共 4 条)
       93 ~      99 | ############################################## 2
       99 ~     106 |                                                0
      106 ~     112 | #######################                        1
      ...
      150 ~     156 | #######################                        1

--------------------------------------------------------------
全部通过,可以开训。
⛔ 覆盖率判据写错过一次,值得单独说 这个脚本最早版本用的是「output 短语的字,能不能在 input 里找到」做覆盖率。结果在完全正确的样本上判出 75%:原文写「每天至少饮用 8 杯水」,标注「多喝水」,字面覆盖率只有 0.33,被当成编造报了出来。
判据本身是错的。标注的本职就是概括,字面对不上恰恰说明概括做对了。改成条目级覆盖——数原文有几个编号条目,比对成品给了几个短语——同一批样本立刻变成 100%。
留下的教训:校验脚本误报比不报更危险,因为它会逼着标注员去迎合一个错的规则,把正确的概括改成机械的复制。

脚本里仍然保留了一条最保守的编造信号:短语与原文一个字符都不沾时才报出来。这条规则不会误伤概括(「多喝水」和原文共享「水」字),只会抓到真正凭空添加的内容。

4.6 把数据集登记到菜单上

菜做好了,还得上菜单,训练框架才找得到。LLaMA-Factory 靠 data/dataset_info.json 这个登记表定位数据集——在文件尾部追加一段键值对即可:

data/dataset_info.json 追加内容
 "qwen_dataset": {
    "file_name": "qwen_dataset.json"
  },

两个字段各管一头:

位置含义谁来引用它
键名 "qwen_dataset"数据集名称训练配置 yaml 里 dataset: 填的就是它。两边对不上,训练直接报找不到数据集
"file_name"数据集文件框架去 data/ 目录下找这个文件。放在别处就要在训练时用 --dataset_dir 指定目录

如果数据落的是 JSON Lines,写法一样,只是 file_name 指向 .jsonl

jsonl 形式的登记写法
{
  "chat-train": {
    "file_name": "train.jsonl"
  }
}

对应的数据文件每行一个对象:

train.jsonl
{"instruction": "提出问题1", "input": "", "output": "对应的答案1"}
{"instruction": "提出问题2", "input": "", "output": "对应的答案2"}
⚠️ 注册这一步的三个高频事故 一、整份覆盖官方文件。 dataset_info.json 里本来有上百个内置数据集条目,直接覆盖会把它们全删掉。要追加,不要覆盖。
二、追加时漏了逗号。 在最后一个条目后面直接粘一段,JSON 立刻解析失败。粘完务必跑一次 python3 -c "import json;json.load(open('data/dataset_info.json',encoding='utf-8'))"
三、字段名与 alpaca 默认不一致却没写 columns 映射。 你的文件里叫 prompt/query/response,框架默认找 instruction/input/output,读出来全是空的——训练照常跑完,loss 也在降,但学到的是噪声。

4.7 技术路线全景与项目指标

数据备齐、菜单登记完,立项会的活就干完了。往后的路线全景长这样:

图④ 从原始语料到线上服务的完整链路
图④ 从原始语料到线上服务的完整链路
阶段技术选择这一步解决什么
训练LoRA + DeepSpeed冻结主干参数,只训低秩矩阵,显存降下来;配合 ZeRO 显存优化与梯度检查点,把训练塞进有限的卡里
压缩LoraQ + GPTQLoraQ 针对 LoRA 适配器做 4-bit 量化,压缩微调参数体积;GPTQ 对主干做 4-bit 权重量化,换来推理提速与显存下降
部署vLLM基于 PagedAttention 支持批量请求并行处理,把吞吐量顶上去

项目给出的性能指标如下。这是该项目在自己的数据和硬件上报出的目标 / 实测值,不是这条技术路线的普适结论——换一份数据、换一张卡、换一个 batch 配置,数字都会变:

指标Qwen-7B(原始)Qwen-7B(量化+LoRA)怎么读这一行
显存占用(推理)16GB6GB降到原来的三分之一多一点,意味着从 A10 级别的卡下放到消费级显卡
吞吐量(tokens/s)120320快到约 2.7 倍。这里同时吃到了量化和推理引擎两份红利,不能全记在量化头上
实体识别 F192.1%91.5%掉了 0.6 个点。这就是为什么 2.5 节要求先画合格线——掉多少算可以接受,必须提前定
摘要 ROUGE-L78.377.8掉了 0.5。与 F1 一起看,说明压缩的代价是均匀的,没有某一类任务被打崩
这张表真正的用法 不要背这几个数字,要记住这张表的形状:左边一列是成本(显存、吞吐),右边一列是质量(F1、ROUGE-L);压缩永远是拿右边换左边。立项时该做的判断是「我的业务能接受右边掉几个点」,然后再去挑能满足这个约束的压缩力度。反过来先定压缩方案再看掉了多少,就只能被动接受结果。

05骨架模板:拿去改就能用

一份样本模板、一份登记表模板,改完 TODO 就能接上训练

5.1 样本模板

发给标注员的那「一页纸」就是它。前四条是标好的范例,最后一条留着 TODO 占位,写清楚每一栏的约束——标注员照着填,不用再去读规范文档。

sample_dataset.template.json —— 样本模板,最后一条是填空位可复用模板
[
  {
    "instruction": "请提取以下内容中的摘要信息",
    "input": "保持身体健康的五个方法:\n\n1. 每天至少饮用8杯水,促进新陈代谢\n2. 每周进行150分钟中等强度运动,如快走或游泳\n3. 保证7-9小时高质量睡眠,避免熬夜\n4. 饮食中增加蔬菜水果比例,减少油炸食品\n5. 定期体检,监测血压、血糖等指标",
    "output": "多喝水、规律运动、充足睡眠、均衡饮食、定期体检"
  },
  {
    "instruction": "请提取以下内容中的摘要信息",
    "input": "提高学习效率的三个技巧:\n\n1. 使用番茄工作法,每25分钟专注后休息5分钟\n2. 建立思维导图整理知识框架\n3. 睡前复习重点内容加强记忆",
    "output": "番茄工作法、思维导图、睡前复习"
  },
  {
    "instruction": "请提取以下内容中的摘要信息",
    "input": "旅行必备物品清单:\n1. 护照/身份证原件及复印件\n2. 便携充电宝和转换插头\n3. 常用药品(退烧药、创可贴)\n4. 轻便折叠雨伞\n5. 分装洗漱用品",
    "output": "证件、充电设备、药品、雨具、洗漱包"
  },
  {
    "instruction": "请提取以下内容中的摘要信息",
    "input": "职场沟通四大原则:\n① 明确沟通目标\n② 使用金字塔表达结构\n③ 注意非语言信号(眼神/姿态)\n④ 及时确认信息理解度",
    "output": "目标明确、结构化表达、非语言交流、信息确认"
  },
  {
    "instruction": "请提取以下内容中的摘要信息",
    "input": "TODO 把待处理的原文贴到这里。要求:\n1. 保留原文的换行与编号,不要压成一行\n2. 每条内容是动宾短语,带细节描述\n3. 长度控制在 cutoff_len 之内,中文按 1 字 ≈ 1 token 估",
    "output": "TODO、顿号分隔的核心短语、必须 100% 覆盖 input 条目、不加原文没有的信息"
  }
]
改哪里改成什么
前四条范例换成你自己业务里的真实样本。范例的作用是定调,标注员会不自觉地模仿它们的颗粒度——范例给 5 个短语,后面标出来的普遍就是 5 个左右
instruction换成你任务的那一句祈使句,然后全文件统一。改了就得全部改,不能只改一条
最后一条 TODO正式使用时整条删掉。它只是给标注员看约束用的,留在训练数据里等于喂了一条垃圾样本

5.2 登记表模板

对应 LLaMA-Factory 的 data/dataset_info.json它不是用来覆盖官方文件的,而是给你看清楚三种写法分别长什么样,然后把需要的那段追加过去。

dataset_info.template.json —— 登记表模板,三种写法对照可复用模板
{
  "_说明": [
    "这份文件对应 LLaMA-Factory 的 data/dataset_info.json。",
    "不要整份覆盖官方文件 —— 官方文件里已有上百个内置数据集条目,",
    "把下面 TODO 之后的键值对追加到文件尾部即可(注意前一条要补逗号)。",
    "键名 = 训练 yaml 里 dataset 字段要填的名字;file_name = data/ 目录下的实际文件名。",
    "正式使用时把本段 _说明 删掉,JSON 里留着它不会报错但没意义。"
  ],

  "qwen_dataset": {
    "file_name": "qwen_dataset.json"
  },

  "TODO_你的数据集名": {
    "file_name": "TODO_你的文件名.json",

    "_可选项1": "数据文件放在 data/ 下时只写文件名;放别处则在训练时用 --dataset_dir 指定目录",
    "_可选项2": "字段名与 alpaca 默认不一致时,用 columns 做映射,例如下面这段",

    "columns": {
      "prompt": "instruction",
      "query": "input",
      "response": "output"
    }
  },

  "chat-train": {
    "file_name": "train.jsonl",
    "_说明": "jsonl 形式的写法:每行一个 {instruction, input, output} 对象,file_name 指向 .jsonl 即可"
  }
}
模板里的段落什么时候用它注意
"qwen_dataset"最常见的情况:JSON 数组,字段名就是标准三字段两行就够,不需要写 columns
"TODO_你的数据集名"字段名与标准不一致时columns 把你的字段名映射到 prompt / query / response
"chat-train"数据是 JSON Linesfile_name 指向 .jsonl,文件内容必须真的是一行一个对象
_说明 字段仅供阅读正式使用时删掉。留着不会报错,但没有意义
✅ 从模板到开训,只有五步 sample_dataset.template.json 定标注规范,发给标注员 · 标注结果整理成 JSON Lines 原始语料 · build_dataset.py --raw 原始.jsonl --out qwen_dataset.json · check_dataset.py qwen_dataset.json --cutoff 1024,退出码 0 才往下走 · 把数据集文件放进 data/,照登记表模板追加一段到 dataset_info.json
做完这五步,训练配置里 dataset: 填上你的键名就能点火了。
⚠️ 第 ④ 步的退出码要当回事 check_dataset.py 有致命问题时退出码为 1,加 --strict 时警告也算不通过。把它串进流水线时记得判断退出码,别只看屏幕输出——体检不通过还硬训,等于明知食材有问题还是端上桌了。

06易错点汇总

按「立项 / 选型 / 标注 / 构造 / 注册 / 验收」六类归并,每一条都会在训练日志里静默通过

⚠️ 一、立项阶段

  • 把知识型缺口当成微调需求。 「模型不知道我们公司的报销标准」不是微调能解决的。判断方法:把答案贴进提示词,模型能答对就说明它缺知识,该上 RAG;贴了还是不按格式输出,才是微调的活。
  • 四件事没定完就开训。 训到一半改指令句式、改分隔符、换基座,前面烧掉的算力全部作废。这不是「再调一下」,是从头再来。
  • 只算显卡钱,不算标注钱。 微调真正的成本大头是成对标注的整理工作量,通常远超训练本身。立项时把标注人天漏掉,项目一定延期。
  • 没留独立评测集。 val_size: 0.1 划出来的是验证集,训练过程中每轮都会看到它。拿它报验收分数,只能证明模型记住了这批数据。

⚠️ 二、基座选型

  • 照着对比表从上往下挑「看起来不错的」。 表是死的,判断链是活的:先问跑在什么设备上(定上限),再问任务属于哪一类(定下限),最后问要不要更高质量(定要不要付多卡的钱)。
  • 以为越大越好。 转换型任务上尺寸的边际收益掉得极快,7B 换 14B 可能只涨一两个点,显存却翻倍。真正能拉高指标的几乎永远是数据质量。
  • 拿推理显存去准备训练机器。 表里 7B 写 ≥10GB 指的是推理;同一个 7B 全量微调实测 42.18GB。按推理显存备机,一开训就撞 CUDA out of memory
  • 先上大模型再调流程。 正确顺序是拿最小尺寸把数据管线和训练流程整条跑通,再换目标尺寸。反过来,一个字段名写错就浪费几小时。
  • 换了基座忘了换 template 模板与基座必须匹配,合并 Qwen2 权重时 template 务必设为 qwen。模板不对,训练不报错,但对话格式全错。

⚠️ 三、标注阶段

  • 指令句式每人一套。 「请提取以下内容中的摘要信息」「帮我总结一下」「提炼要点」混在一起,模型学到的是「指令栏随便写」,任务映射反而散了。用 build_dataset.py 输出里的「instruction 种类数」盯住这一点,不是 1 就有人写飘了。
  • 分隔符一半顿号一半逗号。 上线后输出的分隔符随机漂移,下游按顿号切分的代码全部失效。
  • 漏标条目。 原文列了 5 条只标 3 条,等于在教模型「漏掉两条也算对」。条目级覆盖率就是抓这个的。
  • 标注里加了原文没有的信息。 抽取类任务的底线。出现一次,模型就学会了幻觉——而在金融、医疗场景里这是事故级的。
  • 改写了 input 把换行压成一行、把编号删掉、把全角改半角,都会削弱模型判断条目边界的能力。input 只许 strip(),不许重写。
  • 脱敏拖到训练前才做。 权重训进去之后没有任何办法单独删除某条信息,只能整个重训。脱敏必须砍在语料入库那一刻。

⚠️ 四、增强与构造

  • 回译没锁住专有名词和数字。 「8 杯水」回译成「一些水」,标注立刻对不上,制造出一条错误样本。
  • 实体替换只换了原文没换标注。 原文里的公司名换了,output 里还留着旧的,等于亲手造了一条「编造」样本,比不增强更糟。
  • 增强样本混进了评测集。 增强样本与训练样本同源,拿去评测分数会虚高。增强只服务训练集,界线要在切分那一步就划。
  • 不做长度过滤。 超长样本的尾部被 cutoff_len 截掉,截掉的正好是排在最后的 output。样本看着在,实际是一道没答案的题,而训练日志上一点异常都不会有。
  • json.dump 忘了 ensure_ascii=False 中文全变成 \u5b66\u4e60,框架读得了,人复查不了,标注错误从此没法用肉眼发现。
  • 把清洗和构造混在一步做。 出问题时分不清是清洗规则错了还是构造逻辑错了。清洗属于语料入库,构造属于生成样本,两件事分开。

⚠️ 五、注册数据集

  • 整份覆盖 dataset_info.json 官方文件里有上百个内置条目,覆盖了就全没了。要追加,不要覆盖。
  • 追加时漏逗号。 在最后一个条目后直接粘一段,JSON 解析立刻失败。粘完跑一次 python3 -c "import json;json.load(open('data/dataset_info.json',encoding='utf-8'))"
  • 键名与训练配置里的 dataset: 对不上。 直接报找不到数据集,这个反而是好事——报错总比静默出错强。
  • 字段名不标准却没写 columns 映射。 这条最阴:框架读出来全是空,训练照常跑完,loss 也在降,学到的却是噪声。
  • 文件内容是数组,文件名写成 .jsonl(或反过来)。 两种格式都行,但一个项目只能选一种,且后缀要和内容对得上。

⚠️ 六、验收与指标

  • 校验脚本误报。 用「短语的字必须出现在原文里」算覆盖率,会把正确的概括全判成编造——「每天至少饮用 8 杯水」标成「多喝水」,字面覆盖率只有 0.33。误报比不报更危险,它会逼标注员把正确的概括改成机械的复制。
  • 把项目自报的指标当普适结论。 显存 16GB→6GB、吞吐 120→320、F1 92.1%→91.5%、ROUGE-L 78.3→77.8,都是该项目在自己的数据和硬件上的数字。换数据、换卡、换 batch 配置,数字全会变。
  • 只盯 F1 不做人工抽检。 编造在 F1 上可能只掉一两个点,但在金融、医疗业务里是致命的。抽 100 条逐条看有没有原文没有的信息,这一步没法用指标替代。
  • 先定压缩方案再看掉了多少分。 顺序反了。该先定「业务能接受掉几个点」,再去挑满足这个约束的压缩力度。

07自测题

点击题目展开答案;这 12 题答得下来,立项会就能自己主持了

一、立项与边界
动手训练之前必须钉死哪四件事?顺序能不能换?

任务边界 → 基座选型 → 数据格式 → 评价指标,顺序不能换。边界决定任务难度,难度决定要多大的基座;基座的上下文长度上限反过来卡住原文能写多长;指标必须贴着输出格式定——输出是顿号短语才可能用抽取 F1,输出是整段摘要就得换 ROUGE-L。倒过来定一定互相打架。

怎么判断一个需求该用微调、RAG 还是提示词?

把答案直接贴进提示词试一次。模型能答对,说明它缺的是知识,上 RAG(文档会更新,焊进权重的话改一份文档就要重训一次);贴了答案还是不按你要的格式输出,那才是行为型缺口,归微调;只是想让它客气点、分点作答,一句系统提示词就够了。实时数据(股价、天气、库存)任何训练方式都追不上,归 Function Call。

微调项目最大的成本项通常是什么?

标注,不是显卡。几千条高质量成对样本的整理工作量通常远超训练本身。这也是为什么要在立项会上就把数据格式定死——格式定错,标注返工的代价是成倍的。

二、基座选型
基座选型的判断链是哪三问?每一问砍掉什么?

问 1「跑在什么设备上」定上限——要塞进手机或 IoT,就只能在 0.5B~1.8B 里选,后两问都不用问。问 2「任务属于哪一类」定下限——摘要、抽取、分类这类转换型任务,7B 是性价比甜点;法律医疗这类长链条推理、术语密集的才值得上 14B。问 3「要不要更高质量」定要不要付多卡的钱——要且有多卡上 72B,要但只有一张卡就走 MoE 稀疏激活。本项目三问答下来唯一剩余项就是 Qwen-7B。

Qwen 1.0 为什么被本项目直接排除?

上下文长度那一行。1.0 是 4k~32k tokens,而本项目要处理法律文书、科研文献这类原文动辄数千字的场景,4k 起步的档位不够用。选代次就是看这一行;选量化路线看「推理效率优化」那一行;商用合规看「开源协议」那一行。

参数量对照表里 Qwen-7B 写「显存 ≥10GB」,为什么按它备机会出事?

因为那是推理显存。同一个 7B 模型训练要贵得多——全量微调实测 42.18GB、LoRA 20.17GB、QLoRA 10.97GB。按 10GB 备训练机,一开训就撞 CUDA out of memory

「MoE 激活参数量 1.8B,等效 14B」这句话怎么理解?

模型总参数很大,但每次前向只激活其中一小部分专家,所以算得像 1.8B 那么快、答得像 14B 那么好。代价是总参数仍要装进显存——省的是算力不是全部存储。就像后厨挂了二十口锅,每道菜只点着两口。

三、数据格式
instruction 为什么要全数据集固定成同一句?

为了让模型建立稳定的任务映射关系。映射建稳之后,实际应用时用户即使换了说法(「请总结以下要点」「帮我提炼一下」),模型仍能正确响应。反过来,训练时指令就有十几种写法,模型学到的是「指令这一栏随便写什么都行」,映射反而散了。写法上要用祈使句,并且与后面的 input 连起来是一句完整的话。

output 的六条约束分别是什么?

① 从 input 中提炼核心名词 / 动宾短语,不写成整句;② 用中文顿号「、」分隔;③ 无顺序要求(除非原文有优先级);④ 平均 3~5 个短语;⑤ 100% 覆盖 input 条目;⑥ 不添加原文未提及的信息。最后两条是质量控制的核心,前四条是形态约束。

原文写「每天至少饮用 8 杯水,促进新陈代谢」,标注成「多喝水」,算不算违反「100% 覆盖」?

不算,这是正确的概括。覆盖率是条目级的——原文列了几条,成品就要给几个短语——不是要求短语的字必须出现在原文里。标注的本职就是概括,字面对不上恰恰说明概括做对了。把覆盖率理解成字面包含,做出来的校验脚本会把正确标注全判成编造(这条样本的字符覆盖率只有 0.33)。

为什么 input 里的换行和编号一个都不能删?

「1. 2. 3.」「① ② ③」这些结构信号是模型判断原文列了几条的依据,也是条目级覆盖率的分母来源。压成一行会直接削弱模型的条目对齐能力。input 只许 strip() 去首尾空白,正文一个字都不许改写。

四、构造、注册与验收
为什么 build_dataset.pyMAX_INPUT_CHARS 定成 900 而不是 1024?

训练配置里 cutoff_len: 1024,中文按 1 字符 ≈ 1 token 保守估,还要给 instructionoutput 留位置,所以给原文留 900、余量 100 多。这个数是从训练参数倒推的,不是拍脑袋定的。不做这个过滤,超长样本的尾部会被截断,而被截掉的正好是排在最后的 output——样本还在,实际是一道没答案的题,训练日志上看不出任何异常。

把数据集注册到 dataset_info.json 时,最阴的一个坑是什么?

字段名不标准却没写 columns 映射。你的文件里字段叫 prompt/query/response,框架默认找 instruction/input/output,读出来全是空——但训练照常跑完,loss 也在降,学到的却是噪声。相比之下「键名与 yaml 里的 dataset: 对不上」反而是好事,它会直接报错。另外两个高频事故是整份覆盖官方文件(会删掉上百个内置条目,要追加不要覆盖)和追加时漏逗号。

回译和实体替换各自撑开了什么?各自最容易出什么事?

回译撑开句式与措辞的多样性,让模型明白「同一个意思换个说法,该抽的短语还是那几个」;风险是它会改写原文用词,专有名词、数字、单位必须锁住不译,否则「8 杯水」可能变成「一些水」,标注立刻对不上。实体替换撑开实体覆盖面,防止模型把某个具体实体名和某种输出模式绑死;风险是标注必须跟着一起换,原文换了而 output 里还留着旧实体,就亲手造了一条编造样本。另外,增强样本一律不许进评测集。

项目报出「显存 16GB→6GB、吞吐 120→320、F1 92.1%→91.5%」,该怎么向别人转述这组数字?

要说明这是该项目在自己的数据和硬件上报出的目标 / 实测值,不是这条技术路线的普适结论——换数据、换卡、换 batch 配置,数字都会变。这张表真正该记住的是它的形状:左边是成本(显存、吞吐),右边是质量(F1、ROUGE-L),压缩永远是拿右边换左边。立项时该判断的是「业务能接受右边掉几个点」,再去挑满足这个约束的压缩力度。另外吞吐 120→320 同时吃了量化和推理引擎两份红利,不能全记在量化头上。

术语表

这一页出现过的名词,一句话说清各自管什么

术语一句话解释
指令微调
Instruction Tuning
用「指令 + 输入 + 期望输出」成对样本训练模型,让它学会按指令办事。本项目的数据格式就是它的标准形态。
instruction三字段之一,说明要执行的任务类型。全数据集固定一句祈使句,用来建立稳定的任务映射。
input三字段之一,待处理的原始文本。只许 strip(),保留换行与编号。
output三字段之一,期望结果。顿号分隔的核心短语,100% 覆盖原文条目,不加原文没有的信息。
cutoff_len训练时输入序列的最大 token 长度,超出部分被截断。本项目取 1024,它反向决定了原文能写多长。
val_size从训练集里划出来做验证的比例(本项目 0.1)。它不是验收用的评测集——验证集训练中每轮都会被看到。
dataset_info.jsonLLaMA-Factory 的数据集登记表,位于 data/ 下。键名是数据集名称,file_name 指向实际文件。只能追加,不能覆盖。
columns登记表里的字段映射。当你的字段名不是标准三字段时,用它映射到 prompt / query / response
JSON Lines一行一个 JSON 对象的文件格式,后缀 .jsonl。适合大数据量,可流式读取。与 JSON 数组二选一,一个项目内不要混用。
CN-DBpedia中文实体识别公开数据集,本项目用来给抽取能力打底。
LCSTS中文短文本摘要公开数据集,本项目用来给摘要能力打底。
回译
Back Translation
原文译成外语再译回来,得到说法不同、意思相同的新样本。撑开句式多样性,但要锁住专有名词和数字。
实体替换
Entity Swapping
把原文里的实体换成同类型的另一个。撑开实体覆盖面,标注必须跟着一起换。
F1精确率与召回率的调和平均。本项目的主指标,因为输出本来就是一个短语集合,适合用集合指标去量。
ROUGE-L按最长公共子序列(LCS)衡量生成摘要与参考摘要的重合度。本项目的辅指标,用来盯住输出会不会越写越长。
LoRA冻结主干参数、只训练低秩旁路矩阵的微调方法,大幅降低显存与训练参数量。原理见下一页。
DeepSpeed微软开源的大规模训练优化库,提供 ZeRO 显存分片、Offload、混合精度等能力。详见下一页。
GPTQ训练后权重量化方法,把权重压到 4-bit,换来推理提速与显存下降,代价是少量精度损失。
LoraQ针对 LoRA 适配器本身做的 4-bit 量化,压缩微调参数体积。
vLLM高性能推理引擎,基于 PagedAttention 支持批量请求并行处理,用来把线上吞吐顶上去。
MoE
混合专家
每次前向只激活一小部分专家的稀疏架构。算得像小模型那么快,答得像大模型那么好,但总参数仍要占显存。
template对话模板,必须与基座匹配。Qwen 系列一律填 qwen;填错不报错,但对话格式全乱。