你将做出什么
很多人想“微调一个自己的模型”,真正卡住的往往不是训练命令,而是三件事:问题本来不该用微调解决、训练数据又脏又重复、训练完没有办法证明它变好了。本课不跑训练,只把训练前必须做对的部分做完。
完成后你手里有四样东西:一份“提示词、RAG 还是微调”的判断记录;一份 SFT(监督微调)数据卡;一个经过清洗、去重并划分好的 JSONL 小数据集;一份写在训练之前的评估计划。顺带你会用一个小计算器建立 LoRA、QLoRA 的显存直觉。
本课的任务是虚构的:把学员一句随口的学习求助,改写成固定格式的任务卡,例如“JSON 解析老报错,想今晚花 20 分钟搞定”变成含 goal、minutes、next_step 三个字段的 JSON。所有样本都是原创教学数据,不含真实学员信息。
准备什么
- Python 3.12,一个空文件夹。本课代码只用标准库,不需要显卡,也不联网。
- 先读过 JSON 实操 与 AI 评测实操,本课会直接用到字段校验和固定题评测的做法。
- 以后真要训练时,常见入口是开源微调库(例如 Hugging Face 的 PEFT 库提供 LoRA 实现)或云平台的微调服务。训练这一步在本文中只是练习方案(本站未实跑),不提供任何“训练后效果”数字。
第一步:先判断该不该微调
微调改变的是模型“习惯怎么回答”,不适合用来塞进经常变化的事实。先用下面这张表给你的需求逐项归类:
| 你观察到的问题 | 优先方案 | 原因 |
|---|---|---|
| 答案依赖课程表、价格、政策等会变的事实 | RAG | 事实更新只需改资料库,不用重新训练 |
| 输出格式偶尔不对,例如少字段 | 提示词 + 程序校验 + 失败重试 | 成本最低,往往已经够用 |
| 换几种写法都说不清的风格、术语、固定口径 | 考虑微调 | 这类“习惯”用示例教比用规则描述更有效 |
| 需要同时满足大量格式细节,长提示词明显拖慢、变贵 | 考虑微调 | 把反复出现的长示例固化进权重 |
| 手里只有几十条示例,也说不清好坏标准 | 先别微调 | 数据和评估都不够,训练结果无法判断 |
在本课任务里:“本周开了哪些课”属于会变的事实,应交给 RAG;任务卡的字段和语气属于稳定习惯,可以成为微调候选。但候选不等于结论,要先让提示词方案走完一轮评测。
把判断写成一段记录,保存为 decision.md。下面是示例写法,题数和尝试过的方案按你的实际情况填写,没做过的就别写成“已尝试”:
任务:把学员学习求助改写成任务卡(goal、minutes、next_step)。
事实类信息(课程安排、开课时间):交给 RAG,不进入微调数据。
已尝试:系统提示 + 3 个示例 + JSON 校验与一次重试,在固定 30 题开发集上记录格式合规率与人工评分。
考虑微调的条件:提示词方案迭代两轮后格式合规率仍低于事先定的目标,且失败集中在语气和字段写法,而不是缺少事实。
不微调的情况:失败主要是事实错误或材料缺失。
条件写在训练之前,避免训练完再挑一个对自己有利的口径。
第二步:写一份 SFT 数据卡
数据卡回答“这批数据从哪来、能不能用、长什么样、怎么切”。先写卡,再收数据,能挡住很多后患。下面是本课数据的完整数据卡,你可以照着改:
名称:学习任务卡改写 SFT 数据 v0.1
用途:训练模型把一句学习求助改写成 goal、minutes、next_step 三字段 JSON;不用于回答课程事实。
来源:本站课程答疑的人工改写示例(教学虚构)。每条记录带 source 字段。
授权:仅保留 license 字段非空的记录;来源不明或无授权的样本直接丢弃。
隐私:手机号、邮箱等个人信息的样本不入库;需要保留语义时先人工脱敏再复核。
格式:每行一个 JSON 对象,messages 依次为 system、user、assistant;assistant 内容本身必须是合法 JSON。
字段规则:minutes 为 10~120 的整数;next_step 不超过 60 字;不允许额外字段。
去重:对 user 文本做全角半角统一、去空白和标点后比较,相同即视为重复,保留最早一条。
划分:按规范化文本的 SHA-256 取模分桶,约 80% 训练、10% 验证、10% 测试;先去重后划分。
已知偏差:示例都来自编程类课程,对绘画、视频类求助覆盖不足。
版本:记录每个划分文件的 SHA-256 前 12 位。
“先去重后划分”很关键。若一句话和它只差一个标点的版本分别进了训练集和测试集,测试分数会虚高,这叫数据泄漏。
第三步:清洗、校验与划分
下面的脚本先写出一份模拟的原始文件 raw.jsonl(17 行,其中故意混入了重复、空内容、角色错序、单引号 JSON、超范围数字、手机号、缺授权和一行截断的数据),再逐行检查、去重、分桶,最后写出 train.jsonl、val.jsonl、test.jsonl。保存为 clean_sft.py,在空文件夹中运行 python clean_sft.py:
import hashlib
import json
import re
import unicodedata
from pathlib import Path
SYSTEM = "把学员的学习求助改写成任务卡,只输出JSON:goal、minutes、next_step。"
def card(goal, minutes, step):
return json.dumps({"goal": goal, "minutes": minutes, "next_step": step}, ensure_ascii=False)
def chat(user, answer, source="课程答疑整理", license="本站原创"):
return {"source": source, "license": license, "messages": [
{"role": "system", "content": SYSTEM},
{"role": "user", "content": user},
{"role": "assistant", "content": answer}]}
samples = [
chat("我想这周把 Python 列表推导式弄懂,每天大概能学半小时",
card("掌握 Python 列表推导式", 30, "把 3 个 for 循环改写成列表推导式并运行")),
chat("JSON 解析老报错,想今晚花 20 分钟搞定",
card("定位 JSON 解析报错", 20, "用 json.loads 复现报错并读出行号和列号")),
chat("我想这周把 Python 列表推导式弄懂,每天大概能学半小时",
card("掌握 Python 列表推导式", 30, "把 3 个 for 循环改写成列表推导式并运行")),
chat("JSON解析老报错,想今晚花20分钟搞定!", card("解决 JSON 报错", 20, "复现报错")),
chat("想学提示词,今天有 15 分钟", " "),
{"source": "课程答疑整理", "license": "本站原创", "messages": [
{"role": "system", "content": SYSTEM},
{"role": "assistant", "content": card("学习 RAG", 60, "读资料")},
{"role": "user", "content": "周末想学 RAG"}]},
chat("Agent 工具调用看不懂,想花 40 分钟",
"{'goal':'理解工具调用','minutes':40,'next_step':'运行调度器'}"),
chat("想一口气把评测学完", card("学完评测", 300, "读完所有文章")),
chat("我的手机号 13800001234,想报名提示词课,每天 20 分钟",
card("开始学习提示词", 20, "完成会议待办整理练习")),
chat("想学微调,每天一小时", card("了解微调流程", 60, "写一份数据卡"), source="网上收集", license=""),
chat("想做一个 RAG 小练习,但只有周末有空,每次一小时",
card("完成最小 RAG 检索练习", 60, "准备 3 段带编号的资料并标出每题应命中的段落")),
chat("要比较两版提示词,不知道从哪开始,今天 40 分钟",
card("建立两版提示词的对比评测", 40, "先写 5 道题和每题的通过条件")),
chat("想看懂模型技术报告里的评测表,每天 25 分钟",
card("读懂技术报告评测表", 25, "标出每个基线分数的来源和测试设置")),
chat("本地跑模型总提示显存不够,想用一小时弄清原因",
card("找出本地推理显存不足的原因", 60, "分别估算权重和 KV cache 占用")),
chat("OCR 识别表格老是错列,今晚练 30 分钟",
card("修正 OCR 表格错列", 30, "自制三行表格截图并逐格核对")),
chat("引用核对总漏掉条件,想花 35 分钟练一下",
card("练习逐句核对引用条件", 35, "把一段回答拆成主张并逐条找原文")),
]
out = Path("sft_out")
out.mkdir(exist_ok=True)
truncated = '{"source": "课程答疑整理", "messages": [' # 模拟一行写到一半就中断的数据
raw_lines = [json.dumps(r, ensure_ascii=False) for r in samples] + [truncated]
(out / "raw.jsonl").write_text("\n".join(raw_lines) + "\n", encoding="utf-8")
PHONE = re.compile(r"(?<!\d)1[3-9]\d{9}(?!\d)")
EMAIL = re.compile(r"[\w.+-]+@[\w-]+\.[\w.]+")
def normalize(text):
text = unicodedata.normalize("NFKC", text).lower()
return "".join(ch for ch in text if unicodedata.category(ch)[0] not in "PZ" and not ch.isspace())
def check(record):
if not record.get("source") or not record.get("license"):
return "缺少来源或授权"
messages = record.get("messages")
if not isinstance(messages, list) or [m.get("role") for m in messages] != ["system", "user", "assistant"]:
return "角色顺序不是 system→user→assistant"
user, answer = messages[1]["content"], messages[2]["content"]
if not user.strip() or not answer.strip():
return "存在空内容"
if PHONE.search(user + answer) or EMAIL.search(user + answer):
return "含手机号或邮箱"
try:
result = json.loads(answer)
except json.JSONDecodeError:
return "assistant 不是合法 JSON"
if not isinstance(result, dict) or set(result) != {"goal", "minutes", "next_step"}:
return "字段不是 goal、minutes、next_step"
if type(result["minutes"]) is not int or not 10 <= result["minutes"] <= 120:
return "minutes 不是 10~120 的整数"
if len(user) > 500 or len(result["next_step"]) > 60:
return "长度超出限制"
return None
def bucket(key):
value = int(hashlib.sha256(key.encode("utf-8")).hexdigest()[:8], 16) % 10
return "test" if value == 0 else "val" if value == 1 else "train"
kept, seen = {"train": [], "val": [], "test": []}, {}
lines = (out / "raw.jsonl").read_text(encoding="utf-8").splitlines()
for line_no, line in enumerate(lines, start=1):
try:
record = json.loads(line)
except json.JSONDecodeError:
print(f"丢弃 第{line_no:>2}行:整行无法解析")
continue
reason = check(record)
key = normalize(record["messages"][1]["content"]) if reason is None else ""
if reason is None and key in seen:
reason = f"与第 {seen[key]} 行重复(规范化后相同)"
if reason:
print(f"丢弃 第{line_no:>2}行:{reason}")
continue
seen[key] = line_no
kept[bucket(key)].append(record)
for name, rows in kept.items():
body = "".join(json.dumps(r, ensure_ascii=False) + "\n" for r in rows)
(out / f"{name}.jsonl").write_text(body, encoding="utf-8")
digest = hashlib.sha256(body.encode("utf-8")).hexdigest()[:12]
print(f"{name}: {len(rows)} 条,sha256 前12位 {digest}")
keys = {name: {normalize(r["messages"][1]["content"]) for r in rows} for name, rows in kept.items()}
assert not (keys["train"] & keys["val"] or keys["train"] & keys["test"] or keys["val"] & keys["test"])
print(f"原始 {len(lines)} 行,保留 {len(seen)} 行,三个划分之间没有重复")
本站用 Python 3.12 实际运行得到的输出:
丢弃 第 3行:与第 1 行重复(规范化后相同)
丢弃 第 4行:与第 2 行重复(规范化后相同)
丢弃 第 5行:存在空内容
丢弃 第 6行:角色顺序不是 system→user→assistant
丢弃 第 7行:assistant 不是合法 JSON
丢弃 第 8行:minutes 不是 10~120 的整数
丢弃 第 9行:含手机号或邮箱
丢弃 第10行:缺少来源或授权
丢弃 第17行:整行无法解析
train: 6 条,sha256 前12位 d48e79922dec
val: 1 条,sha256 前12位 51e838bcdf97
test: 1 条,sha256 前12位 d91d8a5a765f
原始 17 行,保留 8 行,三个划分之间没有重复
几处设计说明,对照输出看:
- 第 4 行和第 2 行只差空格和感叹号,规范化后相同,所以被当作重复。只做“完全相同”比较的话,它会溜进另一个划分。
- 分桶用内容哈希而不是随机打乱。同一句话无论文件顺序怎么变,都落在同一个划分;以后追加数据,旧样本也不会换桶。
- 17 行只剩 8 行,这在真实清洗中很常见。样本量这么小只够演示流程,真实训练需要的数量取决于任务难度,应按评估结果决定是否补数据,而不是凑一个整数。
- 清洗脚本只能挡住格式问题。“goal 是否忠实于原话”“next_step 是否可执行”仍需要人工抽查,建议每批至少通读二三十条。
第四步:LoRA 和 QLoRA 的显存直觉
全参数微调要为每个参数保存权重、梯度和优化器状态。LoRA 的做法是冻结原模型,只在选定的矩阵旁边加两个很窄的矩阵 A 和 B,训练时只更新它们。LoRA 论文摘要称,与用 Adam 全量微调 GPT-3 175B 相比,可训练参数可减少约一万倍、显存需求约降为三分之一,并且推理时不增加延迟(2026-10 核对官方页面)。QLoRA 在此基础上把冻结的基座量化成 4 位(论文提出 NF4 数据类型、双重量化和分页优化器),摘要称可在单张 48GB 显卡上微调 65B 模型(2026-10 核对官方页面)。
下面的计算器只用乘法建立量级感,保存为 lora_memory.py 运行:
GIB = 1024 ** 3
# 示例 8B 配置:32 层,hidden 4096,8 个 KV 头 x 128 维(分组查询注意力)
TOTAL = 8.0e9
LAYERS, HIDDEN, KV_DIM = 32, 4096, 8 * 128
TARGETS = {"q_proj": (HIDDEN, HIDDEN), "k_proj": (HIDDEN, KV_DIM),
"v_proj": (HIDDEN, KV_DIM), "o_proj": (HIDDEN, HIDDEN)}
def lora_params(rank):
# 每个目标矩阵 W(d_in x d_out) 旁加 A(d_in x r) 与 B(r x d_out)
return LAYERS * sum(rank * (d_in + d_out) for d_in, d_out in TARGETS.values())
def memory_gib(base_bytes_per_param, trainable):
# 粗估:冻结的基座权重 + 可训练参数按 16 字节/参数
# (半精度权重 2 + 梯度 2 + 单精度主权重 4 + Adam 两个状态 8)。不含激活值与框架缓冲。
return (TOTAL * base_bytes_per_param + trainable * 16) / GIB
for rank in (8, 16, 64):
n = lora_params(rank)
print(f"r={rank:<2} 可训练 {n / 1e6:5.1f}M 参数,占全量 {n / TOTAL:.2%}")
r16 = lora_params(16)
print(f"全参数微调(16 字节/参数) 约 {TOTAL * 16 / GIB:6.1f} GiB")
print(f"LoRA r=16,半精度基座 约 {memory_gib(2, r16):6.1f} GiB")
print(f"QLoRA r=16,4 位基座(按0.55) 约 {memory_gib(0.55, r16):6.1f} GiB")
print(f"同一公式估 65B 的 4 位基座 约 {65e9 * 0.55 / GIB:6.1f} GiB")
实际输出:
r=8 可训练 6.8M 参数,占全量 0.09%
r=16 可训练 13.6M 参数,占全量 0.17%
r=64 可训练 54.5M 参数,占全量 0.68%
全参数微调(16 字节/参数) 约 119.2 GiB
LoRA r=16,半精度基座 约 15.1 GiB
QLoRA r=16,4 位基座(按0.55) 约 4.3 GiB
同一公式估 65B 的 4 位基座 约 33.3 GiB
怎么读这些数字:秩 r 越大,可训练参数越多,但即使 r=64 也不到全量的 1%;显存的大头从“优化器状态”变成了“冻结的基座权重”,所以 QLoRA 压缩基座最省显存。每参数 0.55 字节是把量化常数也算进去的粗略假设,不是某个库的实测值。65B 的估算约 33 GiB,与论文“单张 48GB”在量级上对得上,剩余空间要留给激活值。
这些数字都不含激活值。激活值随序列长度和批大小增长,长样本训练时可能比 LoRA 参数本身大得多,所以真实训练要从短序列、小批次开始试。示例配置是为了演示公式而设的,你的模型请从它的 config.json 读层数、hidden_size 和 KV 头数。
第五步:训练前写好评估计划
评估计划要在第一次训练前定稿,并且只用第三步的 test.jsonl 做最终判断。下面是本课任务的完整计划:
| 项目 | 做法 | 判定 |
|---|---|---|
| 基线 A | 同一基座模型,只用系统提示 + 3 个示例 | 记录各项指标,作为对照 |
| 基线 B | 基线 A + JSON 校验与一次重试 | 微调版至少要比它好才有意义 |
| 格式合规率 | 用第三步的 check 函数判断每条输出 | 事先写下目标值 |
| 内容质量 | 人工对照原话评 goal 是否忠实、next_step 是否可执行,两人独立打分 | 分歧条目单独讨论 |
| 通用能力回归 | 准备 10 道与任务无关的普通问题,微调前后各答一次 | 明显变差就要调整 |
| 失败记录 | 每条不通过的输出保留原文和原因 | 不删除失败样本 |
微调版和基线 B 打平时,结论应该是“暂不微调”:多一个要维护的模型,却没有换来可测的收益。
常见失败与修订
验证集分数很高,换一批真实求助就很差。 多半是数据泄漏或数据太单一。修订:确认先去重后划分;把同一模板批量改写出来的样本视为一组,整组放进同一个划分;再补充不同课程、不同说法的真实求助(获得授权后)重新评估。
格式学会了,却开始编造事实。 例如模型在 next_step 里写出“周三晚上 8 点直播课”,而原话里没有。原因通常是训练数据中混入了带具体事实的答案。修订:从训练目标里删去依赖外部事实的内容,数据卡里写明“事实交给 RAG”;同时在评估里加入“输出不得出现原话没有的时间、价格”的检查项。
微调后普通问题答得更差,或拒答变多。 这是对小数据过度拟合的典型表现。修订:减少训练轮数或降低学习率,在训练数据中混入少量通用对话样本,并用第五步的回归题对比前后版本,确认改善后再使用。
训练正常结束,推理时输出夹带奇怪的标记或角色名。 常见原因是训练和推理用了不同的对话模板。修订:训练与推理都使用基座模型分词器自带的对话模板,并在训练前打印一条样本被模板拼接后的完整文本,人工确认角色和结束标记。
一启动训练就显存不足。 修订顺序:先把最大序列长度降到覆盖 95% 样本的长度,再把批大小降到 1 并用梯度累积补回,然后开启梯度检查点;仍不够再换 QLoRA 或更小的基座。每改一项记录一次,别同时改五个参数。
保存和使用结果
把以下文件放进同一个版本目录:decision.md、数据卡、clean_sft.py、raw.jsonl、三个划分文件,以及脚本打印的 SHA-256 前缀。以后有人问“这个模型用什么数据训练的”,你能给出可复查的答案。
真要训练时(练习方案,本站未实跑),另外记录基座模型的名称和版本、LoRA 的秩和目标矩阵、学习率、轮数、随机种子,以及第五步每一项的结果。未经授权的私人数据不要上传到第三方训练平台;数据卡里的授权字段就是为这一步准备的。
自测与答案
- 课程开课时间每周都变,适合用微调教给模型吗?不适合。会变的事实交给 RAG,微调只教稳定的回答习惯。
- 为什么要先去重再划分?否则近似重复的样本会同时出现在训练集和测试集,测试分数虚高。
- LoRA 为什么省显存?基座权重冻结,只为很少的新增参数保存梯度和优化器状态;QLoRA 进一步把冻结的基座量化成 4 位。
- 微调版与“提示词 + 校验重试”打平,该怎么写结论?写“暂不微调”,并保留两边的评估记录。
加练:给数据卡增加“next_step 至少 8 个字”的规则,在 check 函数中实现。重新运行后,第 4 行应改为因 next_step 过短被丢弃,而不再报告为重复;想一想为什么检查顺序会改变丢弃原因。交付修改后的脚本和新的运行输出。
来源与内容说明
AIScope 中文手册 · AIScope 编辑
本篇为本站原创学习内容,引用资料保留参考来源。
收录:2026年10月19日
适用范围:尚未标注,请结合原文与实际环境核对
人工核验:尚无核验记录