Qwen3.5-9B 微调避坑:用 LLaMA-Factory 做企业 SFT 的 10 个踩坑全记录
适用版本:LLaMA-Factory v0.9.4(2025-12-31)、Transformers v5、PyTorch CUDA 13
训练模型:Qwen3.5-9B(2026-03-02 发布)
硬件环境:单卡 A100 40G(LoRA)/ 单卡 RTX 4090 24G(QLoRA)
前言
用 vLLM 把 Qwen3.5-9B 跑起来之后,下一步想让模型学会公司特定的回复风格和专业术语,这就得微调了。LLaMA-Factory 是目前最主流的开源微调框架(ACL 2024 论文,GitHub 40k+ Star),支持 100+ 模型、多种训练方法,不写一行代码就能开始训练。
听起来很美好,实际上坑不少。Qwen3.5-9B 本身有推理模式/非推理模式之分,加上 v0.9.4 版本的不少 API 变更,网上的教程很多已经过时。
本文记录从数据准备到 vLLM 部署的全链路 10 个真实踩坑,每个坑都给出可直接用的修复命令或配置。
文章结构:
- 环境搭建(新旧版本差异)
- Qwen3.5-9B 的 template 选择陷阱
- 数据格式踩坑(4 个坑)
- 训练配置踩坑(3 个坑)
- 模型合并与导出踩坑(2 个坑)
- 微调后接入 vLLM 踩坑(1 个坑)
- 训练曲线诊断指南
一、环境搭建
v0.9.4 推荐安装方式:改用 uv
v0.9.4 把包管理器从 pip 迁移到了 uv,旧的 pip 安装方式会遇到依赖冲突(尤其是 Transformers v5 和旧版 bitsandbytes 不兼容):
# 1. 安装 uv(如果还没装) curl -LsSf https://astral.sh/uv/install.sh | sh # 2. clone 仓库(--depth 1 只拉最新提交,省带宽) git clone --depth 1 https://github.com/hiyouga/LLaMA-Factory.git cd LLaMA-Factory # 3. 创建虚拟环境并安装(uv 自动处理 CUDA 版本匹配) uv venv --python 3.12 source .venv/bin/activate uv pip install -e ".[torch,bitsandbytes,metrics]" # 4. 验证安装 llamafactory-cli version # v0.9.4 还支持快捷命令 lmf(等价于 llamafactory-cli) lmf versionTransformers v5 兼容性:v0.9.4 已全面迁移到 Transformers v5,如果你机器上有旧项目依赖 Transformers v4,务必用 uv venv 创建独立虚拟环境,不要混用。从 ModelScope 下载模型(国内推荐)
Qwen3.5-9B 在 HuggingFace 下载经常断线,推荐走 ModelScope:
# 设置环境变量,llamafactory-cli 自动走 ModelScope export USE_MODELSCOPE_HUB=1 # 模型 ID 格式:Qwen/Qwen3.5-9B-Instruct(ModelScope 和 HuggingFace 同名) # 也可以提前手动下载 pip install modelscope modelscope download --model Qwen/Qwen3.5-9B-Instruct --local_dir /data/models/Qwen3.5-9B-Instruct二、Qwen3.5-9B 的 Template 选择陷阱(最高频踩坑)
坑1:template 选错,模型输出满是 <think> 标签
Qwen3.5-9B 继承了 Qwen3 系列的双模式设计:思维链模式和普通对话模式,在 LLaMA-Factory 里对应两个不同的 template:
| template | 模式 | 行为 |
|---|---|---|
qwen3 | 思维链模式(默认) | 输出包含 <think>...</think> 推理过程,再给出最终答案 |
qwen3_nothink | 普通对话模式 | 直接输出答案,无思维链 |
企业 SFT 的数据通常是"问题-答案"对,不含思维链。如果用 qwen3 模板训练,模型会在输出里疯狂生成 <think> 占位符,消耗大量 token:
# ❌ 错误:用了思维链模板训练普通对话数据 template: qwen3 # ✅ 正确:企业 SFT(问答、风格迁移、领域知识)用 nothink 模板 template: qwen3_nothink⚠️ 关键原则:训练时用了哪个 template,推理时必须用同一个 template。训练用qwen3_nothink,推理就也要用qwen3_nothink,不能混用,否则输出格式完全乱掉。
坑2:用 Base 模型做 SFT,LoRA 训练后生成不停止
Qwen3.5-9B 有两个版本:Base(预训练基座)和 Instruct(经过指令对齐的对话版)。很多人误以为用 Base 做 SFT 效果会更纯粹,实际上有一个已知 Bug:
Base 模型 + LoRA 微调后,推理时生成不会停止,模型不知道何时输出 EOS(结束符),会一直循环输出直到达到 max_tokens 上限。
原因:Base 模型没有经过 RLHF 对齐,EOS token 的使用权重极低,LoRA 的参数量不足以让模型重新学会停止生成。
# ❌ 踩坑:Base 模型 + LoRA = 生成不停止 model_name_or_path: Qwen/Qwen3.5-9B # Base 模型 # ✅ 正确:用 Instruct 版本做 SFT 起点 model_name_or_path: Qwen/Qwen3.5-9B-Instruct如果业务上确实需要从 Base 模型开始,必须改用全量微调(finetuning_type: full),代价是显存需求从 ~20G 升到 ~80G+。
三、数据格式踩坑
LLaMA-Factory 支持 Alpaca 和 ShareGPT 两种格式,企业场景推荐 ShareGPT(支持多轮对话,格式更灵活)。
ShareGPT 格式示例
[ { "conversations": [ { "from": "human", "value": "我们公司的差旅报销政策是什么?" }, { "from": "gpt", "value": "根据公司2026年差旅管理规定:\n1. 经济舱机票实报实销,上限3000元/次\n2. 酒店标准:一线城市500元/晚,二线城市350元/晚\n3. 需在出行后5个工作日内提交报销申请..." } ] } ]注册到 data/dataset_info.json:
{ "company_qa": { "file_name": "company_qa.json", "formatting": "sharegpt", "columns": { "messages": "conversations" } } }坑3:数据里混入了 <think> 标签,污染训练
如果你的训练数据来自已有的 Qwen3 / DeepSeek-R1 模型输出,数据里可能带有 <think>...</think> 思维链标签。用这样的数据训练 qwen3_nothink 模板,模型会学到错误的输出格式。
# 数据清洗脚本:去除 <think> 块 import re, json def clean_think_tags(text: str) -> str: # 去除 <think>...</think> 及其内容 cleaned = re.sub(r'<think>.*?</think>', '', text, flags=re.DOTALL) return cleaned.strip() with open('raw_data.json') as f: data = json.load(f) cleaned = [] for item in data: for conv in item['conversations']: if conv['from'] == 'gpt': conv['value'] = clean_think_tags(conv['value']) # 过滤掉清洗后答案为空的条目 if all(c['value'].strip() for c in item['conversations']): cleaned.append(item) with open('company_qa_clean.json', 'w', encoding='utf-8') as f: json.dump(cleaned, f, ensure_ascii=False, indent=2) print(f"原始: {len(data)} 条,清洗后: {len(cleaned)} 条")坑4:数据量不足导致严重过拟合
企业 SFT 的数据量往往有限,常见误区是凑不够数据就把同一批数据跑很多 epoch,导致:
- 训练 loss 极低(< 0.1),但实际输出只会复读训练集
- eval loss 反弹上升,模型泛化能力崩溃
各规模数据量建议:
| 目标 | 最少数据量 | 推荐数据量 | epoch |
|---|---|---|---|
| 回复风格迁移 | 200 条 | 500~1000 条 | 2~3 |
| 领域术语学习 | 500 条 | 2000~5000 条 | 2~3 |
| 复杂业务逻辑 | 2000 条 | 5000~20000 条 | 1~2 |
数据质量比数量更重要,100 条高质量标注的价值远超 1000 条机器生成的低质量数据。
坑5:中文标点和特殊字符导致 JSON 解析失败
企业数据往往从 Word/Excel 导出,含有大量"弯引号"、全角空格、零宽字符,这些会让 LLaMA-Factory 的数据加载直接崩溃,且报错信息非常不友好(只显示 JSONDecodeError,不指出具体位置)。
# 数据预处理:清洗不可见字符和特殊标点 import json, unicodedata REPLACEMENTS = { '\u201c': '"', '\u201d': '"', # 弯引号 '\u2018': "'", '\u2019': "'", # 弯单引号 '\u3000': ' ', # 全角空格 '\u00a0': ' ', # 非断行空格 '\ufeff': '', # BOM '\u200b': '', '\u200c': '', # 零宽字符 } def clean_text(text: str) -> str: for old, new in REPLACEMENTS.items(): text = text.replace(old, new) # 去除 Unicode 控制字符(保留换行和制表符).join( c for c in text if unicodedata.category(c) != 'Cc' or c in '\n\t' ) return text.strip() # 验证 JSON 可解析性 try: with open('company_qa_clean.json', encoding='utf-8') as f: data = json.load(f) print(f"✅ JSON 格式正确,共 {len(data)} 条") except json.JSONDecodeError as e: print(f"❌ JSON 解析失败:{e}")四、训练配置踩坑
核心训练配置(YAML,A100 40G 单卡 LoRA)
# qwen35_9b_lora_sft.yaml ### 模型 model_name_or_path: /data/models/Qwen3.5-9B-Instruct trust_remote_code: true flash_attn: fa2 # FlashAttention-2 加速,A100 必开 ### 方法 stage: sft do_train: true finetuning_type: lora lora_rank: 16 # 见坑6 lora_alpha: 32 # 通常 = lora_rank × 2 lora_dropout: 0.05 lora_target: all # 作用于所有线性层(比 q_proj,v_proj 效果更好) ### 数据 dataset: company_qa template: qwen3_nothink # 见坑1:企业 SFT 用 nothink cutoff_len: 2048 # 根据你的最长样本设置,不要无脑设 8192 max_samples: 10000 # 调试时先限制数量,跑通再去掉 overwrite_cache: true preprocessing_num_workers: 8 ### 输出 output_dir: saves/qwen35-9b/lora/sft-v1 logging_steps: 10 save_steps: 200 save_total_limit: 3 # 只保留最新 3 个 checkpoint,省磁盘 plot_loss: true overwrite_output_dir: true ### 训练参数 per_device_train_batch_size: 2 gradient_accumulation_steps: 8 # 等效 batch_size = 2 × 8 = 16 learning_rate: 5.0e-5 # 见坑7 num_train_epochs: 3.0 lr_scheduler_type: cosine warmup_ratio: 0.1 bf16: true # Qwen3.5 用 bf16,不要用 fp16 val_size: 0.05 # 5% 数据作验证集,必须设! per_device_eval_batch_size: 2 eval_strategy: steps eval_steps: 200启动训练:
llamafactory-cli train qwen35_9b_lora_sft.yaml # v0.9.4 支持快捷命令 lmf train qwen35_9b_lora_sft.yaml坑6:LoRA rank 设太大,训练 loss 低但泛化差
LoRA rank(lora_rank)控制可训练参数量,很多人误以为越大越好:
| lora_rank | 可训练参数量(9B 模型,all target) | 适用场景 |
|---|---|---|
| 8 | ~40M | 风格微调、少量数据(< 1000 条) |
| 16 | ~80M | 领域知识注入(推荐起点) |
| 32 | ~160M | 复杂业务逻辑(数据量 > 5000 条) |
| 64+ | ~320M+ | 几乎等于全量微调,显存占用暴增 |
rank 太大的问题:对小数据集来说,过多的参数反而更容易过拟合,loss 曲线下降飞快但 eval loss 同步飙升。
调参策略:从 rank=16 开始,观察 eval loss 是否稳定。如果 training loss 远低于 eval loss(差距 > 1.0),说明过拟合,降 rank 或增加 dropout。
坑7:学习率设太高,训练前期 loss 爆炸
Qwen3.5-9B 的 Instruct 版本已经经过 RLHF 对齐,对学习率非常敏感。网上很多教程的 learning_rate: 1e-4 是针对 Base 模型的,用在 Instruct 版本上会导致前几百步 loss 剧烈震荡甚至 NaN:
Step 10: loss=2.85 Step 20: loss=4.12 ← 上升了!说明学习率过高 Step 30: loss=nan ← 崩了推荐学习率范围:
| 模型版本 | 推荐学习率 |
|---|---|
| Base 模型 | 1e-4 ~ 5e-5 |
| Instruct 版本(SFT) | 5e-5 ~ 1e-5 |
| Instruct 版本(少量数据,< 500 条) | 1e-5 ~ 5e-6 |
配合 warmup(warmup_ratio: 0.1)让学习率从 0 线性升到目标值,再走 cosine 下降,可以有效避免前期震荡。
QLoRA 配置(RTX 4090 24G 单卡)
显存不够用 A100 时,QLoRA 4bit 量化可以在 RTX 4090 上跑 9B 模型:
# qwen35_9b_qlora_sft.yaml(RTX 4090 适配) model_name_or_path: /data/models/Qwen3.5-9B-Instruct flash_attn: fa2 quantization_bit: 4 # 4bit 量化 quantization_method: bitsandbytes # 也可以用 hqq,速度更快 stage: sft do_train: true finetuning_type: lora lora_rank: 8 # QLoRA 显存受限,rank 调小 lora_target: all lora_alpha: 16 dataset: company_qa template: qwen3_nothink cutoff_len: 1024 # 4090 显存紧张,上下文要再压 per_device_train_batch_size: 1 gradient_accumulation_steps: 16 # 等效 batch = 16 learning_rate: 1.0e-4 # QLoRA 学习率可以比 LoRA 高一点 num_train_epochs: 3.0 bf16: true显存占用参考(Qwen3.5-9B):LoRA BF16:约 22G(A100 40G 足够)QLoRA 4bit:约 12G(RTX 4090 24G 绰绰有余)全量微调 BF16:约 75G+(需要至少 2×A100 40G 或 1×H100 80G)
五、模型合并与导出踩坑
训练完得到的是 LoRA adapter(增量权重),需要合并回基础模型才能用 vLLM 部署。
坑8:合并时 dtype 不一致导致精度下降
合并命令很简单,但有一个隐蔽的坑:export_device 和 dtype 设置不当,会让合并后的模型精度悄悄劣化。
# merge_lora.yaml model_name_or_path: /data/models/Qwen3.5-9B-Instruct adapter_name_or_path: saves/qwen35-9b/lora/sft-v1 template: qwen3_nothink finetuning_type: lora export_dir: /data/models/Qwen3.5-9B-SFT-merged export_size: 5 # 每个分片最大 5GB export_device: cpu # ✅ 用 cpu 合并,避免 GPU 显存不够 export_legacy_format: false # ✅ 必须 false,用新的 safetensors 格式llamafactory-cli export merge_lora.yaml常见精度下降场景:
# ❌ 错误:用 GPU 合并,部分精度会被强制转成 float32 再截断 export_device: cuda # ❌ 错误:旧格式保存,加载时可能有精度损失 export_legacy_format: true # ✅ 正确:CPU 合并 + safetensors 格式 export_device: cpu export_legacy_format: false坑9:合并后模型输出乱码,忘记复制 tokenizer 文件
llamafactory-cli export 只导出模型权重,不会自动复制 tokenizer 相关文件。用 vLLM 加载合并后的模型,如果目录里没有 tokenizer 文件,输出会是乱码或报错。
# 检查导出目录是否有 tokenizer 文件 ls /data/models/Qwen3.5-9B-SFT-merged/ # 应该包含:tokenizer.json、tokenizer_config.json、 # special_tokens_map.json、vocab.json(或 sentencepiece 文件) # 如果缺少,从原始模型目录复制 cp /data/models/Qwen3.5-9B-Instruct/tokenizer*.json \ /data/models/Qwen3.5-9B-SFT-merged/ cp /data/models/Qwen3.5-9B-Instruct/special_tokens_map.json \ /data/models/Qwen3.5-9B-SFT-merged/ cp /data/models/Qwen3.5-9B-Instruct/generation_config.json \ /data/models/Qwen3.5-9B-SFT-merged/六、微调后接入 vLLM 踩坑
坑10:vLLM 加载微调模型需要显式指定 chat template
vLLM 默认从 tokenizer_config.json 里的 chat_template 字段读取对话格式。Qwen3.5-9B 的 chat_template 默认启用思维链模式,如果你的 SFT 训练用了 qwen3_nothink,推理时必须覆盖 chat template,否则输出会重新带上 <think> 标签。
# ❌ 直接启动,继承 tokenizer 默认的 think 模式 vllm serve /data/models/Qwen3.5-9B-SFT-merged \ --served-model-name qwen35-9b-sft # ✅ 通过 chat template override 关闭思维链 vllm serve /data/models/Qwen3.5-9B-SFT-merged \ --served-model-name qwen35-9b-sft \ --chat-template /path/to/qwen3_nothink_template.jinja \ --reasoning-parser qwen3 \ --max-model-len 8192qwen3_nothink_template.jinja 可以从 LLaMA-Factory 的 src/llamafactory/data/template.py 里提取 qwen3_nothink 对应的 Jinja2 模板,或者直接在 generation_config.json 里设置:
{ "chat_template": "qwen3_nothink", "enable_thinking": false }七、训练曲线诊断指南
每次训练结束后,打开 output_dir/trainer_log.jsonl 或 LlamaBoard 看 loss 曲线,对照以下模式诊断问题:
正常曲线(理想): train_loss: 2.5 → 1.8 → 1.2 → 0.8(平滑下降) eval_loss: 2.6 → 1.9 → 1.3 → 0.9(略高于 train,同步下降) 过拟合: train_loss: 2.5 → 0.3(下降过快) eval_loss: 2.6 → 1.5 → 2.1 → 2.8(先降后升 ← 危险信号) → 解法:降低 lora_rank、增加 lora_dropout、减少 epoch 学习率过高: train_loss: 2.5 → 4.8 → nan(前期爆炸) → 解法:降低 learning_rate 10 倍,加大 warmup_ratio 到 0.15 欠拟合: train_loss: 2.5 → 2.1(下降极慢,几乎平) → 解法:提高 learning_rate、增大 lora_rank、检查数据格式是否正确 数据格式错误: train_loss 从第一步就极高(> 5.0)且不下降 → 解法:检查 template 是否匹配、数据格式是否符合 ShareGPT/Alpaca 规范八、总结:踩坑速查表
| # | 坑 | 现象 | 解法 |
|---|---|---|---|
| 1 | template 用了 qwen3 而非 qwen3_nothink | 输出充满 <think> 标签 | 企业 SFT 统一用 qwen3_nothink |
| 2 | Base 模型 + LoRA 生成不停止 | 输出到 max_tokens 才停 | 改用 Instruct 版本 |
| 3 | 训练数据混入 <think> 标签 | 模型学会乱用思维链格式 | 上线前清洗数据 |
| 4 | 数据量少但 epoch 设太多 | eval loss 反弹,过拟合 | 控制 epoch ≤ 3,加验证集 |
| 5 | 数据含特殊字符 | JSONDecodeError | 数据预处理脚本清洗 |
| 6 | lora_rank 设太大 | 小数据集过拟合 | 从 rank=16 开始调 |
| 7 | 学习率对 Instruct 模型设太高 | loss 震荡 / NaN | Instruct 版用 5e-5 以下 |
| 8 | 合并用 GPU 或旧格式 | 合并后精度下降 | export_device: cpu + export_legacy_format: false |
| 9 | 忘记复制 tokenizer 文件 | vLLM 输出乱码 | 手动复制 tokenizer 相关文件 |
| 10 | vLLM 继承默认 think 模式 | 微调后又多了 <think> | 覆盖 chat_template 或设 enable_thinking: false |
参考资料
- LLaMA-Factory GitHub
- LLaMA-Factory 官方文档
- Qwen 官方 LLaMA-Factory 接入指南
- LLaMA-Factory v0.9.4 Release Notes
- Qwen3.5-9B HuggingFace
微调是一个迭代过程,第一次跑通只是开始。建议把每次实验的配置文件和 loss 曲线都保存下来,方便对比。如果你遇到了其他坑,欢迎评论区补充,后续会持续更新。