从SFT到LoRA:微调Qwen输出结构化JSON
本文以一个“客服请求分类并输出 JSON”的小项目为例,介绍监督微调(SFT)的运行过程、LoRA 如何影响 Qwen 模型、
labels=-100的作用,以及 LoRA 如何修改 Transformer 的 Attention 与 MLP 计算。
从SFT到LoRA:微调Qwen输出结构化JSON
一、项目目标
本文实现的是一个很小的监督微调(Supervised Fine-Tuning,SFT)任务。给定一条中文客服请求,模型需要输出严格的 JSON:
1 | { |
其中:
intent表示请求类型,例如物流、退款、账户、支付;urgency表示紧急程度,例如high、medium、low;reply是一段简短的客服回复。
本实验使用 Qwen/Qwen2.5-0.5B-Instruct 作为基础模型,使用 LoRA 进行参数高效微调。训练集有 48 条样本,验证集有 12 条样本。这个规模不用于训练实用客服系统,而是用于完整跑通 SFT 的数据处理、训练、推理与评测流程。
整体流程如下:
1 | JSONL 训练数据 |
二、运行环境与项目结构
本实验在单张 RTX 4090D 24GB GPU 上运行。该显卡支持 BF16,因此可以在减少显存占用的同时保持较好的训练稳定性。TensorRT 不参与本次训练,训练使用的是 PyTorch、Transformers 和 PEFT。
项目目录如下:
1 | sft_json_task/ |
安装依赖:
1 | python -m venv .venv |
如果服务器上 Python 位于 Conda 目录,可替换为对应路径,例如:
1 | /root/miniconda3/bin/python -m venv .venv |
训练启动命令:
1 | unset OMP_NUM_THREADS |
若服务器访问 Hugging Face 较慢,可以使用镜像和更长的下载超时:
1 | export HF_ENDPOINT=https://hf-mirror.com |
模型首次运行时会下载 tokenizer、配置文件和约 988MB 的模型权重;之后会优先读取本地缓存。
三、训练数据是什么样的
训练数据采用 JSON Lines 格式,也就是“一行一个 JSON 对象”。一条样本如下:
1 | { |
训练脚本要求每条数据必须有三个字段:
1 | for key in ("instruction", "input", "output"): |
这样的检查能尽早发现数据格式问题,避免模型训练到一半才因为缺字段报错。
本实验包括以下意图:
| intent | 含义 |
|---|---|
shipping |
发货、物流、签收、修改收货地址 |
refund |
退货、退款、商品质量问题 |
cancel |
取消订单 |
account |
登录、验证码、账户安全 |
technical |
App、网页、上传、支付页面故障 |
payment |
扣款、支付方式、账单金额 |
例如:
1 | “包裹签收但没有收到,明天急用” → shipping + high |
四、训练脚本是怎样运行的
训练入口是:
1 | if __name__ == "__main__": |
直接运行 python train_sft.py 时,Python 会执行 main()。main() 的主要步骤如下。
1. 读取命令行参数
脚本通过 argparse 读取训练参数:
1 | parser.add_argument("--model_name", default="Qwen/Qwen2.5-0.5B-Instruct") |
启动脚本实际传入的核心参数是:
1 | python train_sft.py \ |
train_batch_size=4 表示每次前向与反向计算处理 4 条样本;gradient_accumulation_steps=4 表示累计 4 次梯度后才更新一次参数。
因此有效 batch size 为:
$$
4 \times 4 = 16
$$
训练集共有 48 条数据,因此每个 epoch 中:
$$
48 / 4 = 12 ext{个 micro-batch}
$$
$$
12 / 4 = 3 ext{次参数更新}
$$
训练 5 个 epoch,共约 15 次 optimizer update。这就是训练进度中 0/15、15/15 的来源。
2. 读取数据并设置随机种子
1 | set_seed(args.seed) |
随机种子默认是 42。它能使数据顺序、初始化和部分随机操作尽量保持一致,便于复现实验。
3. 构造对话格式
Qwen-Instruct 是聊天模型,因此不能简单把用户问题和答案直接拼接。脚本把每条样本构造成:
1 | system:你是一个客服请求分类器。请严格只输出一个 JSON 对象…… |
对应代码为:
1 | prompt = [ |
之后使用:
1 | tokenizer.apply_chat_template(...) |
将 system、user、assistant 转换为 Qwen 训练时使用的特殊 token 格式。使用官方 Chat Template 的好处是避免手工拼接特殊标记时出现格式不兼容。
五、SFT 到底在优化什么
语言模型不是一次性预测整个 JSON,而是逐个预测下一个 token。假设目标输出是:
1 | {"intent":"shipping","urgency":"high","reply":"..."} |
模型会依次学习预测:
1 | { → " → intent → " → : → " → shipping → " → , → ... |
设标准答案 token 序列为 (y_1, y_2, …, y_T),模型对第 (t) 个 token 的预测概率为 (P_\theta(y_t | y_{<t}, x))。SFT 的交叉熵损失可以写成:
$$
\mathcal{L}{SFT} = - \sum{t=1}^{T} \log P_\theta(y_t | y_{<t}, x)
$$
其中 (x) 表示 system prompt 和用户请求。
若模型在“包裹签收但未收到”的样本上错误地倾向生成 refund,则 shipping 对应 token 的概率较低,loss 会增大。反向传播将调整 LoRA 参数,使模型在类似输入下更倾向输出 shipping。
六、labels 中 prompt 为 -100 是什么意思
训练数据最终会变为三个字段:
1 | { |
它们的职责不同:
| 字段 | 含义 |
|---|---|
input_ids |
模型实际看到的全部 token |
attention_mask |
哪些位置是真实文本,哪些是 padding |
labels |
哪些位置需要与标准答案比较并计算 loss |
对于一段完整对话:
1 | [system prompt] + [user request] + [assistant JSON] |
脚本会构造:
1 | labels = [-100] * len(prompt_ids) |
于是 labels 的概念形式为:
1 | [-100, -100, ..., -100, "{", "intent", ":", "shipping", ...] |
在 PyTorch 的交叉熵损失中,-100 是默认的忽略标记 ignore_index:
1 | label = -100 |
因此,system prompt 和用户请求不会成为模型需要“复述”的训练目标;只有 assistant 的 JSON 会参与 loss。这个方式称为:
1 | response-only loss |
或:
1 | completion-only training |
需要注意,labels=-100 不代表模型看不到 prompt。Prompt 仍在 input_ids 中,且 attention_mask 为 1。模型仍会利用其中的指令与用户问题进行 Attention,只是不会因为“没有预测好 prompt 本身”而被扣分。
可以简单区分为:
1 | input_ids:模型看什么 |
若不屏蔽 prompt,模型会将大量训练能力用于复述 system prompt 与用户问题,assistant 答案获得的监督比例会变小,训练目标也会偏离“根据问题生成答案”。
七、什么是 LoRA
全参数微调会直接更新模型中的每一个权重。假设某个线性层的输入为 (x),权重为 (W),输出为:
$$
y = xW^T
$$
全量微调后,权重变成:
$$
W’ = W + \Delta W
$$
其中 (\Delta W) 与 (W) 一样大,需要训练大量参数。
LoRA(Low-Rank Adaptation)不直接训练完整的 (\Delta W),而是假设有效更新可以由低秩矩阵近似:
$$
\Delta W = BA
$$
于是线性层变为:
$$
y = xW^T + \frac{\alpha}{r}x(BA)^T
$$
其中:
- (W) 是基础模型的原始权重,训练时被冻结;
- (A)、(B) 是 LoRA 新增的小矩阵,训练时更新;
- (r) 是低秩维度;
- (\alpha / r) 是缩放系数。
本项目的 LoRA 配置如下:
1 | lora_config = LoraConfig( |
这里:
1 | r = 16 |
假设某个线性层权重维度为 (896 \times 896)。全量更新需要:
$$
896 \times 896 = 802,816
$$
个参数。若 LoRA 的 (r=16),则只训练:
$$
16 \times 896 + 896 \times 16 = 28,672
$$
个参数。
因此,LoRA 不会复制并修改完整的大权重,而是在原始计算旁边增加一条较小的可训练分支:
1 | 原始线性层输出 = base_linear(x) |
在本实验的日志中,可以看到:
1 | trainable params: 8,798,208 |
模型总参数约 5.03 亿,但实际更新的 LoRA 参数约 880 万,只占 1.75%。
八、训练很少参数,为什么 LoRA 仍然有效
LoRA 训练参数少,不代表它影响很小。主要原因有以下三点。
1. 基础模型已经具备通用能力
Qwen 本来已经能理解中文、识别“退款”“签收”“扣款”等词,也具备生成 JSON 与客服语言的基础能力。
当前任务不是从零教模型学习语言,而是让它更稳定地完成特定映射:
1 | “签收但未收到” → shipping + high |
这类任务通常不需要重写整个模型,只需调整模型调用已有知识时的偏好和输出概率。
2. 有效更新往往位于少数方向
全量微调允许 (\Delta W) 在所有方向上变化。LoRA 限制 (\Delta W = BA),只能沿低秩空间的少数主要方向变化。
对于“将一个通用聊天模型适配为结构化客服分类器”这样的窄任务,这些方向往往已经足够。
当然,LoRA 也有边界。如果任务与基础模型差异极大、需要学习大量新领域知识,或低秩 (r) 设得过小,则可以提高 r、扩展目标层、增加数据,或者考虑全参数微调。
3. LoRA 分布在多层关键路径中
本项目并非只在输出层加入一个小分类器,而是在每一层 Transformer 的 Attention 和 MLP 中加入 LoRA。每层的小变化会逐层累积,最终能够明显改变输出 token 的概率分布。
因此,“训练参数只有 1.75%”描述的是参数数量,不等于“只影响 1.75% 的模型功能”。
九、Transformer Attention 原本如何计算
设一层 Transformer 的输入为 (X),Attention 会先通过三组线性投影计算 Query、Key、Value:
$$
Q = XW_Q^T
$$
$$
K = XW_K^T
$$
$$
V = XW_V^T
$$
之后计算注意力权重:
$$
A = \text{softmax}\left(\frac{QK^T}{\sqrt{d_k}}\right)
$$
最后使用注意力权重加权 Value,并经过输出投影:
$$
O = AVW_O^T
$$
可以直观理解为:
| 部分 | 直观含义 |
|---|---|
| Query(Q) | 当前 token 想寻找什么信息 |
| Key(K) | 每个位置可以提供什么索引线索 |
| Value(V) | 每个位置实际携带的内容 |
| QKT | 当前 token 应该关注哪些位置 |
| Output(O) | 将注意到的信息写回模型主表示 |
当模型要输出:
1 | "urgency": "high" |
它应该更关注用户请求中的“明天要用”“立即”“被盗”“重复扣款”等紧急线索。
十、LoRA 是怎样修改 Attention 的
代码中:
1 | target_modules = ["q_proj", "k_proj", "v_proj", "o_proj", ...] |
说明 LoRA 被插入到 Attention 的四个线性投影层。原本:
$$
Q = XW_Q^T
$$
加入 LoRA 后,概念上变为:
$$
Q = X(W_Q + \Delta W_Q)^T
$$
其中:
$$
\Delta W_Q = B_QA_Q
$$
同理:
$$
K = X(W_K + B_KA_K)^T
$$
$$
V = X(W_V + B_VA_V)^T
$$
输出投影也会加入自己的低秩增量:
$$
O = AV(W_O + B_OA_O)^T
$$
在 PEFT 的实际实现中,逻辑可以近似理解为:
1 | original_output = base_linear(x) # 冻结的 Qwen 原权重 |
反向传播时:
1 | Qwen 原始 Wq / Wk / Wv / Wo:冻结,不更新 |
不同投影层的改动可以这样理解:
1. q_proj:修改“当前 token 想找什么”
q_proj 决定 Query。LoRA 可以让模型在生成 urgency 时,更主动查询“明天”“马上”“紧急”“被盗”等线索。
2. k_proj:修改“什么信息更容易被注意到”
k_proj 决定 Key。LoRA 可以让“验证码”“无法登录”“账户被盗”等文本在生成 intent: account 时更容易成为注意力匹配的目标。
3. v_proj:修改“被取出的语义内容”
v_proj 决定 Value。即使模型已经注意到“支付成功但页面失败”,仍要决定从这段文字中提取哪些表示。LoRA 可以强化“支付异常”“避免重复支付”“需要优先处理”等语义。
4. o_proj:修改“注意力结果如何影响后续层”
o_proj 将多头 Attention 的结果写回残差流。对它加入 LoRA,会改变模型如何将注意到的上下文信息传递给后续 MLP、下一层 Attention 和最终的 token 预测。
因此 LoRA 对 Attention 的影响不是硬编码规则,而是通过调整 Q、K、V、O 的连续向量表示,使某些上下文线索在生成对应类别时更容易被利用。
十一、LoRA 还修改了 MLP
本项目的目标模块不止 Attention:
1 | ["q_proj", "k_proj", "v_proj", "o_proj", |
其中:
| 模块 | 所属部分 | 作用 |
|---|---|---|
q_proj、k_proj、v_proj、o_proj |
Attention | 选择和聚合上下文信息 |
up_proj |
MLP | 将隐藏表示扩展到更高维空间 |
gate_proj |
MLP | 控制哪些特征被放大或抑制 |
down_proj |
MLP | 将计算结果投影回隐藏维度 |
Qwen 的 MLP 可以粗略表示为:
$$
H = \text{activation}(XW_{up}^T) \odot \text{gate}(XW_{gate}^T)
$$
$$
Y = HW_{down}^T
$$
对 up_proj、gate_proj、down_proj 加 LoRA,意味着模型不仅改变“关注输入中的什么信息”,还改变“获得信息后如何加工、筛选和组合这些特征”。
因此,这个项目训练的不是一个额外接在最后的分类头,而是让 Transformer 每一层的 Attention 和 MLP 都发生轻量、分布式的任务适配。
十二、Trainer 如何完成训练
训练脚本使用 Hugging Face 的 Trainer:
1 | trainer = Trainer( |
每个训练 batch 内部大致执行:
1 | 1. 取出一批 input_ids、attention_mask、labels |
训练参数中:
1 | evaluation_strategy="epoch" |
表示每轮训练结束后都进行验证和保存,最后自动恢复验证集 loss 最低的 checkpoint,而不一定使用最后一轮的模型。
对于这个 0.5B 小模型,训练脚本将:
1 | gradient_checkpointing=False |
设为关闭。原因是 4090D 24GB 显存足以训练该任务,并且在某些“冻结基础模型 + LoRA”配置中,开启 gradient checkpointing 可能出现 loss does not require grad 一类梯度图错误。
十三、如何推理与评测
训练完成后,LoRA adapter 和 tokenizer 会保存在:
1 | outputs/qwen25-0.5b-json-lora |
该目录主要保存 adapter,不会复制完整的 Qwen 基础权重。推理时需要组合:
1 | Qwen 基础模型 + 训练后的 LoRA adapter |
单条推理:
1 | python infer.py \ |
推理脚本的核心加载过程:
1 | base = AutoModelForCausalLM.from_pretrained(args.model_name) |
其中 base 是未修改的基础 Qwen,PeftModel.from_pretrained 将 LoRA adapter 挂载回对应层。
脚本设置:
1 | do_sample=False |
表示使用确定性生成。对于分类任务,同一输入多次运行应尽量得到同一结果,不需要随机采样造成的类别波动。
验证集评测:
1 | python evaluate.py \ |
评测会统计四个指标:
| 指标 | 含义 |
|---|---|
Valid JSON |
模型输出是否可解析为 JSON |
Intent accuracy |
intent 是否正确 |
Urgency accuracy |
urgency 是否正确 |
Intent + urgency |
两个字段是否同时正确 |
当前评测不要求 reply 与标注逐字完全一致,因为多种客服表达都可能合理。它的重点是评估结构化输出与两个可客观判断的分类字段。
十四、这个实验的局限与下一步
这个项目适合用来理解 SFT 和 LoRA,但它有明显局限:
- 训练数据只有 48 条,覆盖范围很窄;
- 验证集只有 12 条,统计结果波动较大;
- 模型可能主要学习了关键词,而不是真正复杂的客服推理;
- 没有奖励模型、强化学习或外部工具调用;
- 没有处理提示注入、错误数据、长期记忆等 Agent 问题。
下一步可以在相同任务上尝试强化学习:
1 | JSON 合法且字段齐全 → 给格式奖励 |
然后比较:
1 | 原始基础模型 |
这样可以直观看到:SFT 用标准示范让模型先获得基本行为,而 RL 使用可验证反馈进一步强化目标输出。
十五、总结
本文的小项目展示了一个完整的参数高效微调流程:
1 | JSONL 样本 |
其中最重要的几个结论是:
- SFT 的目标是提高标准答案 token 的条件概率;
labels=-100表示 prompt 仍可被模型看到,但不参与 loss;- LoRA 冻结基础模型,仅训练低秩增量 (BA);
- LoRA 参数虽然少,但分布在多层 Attention 和 MLP 的关键路径上,能够有效调整已有模型能力的调用方式;
- 对 Attention 而言,LoRA 修改了 Q、K、V、O 投影,使模型在生成答案时以不同方式选择、提取和传递上下文信息。
从SFT到LoRA:微调Qwen输出结构化JSON