依赖版本与运行环境锁定
做模型开发最怕的不是算法设计,而是依赖地狱。LLM 生态更新极快,transformers、peft、torch 之间的接口变动经常导致老代码在新环境直接报错。我这次实践固定了以下版本组合,跑通了整个训练和推理流程:
- Python 3.10
- torch == 2.1.2(CUDA 12.1)
- transformers == 4.38.2
- peft == 0.8.2
- datasets == 2.17.0
- accelerate == 0.27.2
这个组合不是随便选的。PyTorch 2.1 引入了更稳定的 torch.compile 支持和改进的 CUDA 内存分配器,对长序列推理和训练都更友好;transformers 4.38 修复了部分 ChatGLM 系列模型的 generation_config 兼容性问题;peft 0.8 之后对 target_modules 的通配符匹配做了优化,配置起来更直观。如果你手头是旧版显卡或驱动,建议先跑一遍 python -c "import torch; print(torch.cuda.is_available())" 确认基础算力正常,再往下走。
基座模型本地验证
在动微调之前,必须确保基座模型能正常跑通推理。我用的是社区开源的 THUDM/chatglm3-6b。很多教程直接贴 AutoModelForCausalLM,但 ChatGLM3 底层是双缓冲架构,官方推荐用 AutoModel 配合其专属的 Tokenizer。最小验证脚本如下:
python
import torch
from transformers import AutoModel, AutoTokenizer
model_name = "THUDM/chatglm3-6b"
tokenizer = AutoTokenizer.from_pretrained(model_name, trust_remote_code=True)
model = AutoModel.from_pretrained(
model_name,
trust_remote_code=True,
torch_dtype=torch.bfloat16,
device_map="auto"
).eval()
# 测试基础对话能力
inputs = tokenizer("介绍一下 PyTorch 的数据并行机制", return_tensors="pt").to(model.device)
with torch.no_grad():
outputs = model.generate(**inputs, max_new_tokens=128)
print(tokenizer.decode(outputs[0], skip_special_tokens=False))
这里有两点容易忽略的细节:一是 trust_remote_code=True 必须带上,ChatGLM 的模型结构和特殊 Token(如 [gMASK]、<sop>)都在远程脚本里定义;二是 device_map="auto" 会自动按显存容量分配层数,单卡 24GB 可以完整加载,但推理时建议使用 torch.bfloat16 而非 float16,避免小数值模型出现溢出导致生成乱码。如果这一步输出已经能连贯回答,说明环境没问题,可以继续。
为什么跳过全量微调,直接上 LoRA

全量微调意味着要把 60 亿参数全部反向传播更新。即使开启 4-bit 量化,训练时的 AdamW 优化器状态、梯度缓存和前向激活值也会轻松吃掉 80GB 以上的显存。对于大多数没有 A100/H100 的开发者来说,这不现实。
LoRA(Low-Rank Adaptation)的核心思路很克制:冻结预训练权重,只在注意力层的投影矩阵上注入低秩分解矩阵。数学上就是把 W∈Rd×kW \in \mathbb{R}^{d \times k}W∈Rd×k 替换为 W+ΔWW + \Delta WW+ΔW,其中 ΔW=BA\Delta W = B AΔW=BA,A∈Rr×dA \in \mathbb{R}^{r \times d}A∈Rr×d,B∈Rk×rB \in \mathbb{R}^{k \times r}B∈Rk×r,且秩 r≪min(d,k)r \ll \min(d, k)r≪min(d,k)。训练时只更新 AAA 和 BBB,参数量通常只有原模型的 1%~2%。
PEFT(Parameter-Efficient Fine-Tuning)库把这套逻辑封装成了开箱即用的接口。下面是对比不同微调方式的硬件需求:
| 方案 | 可调参数比例 | 预估单卡显存占用(24GB) | 适用场景 |
|---|---|---|---|
| 全量微调 | 100% | 需多卡或 80GB+ 显存 | 预算充足、追求极致拟合 |
| LoRA | 1%~2% | 16~22GB (batch=2, accum=4) | 通用指令微调、垂直领域适配 |
| QLoRA | 1%~2% + 4bit 量化 | 10~14GB | 消费级显卡、资源受限环境 |
我没有做过完整的 QLoRA 对比实验,但根据公开基准和社区反馈,QLoRA 在保持准确率的同时会略微增加推理延迟。如果你的目标是快速验证领域适配效果,LoRA 是性价比最高的起点。
指令数据的结构化与 Tokenizer 陷阱

大模型不是传统分类模型,它依赖上下文窗口和特殊的系统提示结构。ChatGLM3 的输入格式要求严格遵循模板:
json
[
{"instruction": "将以下句子翻译成英文:今天天气不错。", "input": "", "output": "The weather is nice today."},
{"instruction": "总结下面这段文字的核心观点。", "input": "人工智能正在改变医疗影像分析的方式...", "output": "AI技术正逐步渗透至医学影像诊断环节..."}
]
很多人直接用 JSON 加载就喂给模型,结果训练 loss 居高不下。问题出在 Tokenizer 的填充对齐上。ChatGLM3 的 Tokenizer 默认会对序列进行右填充,但如果 padding 方向没对齐注意力掩码,模型会把 padding token 当成有效语义去预测,导致梯度噪声极大。
数据处理阶段我用了 datasets 库做流式读取,配合自定义的 Collator:
python
def preprocess_fn(examples):
prompts = []
targets = []
for inst in examples["instructions"]:
# ChatGLM3 标准对话模板拼接
prompt = f"[gMASK]<sop>{inst}"
prompts.append(prompt)
targets.append(inst.get("output", ""))
# 截断并添加 padding
encodings = tokenizer(prompts, padding="max_length", max_length=512, truncation=True)
label_encodings = tokenizer(targets, padding="max_length", max_length=512, truncation=True)
# 关键:将 padding 位置的标签设为 -100,避免损失计算被干扰
label_encodings["input_ids"] = [
[t if t != tokenizer.pad_token_id else -100 for t in seq]
for seq in label_encodings["input_ids"]
]
return encodings
注意看标签掩码的处理。Transformers 的 Trainer 默认以 label_ids 计算 CrossEntropyLoss,如果不把 padding 替换成 -100,模型会在每个 epoch 里强行学习"下一个 token 是空白字符",loss 曲线会呈现震荡甚至发散。这一点在实际调试中非常常见,也是新手最容易忽视的隐形坑。
PEFT 配置与训练循环搭建
数据准备好后,进入核心微调阶段。PEFT 的配置相对简洁,关键是选对目标模块和调整超参数:
python
from peft import LoraConfig, get_peft_model
from transformers import TrainingArguments, Trainer
peft_config = LoraConfig(
task_type="CAUSAL_LM",
inference_mode=False,
r=8, # 低秩维度,平衡表达能力与显存
lora_alpha=16, # 缩放系数,通常设为 2*r
target_modules=["query_key_value"], # ChatGLM 的注意力投影合并方式
lora_dropout=0.05,
bias="none"
)
model = get_peft_model(model, peft_config)
model.print_trainable_parameters() # 打印可训练参数量占比,通常在 0.5%~1.5%
ChatGLM3 的 Attention 层和主流 Decoder-only 模型不同,它的 Query/Key/Value 是合并在一起的线性层,所以 target_modules 必须写成 "query_key_value",否则 PEFT 找不到目标会静默失败。这是版本差异导致的典型陷阱。
训练参数我倾向于保守设置,避免过拟合和显存峰值失控:
python
training_args = TrainingArguments(
output_dir="./chatglm3-lora-output",
per_device_train_batch_size=2,
gradient_accumulation_steps=8, # 等效 batch_size = 16
learning_rate=2e-4,
lr_scheduler_type="cosine",
warmup_ratio=0.05,
weight_decay=0.01,
max_steps=3000,
logging_steps=50,
save_steps=1000,
save_strategy="steps",
bf16=True, # 若显卡不支持 bf16 则改为 fp16
gradient_checkpointing=True, # 启用激活值重计算
report_to="none", # 按需接入 wandb/tensorboard
)
trainer = Trainer(
model=model,
args=training_args,
train_dataset=train_dataset,
data_collator=lambda x: {k: torch.tensor(v).to(model.device) for k, v in zip(["input_ids","attention_mask","labels"], zip(*x))},
)
trainer.train()
gradient_accumulation_steps 在这里起决定性作用。显存瓶颈往往出现在前向传播的激活值保存上。增大累加步数不会增加峰值显存,只会拉长反向传播频率。配合 gradient_checkpointing=True,PEAK 显存能再压低 20%~30%,代价是训练时间增加约 15%。这是一个典型的计算换存储的工程取舍。
显存波动排查与梯度累积

训练跑了几百步后,我观察到显存占用在 step 1200 突然跳涨,随后触发 OOM。排查发现不是模型本身的问题,而是 DataLoader 的多进程数据加载产生了隐式张量拷贝。当 num_workers > 0 时,Python 的子进程会共享父进程的 CUDA Context,部分 DataLoader 版本在 shuffle 或 collate 时会分配临时显存缓冲区,累积到一定阈值就会爆卡。
我的解决步骤是:
- 临时关闭 worker:
num_workers=0,观察显存是否平稳。确认是 DataLoading 引起。 - 恢复 worker 但限制内存碎片:设置
pin_memory=True,并在初始化 DataLoader 时传入persistent_workers=True(仅支持num_workers > 0)。 - 检查是否有未释放的中间变量。在训练循环外层包裹
torch.cuda.empty_cache()并不能解决根本问题,真正有效的是确保所有 Tensor 的生命周期不跨 Batch 泄漏。
另外,学习率调度也值得留意。Cosine 衰减适合长周期训练,但如果你的数据集较小(比如少于 2000 条高质量指令),建议把 max_steps 控制在 1000 以内,并改用 linear 衰减,防止后期梯度幅度过小导致参数陷入局部平坦区。这一点我没有在极端小规模数据上单独做消融实验,但基于常规 Transformer 训练经验,小样本搭配激进衰减更容易引发 Loss 平台期。
推理集成与权重管理

训练完成后,你会得到一个 adapter_model.bin 文件。加载方式有两种:动态挂载和权重合并。
动态挂载推荐用于生产环境,它不修改基座模型,保留随时切换 adapter 的能力:
python
from peft import PeftModel
inference_model = AutoModel.from_pretrained(
"THUDM/chatglm3-6b",
trust_remote_code=True,
torch_dtype=torch.bfloat16,
device_map="auto"
)
model_with_lora = PeftModel.from_pretrained(inference_model, "./chatglm3-lora-output")
model_with_lora.eval()
如果你希望部署时减少内存占用、提升推理速度,可以使用 merge_and_unload() 将 LoRA 权重合并回基座:
python
merged_model = model_with_lora.merge_and_unload()
merged_model.save_pretrained("./chatglm3-lora-merged")
合并操作会重新构建完整的权重矩阵,精度取决于原始训练的 dtype。如果训练时用的是 bf16,合并后务必保持一致,否则强制转 fp32 会放大量化误差,导致生成质量下降。我没有做过合并前后的精确困惑度对比,但工程实践中,动态挂载的延迟通常只比合并慢 5%~8%,而灵活性高出一个数量级,多数团队会选择保留 adapter 独立存储。
写在最后
从零跑通大模型微调,核心不在于调参玄学,而在于把数据流转、显存管理和模型架构特性串成一条可验证的流水线。LoRA 和 PEFT 已经屏蔽了大量底层复杂性,但 ChatGLM 等特定架构的注意力结构、Token 模板和版本接口依然需要手动对齐。
如果你准备将这套流程迁移到其他模型,只需替换 target_modules 的命名规则(例如 Llama 系列是 ["q_proj", "k_proj", "v_proj", "o_proj"]),其余训练循环几乎可以复用。下一步可以考虑加入 RLHF 的奖励建模环节,或者尝试多模态对齐的 Adapter 扩展。这条路还在快速演进,保持对官方 Release Notes 和社区 Issue 的关注,比盲目追新更重要。