pokemon qwen 微调系列(二):SFT 训练实战:基于 QLoRA + Modal 训练 Qwen2.5-7B

April 23, 2026

pokemon qwen 微调系列(二):SFT 训练实战:基于 QLoRA + Modal 训练 Qwen2.5-7B

Hugging Face 发布(本次训练产物)

基于当前 QLoRA + Modal 训练结果的公开链接

这篇只讲训练,不讲数据清洗。目标是把已准备好的 chat JSONL,在 A100 上稳定训出可用 LoRA adapter。

系列导航:上一篇见 pokemon qwen 微调系列(一):SFT 数据工程实战;下一篇见 pokemon qwen 微调系列(三):SFT 评估复盘

TL;DR

阶段关键动作对应文件/命令
配置确认看模型、数据路径、LoRA/量化参数SFT/configs/*.yaml
链路自检先跑 smoke 防止正式训练白烧 GPUmodal run SFT/src/modal_app.py --smoke
正式训练前台或 detach 提交 A100 任务modal run SFT/src/modal_app.py
监控看 app 日志、GPU 利用率、中间 checkpointModal Dashboard + modal app logs
产物回收拉回 adapter、metrics、sample outputsmodal volume get ... ./SFT/outputs
配置yaml + modal_appsmoke10 steps正式训练A100 + QLoRA监控app logs / dashboard产物回收与评估adapter + train/eval metrics

1) 训练入口在哪里

训练入口不是 shell 脚本,而是 SFT/src/modal_app.py 里这条 local entrypoint:

@app.local_entrypoint()
def main(config_path: str = REMOTE_CONFIG_PATH, smoke: bool = False):
    resolved = REMOTE_SMOKE_CONFIG_PATH if smoke else config_path
    train_remote.spawn(resolved).get()

也就是说:

  • modal run SFT/src/modal_app.py --smoke -> 走 smoke 配置
  • modal run SFT/src/modal_app.py -> 走正式配置

SFT/scripts/run_modal_train.sh 只是一个薄包装,内部就是 modal run SFT/src/modal_app.py

2) 配置怎么读

核心配置在两个 YAML:

  • 正式:SFT/configs/qwen25_7b_instruct_qlora_modal.yaml
  • 冒烟:SFT/configs/qwen25_7b_instruct_qlora_modal_smoke.yaml

2.1 正式训练关键参数(实配)

Base modelQwen/Qwen2.5-7B-Instruct
数据data/datasets/sft/pokemon_sft_hq.chat.{train,val,test}.jsonl
seq len1024
epoch2
batchper_device_train_batch_size=1
grad acc16(effective batch=16)
LoRAr=16, alpha=32, dropout=0.05
量化4bit NF4 + double quant + bf16 compute
GPUA100

2.1.1 配置项的工程含义(重点)

字段当前值工程含义主要影响
max_seq_length1024单条样本最大上下文长度越大越吃显存;太小会截断长对话
packingfalse不做样本拼接,保持样本边界训练更直观稳定,但吞吐通常低于 packing=true
assistant_only_losstrue只对 assistant token 计算 loss降低“复读 user/system”风险,提升对答质量
per_device_train_batch_size1单卡微批大小与显存强相关,7B + 4bit 常用 1 起步
gradient_accumulation_steps16梯度累积步数在小微批下放大有效 batch,平衡稳定性与速度
learning_rate2e-4LoRA 层学习率过大易震荡,过小收敛慢
num_train_epochs2全量训练轮数轮数过高易过拟合小领域数据
eval_steps/save_steps100/100验证与保存频率越频繁越稳健,但 I/O 与耗时更高
save_total_limit2最多保留 checkpoint 数控制磁盘/Volume 成本
optimpaged_adamw_8bit8bit optimizer降低优化器状态显存占用
torch_dtypebfloat16前向/反向主要计算精度A100 上通常比 fp16 更稳
load_in_4bittrue基座权重 4bit 加载大幅降显存,允许 7B 在单卡高效微调

2.1.2 这次训练为什么是 1190 step

你当前数据切分是:

  • train = 9508
  • val = 249
  • test = 243

正式配置下:

  • per_device_train_batch_size=1
  • gradient_accumulation_steps=16
  • 单卡(world size = 1)

因此有效 batch:

effective_batch=1×16×1=16\text{effective\_batch} = 1 \times 16 \times 1 = 16

每 epoch 的优化步数近似:

steps_per_epoch=9508/16=595\text{steps\_per\_epoch}=\lceil 9508 / 16 \rceil = 595

2 个 epoch 总步数:

total_steps=595×2=1190\text{total\_steps}=595 \times 2 = 1190

这和产物目录里的 checkpoint-1190 完全对齐,说明训练步数与配置是闭环的。

2.2 smoke 为什么必须先跑

smoke 配置只跑 max_steps=10,用 SFT/smoke_*.jsonl 小数据。

它的作用不是“看效果”,而是确认以下链路都通:

  1. Modal 镜像构建与依赖安装
  2. GPU 任务拉起
  3. 数据读取与格式化
  4. 训练 + eval + 保存产物

modal_app.py 里,正式 HQ 数据是通过 .add_local_file(...) 挂进去的;这一步如果路径不对,正式训练会直接失败。先 smoke 能把这类问题提前暴露。

3) 从 0 到 1 的可复现命令链

3.1 前置

# 1) 生成 HQ chat 数据(如果还没生成)
conda run -n py3_11 env PYTHONPATH=src python -m pokemon_data.cli build-hq-chat --out data/datasets/sft/pokemon_sft_hq.chat.jsonl --target-size 10000 --split
 
# 2) Modal 登录(本机一次性)
modal setup

3.2 先跑 smoke

conda activate py3_11
PYTHONIOENCODING=utf-8 modal run SFT/src/modal_app.py --smoke

Windows 侧注意:尽量用 conda activate 再执行 modal run,不要用 conda run -n ... modal ...,避免编码链路导致 CLI 输出异常。

3.3 正式训练

conda activate py3_11
 
# 前台看日志
PYTHONIOENCODING=utf-8 modal run SFT/src/modal_app.py
 
# 或后台执行
PYTHONIOENCODING=utf-8 modal run --detach SFT/src/modal_app.py

4) 训练代码做了什么

4.1 train.py 执行链

训练主逻辑在 SFT/src/train.py,关键调用路径如下:

  1. load_config():把 YAML 转成 TrainingConfig,并解析成绝对路径
  2. create_tokenizer():补齐 pad_token,设置 padding_side=right
  3. load_model():按 BitsAndBytesConfig 以 4bit NF4 加载基座
  4. prepare_model_for_kbit_training():让量化模型可训练 LoRA 分支
  5. create_peft_config():构建 LoraConfig(r/alpha/dropout/target_modules)
  6. create_training_args():构建 SFTConfig(含 assistant_only_lossoptimgradient_checkpointing
  7. SFTTrainer(...).train() + evaluate()
  8. 导出 adapter/train_metrics.jsoneval_metrics.jsonresolved_config.jsonsample_outputs.json

SFT/src/data.py 还做了消息清洗:会剔除空 content,确保每条 messages 都可用于监督训练。

4.2 QLoRA 具体挂载了哪些层

当前 target_modules(来自正式 YAML 与 adapter_config.json):

q_proj, k_proj, v_proj, o_proj, gate_proj, up_proj, down_proj

对应到 Transformer block 的含义:

层名所在子模块作用为什么要挂 LoRA
q_projSelf-Attention生成 Query调整注意力“问什么”
k_projSelf-Attention生成 Key调整注意力“对齐什么”
v_projSelf-Attention生成 Value调整注意力“取什么信息”
o_projSelf-Attention注意力输出映射统一 attention 子层输出分布
gate_projMLP/SwiGLU门控分支决定激活通道开闭
up_projMLP/SwiGLU扩维分支扩展中间表示容量
down_projMLP/SwiGLU回投影分支将中间表示压回隐藏维

这 7 个层覆盖了两大核心子系统(Attention + MLP),在参数效率和可学习能力之间是一个很常见的工程折中。

很多人第一次看到 gate_proj / up_proj / down_proj 会疑惑:不是说 Transformer 的 FFN 只有两层线性层吗?这个疑惑本身是对的,但前提是你脑中对应的是经典 FFN,而不是现在大模型里更常见的 SwiGLU gated FFN。

经典 Transformer 的 FFN 一般写成:

FFN(x)=W2(σ(W1x))\text{FFN}(x) = W_2(\sigma(W_1 x))

也就是先用 W_1 把 hidden 维升到 intermediate 维,过一次激活,再由 W_2 投回 hidden 维。所以经典写法里,确实通常只会看到“两层线性层”。

但 Qwen、LLaMA 这类模型的 MLP 更常见的是 SwiGLU 结构。它不是只有一个“升维投影”,而是把前半段拆成两条并行分支:

u=Wupx,g=Wgatexu = W_{up}x, \quad g = W_{gate}x h=SiLU(g)uh = \mathrm{SiLU}(g) \odot u y=Wdownhy = W_{down}h

对应到参数名就是:

  • up_proj = W_up:内容分支,把输入映射到 intermediate 维,产生候选特征
  • gate_proj = W_gate:门控分支,同样映射到 intermediate 维,生成门控信号
  • down_proj = W_down:把门控后的中间表示投回 hidden 维

所以它和经典 FFN 并不矛盾,只是“第一层”不再是单一路径,而是变成了“两条并行升维分支 + 逐元素门控融合”。也可以把它理解成:

  • 经典 FFN:W1 -> activation -> W2
  • SwiGLU FFN:(W_up, W_gate) -> SiLU(g) ⊙ u -> W_down
经典两层 FFNxW1 / up-project激活W2 / down-projectyhidden → intermediateintermediate → hidden

对应代码片段:

class ClassicFFN(nn.Module):
    def __init__(self, hidden_size, intermediate_size):
        super().__init__()
        self.up_proj = nn.Linear(hidden_size, intermediate_size)
        self.act_fn = nn.GELU()
        self.down_proj = nn.Linear(intermediate_size, hidden_size)
 
    def forward(self, x):
        h = self.up_proj(x)
        h = self.act_fn(h)
        y = self.down_proj(h)
        return y
SwiGLU gated FFNxup_proj候选内容 ugate_proj门控信号 gSiLU(g)h = SiLU(g) ⊙ udown_projyhidden → intermediateintermediate → hidden

对应代码片段:

class SwiGLUFFN(nn.Module):
    def __init__(self, hidden_size, intermediate_size):
        super().__init__()
        self.up_proj = nn.Linear(hidden_size, intermediate_size)
        self.gate_proj = nn.Linear(hidden_size, intermediate_size)
        self.act_fn = nn.SiLU()
        self.down_proj = nn.Linear(intermediate_size, hidden_size)
 
    def forward(self, x):
        u = self.up_proj(x)
        g = self.gate_proj(x)
        h = self.act_fn(g) * u
        y = self.down_proj(h)
        return y

如果只从“矩阵个数”看,SwiGLU 确实比经典 FFN 多出一个线性投影;但从功能分层看,它仍然是“前半段生成中间表示,后半段投回主干”的 MLP,只不过前半段多了一条门控支路。这也是为什么在 QLoRA 里,你会看到 gate_proj / up_proj / down_proj 作为一组一起出现。

4.2.1 SwiGLU 激活函数细节

上面只是从“结构”角度讲了 SwiGLU,这一节再把它作为激活函数本身拎出来看。

SwiGLU 来自 GLU(Gated Linear Unit)家族。GLU 的通用形式是把一条线性分支用另一条线性分支的 sigmoid 门控住:

GLU(x,W,V,b,c)=σ(xW+b)(xV+c)\text{GLU}(x, W, V, b, c) = \sigma(xW + b) \odot (xV + c)

把其中的 sigmoid 门控 σ()\sigma(\cdot) 换成 Swish/SiLU,就是 SwiGLU:

SwiGLU(x,W,V,b,c)=SiLU(xW+b)(xV+c)\text{SwiGLU}(x, W, V, b, c) = \text{SiLU}(xW + b) \odot (xV + c)

其中 SiLU(Sigmoid Linear Unit,也叫 Swish-1)的标量定义为:

SiLU(x)=xσ(x)=x1+ex\text{SiLU}(x) = x \cdot \sigma(x) = \frac{x}{1 + e^{-x}}

对应到 Qwen / LLaMA 的 MLP 里,就是 SiLU(gate_proj(x)) ⊙ up_proj(x),再经 down_proj 投回 hidden 维。

SiLU 有几个值得记一下的性质:

  • 处处连续可导,没有 ReLU 在 x=0x=0 处的硬拐点
  • 在负半轴并不是恒为 0:在 x1.278x \approx -1.278 处取得最小值约 0.278-0.278,给了少量负梯度信号,缓解 ReLU 的 dead neuron 问题
  • 大正值时近似线性(趋近 y=xy=x),大负值时快速衰减到 0
  • 是“self-gated”的:门控信号就是输入自身经过 sigmoid

下面是 SiLU 与 ReLU 的对比曲线:

SiLU vs ReLUxy-6-4-20246-1012345SiLU(x) = x · σ(x)ReLU(x) = max(0, x)

注意负半轴蓝色曲线下方那一小段“凹槽”:这就是 SiLU 区别于 ReLU 的关键——它没有把所有负值一刀切成 0,而是保留了一个小幅负输出,让梯度仍能向回传播。

在 LLaMA / Qwen 这类大模型里采用 SwiGLU,工程上常见的几条理由是:

  1. 门控 SiLU(g)u\text{SiLU}(g) \odot u 让 FFN 能学“按通道开闭信息”,表达力强于纯前馈
  2. SiLU 在小负值区间保留少量梯度,缓解 ReLU dead neuron 问题
  3. 在同等参数预算下,SwiGLU 版 FFN 的下游困惑度通常优于 ReLU / GELU 版(来自 Noam Shazeer 的 GLU Variants Improve Transformer

也正因如此,QLoRA 一旦把 LoRA 注入 MLP 子层,gate_proj / up_proj / down_proj 就必须作为一组一起挂——它们三个共同构成了一次完整的 SwiGLU 前向。

4.3 这次没挂哪些层,为什么

当前方案没有把 LoRA 挂到 embedding / lm_head。
这样做通常有两个现实收益:

  1. 减少可训练参数规模和显存占用,稳定首版训练链路
  2. 降低对词表层的过拟合风险,先优先学习“回答行为”而不是“改词表映射”

5) 监控与产物

5.1 监控

  • Dashboard:https://modal.com/apps/<workspace>/main
  • CLI:modal app list
  • 日志:modal app logs <app-id>

5.2 产物目录

训练结束后,产物在 Volume pokemon-qlora-outputs,路径:

/outputs/pokemon-qwen25-7b-instruct-qlora/

包括:

  • adapter/(LoRA 权重与 tokenizer)
  • train_metrics.json
  • eval_metrics.json
  • resolved_config.json
  • sample_outputs.json

拉回本地:

modal volume get pokemon-qlora-outputs /pokemon-qwen25-7b-instruct-qlora ./SFT/outputs

6) 这次实跑指标(来自当前仓库产物)

下面是 SFT/outputs/pokemon-qwen25-7b-instruct-qlora/ 中的真实结果:

指标数值
train_runtime11586.0931 s(约 3.22 小时)
optimizer steps1190(由配置与 checkpoint-1190 对齐)
train_loss0.9131
train_steps_per_second0.103
eval_loss0.8178
eval_runtime6.50 s

这说明从提交到产物落盘,流程是可跑通的;后续调参重点通常是数据配比和 prompts,而不是先急着换更复杂训练技巧。

7) 常见坑与修复

症状根因修复
一启动就找不到训练数据add_local_file 路径或本地文件缺失先跑 smoke,核对 data/datasets/sft/*.jsonl 是否存在
训练正常但效果怪sample_outputs 出现模板偏差或知识错配回看数据侧 kind 配比与清洗链路,不要只盯学习率
本地终端关掉后担心任务丢前台模式绑定终端modal run --detach ...,再用 modal app logs 追进度
想省钱但总在重下模型误删 pokemon-hf-cache保留 cache volume,避免反复下载 7B 基座

8) 训练配置字段释义表

8.1 优化与调度字段

字段含义当前值
learning_rate初始学习率2e-4
num_train_epochs全量训练轮数2
gradient_accumulation_steps梯度累积步数16
warmup_ratio预热比例0.03
max_grad_norm梯度裁剪阈值0.3

8.2 QLoRA 字段

字段含义当前值
load_in_4bit是否 4bit 加载基座true
bnb_4bit_quant_type量化类型nf4
bnb_4bit_use_double_quant是否二次量化true
bnb_4bit_compute_dtype4bit 计算时累积精度bfloat16
lora_rLoRA rank16
lora_alphaLoRA 缩放系数32
lora_dropoutLoRA 分支 dropout0.05
target_modules注入 LoRA 的线性层q/k/v/o/gate/up/down_proj

9) 如何用已训练模型做推理

可以,核心就是:base model + adapter 叠加推理。
下面给一个可直接跑的最小脚本(本地 Python / Colab 都可):

import torch
from transformers import AutoModelForCausalLM, AutoTokenizer
from peft import PeftModel
 
base_model = "Qwen/Qwen2.5-7B-Instruct"
adapter_model = "hapda12/pokemon-qwen25-7b-instruct-qlora"
 
tokenizer = AutoTokenizer.from_pretrained(base_model)
if tokenizer.pad_token is None:
    tokenizer.pad_token = tokenizer.eos_token
 
base = AutoModelForCausalLM.from_pretrained(
    base_model,
    torch_dtype=torch.bfloat16,
    device_map="auto",
)
model = PeftModel.from_pretrained(base, adapter_model)
model.eval()
 
messages = [
    {"role": "system", "content": "You are a knowledgeable assistant specialised in the Pokemon franchise. Answer based on canonical Pokedex and game data. Be concise and factual."},
    {"role": "user", "content": "What is the Electric type weak to?"}
]
 
inputs = tokenizer.apply_chat_template(
    messages,
    tokenize=True,
    add_generation_prompt=True,
    return_tensors="pt",
    return_dict=True,
).to(model.device)
 
with torch.no_grad():
    outputs = model.generate(
        **inputs,
        max_new_tokens=128,
        do_sample=False,
        temperature=1.0,
        top_p=1.0,
    )
 
print(tokenizer.decode(outputs[0], skip_special_tokens=True))

说明:为了复现实验日志,示例使用 do_sample=False 的贪心解码;

10)推理案例(真实实测)

下面三条不是离线缓存样例,而是我在 2026-04-23 用真实模型在线跑出来的结果。

实测命令:

$env:PYTHONIOENCODING='utf-8'; D:/Anaconda/envs/py3_11/Scripts/modal.exe run SFT/src/modal_infer.py

10.1 案例 A:能力解释

Prompt

Explain what Levitate does in Pokemon battles.

Model output(原始)

Nullifies effects of being grounded.

点评

  • 输出简短且方向基本正确。
  • 但解释粒度偏粗,后续可增加“机制细节型”训练样本提升完整性。

10.2 案例 B:实体对比

Prompt

Compare Pikachu and Raichu in one short paragraph.

Model output(原始)

Pikachu is smaller and has less power than Raichu.

点评

  • 输出简短、方向正确,符合 summary/short-form 训练目标。
  • 细节覆盖还偏少,后续可增加“同题多角度对比”样本提升信息密度。

10.3 案例 C:属性克制

Prompt

What type is Pikachu and what is it weak to?

Model output(原始)

electric, fairy

点评

  • 该条存在明显知识偏差(Pikachu 的弱点未答对)。
  • 这说明当前模型在“属性克制”这类规则型知识上仍需强化,适合在下一轮数据中加大 reasoning + factoid 的针对性覆盖。

总结

这套 E:/Pokemon-data/SFT 训练链路的关键不在于“参数多先进”,而在于先 smoke 保链路,再正式跑,最后把产物和指标回收到本地形成闭环
训练这件事做到可复现,后续调参和横向对比才有意义。