Guide · 使用教程
Unsloth QLoRA 教程:数据去重、来源分组与训练前后评估
以虚构中文工单分类任务演示人工审核、来源分组、训练验证测试隔离、基线输出、QLoRA 短实验和分类评估;提供两个本地 Python 脚本。
先确定数据和评价规则,再开始 QLoRA 训练。本教程用一个虚构的中文工单分类任务,演示人工审核样本、去重、按来源划分数据、在 Unsloth 中配置短实验,以及比较基座与适配器输出。最后得到的是可追溯的实验记录,不是未经验证的上线模型。
资料核对日期为 2026 年 10 月 14 日。文中的数据处理和评估脚本可在本地用 Python 标准库运行;Unsloth 的 GPU 训练、驱动兼容性和性能没有在本文环境中实测。具体界面与配置以你安装版本和官方示例为准。
这篇教程会完成什么
以工单分类为例,固定三个标签:账号、账单、技术。模型只返回一个 JSON 对象,例如 {"label":"账号"}。目标是检查分类是否正确、输出是否可解析,以及哪些类别容易出错。
工作流分为六步:人工审查数据;去重与分组划分;保存基线;配置短训练;比较结果;审核产物并决定是否扩大实验。文中的样本只是格式示例,不包含真实客户信息。
开始前需要准备
- 一台符合目标入口与模型要求的设备,或有预算限制的 GPU 实例。
- 一个明确支持的训练模型,并记录精确模型标识、revision、tokenizer 和许可。
- Python 3,用来执行下面两个标准库脚本。
- 已获得训练授权的数据、人工标注规则和审核记录。
- 固定的输出解析规则、独立验证集和保留测试集。
Unsloth 当前有 Desktop、Studio 与 Core 三种入口,要求不同。不要把可以运行 GGUF 推理理解成可以直接用同一文件训练;Studio 文档明确将 GGUF 排除在训练模型之外。设备要求、Studio 模型与训练方法
本教程建议从已有官方支持示例的 Text 模型开始。不要在第一个实验同时尝试新模型、多模态、超长上下文和多 GPU。
第一步:先定义分类与人工审核规则
先写清标签边界。例如:登录、账号恢复归为账号;收费、发票归为账单;运行故障归为技术。遇到混合诉求时制定固定优先规则,或者标记为暂不纳入训练。这里的三分类仅为演示,真实业务应按实际流程设计。
将审核后的样本保存为 UTF-8 的 approved.jsonl,每行一个对象:
{"id":"ticket-001","group_id":"case-001","approved":true,"label":"账号","messages":[{"role":"system","content":"将工单分为账号、账单、技术。只输出一个含 label 字段的 JSON 对象。"},{"role":"user","content":"无法登录,需要恢复账号。"},{"role":"assistant","content":"{\"label\":\"账号\"}"}]}
group_id 表示同一来源,例如同一客户、原始文档或同一工单线程。由同一案例改写的样本,应该保留相同 group_id。approved:true 是人工完成授权、脱敏与标注审查后的记录,脚本不会自动替你确认这些事项。
训练输入只使用 messages 中的内容。id、group_id 和 label 用来追踪与评估,不要自动拼接到用户问题中。推理时必须移除最后一条目标 assistant 回答,防止把答案泄漏给模型。
第二步:去重后按来源划分
逐行随机切分可能把同一工单的改写放到训练和测试中。下面程序先检查 ID、标签与消息,再合并同组的完全重复文本,最后按 group_id 划分。跨组出现完全重复消息时会停止,要求人工修正来源关系。
它只能检查完全重复及已提供的分组,不能识别所有语义近重复,也不能替你判断分组粒度是否合理。应另做近重复检查,并抽样审查划分结果。官方数据准备指南
保存为 prepare_data.py:
import hashlib
import json
import random
import sys
from collections import defaultdict
from pathlib import Path
source, out_dir = Path(sys.argv[1]), Path(sys.argv[2])
labels = {"账号", "账单", "技术"}
groups = defaultdict(list)
ids, seen = set(), {}
duplicates = 0
for line_no, line in enumerate(source.read_text(encoding="utf-8").splitlines(), 1):
if not line.strip():
continue
row = json.loads(line)
sample_id, group_id = row.get("id"), row.get("group_id")
if not isinstance(sample_id, str) or not sample_id.strip() or sample_id in ids:
raise ValueError(f"第 {line_no} 行 ID 无效或重复")
if not isinstance(group_id, str) or not group_id.strip():
raise ValueError(f"第 {line_no} 行缺少来源分组")
ids.add(sample_id)
if row.get("approved") is not True or row.get("label") not in labels:
raise ValueError(f"第 {line_no} 行未审核或标签无效")
messages = row.get("messages")
if not isinstance(messages, list) or len(messages) < 2:
raise ValueError(f"第 {line_no} 行消息格式无效")
clean = []
for message in messages:
if not isinstance(message, dict) or message.get("role") not in {"system", "user", "assistant"}:
raise ValueError(f"第 {line_no} 行角色无效")
text = message.get("content")
if not isinstance(text, str) or not text.strip():
raise ValueError(f"第 {line_no} 行内容为空")
clean.append({"role": message["role"], "content": text.strip()})
if not any(m["role"] == "user" for m in clean) or clean[-1]["role"] != "assistant":
raise ValueError(f"第 {line_no} 行缺少用户输入或目标回答")
target = json.loads(clean[-1]["content"])
if target != {"label": row["label"]}:
raise ValueError(f"第 {line_no} 行目标回答与标签不一致")
normalized = [{"role": m["role"], "content": " ".join(m["content"].split())} for m in clean]
fingerprint = hashlib.sha256(json.dumps(normalized, ensure_ascii=False, sort_keys=True).encode()).hexdigest()
if fingerprint in seen:
if seen[fingerprint] != group_id:
raise ValueError(f"第 {line_no} 行存在跨来源重复,请人工合并分组")
duplicates += 1
continue
seen[fingerprint] = group_id
groups[group_id].append({"id": sample_id, "group_id": group_id, "label": row["label"], "messages": clean})
group_ids = sorted(groups)
if len(group_ids) < 10:
raise ValueError("此演示划分要求至少 10 个独立来源组;更小数据请人工设计划分")
random.Random(42).shuffle(group_ids)
heldout = max(1, len(group_ids) // 10)
parts = {
"validation": group_ids[:heldout],
"test": group_ids[heldout:2 * heldout],
"train": group_ids[2 * heldout:],
}
out_dir.mkdir(parents=True, exist_ok=False)
manifest = {"seed": 42, "exact_duplicates_removed": duplicates, "splits": {}}
for name, members in parts.items():
rows = [row for group in members for row in groups[group]]
content = "".join(json.dumps(row, ensure_ascii=False) + "\n" for row in rows)
(out_dir / f"{name}.jsonl").write_text(content, encoding="utf-8")
manifest["splits"][name] = {
"rows": len(rows), "groups": len(members),
"sha256": hashlib.sha256(content.encode()).hexdigest(),
}
(out_dir / "manifest.json").write_text(json.dumps(manifest, ensure_ascii=False, indent=2), encoding="utf-8")
print(json.dumps(manifest, ensure_ascii=False, indent=2))
执行 python3 prepare_data.py approved.jsonl splits-v1。输出目录必须不存在,避免覆盖旧实验。程序产生 train、validation、test 三个文件和清单。这里按组数近似 80/10/10 划分,不保证样本数或类别比例相同;训练前必须检查每个分区是否有足够的类别覆盖。
不要把 test 上传到训练界面。如果需要根据测试结果继续调参,应建立新的保留测试集;持续用同一测试答案改训练数据,会使结论越来越偏向已见案例。
第三步:保存未训练模型的基线
用基座模型回答固定验证问题,输入中只保留 system 与 user,以及真正存在的历史上下文。保存 model revision、模板、temperature、长度上限、停止规则和原始输出。
对于这个分类示例,结果保存为 JSONL,每行 {"id":"ticket-001","output":"{\"label\":\"账号\"}"}。输出必须由模型产生,不能从数据标签复制。保留无法解析的输出,它们也需要计入失败。
检查基线能否满足业务要求。如果正确格式提示已经解决大多数问题,可以先改进提示词与解析流程,不必强行训练。
第四步:在 Unsloth 配置短实验
安装与设备相关依赖请跟随对应版本的官方文档。已安装 Studio 的情况下,可以仅绑定本机地址启动:
unsloth studio -H 127.0.0.1 -p 8888
在本机浏览器打开 http://127.0.0.1:8888。需要共享给其他设备时,先按安全文档处理身份验证、工具执行权限和暴露范围;不要直接把训练界面当作公共服务。安装说明、安全说明
按模型、数据、参数、训练配置四个区域依次完成:
- 选择 Text 与支持的可训练模型,核对 model revision 与设备要求。
- 选择 QLoRA。这里是量化基座配合适配器训练,不是把 GGUF 推理文件直接重新训练。
- 在 Local 数据入口选择 train 文件,明确映射
messages,检查预览中三个角色和目标答案。 - 配置独立验证数据。界面版本若不能将两个本地文件指定为固定分区,则使用官方 Core notebook 的 train_dataset / eval_dataset 路径;不要退回随机按行划分而声称仍保持来源隔离。
- 在参数区保存配置,设置短训练上限和足够频繁的保存,先验证一小轮是否正常。
- 点击 Start Training,观察加载、训练和验证过程,检查实际产物是否保存。
界面流程见 Studio 入门,代码入口与当前模型示例见 官方 notebooks。不同入口的配置键可能不同,不能把 Studio 导出的 YAML 无检查地交给另一套框架。
参数怎么设才有意义
先以所选模型官方示例为起点,再做小范围实验。不要宣称一个 rank 或学习率能适配所有业务。建议记录而不是一次性大量修改:
| 参数 | 需要记录的原因 |
|---|---|
| Context Length | 影响显存与截断,必须覆盖关键目标内容 |
| Batch Size / 梯度累积 | 决定有效批量,并影响资源压力 |
| Rank / Alpha / Target Modules | 描述适配器容量与训练位置 |
| Learning Rate / Steps / Epochs | 决定更新强度与训练长度 |
| Packing / Loss Masking | 影响样本组织与哪些 token 被训练 |
| Eval / Save 设置 | 决定何时检查退化以及能否恢复 |
QLoRA 的显存收益受模型、上下文与实现影响。首次 OOM 时,先减少单设备批量、检查长样本和并发占用,不要直接扩大租用设备并同时改变全部参数。超参数指南
训练目标为回答内容时,还需核对损失屏蔽是否正确。某些 assistant-only loss 路径要求聊天模板支持相应标记;打开选项之后仍应检查目标 token 是否实际参与训练。TRL 损失与模板说明
第五步:比较基座与适配器输出
先在验证集挑选候选 checkpoint,再用保留测试集作最终验收。以相同推理参数分别生成基座与适配器输出,保持样本 ID 一致。
下面脚本计算整体准确率、无效输出数和各类别召回率。无效 JSON、额外字段、缺少结果都会作为失败。它只是分类任务的一个评估示例,不能评价回答质量、推理能力或其他业务任务。
保存为 evaluate_labels.py:
import json
import sys
from collections import Counter
from pathlib import Path
def read_rows(path):
return [json.loads(line) for line in Path(path).read_text(encoding="utf-8").splitlines() if line.strip()]
gold_rows, predictions = read_rows(sys.argv[1]), read_rows(sys.argv[2])
gold = {row["id"]: row["label"] for row in gold_rows}
if not gold or len(gold) != len(gold_rows):
raise ValueError("参考集为空或 ID 重复")
labels = {"账号", "账单", "技术"}
if not set(gold.values()) <= labels:
raise ValueError("参考标签无效")
by_id = {}
for row in predictions:
sample_id = row.get("id")
if sample_id not in gold or sample_id in by_id:
raise ValueError("预测中存在未知或重复 ID")
by_id[sample_id] = row.get("output")
correct = invalid = 0
totals, hits, errors = Counter(), Counter(), Counter()
for sample_id, target in gold.items():
totals[target] += 1
predicted = None
try:
obj = json.loads(by_id[sample_id])
if isinstance(obj, dict) and set(obj) == {"label"} and isinstance(obj["label"], str) and obj["label"] in labels:
predicted = obj["label"]
except (KeyError, TypeError, ValueError):
pass
if predicted is None:
invalid += 1
if predicted == target:
correct += 1
hits[target] += 1
else:
errors[f"{target} -> {predicted or '无效'}"] += 1
report = {
"total": len(gold), "missing": len(set(gold) - set(by_id)),
"accuracy": correct / len(gold), "invalid_outputs": invalid,
"recall_by_label": {label: hits[label] / totals[label] for label in sorted(totals)},
"errors": dict(errors),
}
print(json.dumps(report, ensure_ascii=False, indent=2))
分别执行 python3 evaluate_labels.py splits-v1/test.jsonl baseline.jsonl 和 python3 evaluate_labels.py splits-v1/test.jsonl adapted.jsonl。对比报告后,还要人工查看错例。总体准确率上升但关键类别召回下降,可能仍不适合上线。
样本很少或类别不均衡时,百分比容易波动。保存每题结果、分组覆盖与错误明细,再决定是否补标注、重新定义任务或停止训练。
第六步:保存产物并设置发布条件
产物清单至少包含:基座与 revision、tokenizer 与聊天模板、数据校验值、配置、依赖版本、checkpoint 或适配器、训练日志和评价报告。适配器不能脱离对应基座解释;导出或合并后需要重新验证推理。保存与部署说明
发布前按实际业务定义格式通过率、分类指标、关键错例容忍范围与回滚条件。不要把本教程的示例阈值或一次小样本结果当作生产标准。
上传到公开模型仓库需要单独人工确认。检查数据授权、模型条款、产物可能携带的敏感信息,以及软件组件许可。Unsloth Core 与部分界面组件采用不同许可证,应按实际使用内容核对。许可证说明
常见错误与下一步
| 问题 | 优先检查 |
|---|---|
| 训练 loss 降低,分类没改善 | 标注一致性、损失屏蔽、模板与独立数据 |
| 验证结果异常好 | 同来源泄漏、近重复、答案是否进入输入 |
| OOM | 长样本、批量、并发进程、后端兼容性 |
| 输出反复或不停止 | tokenizer、角色模板、EOS 与生成参数 |
| 产物恢复后效果变化 | 基座 revision、适配器加载、模板、量化路径 |
| 费用超预算 | 闲置实例、自动重试、存储与保存频率 |
若需求主要是更新知识,先验证 RAG;若希望维护配置流水线,比较 Axolotl;若想结合图形界面与模型模板,比较 LlamaFactory。更多分工见 5 款微调工具推荐。
常见问题
- 为什么不直接随机按行切分?
- 同一工单、客户或文档的改写可能跨分区,造成评价偏高。先定义合理来源组,再检查完全与语义近重复。
- 脚本要求十个来源组,是否就是足够的数据?
- 不是。这只是演示划分条件,不是训练质量标准;真实任务还要检查类别覆盖、标注一致性和代表性。
- approved 标记能自动证明数据合规吗?
- 不能。它只记录人工审核状态,授权、脱敏和标签审查需在写入标记之前完成。
- 训练 loss 降低是否代表效果提高?
- 不能直接推断。需要比较独立数据的业务指标、无效输出和关键错误,尤其检查类别退化。
- 数据和模型能完全留在本地吗?
- 取决于所用入口及配置。检查模型下载、生成 API、日志平台、远程访问和上传行为,不能仅根据本地界面判断。
- 本文是否实际跑过 GPU 微调?
- 没有。只执行了数据准备和分类评估脚本的本地检查,GPU、驱动和训练性能仍需在目标环境验证。