
用 Unsloth 微调 Qwen3.5-4B 训练一个意图分类决策模型
用 Unsloth 微调 Qwen3.5-4B 训练一个意图分类决策模型
把开放权重的 Qwen3.5-4B 改造成一个「做选择」而不是「写长文」的决策模型:用 Unsloth 的 FastDecisionModel 在 CLINC150 意图数据集上做 4-bit LoRA 微调,让模型对固定候选意图打分并返回置信度,最后完成评估、校准、测试与保存。
很多对话场景其实不需要模型写出一段解释,只需要它从一组固定选项里挑一个,并给出这个选择的可信程度。比如用户说「帮我查一下储蓄账户余额」,业务系统真正想要的是 {"intent": "balance", "confidence": 0.97} 这样的结构化结果,而不是一段「您可以打开手机银行,点击账户……」的说明文字。本教程要做的,就是把一个开放权重的语言模型改造成这种「决策模型」:它不再逐 token 生成文本,而是对预先给定的候选答案逐一打分,输出概率最高的那一项。
具体做法是用 Unsloth 的 FastDecisionModel 加载 Qwen3.5-4B,在 CLINC150 意图分类数据集上做 4-bit LoRA 微调,训练出一个能识别用户意图的分类器,并完成评估、置信度校准、实际测试与保存。整个过程可以在 Google Colab 的免费 GPU 上跑通,不需要在本地配置 CUDA 驱动。适合已经会用 Transformers 和 LoRA 微调、想进一步把模型输出收敛成「结构化决策」的开发者阅读。
准备工作
环境与硬件
整个实验在 Google Colab 中完成。打开 Colab 后,先在 Runtime → Change runtime type 里选择一块可用的 NVIDIA GPU。教程中的配置在 Tesla T4 上运行,显存约 14.6 GB。这个配置能跑起来,但训练速度会比更新一代的 GPU 慢不少。如果手头有更大显存的卡,把 batch size 调大可以明显缩短训练时间。
建议先把官方提供的 Qwen3.5-4B Decision Model 笔记本复制一份到自己的 Google Drive,再在副本上改动,避免误改原始文件。
组件选型
本教程使用的组合如下,替换其中任何一项都需要相应调整代码:
| 组件 | 选择 |
|---|---|
| 基座模型 | Qwen3.5-4B |
| 训练框架 | Unsloth |
| 数据集 | CLINC150 |
| 训练方式 | 4-bit LoRA |
| 决策类型 | Choice(多选一) |
| 运行环境 | Google Colab |
版本号、依赖组合与显存占用会随 Unsloth、Transformers、PyTorch 的迭代而变化,安装前请以各项目官网当前给出的说明为准。
先理解决策模型在做什么
传统语言模型一次生成一个 token,把答案「写」出来。决策模型换了一条路:它接收输入文本和一组候选选项,对每个选项计算一个分数,再归一化成概率。Unsloth 的 FastDecisionModel 就是在预训练 LLM 之上加了一个专门的 decision head(决策头),让模型处理「输入 + 候选」并给候选打分,而不是走常规的文本补全路径。
它支持三种问题类型:
- Choice:从多个互斥选项中选一个,适合意图分类、路由分发。
- Noul:返回是或否的二值判断,适合内容审核、合规拦截。
- Score:从一组有序等级中选一个,适合情感强度、风险等级这类有序标签。
本教程只使用 Choice 类型,因为目标是判断用户消息背后的意图。决策模型常见的落地场景还包括内容审核、请求路由、情感分析和各类 agent 工作流——在这些地方,选对动作比生成漂亮文字重要得多。
操作步骤
第一步:安装依赖
在 Colab 中新建一个代码单元,运行下面的安装脚本。它先装 uv 作为包管理器,再按当前环境判断需要安装哪些包:如果环境里没有 PyTorch,或者检测到 Colab 环境变量,就安装指定版本的 PyTorch、triton、bitsandbytes、xformers 以及 Unsloth 系列包;如果已经有 PyTorch 但没有 Unsloth,则走另一条安装分支。最后统一升级 tokenizers、trl、transformers 等关键依赖,并安装 Unsloth 的加速 kernel。
%%capture
import os, importlib.util
!pip install --upgrade -qqq uv
if importlib.util.find_spec("torch") is None or "COLAB_" in "".join(os.environ.keys()):
try:
import numpy, PIL
_numpy = f"numpy=={numpy.__version__}"
_pil = f"pillow=={PIL.__version__}"
except:
_numpy = "numpy"
_pil = "pillow"
!uv pip install -qqq \
"torch==2.8.0" "triton>=3.3.0" {_numpy} {_pil} torchvision "torchaudio==2.8.0" \
bitsandbytes xformers==0.0.32.post2 \
"unsloth_zoo[base] @ git+https://github.com/unslothai/unsloth-zoo" \
"unsloth[base] @ git+https://github.com/unslothai/unsloth"
!uv pip install -qqq --no-deps "torchcodec==0.7.0"
elif importlib.util.find_spec("unsloth") is None:
!uv pip install -qqq unsloth
!uv pip install --upgrade --no-deps "tokenizers>=0.22.0,<=0.23.0" trl==0.22.2 unsloth unsloth_zoo
!uv pip install transformers==5.2.0
!unsloth install-kernels
!uv pip install --no-deps --upgrade "torchao>=0.16.0"
开头的 %%capture 会把安装日志吞掉,让笔记本界面保持干净。安装完成后不要重启运行时,直接进入下一步加载模型。
第二步:用 FastDecisionModel 加载 Qwen3.5-4B
关键差别在于加载器:这里不用常规的语言模型加载接口,而是用 FastDecisionModel.from_pretrained。它会同时准备好基座模型和待训练的决策头。
from unsloth import FastDecisionModel, DecisionTrainer, is_bfloat16_supported
import torch
model, tokenizer = FastDecisionModel.from_pretrained(
model_name="unsloth/Qwen3.5-4B",
max_seq_length=2048,
load_in_4bit=True,
)
三个参数的含义:
model_name:Hugging Face 上的模型标识,这里用 Unsloth 提供的 Qwen3.5-4B 版本。max_seq_length:最大序列长度,设为 2048。CLINC150 的句子都很短,这个值足够覆盖,同时不会浪费显存。load_in_4bit:开启 4-bit 量化,显著降低显存占用,是 T4 这类小显存卡能跑起来的前提。
加载过程中会看到 Unsloth 打印环境信息,包括 GPU 型号、显存总量、PyTorch 与 CUDA 版本、是否启用 bfloat16、xformers 版本等。T4 不支持 bfloat16,日志里会显示 Bfloat16 = FALSE,并提示 qwen3_5 无法使用 float16 精度、改用 float32。这是正常现象,不影响后续训练。权重下载完成后会显示 Fetching 13 files: 100%,表示模型与分词器都已就绪。
第三步:配置 LoRA
不微调全部参数,而是用 LoRA 只训练一小部分新增参数,基座模型基本保持不动。决策头本身也会参与训练。
model = FastDecisionModel.get_peft_model(
model,
r=16,
lora_alpha=16,
lora_dropout=0,
use_gradient_checkpointing="unsloth",
random_state=3407,
)
参数说明:
r=16:LoRA 秩。16 是这个实验里比较稳妥的起点,秩越大可训练参数越多、显存占用越高。lora_alpha=16:缩放系数,与r取相同值意味着缩放比例为 1。lora_dropout=0:不启用 dropout。数据量较大时通常可以关掉。use_gradient_checkpointing="unsloth":用 Unsloth 实现的梯度检查点,用少量计算时间换显存,是 T4 上跑得动的关键。random_state=3407:随机种子,固定后便于复现结果。
第四步:下载并检查 CLINC150
CLINC150 是专门为意图分类设计的公开数据集,包含 150 个意图类别,覆盖银行、旅行、购物、日常助手等场景,并且额外提供了「超出系统能力范围」的样本,用来训练模型识别自己不该回答的请求。
完整数据集包含 15000 条域内训练样本、3000 条验证样本、4500 条域内测试样本,以及单独的域外(out-of-scope)样本。先下载原始 JSON 并检查结构:
import requests
import random
from collections import Counter
url = (
"https://raw.githubusercontent.com/clinc/oos-eval/"
"master/data/data_full.json"
)
response = requests.get(url, timeout=60)
response.raise_for_status()
data = response.json()
print("Dataset splits:", list(data.keys()))
print("Training examples:", len(data["train"]))
print("Validation examples:", len(data["val"]))
print("Test examples:", len(data["test"]))
print("Sample:", data["train"][0])
输出会显示六个划分:oos_val、val、train、oos_test、test、oos_train,以及各划分的样本数量。每条样本是一个二元组,第一个元素是用户消息,第二个元素是意图标签,例如:
['what expression would i use to say i love you if i were an italian', 'translate']
在正式转换格式之前,建议先做两件事:统计训练集里各意图的样本分布,确认是否存在长尾类别;再从训练集里随机抽若干条人工看一眼,确认文本与标签的对应关系没有错位。这两步能提前暴露数据层面的问题,比训练完再排查省事得多。
第五步:把数据转换成决策格式
决策模型训练时需要的不是「输入 → 目标文本」,而是「输入 + 候选列表 → 正确候选的索引」。因此要把每条样本改写成包含问题、候选选项和答案的结构。转换时需要注意三点:
- 候选集合要固定。所有样本共享同一份 150 个意图的候选列表,顺序保持一致,这样模型学到的才是「在这组选项里选哪个」,而不是靠位置猜。
- 标签要映射成索引。把意图字符串映射为候选列表中的位置,训练时用索引作为监督信号。
- 域外样本单独处理。CLINC150 的
oos_*划分可以用来检验模型面对未知意图时是否会给出一致的高置信错误答案,建议保留下来做后续测试,不要混进训练集。
转换完成后,抽几条样本打印出来核对:问题文本、候选列表长度、答案索引是否与原始标签一致。这一步做扎实,后面训练出问题时就少一个怀疑方向。
第六步:配置训练参数并开始训练
用 DecisionTrainer 组织训练流程。由于决策任务的输出是候选打分而非文本序列,损失函数与常规的因果语言模型微调不同,这部分由框架内部处理,调用方只需要提供数据、模型和训练超参。需要关注的超参包括:
- batch size:受显存限制。T4 上要从小值起步,配合梯度累积达到等效的较大 batch。
- 学习率:LoRA 微调通常用比全参微调更大的学习率,具体取值以实际收敛情况调整。
- 训练轮数:意图分类任务容易过拟合,轮数不宜过多,配合验证集早停更稳妥。
- 评估策略:每个 epoch 结束后在验证集上算一次准确率,作为是否继续训练的判据。
训练过程中留意 loss 曲线。如果训练 loss 持续下降而验证准确率停滞甚至回落,说明模型在记忆训练样本,应减少轮数或降低 LoRA 秩。
一个完整示例
下面把前面的步骤串成一个可执行的最小流程,从加载模型到得到一条预测结果。假设数据已经按第五步转换成决策格式,得到 train_dataset 与 val_dataset,候选意图列表为 intent_choices。
from unsloth import FastDecisionModel, DecisionTrainer
# 1. 加载基座模型与决策头
model, tokenizer = FastDecisionModel.from_pretrained(
model_name="unsloth/Qwen3.5-4B",
max_seq_length=2048,
load_in_4bit=True,
)
# 2. 挂上 LoRA
model = FastDecisionModel.get_peft_model(
model,
r=16,
lora_alpha=16,
lora_dropout=0,
use_gradient_checkpointing="unsloth",
random_state=3407,
)
# 3. 组织训练
trainer = DecisionTrainer(
model=model,
train_dataset=train_dataset,
eval_dataset=val_dataset,
tokenizer=tokenizer,
)
trainer.train()
# 4. 切到推理模式并预测
FastDecisionModel.for_inference(model)
message = "Can you help me check the balance in my savings account?"
result = model.predict(
question=message,
choices=intent_choices,
)
print(result)
预测结果应当是一个包含意图名称与置信度的结构,形如:
{"intent": "balance", "confidence": 0.97}
如果模型返回的意图正确但置信度偏低,说明决策头还没有充分校准,可以进入下一节的评估与校准环节;如果意图本身就错了,先回到数据格式检查候选列表是否与训练时完全一致。
评估模型表现
训练结束后必须在测试集上单独评估,不能只看训练 loss。评估时至少统计三个指标:
- 整体准确率:在 4500 条域内测试样本上的 top-1 命中率。
- 各类别准确率:找出表现最差的意图类别,判断是否需要补充样本。
- 域外识别率:在
oos_test上观察模型给出的最高置信度分布。如果域外样本也普遍得到很高的置信度,说明模型缺乏「不确定」的表达能力,需要引入阈值或补充负样本训练。
把混淆矩阵打印出来也很有价值:意图分类的错误往往集中在语义相近的类别之间,比如「查余额」与「查交易记录」,这类混淆能直接指导后续的数据增强方向。
校准置信度
决策模型的价值一半在「选得对」,另一半在「知道自己有多确定」。未经校准的模型常常过度自信:明明只有六成把握,也输出 0.95。校准的常见做法是在验证集上收集预测置信度与实际正确率的对应关系,再拟合一个映射把原始分数调整到更贴近真实概率的区间。校准之后,业务侧就可以放心地设置阈值——比如置信度低于某个值时转人工处理,而不是让模型硬猜。
保存与加载
训练完成后把 LoRA 适配器和决策头的权重保存下来,而不是保存整个 4-bit 量化模型。保存时同时记录候选意图列表及其顺序,因为推理时必须使用与训练时完全一致的候选集合,顺序错乱会直接导致预测错误。加载时先按同样的方式加载基座模型,再挂上保存的适配器,最后切换到推理模式。
注意事项
- 显存是硬约束。T4 的 14.6 GB 显存能跑通这套配置,但 batch size 和序列长度都没有太多余量。一旦出现 OOM,优先降低 batch size、缩短
max_seq_length,而不是关掉梯度检查点。 - T4 不支持 bfloat16。加载日志会提示 qwen3_5 无法使用 float16 精度并回退到 float32,这会增加显存占用和计算开销,属于预期行为。
- 候选列表必须固定且一致。训练与推理使用不同的候选集合或不同顺序,是这类决策模型最常见的错误来源,且往往表现为「准确率莫名很低」而非直接报错。
- 域外样本不要混入训练集。CLINC150 的
oos_*划分是评估模型拒识能力的关键,混进训练会让评估结果失去意义。 - 依赖版本敏感。安装脚本里锁定了 PyTorch、Transformers、trl、tokenizers 等版本,随意升级可能导致 Unsloth 的补丁失效。实际安装时请以 Unsloth 官网当前给出的依赖组合为准。
- Colab 运行时可能被回收。长时间训练前先把笔记本保存到 Drive,训练中途断开会导致进度丢失。
- 置信度不等于正确率。未经校准的置信度只能用于排序,不能直接当作概率阈值使用;上线前务必在独立测试集上验证校准效果。
- 价格、配额与可用性会变。Colab 的 GPU 类型、可用时长,以及各模型与框架的许可条款都可能调整,使用前请查阅对应服务的官网当前信息。