简介:基于 DeepSpeed 的 ChatGLM 多卡微调实战项目包,面向有一定深度学习基础、希望快速上手大模型微调的研究者与开发人员。资源定位在解决单机多卡环境下微调 ChatGLM 的配置复杂、资源管理困难等问题,提供从环境搭建、数据准备、模型训练到评估测试的完整流程教程。
包体仅 118KB,共 17 个文件,以 Python 代码为主体,涵盖模型加载、数据加载、训练循环等核心模块;同时配有 3 个 Shell 启动脚本、2 个 JSON 配置文件和 1 份 Markdown 说明文档,便于直接复现实验。目前已有 267 人浏览学习。
读者可由此掌握基于 DeepSpeed 做多卡并行训练的关键参数配置与启动方式,并使用其中封装的 LoRA、Ptuning、Freeze 三类微调方法处理不同任务场景。源码注释清晰、模块划分明确,既适合入门者按教程逐步操作,也适合有经验者对照修改,有效缩短 ChatGLM 微调项目的落地周期。
1. 单卡跑不动、多卡不会配:DeepSpeed 是入门大模型微调的第一道分水岭
手里两张 3090,想微调 ChatGLM-6B 做垂直业务,单卡 batch size 开到 2 就爆显存,迭代一轮要跑大半天——这是很多人的真实处境。大模型微调不是把数据喂进去就行,基座模型、微调范式、分布式训练策略三件事没定下来,显存和训练速度会一起失控。
用 DeepSpeed 做多卡微调,是目前把 ChatGLM 这类 6B 量级模型真正训起来的常规路径。它靠 ZeRO 显存优化、CPU offload、梯度累积把多张 GPU 的显存整合到一起,让原本单卡塞不下的训练配置变得可运行,把训练时间从周级别压到小时级别。这篇按我从环境配置到效果验证完整走过的流程讲,每一步都给出能直接复用的脚本和参数,也把最容易翻车的几个坑提前说出来。
2. 微调前必须定的三件事:基座模型、微调范式与多卡并行策略
2.1 选 ChatGLM 还是 Qwen2.5:中文能力和显存预算先对齐
定基座模型时,很多人一上来就看榜单分数,实际落地时要先算一笔显存账。ChatGLM-6B 的 FP16 权重占 12GB 左右,加上激活值、梯度和优化器状态,全参微调单卡基本要 22GB 以上;如果选 Qwen2.5-7B,参数量更大,显存占用会再往上抬一截。我见过不少团队在 2 张 3090 上硬跑 7B 全参,最后只能把 sequence length 压到 512,效果反而比 6B 还差。
如果你所在行业已经有比较成熟的公开中文语料,qwen2.5-7b 微调行业大模型是社区热度很高的组合;但 ChatGLM 的中文指令跟随表现更稳,生态里现成的微调案例也多,遇到问题容易搜到解决方案。我的建议是:显存总量 48GB 以下优先 ChatGLM-6B,48GB 以上再考虑更大的基座,不要在显存临界点上赌玄学。
还要确认基座模型的授权和商用边界。ChatGLM 系列是开源权重,但不同版本的开源协议有差异,商用前要再看一眼当时版本的授权说明。模型选型这件事,放到整个微调流程里只占半天决策时间,但后面所有显存估算、训练时长、推理部署成本都由它决定。
2.2 全参、LoRA 还是 P-Tuning v2:显存、效果和可控性的三角权衡
基座定下来之后,紧接着要选择微调范式。ChatGLM 官方示例里提供的是 P-Tuning v2,只训练 prompt encoder 那一小部分参数,显存占用低,但效果上限受限于可学习的参数规模,适合样本量不大、任务比较单一的场合。如果你只有几十条到几百条标注数据,P-Tuning v2 能快速跑通一个基线。
LoRA 是产业里用得最多的方案。它冻结原始权重,只训练注入的低秩矩阵,可学习参数通常在 0.1% 到 1% 之间。以 ChatGLM-6B 为例,LoRA 的显存占用可以压到全参微调的一半以下,效果在大多数指令微调场景里已经很接近全参。我做行业大模型微调时,默认都是 LoRA 起步,跑通之后再决定要不要尝试全参。
全参微调的效果上限最高,但对显存、数据质量和调参经验的要求也最高。数据少于一万条时,全参反而容易过拟合,训练出来的模型在测试集上可能表现得比 LoRA 还差。三者的权衡可以看这张表:
| 微调范式 | 可训练参数占比 | 显存占用 | 效果上限 | 适用场景 |
|---|---|---|---|---|
| P-Tuning v2 | 0.01% 级别 | 低 | 中低 | 小样本、单任务 |
| LoRA | 0.1%-1% | 中 | 高 | 行业微调、快速迭代 |
| 全参微调 | 100% | 高 | 最高 | 数据量大、算力充足 |
选定范式之后,DeepSpeed 的角色就清晰了:它对三种范式都适用,但在全参和 LoRA 场景下收益最明显。多卡并行不是魔法,它解决的是“单卡物理显存放不下”和“单卡算力不够”这两个硬约束。
2.3 DeepSpeed 在其中的位置:ZeRO 机制与多卡加速的本质
先理解 DeepSpeed 解决什么问题。普通的数据并行训练,每张卡都保存一份完整的模型参数、梯度和优化器状态。4 张卡跑 ChatGLM-6B 全参微调,优化器状态用 AdamW 时要占参数量的 8 倍以上,显存翻倍式增长,卡越多浪费越明显。
DeepSpeed 的 ZeRO 把这三样东西切分到各张卡上。stage 1 只切分优化器状态,stage 2 切分梯度和优化器状态,stage 3 连模型参数也切分。我做单机多卡微调时,默认用 stage 2,因为 stage 3 在训练过程中需要频繁做参数聚合,通信开销大,4 卡规模下收益不明显,反而可能变慢。
ZeRO 之外的几个机制也值得了解。offload_optimizer 把优化器状态放到 CPU 内存,能用更少显存跑更大的 batch,但训练速度会下降,适合显存不够时应急。梯度累积则把多个 micro batch 的梯度攒起来再更新一次,表面上看是省显存,实际上是调整全局 batch size 的手段。数据并行、ZeRO、梯度累积这三者的关系,决定了多卡训练的速度上限和效果稳定性,第 4 章会展开到具体参数。
3. 环境与数据准备:CUDA、conda 和训练数据的硬性门槛
3.1 先跑这个命令检查 CUDA 与显卡:环境对了才谈多卡
环境配置这步会卡住一半人。很多翻车案例不是模型训练问题,而是 PyTorch 装成了 CPU 版本,或者 CUDA 驱动和 PyTorch 编译版本不匹配,导致torch.cuda.is_available()返回 False。拿到一台多卡机器,第一步我会先跑一段最小检查:
nvidia-smi python -c "import torch; print(torch.__version__, torch.cuda.is_available(), torch.cuda.device_count())"nvidia-smi看驱动版本和每张卡的显存状态,确认 4 张卡都能被系统正常识别。第二行命令验证 PyTorch 是否编译了 CUDA 支持,以及能调用几张卡。如果输出里torch.cuda.is_available()是 False,先别急着配 DeepSpeed,把 PyTorch 装回 CUDA 版本再继续。
多卡机器还要检查卡间通信。用nvidia-smi topo -m看两张卡之间的拓扑,如果是 PCIe 直连,通信带宽有限;如果是 NVLink,多卡训练的效率会高很多。这个问题在单机多卡上影响不大,但如果之后要扩展到多机多卡,节点间的网络带宽会成为关键瓶颈。
驱动版本我建议保持较新的稳定版,CUDA Toolkit 用 11.8 或 12.1 都行,关键是 PyTorch 的预编译 wheel 要和它对应。很多老教程还在用 CUDA 10.x,新显卡驱动根本不认,照抄就会在环境这步浪费一整天。
3.2 conda 环境复现:Python 版本与依赖锁定的最小集合
环境问题一旦出现,排查起来非常耗时。我一般用 conda 建一个独立环境,把依赖版本锁定,避免和服务器上其他项目互相污染。完整的依赖如下:
conda create -n glm-ft python=3.10 -y conda activate glm-ft pip install torch==2.1.2 torchvision==0.16.2 --index-url https://download.pytorch.org/whl/cu121 pip install transformers==4.36.2 datasets peft deepspeed==0.13.1 acceleratePython 3.10 是当前大模型生态兼容性最好的版本。torch 2.1.2 对应 CUDA 12.1,这一组版本我跑过多次,和 DeepSpeed 0.13.1 配合稳定。transformers 4.36.2 支持 ChatGLM 系列通过trust_remote_code=True加载,datasets 用于数据集的加载和预处理,peft 是 LoRA 的官方实现库,accelerate 在部分训练脚本里会被间接依赖。
装完之后跑一个快速验证:
python -c "import deepspeed; print(deepspeed.__version__)" python -c "from transformers import AutoModel; print('ok')"如果 DeepSpeed 的__version__能正常打印,说明安装成功。这里有个小坑:DeepSpeed 在安装时会尝试编译一些 CUDA 算子,如果编译过程报错,先看是不是 gcc 版本太老,常见做法是apt install build-essential装一下编译工具链。
3.3 训练数据格式:instruction-input-output 的清洗与容量估算
ChatGLM 微调最常用的数据格式是 JSON,每条样本包含指令、输入和期望输出三个字段。下面是一个典型的对话样本:
{ "instruction": "请根据以下合同条款,判断甲方是否违约", "input": "甲方未在约定的30天内支付第二期款项,且未提供任何书面说明。", "output": "甲方已构成违约。根据合同第7.2条,甲方应在30天内支付第二期款项,逾期未付且未说明理由,属于明确违约情形。" }数据量不是越多越好。几百条高质量数据就能看到一个明显的效果变化,但要达到“能用”的程度,我一般建议至少 3000 到 5000 条。数据质量比数据量更关键,常见问题包括:标签前后不一致、output 里包含额外解释导致模型学到错误的输出格式、样本之间重复度过高。
清洗时我会按照几个固定步骤处理:去重、去空、检查标签一致性、过滤超长样本。每条样本的 token 长度直接影响训练显存,如果某个样本特别长(比如超过 2048 token),要么截断,要么单独处理,不要混在正常样本里。估算训练数据的 token 总量有个简单公式:平均每条样本 token 数乘以样本数。这个总量除以全局 batch size,就是一个 epoch 的步数,可以用来预估训练时长。
4. DeepSpeed 多卡微调实操:从启动命令到 ds_config 参数
4.1 启动命令与工程结构:deepspeed --num_gpus 的前后顺序
环境就绪、数据就位之后,进入核心的实操环节。多卡微调的工程结构并不复杂,我习惯把训练脚本、配置文件、数据和输出目录分开放:
glm-ft/ ├── train_chatglm.py ├── ds_config.json ├── data/ │ └── train.json └── output/训练脚本的启动命令用 DeepSpeed 的 launcher 来跑,而不是直接用 python。这一行命令是最容易被忽略的地方:
deepspeed --num_gpus=4 --master_port=29500 train_chatglm.py \ --model_name_or_path /data/models/chatglm-6b \ --data_path ./data/train.json \ --output_dir ./output \ --train_batch_size 128 \ --micro_batch_size 4 \ --num_train_epochs 3 \ --learning_rate 2e-5--num_gpus=4告诉 DeepSpeed 使用 4 张卡,--master_port是分布式训练的控制通信端口。如果服务器上同时有别人在跑训练,29500这个默认端口很容易冲突,报错信息通常是地址被占用,换一个不常用的端口就能解决。--train_batch_size 128是全局 batch size,--micro_batch_size 4是单卡一次前向传播的样本数,两者的关系由梯度累积步数连接。
这里的--micro_batch_size 4不是随便定的。ChatGLM-6B 在 24GB 显存的卡上,FP16 混合精度 + 最长序列 1024 的条件下,micro batch size 开到 4 是比较稳的值。如果序列长度更长,比如 2048,就要降到 2 甚至 1。全局 batch size 的选择会影响收敛效果,一般分类任务用 32 到 128 都常见,太小噪声大,太大收敛慢,需要根据数据规模调整。
4.2 ds_config.json 逐字段拆解:ZeRO stage、offload 与混合精度
DeepSpeed 的核心配置都在ds_config.json里,训练脚本通过这个文件感知分布式训练的所有细节。下面是我在 4 张 3090 上微调 ChatGLM-6B 的完整配置:
{ "train_batch_size": 128, "train_micro_batch_size_per_gpu": 4, "gradient_accumulation_steps": 8, "optimizer": { "type": "AdamW", "params": { "lr": 2e-5 } }, "fp16": { "enabled": true, "loss_scale": 0, "loss_scale_window": 1000, "initial_scale_power": 16 }, "zero_optimization": { "stage": 2, "offload_optimizer": { "device": "cpu", "pin_memory": true }, "allgather_partitions": true, "allgather_bucket_size": 5e8, "reduce_scatter": true, "contiguous_gradients": true } }配置里的参数对应关系需要理解到位。train_batch_size是全局 batch size,train_micro_batch_size_per_gpu是每张卡每个微批的样本数,gradient_accumulation_steps是梯度累积步数,三者满足一个固定等式:全局 batch = 单卡 micro batch × 梯度累积 × 显卡数。这里 4 × 8 × 4 = 128,正好配平。
关于 offload_optimizer,这是一个取舍点。开启后优化器状态放到 CPU 内存,显存占用明显下降,但训练速度会慢 10% 到 20%。我的建议是:显存够用就不要开,显存不够或者想跑更大的 micro batch 再开。fp16 混合精度尽量保持开启,不开启的话显存占用会直接翻倍,而且训练速度显著变慢。
| 参数 | 作用 | 建议值 |
|---|---|---|
| train_batch_size | 一次优化更新消耗的总样本数 | 数据量小时用 32-64,大时用 128 |
| train_micro_batch_size_per_gpu | 单卡单步前向反向的样本数 | 24GB 显存从 4 起步 |
| gradient_accumulation_steps | 累积多少微批才更新参数 | 由另外两个参数推导,不要手填矛盾 |
| zero_optimization.stage | ZeRO 切分等级 | 单机多卡用 stage 2 |
| offload_optimizer.device | 优化器状态存放位置 | 显存吃紧才设 cpu |
| fp16.enabled | 是否开启混合精度 | 保持 true |
有一个常见误区是三个 batch 参数不匹配。DeepSpeed 启动时会校验train_batch_size和另外两个参数的乘积是否一致,如果不一致直接报错。报错信息会明确告诉你当前的 grad_accum 应该是多少,照着改就行。
4.3 接住 DeepSpeed 的训练循环:Trainer 还是手动循环
配置写好之后,训练脚本里最关键的是如何让模型、优化器和 DeepSpeed 引擎正确对接。直接看代码:
import torch import deepspeed from transformers import AutoModel, AutoTokenizer tokenizer = AutoTokenizer.from_pretrained("/data/models/chatglm-6b", trust_remote_code=True) model = AutoModel.from_pretrained("/data/models/chatglm-6b", trust_remote_code=True).half() model, optimizer, _, lr_scheduler = deepspeed.initialize( model=model, model_parameters=model.parameters(), config="ds_config.json" ) for step, batch in enumerate(train_loader): inputs = tokenizer(batch["text"], return_tensors="pt", padding=True, truncation=True, max_length=1024).to(model.device) outputs = model(**inputs, labels=inputs["input_ids"]) loss = outputs.loss model.backward(loss) model.step()这段代码里有三个容易出错的地方。第一,deepspeed.initialize返回的 model 已经是被 DeepSpeed 包装过的对象,不能用原来的loss.backward(),必须调用model.backward(loss),由 DeepSpeed 处理梯度切分和跨卡同步。第二,model.step()替代了原来手动调optimizer.step()和optimizer.zero_grad()的流程,DeepSpeed 会根据配置的梯度累积步数自动决定何时更新参数。第三,train_loader 要自己构造,DeepSpeed 不负责数据加载。
如果你更习惯用 HuggingFace 的 Trainer,也可以在训练参数里指定deepspeed="ds_config.json",Trainer 会读取配置并完成同样的初始化流程。我早期用 Trainer 省事,但出了问题不好判断是 Trainer 的问题还是 DeepSpeed 的问题,后来改成手动初始化,整个流程在自己的掌控之内,排查效率高了不少。
手动循环还有一个优势:可以在每个 step 里打印 loss、学习率和显存占用,实时观察训练状态。加两行代码就能实现:
if step % 10 == 0: print(f"step {step}, loss {loss.item():.4f}, lr {lr_scheduler.get_last_lr()[0]:.2e}, mem {torch.cuda.max_memory_allocated() / 1024**3:.2f}GB")5. 多卡微调避坑指南:NCCL 超时、显存 OOM 与 Loss 不收敛的排查清单
5.1 NCCL 超时与初始化卡死:多卡通信最常见的翻车现场
现象:启动训练后,rank 0 开始正常打印日志,但其他 rank 一直卡在初始化阶段,几分钟后报NCCL timeout错误,整个进程退出。
原因:最常见的是master_port被占用,或者多机多卡场景下MASTER_ADDR和NODE_RANK没设置对。另一个隐蔽原因是服务器防火墙阻止了节点间的通信端口。我遇到过一次,单机多卡跑得好好的,换成多机多卡就卡死,排查了半天发现是第二台机器的网卡 IP 写错了。
解决:先把--master_port换成一个冷门端口,比如29888。如果还卡,在启动命令前加一行环境变量看详细日志:
export NCCL_DEBUG=INFONCCL_DEBUG=INFO会打印通信初始化的详细过程,哪个 rank 在等谁一目了然。确认是网络问题后,检查MASTER_ADDR是否指向主节点的内网 IP,NNODES是否和实际机器数一致。多机多卡不是简单地把--num_gpus改成 8 就行,每台机器的启动命令还要带--node_rank,这些参数我在第一次跑多机时踩了整整一个下午。
5.2 CUDA OOM:先看微调范式,再谈调 ZeRO stage
现象:训练跑到第三个 batch,直接报CUDA out of memory,报错信息里能看到是哪一层 transformer 爆的显存。
原因:显存爆掉的原因分三类。第一类是 micro batch size 太大,24GB 卡跑 1024 序列长度的 ChatGLM,micro batch size 超过 4 就会危险。第二类是序列长度过长,个别样本超过 2048 token,显存峰值被拉高。第三类是开启了全参微调但没开梯度检查点,激活值占用直接翻倍。
解决:按优先级依次处理。先开梯度检查点,只需一行:
model.gradient_checkpointing_enable()这行代码用计算换显存,训练速度会慢一些,但显存峰值能降低 30% 到 40%,多数情况下这一行就解决问题。如果还不行,把train_micro_batch_size_per_gpu降到 2 或 1。仍然不行,再把offload_optimizer打开,把优化器状态挪到 CPU。这三步走完,OOM 基本不会再来。
这里要提醒一个容易忽略的点:如果用的是 LoRA,显存占用远低于全参,不要让 LoRA 把显存放宽之后就把 batch 拉得很大。batch 太大会让收敛变慢,模型在验证集上的表现不升反降。
5.3 Loss 不降或持续震荡:学习率与数据质量是主角
现象:loss 从 2.x 开始,训练了 1000 步还在 2.x 附近横盘,或者每过几个 step loss 突然跳高再回落,震荡得厉害。
原因:最常见的是学习率太大。大模型微调的学习率一般是 1e-5 到 5e-5 这个量级,比训练小模型用的 1e-3 低两到三个数量级。另一个高频原因是数据问题——很多样本的 output 字段里混入了原始文本,模型试图记住这些噪声,loss 自然降不下去。
解决:先把学习率降一个数量级,比如从 2e-5 降到 5e-6,看 loss 是否开始平稳下降。如果没改善,检查数据清洗环节,随机抽 20 条样本人工看一遍,确认 output 里没有脏数据。还要检查max_length设置,如果训练样本的序列长度超过截断值,大量样本的标签在截断后被丢弃,模型学到的东西会很有限。
Loss 不收敛的场景里,约一半是学习率问题,另一半是数据问题。环境问题导致的 loss 异常很少见,所以先从这两点入手排查,不要动不动就怀疑 DeepSpeed 配置。
5.4 DeepSpeed 启动了但实际没生效:日志里找这行字
现象:训练能正常跑,loss 也在下降,但 nvidia-smi 里看到 GPU 利用率很低,训练速度甚至比单卡还慢,4 张卡跑出了 1 张卡的效果。
原因:训练脚本里虽然用了 deepspeed 命令启动,但实际没有调用deepspeed.initialize,或者ds_config.json的路径没传对,DeepSpeed 退化成普通数据并行。这种情况不会有报错,因为 PyTorch 的 DistributedDataParallel 也能跑多卡,只是 ZeRO 的显存优化完全没生效。
解决:看训练启动时的日志。DeepSpeed 初始化成功时,会打印一段包含模型参数量的信息,其中会明确显示 zero stage 的数值。如果日志开头没有出现DeepSpeed info相关的输出,说明 DeepSpeed 没有被真正加载。检查两点:训练脚本里是否调用了deepspeed.initialize;config="ds_config.json"的路径是否相对于当前工作目录正确。遇到这种问题,我一般在训练脚本第一行加一个断言:
import deepspeed assert deepspeed.initialize is not None, "deepspeed not loaded"这样至少能确认 import 成功。更直接的验证方法是在deepspeed.initialize之后打印model.global_batch_size,如果和配置里的train_batch_size一致,说明 DeepSpeed 已经接管。
5.5 Checkpoint 保存、ZeRO 权重合并与断电后悔药
现象:训练跑了一晚上,第二天发现服务器断电或者被运维重启,所有进度丢失,只能从头再训。
原因:没有配置 checkpoint 保存机制,或者训练脚本里根本没有写保存逻辑。另一个更隐蔽的问题是:ZeRO stage 2 训练时,每个 rank 只保存自己分片的那部分优化器和梯度状态,直接用torch.save(model.state_dict())保存的权重是不完整的。
解决:DeepSpeed 提供了专门的保存接口,训练循环里每隔固定步数调用一次:
if step % 500 == 0: model.save_16bit_model("./output/ckpt") client_state = {"step": step, "trainer_state": step} model.save_checkpoint("./output/ckpt", tag=f"step_{step}", client_state=client_state)save_16bit_model会合并各 rank 的权重,输出一份完整的 FP16 模型,这个文件可以直接用来加载推理。save_checkpoint保存的是 DeepSpeed 的完整训练状态,包括优化器状态和当前步数,配合resume_from_checkpoint可以无缝续训:
deepspeed --num_gpus=4 train_chatglm.py \ --resume_from_checkpoint ./output/ckpt/step_1500checkpoint 是训练过程中的后悔药,配置好之后,再遇到断电、蓝屏、OOM 终止都不会太心疼。我自己跑长训练时,每 500 步保存一次,磁盘占用不大,但心理安全感强很多。
6. 验证与进阶:loss 曲线之外,还要看真实问答与推理部署
6.1 微调效果的三个验证层次
训练完成不代表微调成功。我先看训练 loss 和验证 loss 的差距,判断是否过拟合;然后跑测试集 prompt 对比微调前后的回答质量;最后抽一批真实业务问题做人工打分。第三层最重要,loss 再低,模型在真实问题上胡说八道也没用。加载合并后的权重测试生成的代码很简单:
tokenizer = AutoTokenizer.from_pretrained("./output/ckpt", trust_remote_code=True) model = AutoModel.from_pretrained("./output/ckpt", trust_remote_code=True).half().cuda() prompt = "请根据以下合同条款,判断甲方是否构成违约:甲方未在30天内支付第二期款项。" inputs = tokenizer(prompt, return_tensors="pt").to("cuda") out = model.generate(**inputs, max_new_tokens=200) print(tokenizer.decode(out[0], skip_special_tokens=True))6.2 进阶路径:LoRA + DeepSpeed、梯度检查点和多机扩展
验证通过之后,你的微调流程已经闭环了。此后的进阶方向是把 LoRA 和 DeepSpeed 结合,进一步降低显存占用。做法是先通过 HuggingFace 的 AutoModel 加载模型,再用 peft 包一层 LoRA,最后把包好的 model 传给deepspeed.initialize,训练完用merge_and_unload()合并权重再部署。这个方案适合需要频繁迭代的行业模型场景,每次微调都能省下大量算力。
多卡微调的最终形态是多机多卡,但这需要万兆以上网络才有效果。单机多卡 4 张卡跑不动的场景,加一台机器多机多卡是有价值的;如果只是想跑得更快,先把单机多卡吃透,它的效率提升空间远比贸然跨机器来得大。
最后说一个我自己的习惯:每次微调开始前,先把ds_config.json和启动命令存成独立文件,训练完把日志里最后的 loss 记录保存下来。这个做法帮我积累了不同数据规模下的最佳参数组合,下一次微调直接照着上次的参数起步,省掉了大量试错时间。希望帮到你。
本文还有配套的精品资源,点击获取