☰
16G显存也能跑通Qwen 7B LoRA微调:显存优化与实战全流程
2026/9/25 13:25:22 网站建设 项目流程

我手里这张 16G 显存的卡,在服务器机箱里躺了快两周,一直被当成纯推理卡用——跑跑 Qwen 7B 的量化版还行,一聊到微调就心虚。全参微调 7B,光权重、梯度、优化器状态加起来就要上百 GB,16G 想都不用想。但换一条路:LoRA 微调,同样的模型,16G 不仅能练,练完还能接着跑推理。这篇是我们远程调试系列的第三篇,前两篇把远程环境、端口转发、日常调试工具都备好了,这次就把 LoRA 微调 Qwen 7B 这条主线完整走一遍:先拆显存账,再讲环境与数据准备,接着逐行解读训练配置,最后聊训练中踩过的坑和验收方式。想把消费级显卡利用起来的人,这篇可以直接当实操手册用。

1. 先算显存账:为什么 7B 全参微调在 16G 卡上是伪命题

1.1 一次全参微调的显存开销到底有多离谱

先做个简单的算术,单位是 GB:

开销项全参微调(bf16/fp16)LoRA 微调
模型权重约 14GB约 14GB(冻结)
梯度约 14GB只算 LoRA 部分,约 0.2GB
AdamW 优化器状态约 84GB约 1.1GB
激活值(开 grad_ckpt)约 3-6GB约 1-2GB
合计约 115GB约 16-18GB

我故意把激活值写得保守一点。全参微调的真正大头不是模型本身,而是优化器状态:AdamW 要为每个参数额外保存一份 fp32 副本,再加上一阶动量 m 和二阶动量 v,一个参数摊下来是 12 个字节。7B 参数一乘,84GB 就没了。所以 A100 的 80GB 显存跑全参微调都吃力,不是卡的问题,是数学问题。

这里要顺便纠正一个常见误区:很多人以为显存只用来装模型权重,其实训练和推理差别巨大。推理时你只需要权重加 KV Cache,14GB 的模型在 16G 显存上跑得动;一旦进入训练模式,梯度要存、优化器状态要存、前向传播的中间激活值也要存,哪一样都是真金白银。

1.2 LoRA 把钱花在了刀刃上:只训一条窄通道

LoRA 的核心思路是:大模型权重在微调时,不需要把 7B 个参数全动一遍,而是假设参数的更新量本身是低秩的。于是它把原始权重 W 冻结住,在旁边挂两个小矩阵 A 和 B,前向传播时输出变成 Wx + BAx,训练时只更新 A、B 的参数,最后把 BA 合并回 W 或者作为一个独立 adapter 保存。

用大白话讲:全参微调像是要把整本书重新印刷一遍,代价是按书的页数算;LoRA 相当于在原书上贴便利贴,只改需要改的地方,最后把便利贴汇总成一本小册子。书还是那本书,但批注全在小册子里。

以 Qwen2.5-7B 为例,hidden size 是 3584,如果 rank 取 64,每个线性投射层新增 2×3584×64 大约 46 万个参数,7B 模型里几十个目标层加起来就是 7000 万到 1 亿这个量级。对比原模型的 70 亿参数,相当于只训练 1% 左右。这才是 LoRA 能跑在 16G 显存上的根本原因:优化器状态和梯度体积直接被砍掉了两个数量级。

2. 远程服务器上的准备:环境、框架、数据三件套

2.1 第一步不是写代码,而是确认显卡和 CUDA 环境

远程调试最大的特点就是:你人不在机器前面。所以你首先要确认的不是代码,而是nvidia-smi输出能不能稳定看到。我自己的习惯是先跑一遍nvidia-smi -L看显存型号,再看 CUDA 版本,然后nvidia-smi --query-gpu=memory.total,memory.free --format=csv确认没有别的进程占着显存。

注意一个隐性坑:远程机器通常会有别的任务在跑,16G 显卡可能只剩 10G 可用。在微调前先清掉不用的进程,或者至少弄清当前可用显存是多少,否则你会在“配置文件看着没问题,一启动就 OOM”这类问题上耗掉不少时间。确认命令:

nvidia-smi python -c "import torch; print(torch.cuda.is_available(), torch.cuda.get_device_name(0))"

2.2 LLaMA-Factory 装好,省一半的力

在 LoRA 微调这个赛道上,LLaMA-Factory 可以说把“轮子”造得非常完整。它内置了 Qwen 等一堆模型的加载逻辑、对话模板、LoRA/QLoRA/Freeze 训练方式,还有 WebUI 和 CLI 两套入口。对我们这种远程调试场景,WebUI 配合端口转发,用得很顺。

推荐的安装方式:

conda create -n llamafactory python=3.10 -y conda activate llamafactory cd ~ git clone --depth 1 https://github.com/hiyouga/LLaMA-Factory.git cd LLaMA-Factory pip install -e .

如果从零开始,还要确保 PyTorch 版本和 CUDA 版本匹配。我实际装机时踩过最大的坑是 PyTorch 编译版和驱动不匹配导致torch.cuda.is_available()直接 False,遇到这种情况优先考虑重装对应版本的 PyTorch 轮子,而不是去刷驱动——服务器上动驱动风险太大。

2.3 数据决定模型上限:alpaca 格式怎么组织

数据这一步最容易被低估。模型能学成什么样,训练参数只是放大器,数据内容才是信号本身。LLaMA-Factory 默认支持两类格式:alpaca 和 sharegpt。

alpaca 格式是经典的三段式 JSON:

[ { "instruction": "请判断以下投诉属于哪个分类,并给出回复建议。", "input": "昨天买的鼠标今天双击失灵,售后说要返厂检测,太浪费时间了。", "output": "分类:售后投诉。回复建议:先安抚客户情绪,说明返厂是标准流程…" } ]

有几个细节我想强调一下。

第一,instruction 和 input 可以拆分:instruction 是固定的任务描述,input 是变化的内容。如果任务简单,input 留空完全没问题,框架也支持。第二,output 一定不能为空,也不能用省略号糊弄——空字符串在部分 tokenizer 下不会崩,但会把模型带偏。第三,数据量不在多而在覆盖:几百条干净、风格一致的数据,往往比几千条参差不齐的数据更有效。我自己调过一轮测试,500 条对齐数据微调出来的效果,明显好过 1500 条混杂数据的版本。

3. 配置逐行拆:base_model、train_data、val_data、output_dir 以及 LoRA 的关键参数

3.1 配置文件的四个地基层次

很多人拿到的 LoRA 训练配置长这样:

base_model: /models/Qwen2.5-7B-Instruct train_data: data/train.json val_data: data/val.json output_dir: outputs/qwen2.5-lora

这四个字段是地基。

  • base_model:指向原始模型目录,必须和模型文件的路径一一对应。这里有个很容易犯的错误:填了 Hugging Face 仓库名而不是本地路径。如果服务器网络不通或者本地缓存不全,加载会卡很久或者直接失败。建议先把模型下载到本地目录,路径写绝对路径。
  • train_data:训练集,一般指上面说的 alpaca 或 sharegpt 格式 JSON 文件。
  • val_data:验证集,用来在每个保存点计算 loss。验证集不需要很大,几百条就够;甚至可以用 train_data 本身切出 5% 作为验证集,框架一般都支持val_size一类的参数。
  • output_dir:输出目录。LoRA 训练完不是直接产出一个新模型,而是产出一堆 checkpoint 目录和 adapter_config.json、adapter_model.safetensors。这个目录会不断膨胀,注意磁盘配额,别把 /root 撑爆。

3.2 LoRA 参数三兄弟:rank、alpha、target_modules

lora_rank(r)决定了低秩矩阵的宽度。r=32 适合轻量任务,r=64 是很多博客默认值,r=128 效果理论上更好,但显存和磁盘开销上涨。作为 7B 模型在 16G 显存上的选择,64 是甜点值,理由前面算过:它带来的额外参数量刚好在可控范围。

lora_alpha(α)是缩放系数。它表示 LoRA 更新量的放大倍数,常见的经验是取 rank 的 2 倍,也就是 α=128。如果 α 太大,训练初期输出会被扰动得特别厉害,loss 曲线可能直接起飞;如果太小说更新量被低估,训练半天没变化。

target_modules指定在哪些模块上挂 LoRA。最省事的做法是设为全部线性层(all),因为 Qwen 是稠密模型,q/k/v/o/gate/up/down 这些投射层都值得微调。如果显存紧张,可以只选q_proj、v_proj,显存占用能降一点,但效果通常不如 all。

3.3 训练参数:这两行的存在就是为了保显存

接着看训练参数:

per_device_train_batch_size: 1 gradient_accumulation_steps: 8 learning_rate: 1e-4 num_train_epochs: 3 max_seq_length: 1024 gradient_checkpointing: true fp16: true

这里需要建立一个基本概念:per_device_batch_size × gradient_accumulation_steps才是真正的更新步 batch size。显存不够,batch size 就压到 1,用 8 步梯度累加来模拟 batch size 8 的更新效果。代价是训练时间变长、GPU 利用率下降,但显存是硬约束,时间不是。

max_seq_length是另一个显存大头。激活值随序列长度线性增长,seq length 从 1024 涨到 2048,激活值几乎翻倍。16G 卡跑 Qwen 7B 时,我一般先试 1024;如果 OOM,先砍长度到 512,而不是急着砍 batch size。这个顺序非常关键:序列长度影响的是所有样本的显存占用,batch size 影响的是并行的样本数,两者的优先级完全不同。

gradient_checkpointing常开,它把前向过程中的激活值扔掉一部分,反向时重算,空间换时间。实测能把峰值压下来 2-3G,代价是每个 step 慢 20% 左右,这个性价比很值。

fp16还是bf16?如果你手里是 RTX 3090/4090 这类支持 bf16 的卡,优先 bf16,数值稳定性更好;如果是老一点的 16G 卡(比如 T4、P100),只能 fp16,那么梯度缩放和混合精度要开好,后面 NaN 那节会再提到。

4. 真正点下启动按钮:训练现场与显存实况

4.1 用长任务的方式启动:tmux 加端口转发

远程训练和本地训练有个本质区别:你随时可能断网。一个训练流程动不动几小时,ssh 窗口一断,进程跟着没了。所以第一步是创建 tmux 会话:

tmux new -s train # 在会话中执行训练,比如: llamafactory-cli train config.yaml

退出会话用Ctrl+B再按D,断线重连用tmux attach -t train。这是一个不会让你后悔的小习惯。之前我犯过傻,直接前台跑,结果公司断网一次,两小时白训。

如果用的是 WebUI,本地浏览器远程访问也很简单:

ssh -L 7860:localhost:7860 user@gpu-server

然后本地打开http://127.0.0.1:7860,就能以浏览器方式配置和观察训练过程。

4.2 训练中的四个实时指标盯什么

训练开始后不要只盯着nvidia-smi看显存数字。我一般开四个并行窗口:一个tmux attach看训练日志;一个跑nvidia-smi -l 2实时看显存;一个跑htop看 CPU 负载;最后一个随时待命应急。

nvidia-smi里最值得看的两列是Memory-Usage和GPU-Util。显存稳定在一个值附近,说明正常;GPU-Util 如果长期低于 30%,大概率是 batch size 太小、数据加载卡 IO 或者 PyTorch 数据线程没调好。此时把dataloader_num_workers调到 4 以上,或者检查数据是不是在机械硬盘上。

训练日志里,我们主要盯loss。正常的曲线是缓慢波动下降,比如从 1.8 降到 0.9,中间有小幅抖动完全正常。如果 loss 在某个 epoch 突然变成 3.5 以上并且回不来,先停训练,不要等它自愈——大概率是学习率或者数据问题。

4.3 16G 下的实测显存数据:能跑和稳跑是两回事

用 Qwen2.5-7B-Instruct 实测,配置按第三章那套来:rank=64、target_modules=all、batch_size=1、max_seq_length=1024、开启 gradient checkpointing、bf16 精度,显存大概稳定在 14.5GB 到 16GB 之间。

说明一下“能跑”和“稳跑”的区别:能跑是峰值显存刚好压在 16G 以下,稳跑则是把峰值降到 15G 以内,留下 1G 以上余量给验证集推理和临时波动。我实际遇到的情况是,训练到某个保存点瞬间显存会跳高一点,val_loss 计算时如果验证集 batch 太大还会再峰值一把,所以不要顶着 15.9G 的边训练边提心吊胆。

5. 四个崩溃瞬间:我在 Qwen 7B 上踩过的 LoRA 坑

5.1 OOM 不只是发生在训练,还发生在验证

第一次跑的时候我以为只要训练 batch 小就没问题,结果训到第三个保存点崩溃了。日志里torch.cuda.OutOfMemoryError,但前面明明跑得好好的。排查一圈才发现是验证集 batch size 问题:我的配置里训练 batch 是 1,但默认的验证 batch 可能是 2 或者 8,模型一下把验证 batch 的全量激活值放进显存,峰值立刻越界。

验证 batch 单独设小是很多人忽略的动作。通用的修复是在配置里加:

per_device_eval_batch_size: 1 max_eval_samples: 50

前一个参数把验证 batch 压到和训练一致,后一个参数限制验证集条数,省得每轮都拿几百条验证数据在显存里反复跑。

5.2 loss 变成了 NaN 或直接起飞

loss 变 NaN 的原因通常是两类:学习率太大,或者数据里有不合法文本。第一次我用learning_rate=1e-3跑 7B 的 LoRA,前几百步 loss 直线上升,最后变 NaN。LoRA 因为只更新少量参数,对学习率的敏感度和全参微调不完全一样,1e-4 甚至 5e-5 更稳。

数据侧的问题更隐蔽。排查时我发现有一条 JSON 记录里output字段是null,tokenizer 处理时产生了空序列,前向计算出现 inf。修复很简单:写个脚本把空字段、None、以及超过max_seq_length的超长样本全部过滤掉。我习惯在训练前加一步数据体检脚本:

import json from tqdm import tqdm with open("train.json", "r", encoding="utf-8") as f: data = json.load(f) clean = [] for item in tqdm(data): text = item.get("output", "") or "" if len(text.strip()) == 0: continue clean.append(item) with open("train_clean.json", "w", encoding="utf-8") as f: json.dump(clean, f, ensure_ascii=False, indent=2)

把隐性炸弹提前挖掉,比崩溃后再一步步排查省太多时间。

5.3 训练结束效果像没练:这口锅谁来背

最让人崩溃的不是训练崩了,而是训练正常结束,loss 也降了,模型回答却像没练过一样。我总结出三个最常见原因。

第一个是底模选错了:拿 base 模型做 SFT,对话模板没有 chat 模板,推理时没有正确拼上对应格式,效果自然稀烂。建议直接用 Instruct/Chat 版本,比如Qwen2.5-7B-Instruct。

第二个是训练轮数过多导致灾难性遗忘:LoRA 只是把模型“钉”在某个区域附近,并不天然免疫遗忘。我一度用 10 个 epoch 去跑几百条数据,结果微调后连原有的通用能力都退化了。7B 模型在 500 到 1000 条数据上,2 到 3 个 epoch 通常是合理区间,超过 5 个就很容易过拟合。

第三个是推理时没加载 LoRA adapter。很多框架训练完默认只存 adapter,不合并进底模。如果你在普通 transformers 管道里直接加载output_dir,等于拿原始模型推理,效果当然没变化。关于加载方式,下一节展开说。

6. 训练完后怎么验收:合并、加载、试答

6.1 两种加载方式,别搞混

LoRA 训练产物有两种用法。

第一种是单独保存 adapter,推理时代码里显式加载:

from peft import PeftModel from transformers import AutoModelForCausalLM, AutoTokenizer model = AutoModelForCausalLM.from_pretrained(base_model_dir) model = PeftModel.from_pretrained(model, "outputs/qwen2.5-lora")

好处是多 adapter 切换方便:同一个底模挂不同 adapter,应对不同业务。坏处是每次启动推理都要多做一层加载,部署链路稍微复杂一点。

第二种是先把 LoRA 合并到底模权重里,再导出一个完整的模型文件。框架一般提供 merge 命令或 API,合并后的模型和普通模型文件没有区别,后续 vLLM、Ollama 都能直接加载。适合“一次调优,长期部署”的场景。

我实际更常用第二种:因为要部署的服务器通常没有训练框架,一个完整模型目录部署起来最省心。如果是不断迭代调优的阶段,则第一种更方便。

6.2 用 20 条评测题快速判断效果

不要只看训练 loss。训练 loss 下降只说明模型记住了训练集的模式,不代表它对真实问题回答变好了。我的做法是准备 20 到 50 条评测题,每条都不能出现在训练集里,内容覆盖你真正关心的业务场景。然后把底模和微调后的模型分别跑一遍同样的问题,逐条对比。

对比时注意看三点:格式是否对齐、语气是否变化、事实性是否保持。比如你微调的是售后话术风格,底模可能回答得中规中矩但不够落地,adapter 加载后的回答应该表现出明确的任务感:先分类、再安抚、最后给方案。如果格式对齐但事实歪了,说明训练数据里存在错误映射,或者 epoch 太多导致旧知识被覆盖。

6.3 显存还有余量?QLoRA 与多点扩展

训练结束如果显存还有富余,或者你手里的卡只有 8G,可以考虑 QLoRA。QLoRA 把底模量化到 4bit,底模权重从 14GB 降到大约 4GB,加训练开销之和往往能压在 8G 以内。代价是量化引入一点精度损失,以及训练和推理速度略降。对很多轻量场景来说,QLoRA 是更现实的选择。

另外可以试试 NEFTune 这类训练技巧:在 embedding 上注入轻微噪声,对部分数据量较小的场景有正面效果。我自己实验下来,数据量少于 200 条时它的收益更明显,数据量大了之后差异不大。这些都是一些可以继续扩展的方向,不过先把 LoRA 基础流程跑通,比什么都重要。

最后再分享一个个人习惯。我在远程训练前,会把nvidia-smi的输出截个图,训练过程中每 500 步再截一张,最后把 loss 曲线和显存曲线放在一起看。这样能很快发现一些问题:比如前 500 步显存只有 13G,到了第 1200 步突然变成 15.5G,很可能是有别的进程插进来了。系列前两篇搭好的远程环境,在这轮训练里发挥了很大作用;如果你也打算在 16G 卡上把 Qwen 7B 练起来,建议先从第三章那份配置复制走,跑通一次,再去折腾微调技巧。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询