适用版本: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 个真实踩坑,每个坑都给出可直接用的修复命令或配置。

文章结构:

  1. 环境搭建(新旧版本差异)
  2. Qwen3.5-9B 的 template 选择陷阱
  3. 数据格式踩坑(4 个坑)
  4. 训练配置踩坑(3 个坑)
  5. 模型合并与导出踩坑(2 个坑)
  6. 微调后接入 vLLM 踩坑(1 个坑)
  7. 训练曲线诊断指南

一、环境搭建

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 version

Transformers 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 控制字符(保留换行和制表符)
    text = ''.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 8192

qwen3_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

参考资料


微调是一个迭代过程,第一次跑通只是开始。建议把每次实验的配置文件和 loss 曲线都保存下来,方便对比。如果你遇到了其他坑,欢迎评论区补充,后续会持续更新。

Logo

AtomGit 是由开放原子开源基金会联合 CSDN 等生态伙伴共同推出的新一代开源与人工智能协作平台。平台坚持“开放、中立、公益”的理念,把代码托管、模型共享、数据集托管、智能体开发体验和算力服务整合在一起,为开发者提供从开发、训练到部署的一站式体验。

更多推荐