最近在业务场景里做 Qwen3.5-4B 的指令微调,第一次尝试踩了不少坑:Loss 不降、模型乱答、显存直接打满、训练到一半进程被杀。这周重新换了数据清洗策略和 LoRA 参数,调整了训练流程,总算把效果拉起来了。这篇文章把第二次尝试的完整流程、代码、调参记录和配套排错方案整理出来,不管你是第一次接触大模型微调,还是已经试过一轮但效果不理想,应该都能从中找到可复用的内容。
1. 背景与核心概念
1.1 为什么要微调 Qwen3.5-4B
Qwen3.5-4B 是一个 4B 参数规模的中文开源模型,相比 7B、14B 甚至更大体量的模型,它在单卡训练和推理上更友好,尤其是在 24G 显存的家用卡或云主机上也能跑起来。但通用模型在特定领域里的表现往往不够精准,比如让它回答企业内部知识库的问题、按照固定格式输出结构化内容、或者模仿某种客服语气,基座模型通常只能给出“通用但不够准确”的答案。
微调的目的就是让模型适配目标领域的分布。通过一批高质量、符合业务场景的“问题-答案”数据,把模型的输出习惯拉向目标任务。这里要区分两个概念:预训练和微调。预训练是从头学习语言规律,成本极高;微调是在已学好的语言能力基础上做“定向强化”,成本低很多,也是大多数团队落地大模型的主要方式。
1.2 全参微调与 LoRA 微调的区别
微调可以分成两大类:全参微调(Full Parameter Fine-Tuning)和参数高效微调(PEFT,Parameter-Efficient Fine-Tuning),LoRA 是 PEFT 中最常用的一种。
全参微调会更新模型的所有参数,效果上限通常更高,但对显存和计算资源的要求也高很多。以 Qwen3.5-4B 为例,仅仅把模型参数以 FP16 加载到显存,就至少需要 4B × 2 字节 ≈ 8GB,这还只是模型权重。优化器状态、梯度、中间激活值加起来,实际训练显存可能是模型权重的 3 到 6 倍,也就是 30GB 到 50GB。这也是为什么全参微调 4B 模型也需要多卡或大显存的原因。
LoRA 的核心思路是冻结原始模型权重,在 Transformer 的线性层旁边插入低秩矩阵。训练时只更新这些低秩参数,原始权重保持不变。这样可以大幅减少需要更新的参数量和优化器状态占用。以 4B 模型为例,如果只训练 LoRA 参数,显存占用可能从 40GB 降到 12GB 到 20GB,单张 24G 显卡就能跑起来。
| 对比维度 | 全参微调 | LoRA 微调 |
|---|---|---|
| 训练参数量 | 全部参数 | 通常 0.1% ~ 1% |
| 显存要求 | 高,4B 模型约 30GB 以上 | 较低,4B 模型约 12GB ~ 20GB |
| 训练速度 | 慢 | 快 |
| 效果上限 | 高 | 接近全参微调,视数据量而定 |
| 部署方式 | 替换整个模型 | 基座模型 + LoRA 权重合并或动态加载 |
| 适用场景 | 数据量充足、算力充足、追求极致效果 | 数据量中等、单卡训练、快速迭代 |
第二次尝试我选择的是 LoRA 微调,主要原因就是显存可控、迭代快,而且对于垂直场景的数据规模(几千到几万条),LoRA 的效果已经非常接近全参微调。
1.3 LoRA 微调的常见应用场景
LoRA 微调在实际项目中覆盖的场景很广,常见的有:
- 指令遵循优化:让模型严格按照指定格式输出,比如 JSON 结构、固定字段。
- 垂直领域问答:医疗、法律、金融、教育等领域的专业问答。
- 风格迁移:把答案改写成更口语化、更正式或更简洁。
- 代码生成增强:针对特定语言或框架的代码补全。
- 小样本适配:在少量业务数据基础上快速给模型“打补丁”。
这篇文章的实操部分围绕指令微调(Instruction Tuning)展开,也就是让模型学会“看到问题就按期望格式回答”的能力。
2. 环境准备与版本说明
2.1 硬件环境
微调 Qwen3.5-4B 的最低推荐配置是:单张 24G 显存的 GPU,例如 NVIDIA RTX 3090、4090、A5000 或云上的 T4/A10 实例。如果显存只有 16G,也能尝试,但需要开启 4bit 量化和梯度检查点(gradient checkpointing)。
我第二次尝试使用的环境如下:
- GPU:NVIDIA 4090 24G
- 显存:24GB
- 系统:Ubuntu 22.04 LTS
- Python:3.10
- CUDA:12.1
- PyTorch:2.1.2
这套组合在训练 Qwen3.5-4B + LoRA 时,batch size 可以开到 4 到 8,具体取决于序列长度和 LoRA 维度。
2.2 软件依赖
核心依赖库包括:
- transformers:加载模型和 Tokenizer
- peft:LoRA 配置与训练
- datasets:数据处理
- accelerate:分布式训练与混合精度
- bitsandbytes:4bit/8bit 量化加载
- trl:SFTTrainer 指令微调工具(可选但推荐)
安装命令如下:
pip install torch==2.1.2 --index-url https://download.pytorch.org/whl/cu121 pip install transformers peft datasets accelerate bitsandbytes trl这里要提醒一点:版本不要盲目追求最新。transformers 版本太新可能导致某些 API 变化,比如TrainingArguments的参数名调整。建议在项目目录下维护一个requirements.txt,把实际验证过的版本固定下来。
2.3 模型下载与目录结构
Qwen3.5-4B 可以从官方仓库或镜像站点下载。建议先把模型下载到本地目录,再加载训练,避免每次启动都从网络拉取。
示例项目目录结构:
qwen-finetune/ ├── data/ │ └── train.jsonl ├── scripts/ │ ├── train_lora.py │ └── inference.py ├── models/ │ └── Qwen3.5-4B/ ├── output/ │ ├── lora_weights/ │ └── merged_model/ └── requirements.txt这样做的好处是:数据、代码、模型权重、输出结果分层清晰,后续调试和迁移都方便。
3. 核心原理拆解:LoRA 与训练策略
3.1 LoRA 的数学原理
LoRA 的关键想法很简单:在预训练模型的线性层中,权重矩阵 W 是固定的,LoRA 在旁边增加两个低秩矩阵 A 和 B。前向计算变成:
y = Wx + BAx其中 A 的维度是 r × d,B 的维度是 d × r,r 远小于 d。训练时只更新 A 和 B,W 保持冻结。这样做的好处是:
- 训练参数量大幅减少。
- 显存占用降低。
- 推理时可以合并回原始权重,不增加推理延迟。
实际使用时,并不是所有层都加 LoRA,而是选择 Attention 层的 q、k、v、o 投影以及 MLP 层的某些线性层。通过target_modules参数控制。
3.2 关键超参数
LoRA 训练中最重要的几个超参数:
| 参数 | 作用 | 建议初始值 |
|---|---|---|
| r | 低秩矩阵的秩,影响模型表达能力和参数量 | 8 ~ 64 |
| alpha | 缩放系数,相当于 LoRA 权重的学习率缩放 | r 的 1 ~ 2 倍 |
| dropout | 防止过拟合 | 0.05 ~ 0.1 |
| learning_rate | 学习率,LoRA 通常比全参微调大一些 | 1e-4 ~ 5e-4 |
| batch_size | 批大小 | 4 ~ 8 |
| num_epochs | 训练轮数 | 2 ~ 5 |
| max_seq_len | 最大序列长度 | 512 ~ 2048 |
第一次尝试时,我把r设为 32,alpha设为 64,学习率设为 1e-5,结果模型收敛非常慢。后来把学习率调到 2e-4,效果明显改善。这说明 LoRA 训练中学习率的选择和全参微调差异很大,不能照搬经验。
3.3 全参训练与微调对显存要求的区别
很多人关心“为什么我连一个 4B 模型都训练不起来”,原因在于显存中不仅放模型权重,还要放优化器状态、梯度和激活值。
以 Qwen3.5-4B 全参微调为例,粗略估算:
- 模型权重(FP16):约 8GB
- 梯度:约 8GB
- Adam 优化器状态:权重、一阶动量、二阶动量各一份,约 24GB
- 激活值:与 batch size 和序列长度直接相关,低则几 GB,高则几十 GB
加总之后,全参微调 4B 模型单卡 24G 几乎不够用,更不要说 7B、13B 模型。
而 LoRA 微调时:
- 模型权重以 FP16 或 4bit 加载。
- 梯度只需要在 LoRA 参数上计算,原始权重不产生梯度。
- 优化器状态只保存 LoRA 参数,可能只有几十 MB 到几百 MB。
- 激活值仍然会占用显存,但可以通过 gradient checkpointing 进一步降低。
这就是 LoRA 微调能在单张消费级显卡上跑 4B 模型的核心原因。
3.4 量化加载:让显存更宽裕
如果你的显卡显存只有 16G,可以考虑用 bitsandbytes 把模型以 4bit 或 8bit 方式加载,再叠加 LoRA。这种方式被称为 QLoRA。
4bit 量化会把模型权重压缩到原来的四分之一,模型显存占用从 8GB 降到 2GB 左右。虽然量化会带来少量精度损失,但 LoRA 训练本身是低精度适配,实际业务场景中效果损失往往可以接受。
需要注意:使用 4bit 量化后,训练速度会略慢,因为反量化过程需要额外的计算。如果显存充足,还是建议优先使用 FP16 加载。
4. 完整实战:Qwen3.5-4B LoRA 微调流程
4.1 准备训练数据
微调效果的上限由数据质量决定。第一次尝试失败,很大程度就是直接拿网上爬的原始数据喂给模型,很多问答对格式混乱、答案残缺。第二次我重新整理了数据,统一采用 JSONL 格式,每行一个样本。
指令微调数据的基本结构:
{ "instruction": "请根据以下问题给出回答。", "input": "什么是学习率?", "output": "学习率是控制模型参数更新步长的超参数,学习率过大可能导致训练震荡,过小则收敛缓慢。" }训练文件data/train.jsonl示例:
{"instruction": "请用一句话解释什么是LoRA。", "input": "", "output": "LoRA是一种参数高效微调方法,通过低秩矩阵更新模型权重。"} {"instruction": "翻译成英文:今天天气很好。", "input": "", "output": "The weather is nice today."} {"instruction": "请列举三个健康的饮食习惯。", "input": "", "output": "1. 均衡摄入蔬菜水果;2. 控制油盐用量;3. 定时定量进餐。"}这里要注意几个细节:
- 数据量不是越多越好,但至少要覆盖目标场景的典型问题。
- 如果让模型输出 JSON,每条 sample 的 output 必须是合法的 JSON 字符串,且格式一致。
- 每条样本长度不要超过 max_seq_len,否则会被截断,导致答案不完整。
4.2 编写 LoRA 训练脚本
下面是基于transformers+peft+trl的完整训练脚本,保存为scripts/train_lora.py。
# 文件路径:scripts/train_lora.py import json import torch from datasets import Dataset from transformers import ( AutoModelForCausalLM, AutoTokenizer, TrainingArguments, BitsAndBytesConfig ) from peft import ( LoraConfig, get_peft_model, prepare_model_for_kbit_training ) from trl import SFTTrainer # 模型路径,根据实际下载位置修改 model_path = "./models/Qwen3.5-4B" # ---------- 1. 加载 Tokenizer ---------- tokenizer = AutoTokenizer.from_pretrained( model_path, trust_remote_code=True ) if tokenizer.pad_token is None: tokenizer.pad_token = tokenizer.eos_token # ---------- 2. 加载数据 ---------- def load_data(path): samples = [] with open(path, "r", encoding="utf-8") as f: for line in f: line = line.strip() if not line: continue data = json.loads(line) instruction = data["instruction"] input_text = data.get("input", "") output = data["output"] if input_text: text = f"问题:{instruction}\n补充材料:{input_text}\n回答:{output}" else: text = f"问题:{instruction}\n回答:{output}" samples.append({"text": text}) return Dataset.from_list(samples) train_dataset = load_data("data/train.jsonl") # ---------- 3. 量化配置(24G 以下显存建议开启) ---------- bnb_config = BitsAndBytesConfig( load_in_4bit=True, bnb_4bit_quant_type="nf4", bnb_4bit_use_double_quant=True, bnb_4bit_compute_dtype=torch.float16 ) # ---------- 4. 加载模型 ---------- model = AutoModelForCausalLM.from_pretrained( model_path, quantization_config=bnb_config, device_map="auto", trust_remote_code=True, torch_dtype=torch.float16 ) # 准备 kbit 训练 model = prepare_model_for_kbit_training(model) # ---------- 5. LoRA 配置 ---------- lora_config = LoraConfig( r=16, lora_alpha=32, target_modules=["q_proj", "k_proj", "v_proj", "o_proj", "gate_proj", "up_proj", "down_proj"], lora_dropout=0.05, bias="none", task_type="CAUSAL_LM" ) model = get_peft_model(model, lora_config) model.print_trainable_parameters() # ---------- 6. 训练参数 ---------- training_args = TrainingArguments( output_dir="./output/checkpoints", per_device_train_batch_size=4, gradient_accumulation_steps=4, num_train_epochs=3, learning_rate=2e-4, fp16=True, logging_steps=10, save_steps=500, eval_strategy="no", save_total_limit=2, report_to="none", gradient_checkpointing=True, optim="paged_adamw_8bit", ) # ---------- 7. SFT 训练器 ---------- trainer = SFTTrainer( model=model, tokenizer=tokenizer, train_dataset=train_dataset, args=training_args, max_seq_length=1024, dataset_text_field="text", ) trainer.train() # ---------- 8. 保存 LoRA 权重 ---------- model.save_pretrained("./output/lora_weights") tokenizer.save_pretrained("./output/lora_weights") print("训练完成,LoRA 权重已保存到 ./output/lora_weights")这段代码里需要注意的参数:
load_in_4bit=True开启 4bit 量化,如果显存足够,可以改成False并用torch_dtype=torch.float16。target_modules必须根据模型实际模块名调整。Qwen 系列通常包含q_proj、k_proj、v_proj、o_proj等,但不同版本命名可能有差异,建议先打印模型结构确认。max_seq_length控制训练时序列的最大长度,超过部分会被截断。这个值要结合数据分布设置,不能太小,否则长答案会被截断。
4.3 运行训练
在项目根目录执行:
python scripts/train_lora.py如果一切正常,日志中会输出可训练参数量,类似:
trainable params: 33,554,432 || all params: 4,021,723,136 || trainable%: 0.8345这说明实际训练的参数量只占全部参数的不到 1%。
4.4 合并模型并推理
训练完成后,LoRA 权重保存在output/lora_weights。如果你希望得到一个完整的模型供后续部署,可以把 LoRA 权重合并回基座模型。
# 文件路径:scripts/merge_model.py import torch from transformers import AutoModelForCausalLM, AutoTokenizer from peft import PeftModel base_model_path = "./models/Qwen3.5-4B" lora_path = "./output/lora_weights" merged_path = "./output/merged_model" tokenizer = AutoTokenizer.from_pretrained(base_model_path, trust_remote_code=True) model = AutoModelForCausalLM.from_pretrained( base_model_path, torch_dtype=torch.float16, device_map="auto", trust_remote_code=True ) model = PeftModel.from_pretrained(model, lora_path) model = model.merge_and_unload() model.save_pretrained(merged_path) tokenizer.save_pretrained(merged_path) print(f"合并后的模型已保存到 {merged_path}")合并后的模型可以直接用常规方式加载推理,也可以部署到 vLLM、TGI 等推理框架中。
4.5 推理验证
写一个简单的推理脚本,验证微调效果:
# 文件路径:scripts/inference.py import torch from transformers import AutoModelForCausalLM, AutoTokenizer model_path = "./output/merged_model" tokenizer = AutoTokenizer.from_pretrained(model_path, trust_remote_code=True) model = AutoModelForCausalLM.from_pretrained( model_path, torch_dtype=torch.float16, device_map="auto", trust_remote_code=True ) prompt = "问题:什么是LoRA?\n回答:" inputs = tokenizer(prompt, return_tensors="pt").to("cuda") with torch.no_grad(): outputs = model.generate( **inputs, max_new_tokens=256, do_sample=True, temperature=0.7, top_p=0.9, ) response = tokenizer.decode(outputs[0], skip_special_tokens=True) print(response)运行结果会打印微调后模型对“什么是LoRA”的回答。如果数据准备得好,输出通常比基座模型更符合业务语境。
5. 常见问题与排查思路
第二次尝试过程中,我仍然遇到了几个问题,这里整理成表格,方便大家对照排查。
| 问题现象 | 常见原因 | 解决思路 |
|---|---|---|
| 训练时 CUDA Out of Memory | 显存不足,batch size 太大,序列过长 | 调小 batch size、开启梯度检查点、使用 4bit 量化 |
| Loss 不下降 | 学习率太小、数据格式错误、tokenizer 截断导致 label 错位 | 调大学习率到 1e-4 ~ 5e-4,检查数据格式,确认 max_seq_length |
| 训练很快但效果很差 | 数据量过少,或者 LoRA r 设置太小 | 增加数据量,尝试把 r 从 8 提升到 32 或 64 |
| 训练 Loss 下降但推理输出乱码 | 特殊 token 未正确设置,或者推理时 prompt 格式与训练不一致 | 检查 pad_token 和 eos_token,保持推理 prompt 格式和训练一致 |
| 加载模型报错 | 本地模型文件不完整,或 transformers 版本不匹配 | 重新下载模型,固定 transformers 版本 |
| LoRA 层没有生效 | target_modules 设置错误,模型实际模块名不同 | 打印模型结构,确认线性层名称后再设置 |
5.1 显存不足的排查顺序
如果训练启动后直接报 CUDA OOM,按以下顺序排查:
- 把
per_device_train_batch_size改成 1。 - 开启
gradient_checkpointing=True,并用gradient_accumulation_steps补偿 batch size。 - 开启 4bit 量化加载。
- 把
max_seq_length减小,比如从 2048 降到 1024。 - 检查是否有其他进程占用显存,用
nvidia-smi查看。
5.2 训练不收敛的排查方式
如果你发现 Loss 在 3 到 10 个 logging step 后基本不变,先不要急着调超参数,按下面顺序检查:
- 确认训练数据中“问题-答案”分隔符和统一格式是否一致。
- 确认 tokenizer 的 pad_token 是否设置,否则某些 API 会警告并可能影响掩码。
- 确认 LoRA 参数已经生效,训练日志里应显示 trainable params 数量。
- 尝试把学习率调大到 5e-4,观察 Loss 是否出现明显下降。
5.3 推理效果不佳的优化方向
训练完成但推理效果不理想,可以从数据、参数、提示词三个方向优化:
- 数据方向:补充更多边界案例,修正答案格式不一致的问题。
- 参数方向:提高 LoRA rank,增加训练轮数,调整学习率。
- 提示词方向:推理时的 prompt 格式必须和训练数据完全一致,这一点最容易忽略。
6. 最佳实践与工程建议
微调并不是“把数据塞进去跑一轮”就结束的事情,工程上需要关注的点很多。这里整理几条对实际项目有帮助的建议。
6.1 数据质量优先于数据量
第一次尝试我用了 5 万条数据,效果反而不如第二次精挑细选后的 8000 条。关键原因是大模型微调对数据质量极其敏感,错误格式、重复样本、噪声答案会被模型“学进去”。
建议在训练前做一轮数据清洗:
- 去重,尤其是完全相同的指令。
- 过滤答案过短或过长的样本。
- 统一答案格式,比如都要以句号结尾,或者都必须输出 JSON。
- 剔除答案中包含大量原文复制的样本。
6.2 留出验证集
虽然 LoRA 训练可以在小数据集上快速完成,但最好还是从训练集中切出 5% 到 10% 作为验证集。验证集不参与训练,用来观察模型是否过拟合。
如果训练 Loss 持续下降,但验证 Loss 上升,说明过拟合了。这时可以增加 dropout、减少训练轮数、增加数据量。
6.3 保持训练与推理格式一致
模型在微调时学会的是“问题 + 分隔符 + 回答”的文本模式。如果推理时换了 prompt 格式,例如把“问题:”改成“请回答:”,模型可能输出不稳定。
建议在项目里定义一个统一的 prompt 模板,训练和推理共用同一个函数生成,避免手工复制导致格式漂移。
6.4 训练日志与实验记录
微调过程中超参数组合很多,r、alpha、lr、epochs 不同组合效果差异很大。建议每次实验都记录:
- 数据版本和样本数。
- 超参数配置。
- 训练 Loss 曲线。
- 验证 Loss。
- 几个固定测试问题的输出样例。
这样后续调整时可以快速对比,而不是凭感觉重复实验。
6.5 模型保存与版本管理
LoRA 权重文件通常只有几十到几百 MB,非常适合用 Git LFS 或对象存储管理。建议不要每次都保存合并后的完整模型,节省空间;需要部署时再执行合并脚本。
同时,给每个模型版本打上标签,例如qwen3.5-4b-lora-v2-data8000-r16-lr2e-4,方便回溯。
6.6 生产环境部署的注意点
如果微调后的模型要上线服务,还需要关注几个问题:
- 推理框架兼容性:合并后的模型是否支持 vLLM 等框架,需要提前验证。
- 显存占用:4B 模型 FP16 推理约 8GB,加上 KV Cache,建议至少 16G 显存。
- 稳定性:上线前用一批真实请求测试,观察是否出现长回答截断、重复生成等问题。
- 回滚方案:保留基座模型权重,如果微调后效果不达标,可以随时回滚。
7. 总结与后续规划
第二次尝试相比第一次,核心变化有三点:第一,数据做了清洗和格式统一,样本量从 5 万降到 8000,但质量明显提升;第二,LoRA 参数从 r=32/lr=1e-5 调整为 r=16/lr=2e-4;第三,训练与推理的 prompt 格式保持完全一致,解决了第一次“训练时一个格式,推理时另一个格式”的问题。
目前微调后的模型在垂直问答、固定格式输出两个任务上已经达到业务可用水平。下一步准备继续做三件事:
- 扩展数据覆盖范围,加入更多边界 case,提升模型泛化能力。
- 对比不同 rank 值对效果和显存的影响,找到当前数据量下的最优配置。
- 把 LoRA 权重合并后的模型接入 vLLM,测试生产环境下的推理性能和稳定性。
如果在阅读过程中遇到和本文类似的问题,建议先检查数据格式和 prompt 一致性,再动超参数。这两点解决了,LoRA 微调 Qwen3.5-4B 的成功率会高很多。