这篇只讲训练,不讲数据清洗。目标是把已准备好的 chat JSONL,在 A100 上稳定训出可用 LoRA adapter。
系列导航:上一篇见 pokemon qwen 微调系列(一):SFT 数据工程实战;下一篇见 pokemon qwen 微调系列(三):SFT 评估复盘。
TL;DR
| 阶段 | 关键动作 | 对应文件/命令 |
|---|---|---|
| 配置确认 | 看模型、数据路径、LoRA/量化参数 | SFT/configs/*.yaml |
| 链路自检 | 先跑 smoke 防止正式训练白烧 GPU | modal run SFT/src/modal_app.py --smoke |
| 正式训练 | 前台或 detach 提交 A100 任务 | modal run SFT/src/modal_app.py |
| 监控 | 看 app 日志、GPU 利用率、中间 checkpoint | Modal Dashboard + modal app logs |
| 产物回收 | 拉回 adapter、metrics、sample outputs | modal volume get ... ./SFT/outputs |
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 model | Qwen/Qwen2.5-7B-Instruct |
| 数据 | data/datasets/sft/pokemon_sft_hq.chat.{train,val,test}.jsonl |
| seq len | 1024 |
| epoch | 2 |
| batch | per_device_train_batch_size=1 |
| grad acc | 16(effective batch=16) |
| LoRA | r=16, alpha=32, dropout=0.05 |
| 量化 | 4bit NF4 + double quant + bf16 compute |
| GPU | A100 |
2.1.1 配置项的工程含义(重点)
| 字段 | 当前值 | 工程含义 | 主要影响 |
|---|---|---|---|
max_seq_length | 1024 | 单条样本最大上下文长度 | 越大越吃显存;太小会截断长对话 |
packing | false | 不做样本拼接,保持样本边界 | 训练更直观稳定,但吞吐通常低于 packing=true |
assistant_only_loss | true | 只对 assistant token 计算 loss | 降低“复读 user/system”风险,提升对答质量 |
per_device_train_batch_size | 1 | 单卡微批大小 | 与显存强相关,7B + 4bit 常用 1 起步 |
gradient_accumulation_steps | 16 | 梯度累积步数 | 在小微批下放大有效 batch,平衡稳定性与速度 |
learning_rate | 2e-4 | LoRA 层学习率 | 过大易震荡,过小收敛慢 |
num_train_epochs | 2 | 全量训练轮数 | 轮数过高易过拟合小领域数据 |
eval_steps/save_steps | 100/100 | 验证与保存频率 | 越频繁越稳健,但 I/O 与耗时更高 |
save_total_limit | 2 | 最多保留 checkpoint 数 | 控制磁盘/Volume 成本 |
optim | paged_adamw_8bit | 8bit optimizer | 降低优化器状态显存占用 |
torch_dtype | bfloat16 | 前向/反向主要计算精度 | A100 上通常比 fp16 更稳 |
load_in_4bit | true | 基座权重 4bit 加载 | 大幅降显存,允许 7B 在单卡高效微调 |
2.1.2 这次训练为什么是 1190 step
你当前数据切分是:
- train =
9508 - val =
249 - test =
243
正式配置下:
per_device_train_batch_size=1gradient_accumulation_steps=16- 单卡(world size = 1)
因此有效 batch:
每 epoch 的优化步数近似:
2 个 epoch 总步数:
这和产物目录里的 checkpoint-1190 完全对齐,说明训练步数与配置是闭环的。
2.2 smoke 为什么必须先跑
smoke 配置只跑 max_steps=10,用 SFT/smoke_*.jsonl 小数据。
它的作用不是“看效果”,而是确认以下链路都通:
- Modal 镜像构建与依赖安装
- GPU 任务拉起
- 数据读取与格式化
- 训练 + 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 setup3.2 先跑 smoke
conda activate py3_11
PYTHONIOENCODING=utf-8 modal run SFT/src/modal_app.py --smokeWindows 侧注意:尽量用 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.py4) 训练代码做了什么
4.1 train.py 执行链
训练主逻辑在 SFT/src/train.py,关键调用路径如下:
load_config():把 YAML 转成TrainingConfig,并解析成绝对路径create_tokenizer():补齐pad_token,设置padding_side=rightload_model():按BitsAndBytesConfig以 4bit NF4 加载基座prepare_model_for_kbit_training():让量化模型可训练 LoRA 分支create_peft_config():构建LoraConfig(r/alpha/dropout/target_modules)create_training_args():构建SFTConfig(含assistant_only_loss、optim、gradient_checkpointing)SFTTrainer(...).train()+evaluate()- 导出
adapter/、train_metrics.json、eval_metrics.json、resolved_config.json、sample_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_proj | Self-Attention | 生成 Query | 调整注意力“问什么” |
k_proj | Self-Attention | 生成 Key | 调整注意力“对齐什么” |
v_proj | Self-Attention | 生成 Value | 调整注意力“取什么信息” |
o_proj | Self-Attention | 注意力输出映射 | 统一 attention 子层输出分布 |
gate_proj | MLP/SwiGLU | 门控分支 | 决定激活通道开闭 |
up_proj | MLP/SwiGLU | 扩维分支 | 扩展中间表示容量 |
down_proj | MLP/SwiGLU | 回投影分支 | 将中间表示压回隐藏维 |
这 7 个层覆盖了两大核心子系统(Attention + MLP),在参数效率和可学习能力之间是一个很常见的工程折中。
很多人第一次看到 gate_proj / up_proj / down_proj 会疑惑:不是说 Transformer 的 FFN 只有两层线性层吗?这个疑惑本身是对的,但前提是你脑中对应的是经典 FFN,而不是现在大模型里更常见的 SwiGLU gated FFN。
经典 Transformer 的 FFN 一般写成:
也就是先用 W_1 把 hidden 维升到 intermediate 维,过一次激活,再由 W_2 投回 hidden 维。所以经典写法里,确实通常只会看到“两层线性层”。
但 Qwen、LLaMA 这类模型的 MLP 更常见的是 SwiGLU 结构。它不是只有一个“升维投影”,而是把前半段拆成两条并行分支:
对应到参数名就是:
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
对应代码片段:
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对应代码片段:
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 门控住:
把其中的 sigmoid 门控 换成 Swish/SiLU,就是 SwiGLU:
其中 SiLU(Sigmoid Linear Unit,也叫 Swish-1)的标量定义为:
对应到 Qwen / LLaMA 的 MLP 里,就是 SiLU(gate_proj(x)) ⊙ up_proj(x),再经 down_proj 投回 hidden 维。
SiLU 有几个值得记一下的性质:
- 处处连续可导,没有 ReLU 在 处的硬拐点
- 在负半轴并不是恒为 0:在 处取得最小值约 ,给了少量负梯度信号,缓解 ReLU 的 dead neuron 问题
- 大正值时近似线性(趋近 ),大负值时快速衰减到 0
- 是“self-gated”的:门控信号就是输入自身经过 sigmoid
下面是 SiLU 与 ReLU 的对比曲线:
注意负半轴蓝色曲线下方那一小段“凹槽”:这就是 SiLU 区别于 ReLU 的关键——它没有把所有负值一刀切成 0,而是保留了一个小幅负输出,让梯度仍能向回传播。
在 LLaMA / Qwen 这类大模型里采用 SwiGLU,工程上常见的几条理由是:
- 门控 让 FFN 能学“按通道开闭信息”,表达力强于纯前馈
- SiLU 在小负值区间保留少量梯度,缓解 ReLU dead neuron 问题
- 在同等参数预算下,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。
这样做通常有两个现实收益:
- 减少可训练参数规模和显存占用,稳定首版训练链路
- 降低对词表层的过拟合风险,先优先学习“回答行为”而不是“改词表映射”
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.jsoneval_metrics.jsonresolved_config.jsonsample_outputs.json
拉回本地:
modal volume get pokemon-qlora-outputs /pokemon-qwen25-7b-instruct-qlora ./SFT/outputs6) 这次实跑指标(来自当前仓库产物)
下面是 SFT/outputs/pokemon-qwen25-7b-instruct-qlora/ 中的真实结果:
| 指标 | 数值 |
|---|---|
| train_runtime | 11586.0931 s(约 3.22 小时) |
| optimizer steps | 1190(由配置与 checkpoint-1190 对齐) |
| train_loss | 0.9131 |
| train_steps_per_second | 0.103 |
| eval_loss | 0.8178 |
| eval_runtime | 6.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_dtype | 4bit 计算时累积精度 | bfloat16 |
lora_r | LoRA rank | 16 |
lora_alpha | LoRA 缩放系数 | 32 |
lora_dropout | LoRA 分支 dropout | 0.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.py10.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 保链路,再正式跑,最后把产物和指标回收到本地形成闭环。
训练这件事做到可复现,后续调参和横向对比才有意义。