从SFT到LoRA:微调Qwen输出结构化JSON

本文以一个“客服请求分类并输出 JSON”的小项目为例,介绍监督微调(SFT)的运行过程、LoRA 如何影响 Qwen 模型、labels=-100 的作用,以及 LoRA 如何修改 Transformer 的 Attention 与 MLP 计算。

从SFT到LoRA:微调Qwen输出结构化JSON


一、项目目标

本文实现的是一个很小的监督微调(Supervised Fine-Tuning,SFT)任务。给定一条中文客服请求,模型需要输出严格的 JSON:

1
2
3
4
5
{
"intent": "shipping",
"urgency": "high",
"reply": "我们会优先核查物流情况。"
}

其中:

  1. intent 表示请求类型,例如物流、退款、账户、支付;
  2. urgency 表示紧急程度,例如 highmediumlow
  3. reply 是一段简短的客服回复。

本实验使用 Qwen/Qwen2.5-0.5B-Instruct 作为基础模型,使用 LoRA 进行参数高效微调。训练集有 48 条样本,验证集有 12 条样本。这个规模不用于训练实用客服系统,而是用于完整跑通 SFT 的数据处理、训练、推理与评测流程。

整体流程如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
JSONL 训练数据

构造成 Qwen 的 system / user / assistant 对话

Tokenizer 将文本转换为 token ID

只对 assistant 的 JSON 答案计算 loss

Qwen 基础模型 + LoRA adapter

反向传播,只更新 LoRA 参数

保存 adapter,并在验证集上评测

二、运行环境与项目结构

本实验在单张 RTX 4090D 24GB GPU 上运行。该显卡支持 BF16,因此可以在减少显存占用的同时保持较好的训练稳定性。TensorRT 不参与本次训练,训练使用的是 PyTorch、Transformers 和 PEFT。

项目目录如下:

1
2
3
4
5
6
7
8
9
sft_json_task/
├── data/
│ ├── train.jsonl # 48 条训练样本
│ └── valid.jsonl # 12 条验证样本
├── train_sft.py # SFT 训练脚本
├── infer.py # 单条样本推理脚本
├── evaluate.py # 验证集评测脚本
├── requirements.txt # Python 依赖
└── run_train.sh # 训练启动脚本

安装依赖:

1
2
3
4
python -m venv .venv
source .venv/bin/activate
python -m pip install -U pip
python -m pip install -r requirements.txt

如果服务器上 Python 位于 Conda 目录,可替换为对应路径,例如:

1
2
/root/miniconda3/bin/python -m venv .venv
source .venv/bin/activate

训练启动命令:

1
2
3
unset OMP_NUM_THREADS
export OMP_NUM_THREADS=8
bash run_train.sh

若服务器访问 Hugging Face 较慢,可以使用镜像和更长的下载超时:

1
2
3
4
export HF_ENDPOINT=https://hf-mirror.com
export HF_HUB_ETAG_TIMEOUT=120
export HF_HUB_DOWNLOAD_TIMEOUT=120
bash run_train.sh

模型首次运行时会下载 tokenizer、配置文件和约 988MB 的模型权重;之后会优先读取本地缓存。


三、训练数据是什么样的

训练数据采用 JSON Lines 格式,也就是“一行一个 JSON 对象”。一条样本如下:

1
2
3
4
5
{
"instruction": "请判断这条客服请求的意图和紧急程度,并只输出 JSON。",
"input": "我的包裹已经签收但没有收到,明天要用。",
"output": "{\"intent\":\"shipping\",\"urgency\":\"high\",\"reply\":\"我们会立即核查签收记录并联系配送方处理。\"}"
}

训练脚本要求每条数据必须有三个字段:

1
2
3
for key in ("instruction", "input", "output"):
if key not in row:
raise ValueError(...)

这样的检查能尽早发现数据格式问题,避免模型训练到一半才因为缺字段报错。

本实验包括以下意图:

intent 含义
shipping 发货、物流、签收、修改收货地址
refund 退货、退款、商品质量问题
cancel 取消订单
account 登录、验证码、账户安全
technical App、网页、上传、支付页面故障
payment 扣款、支付方式、账单金额

例如:

1
2
3
“包裹签收但没有收到,明天急用” → shipping + high
“退款通常几天到账” → refund + low
“银行卡被重复扣款” → payment + high

四、训练脚本是怎样运行的

训练入口是:

1
2
if __name__ == "__main__":
main()

直接运行 python train_sft.py 时,Python 会执行 main()main() 的主要步骤如下。

1. 读取命令行参数

脚本通过 argparse 读取训练参数:

1
2
3
4
5
6
parser.add_argument("--model_name", default="Qwen/Qwen2.5-0.5B-Instruct")
parser.add_argument("--max_length", type=int, default=512)
parser.add_argument("--num_train_epochs", type=float, default=5.0)
parser.add_argument("--learning_rate", type=float, default=2e-4)
parser.add_argument("--train_batch_size", type=int, default=4)
parser.add_argument("--gradient_accumulation_steps", type=int, default=4)

启动脚本实际传入的核心参数是:

1
2
3
4
5
6
7
python train_sft.py \
--model_name Qwen/Qwen2.5-0.5B-Instruct \
--num_train_epochs 5 \
--max_length 512 \
--train_batch_size 4 \
--gradient_accumulation_steps 4 \
--precision auto

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/1515/15 的来源。

2. 读取数据并设置随机种子

1
2
3
4
5
set_seed(args.seed)
random.seed(args.seed)

train_rows = read_jsonl(args.train_file)
valid_rows = read_jsonl(args.valid_file)

随机种子默认是 42。它能使数据顺序、初始化和部分随机操作尽量保持一致,便于复现实验。

3. 构造对话格式

Qwen-Instruct 是聊天模型,因此不能简单把用户问题和答案直接拼接。脚本把每条样本构造成:

1
2
3
4
system:你是一个客服请求分类器。请严格只输出一个 JSON 对象……
user:请判断这条客服请求的意图和紧急程度,并只输出 JSON。
用户请求:我的包裹已经签收但没有收到,明天要用。
assistant:{"intent":"shipping","urgency":"high","reply":"……"}

对应代码为:

1
2
3
4
5
prompt = [
{"role": "system", "content": SYSTEM_PROMPT},
{"role": "user", "content": user_text},
]
full = prompt + [{"role": "assistant", "content": row["output"]}]

之后使用:

1
tokenizer.apply_chat_template(...)

systemuserassistant 转换为 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
2
3
4
5
{
"input_ids": ... ,
"attention_mask": ... ,
"labels": ... ,
}

它们的职责不同:

字段 含义
input_ids 模型实际看到的全部 token
attention_mask 哪些位置是真实文本,哪些是 padding
labels 哪些位置需要与标准答案比较并计算 loss

对于一段完整对话:

1
[system prompt] + [user request] + [assistant JSON]

脚本会构造:

1
2
labels = [-100] * len(prompt_ids)
labels += answer_token_ids

于是 labels 的概念形式为:

1
[-100, -100, ..., -100, "{", "intent", ":", "shipping", ...]

在 PyTorch 的交叉熵损失中,-100 是默认的忽略标记 ignore_index

1
2
3
label = -100
→ 该位置不计算 loss
→ 该位置不产生训练梯度

因此,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
2
input_ids:模型看什么
labels:模型为了什么答案被计算误差

若不屏蔽 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
$$

其中:

  1. (W) 是基础模型的原始权重,训练时被冻结;
  2. (A)、(B) 是 LoRA 新增的小矩阵,训练时更新;
  3. (r) 是低秩维度;
  4. (\alpha / r) 是缩放系数。

本项目的 LoRA 配置如下:

1
2
3
4
5
6
7
8
9
10
11
lora_config = LoraConfig(
r=16,
lora_alpha=32,
lora_dropout=0.05,
target_modules=[
"q_proj", "k_proj", "v_proj", "o_proj",
"gate_proj", "up_proj", "down_proj"
],
bias="none",
task_type="CAUSAL_LM",
)

这里:

1
2
3
r = 16
alpha = 32
scale = alpha / r = 2

假设某个线性层权重维度为 (896 \times 896)。全量更新需要:

$$
896 \times 896 = 802,816
$$

个参数。若 LoRA 的 (r=16),则只训练:

$$
16 \times 896 + 896 \times 16 = 28,672
$$

个参数。

因此,LoRA 不会复制并修改完整的大权重,而是在原始计算旁边增加一条较小的可训练分支:

1
2
3
原始线性层输出 = base_linear(x)
LoRA 分支输出 = B(A(dropout(x))) × scale
最终输出 = 原始输出 + LoRA 分支输出

在本实验的日志中,可以看到:

1
2
3
trainable params: 8,798,208
all params: 502,830,976
trainable%: 1.7497

模型总参数约 5.03 亿,但实际更新的 LoRA 参数约 880 万,只占 1.75%。


八、训练很少参数,为什么 LoRA 仍然有效

LoRA 训练参数少,不代表它影响很小。主要原因有以下三点。

1. 基础模型已经具备通用能力

Qwen 本来已经能理解中文、识别“退款”“签收”“扣款”等词,也具备生成 JSON 与客服语言的基础能力。

当前任务不是从零教模型学习语言,而是让它更稳定地完成特定映射:

1
2
3
“签收但未收到” → shipping + high
“退款多久到账” → refund + low
“重复扣款” → payment + 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
2
3
original_output = base_linear(x)       # 冻结的 Qwen 原权重
lora_output = lora_B(lora_A(x)) # 可训练的小分支
output = original_output + scale * lora_output

反向传播时:

1
2
Qwen 原始 Wq / Wk / Wv / Wo:冻结,不更新
LoRA 的 A / B 矩阵:接收梯度并更新

不同投影层的改动可以这样理解:

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
2
["q_proj", "k_proj", "v_proj", "o_proj",
"gate_proj", "up_proj", "down_proj"]

其中:

模块 所属部分 作用
q_projk_projv_projo_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_projgate_projdown_proj 加 LoRA,意味着模型不仅改变“关注输入中的什么信息”,还改变“获得信息后如何加工、筛选和组合这些特征”。

因此,这个项目训练的不是一个额外接在最后的分类头,而是让 Transformer 每一层的 Attention 和 MLP 都发生轻量、分布式的任务适配。


十二、Trainer 如何完成训练

训练脚本使用 Hugging Face 的 Trainer

1
2
3
4
5
6
7
8
9
trainer = Trainer(
model=model,
args=training_args,
train_dataset=train_dataset,
eval_dataset=valid_dataset,
data_collator=CausalLMCollator(tokenizer.pad_token_id),
)

trainer.train()

每个训练 batch 内部大致执行:

1
2
3
4
5
6
7
8
1. 取出一批 input_ids、attention_mask、labels
2. Qwen + LoRA 前向计算,输出每个位置的 token logits
3. 忽略 labels=-100 的 prompt 位置
4. 仅对 assistant JSON 的 token 计算交叉熵 loss
5. loss.backward() 反向传播
6. 将梯度累积 4 个 micro-batch
7. AdamW 更新 LoRA 的 A/B 矩阵
8. 每个 epoch 后在验证集计算 eval_loss 并保存 checkpoint

训练参数中:

1
2
3
4
5
evaluation_strategy="epoch"
save_strategy="epoch"
load_best_model_at_end=True
metric_for_best_model="eval_loss"
greater_is_better=False

表示每轮训练结束后都进行验证和保存,最后自动恢复验证集 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
2
3
python infer.py \
--adapter_dir outputs/qwen25-0.5b-json-lora \
--text "我的包裹显示已经签收,但我没有收到,明天急用,怎么办?"

推理脚本的核心加载过程:

1
2
base = AutoModelForCausalLM.from_pretrained(args.model_name)
model = PeftModel.from_pretrained(base, args.adapter_dir)

其中 base 是未修改的基础 Qwen,PeftModel.from_pretrained 将 LoRA adapter 挂载回对应层。

脚本设置:

1
do_sample=False

表示使用确定性生成。对于分类任务,同一输入多次运行应尽量得到同一结果,不需要随机采样造成的类别波动。

验证集评测:

1
2
3
python evaluate.py \
--adapter_dir outputs/qwen25-0.5b-json-lora \
--valid_file data/valid.jsonl

评测会统计四个指标:

指标 含义
Valid JSON 模型输出是否可解析为 JSON
Intent accuracy intent 是否正确
Urgency accuracy urgency 是否正确
Intent + urgency 两个字段是否同时正确

当前评测不要求 reply 与标注逐字完全一致,因为多种客服表达都可能合理。它的重点是评估结构化输出与两个可客观判断的分类字段。


十四、这个实验的局限与下一步

这个项目适合用来理解 SFT 和 LoRA,但它有明显局限:

  1. 训练数据只有 48 条,覆盖范围很窄;
  2. 验证集只有 12 条,统计结果波动较大;
  3. 模型可能主要学习了关键词,而不是真正复杂的客服推理;
  4. 没有奖励模型、强化学习或外部工具调用;
  5. 没有处理提示注入、错误数据、长期记忆等 Agent 问题。

下一步可以在相同任务上尝试强化学习:

1
2
3
4
JSON 合法且字段齐全      → 给格式奖励
intent 正确 → 给分类奖励
urgency 正确 → 给分类奖励
有多余文字或字段非法 → 给惩罚

然后比较:

1
2
3
原始基础模型
SFT 模型
SFT + RL 模型

这样可以直观看到:SFT 用标准示范让模型先获得基本行为,而 RL 使用可验证反馈进一步强化目标输出。


十五、总结

本文的小项目展示了一个完整的参数高效微调流程:

1
2
3
4
5
6
7
8
9
JSONL 样本
→ Chat Template
→ Tokenization
→ response-only labels
→ Qwen + LoRA
→ 交叉熵损失
→ 反向传播更新 LoRA
→ 保存 adapter
→ 推理与评测

其中最重要的几个结论是:

  1. SFT 的目标是提高标准答案 token 的条件概率;
  2. labels=-100 表示 prompt 仍可被模型看到,但不参与 loss;
  3. LoRA 冻结基础模型,仅训练低秩增量 (BA);
  4. LoRA 参数虽然少,但分布在多层 Attention 和 MLP 的关键路径上,能够有效调整已有模型能力的调用方式;
  5. 对 Attention 而言,LoRA 修改了 Q、K、V、O 投影,使模型在生成答案时以不同方式选择、提取和传递上下文信息。

从SFT到LoRA:微调Qwen输出结构化JSON

https://garyaacm.github.io/2026/09/11/从SFT到LoRA微调Qwen/

作者

Gary

发布于

2026-09-11

更新于

2026-09-11

许可协议

评论

:D 一言句子获取中...

加载中,最新评论有1分钟缓存...