1. 6GB 显存跑双模型,这个念头是怎么冒出来的
先把背景交代清楚。我手头只有一张 6GB 显存的显卡,具体型号不重要,反正就是那种跑个 7B 模型做推理都得掐着显存过日子的水平。之前一直在用 LoRA 做微调实验,踩过的坑不算少,但这次想玩点不一样的——把两个"决策模型"同时装进去,一个叫 Kev,一个叫 Laya。
为什么叫"决策模型"?因为它们不是那种通用的对话模型,而是针对特定决策场景做了定向微调的版本。Kev 偏向逻辑推演和结构化输出,Laya 偏向快速判断和短决策链。两个模型各有各的脾气,单独跑的时候都挺稳,但我想让它们在同一张卡上共存,甚至能交替调用。
这个想法的来源很朴素:我在实际项目里经常需要两种不同风格的决策输出做对比。以前的做法是跑完一个、卸载、再加载另一个,来回切换一次至少等两三分钟。如果能让它们同时驻留显存,切换成本几乎为零。但 6GB 显存要装两个模型,听起来就像把两头大象塞进一辆小轿车。
先说结论:最后确实跑起来了,但中间翻车了不止一次。NaN 损失、显存溢出、LoRA 权重加载错位、推理输出乱码,能踩的坑基本踩了个遍。这篇文章就是把整个过程拆开讲,包括我为什么这么设计、每一步的判断依据、以及那些文档里不会写的教训。
如果你也是小显存玩家,或者正在做 LoRA 微调和多模型部署的实验,这篇内容应该能帮你省下几个晚上的时间。如果你只是好奇 6GB 到底能压榨到什么程度,也可以当个参考案例看。
2. 为什么选 LoRA 而不是全量微调:显存账本要先算清楚
2.1 全量微调的显存开销到底有多大
很多人一上来就想做全量微调,觉得效果最好。但全量微调的显存占用不是线性增长的,它跟模型参数量、优化器状态、梯度、激活值都有关系。粗略估算一下:一个 1B 参数的模型,全量微调时显存占用大概是模型本身的 4 到 6 倍。也就是说,1B 模型光权重就要 2GB(FP16),加上梯度 2GB、优化器状态(Adam 的话)4GB,再算上激活值,轻松突破 10GB。
6GB 显存做全量微调,基本上只能玩 0.5B 以下的模型,而且 batch size 只能设 1,序列长度还得砍。这显然不现实。
2.2 LoRA 的显存优势来自哪里
LoRA 的核心思路是不动原始权重,只在旁边挂两个低秩矩阵 A 和 B。训练时只更新这两个小矩阵,原始权重冻结。这样一来,梯度、优化器状态都只跟 LoRA 矩阵有关,参数量可能只有原模型的 0.1% 到 1%。
具体到显存账本:假设原始模型是 1B 参数,FP16 加载需要 2GB。LoRA 矩阵如果 rank 设 8,参数量大概几十万到几百万,显存占用可以忽略不计。优化器状态也只针对 LoRA 参数,额外开销很小。所以 6GB 显存跑 LoRA 微调,模型本身控制在 3B 以内是比较稳妥的。
但这里有个容易被忽略的点:推理时的显存占用和训练时不一样。训练时需要保存激活值用于反向传播,推理时不需要。所以如果你只是做推理部署,6GB 能装的模型比训练时大不少。
2.3 Kev 和 Laya 的模型规格与显存预算
Kev 和 Laya 的基础模型都是 1.5B 级别的,FP16 加载各占约 3GB。两个加起来 6GB,刚好卡在边界上。如果直接这么加载,系统本身还要占一部分显存,肯定溢出。
我的方案是:用 4-bit 量化加载基础模型,LoRA 权重用 FP16 单独加载。4-bit 量化后,每个模型的权重大概降到 0.8GB 左右,两个加起来 1.6GB。LoRA 权重很小,两个加起来不到 100MB。剩下的显存留给 KV Cache 和中间激活值,6GB 绰绰有余。
这个方案的关键在于:量化只作用于基础模型,LoRA 权重保持 FP16 精度。因为 LoRA 本身就是低秩的,参数量小,保持 FP16 不会带来明显的显存压力,但能保证微调效果的完整性。
注意:4-bit 量化加载需要 bitsandbytes 库的支持,而且不同版本的量化实现可能有差异。建议锁定版本,避免因为库更新导致加载失败。
3. 双模型共存的核心障碍:显存碎片与加载顺序
3.1 为什么两个模型不能简单叠加
理论上,两个 4-bit 量化的 1.5B 模型加起来不到 2GB,6GB 显存应该随便装。但实际跑起来,第一次加载就爆了。
原因在于显存碎片。深度学习框架在加载模型时,会向显存申请连续的大块空间。如果你先加载 Kev,它占了一块;再加载 Laya,它又占一块。但中间可能因为临时变量、缓存等原因,留下了一些不连续的小块。当 Laya 需要一块较大的连续空间时,虽然总剩余显存够,但没有一块足够大的连续区域,就会报 OOM。
这个问题在单模型加载时不太明显,因为模型加载完就稳定了。但双模型场景下,第二个模型加载时面对的是一个已经被分割过的显存空间,碎片问题会被放大。
3.2 加载顺序与显存整理策略
我试过几种加载顺序:
- 先 Kev 后 Laya:Laya 加载时 OOM 概率较高,因为 Kev 的加载过程留下了碎片。
- 先 Laya 后 Kev:情况类似,只是换了个受害者。
- 同时加载:框架会尝试并行分配,碎片更严重。
最后有效的做法是:在加载第二个模型之前,手动触发一次显存整理。具体操作是在 PyTorch 里调用torch.cuda.empty_cache(),然后强制做一次垃圾回收。这能释放掉加载第一个模型时产生的临时缓存,把碎片合并成较大的连续块。
另外,加载顺序也有讲究。我最后固定为:先加载参数量稍大的那个(Kev),再加载 Laya。因为大模型加载时申请的块更大,先满足它,小模型更容易找到合适的空隙。
3.3 实测有效的显存整理代码片段
import torch import gc def load_model_safely(model_path, quant_config): # 加载前先清理 gc.collect() torch.cuda.empty_cache() # 加载模型 model = load_quantized_model(model_path, quant_config) # 加载后再清理一次 gc.collect() torch.cuda.empty_cache() return model # 先加载 Kev kev = load_model_safely("kev_base", quant_config_4bit) # 关键:加载 Laya 前强制整理 gc.collect() torch.cuda.empty_cache() # 再加载 Laya laya = load_model_safely("laya_base", quant_config_4bit)这段代码看起来简单,但gc.collect()和torch.cuda.empty_cache()的调用时机很关键。我试过只在加载前调用,效果不好;加载前后都调用,并且中间加一次,才能稳定加载成功。
提示:
torch.cuda.empty_cache()不会释放正在使用的显存,它只释放缓存分配器持有的未使用显存。所以不用担心它会把你正在跑的模型干掉。
4. LoRA 权重加载的坑:NaN 损失是怎么来的
4.1 NaN 出现的典型场景
NaN 损失是 LoRA 训练和推理中最常见的问题之一。我遇到的情况是:Kev 和 Laya 的 LoRA 权重分别训练好后,单独加载推理都正常,但两个同时加载时,其中一个的输出开始出现 NaN。
排查过程很折磨人。先怀疑是数值精度问题,把 LoRA 权重从 FP16 换成 FP32,NaN 消失了,但显存占用上去了。再怀疑是模型间的干扰,把两个模型隔离开分别跑,又都正常。
最后定位到原因:两个 LoRA 权重的加载顺序和命名空间冲突。因为两个模型都是基于同一个基础架构微调的,LoRA 权重的键名(key)有重叠。当框架尝试把 LoRA 权重注入到基础模型时,如果命名空间没有正确隔离,就会出现权重覆盖或错位。
4.2 命名空间隔离的具体做法
解决方法是给每个模型的 LoRA 权重加上独立的前缀。比如 Kev 的 LoRA 键名统一加kev_前缀,Laya 的加laya_。加载时分别注入到对应的模型实例中。
# 加载 Kev 的 LoRA 权重 kev_lora_state = torch.load("kev_lora.bin") kev_lora_state = {f"kev_{k}": v for k, v in kev_lora_state.items()} kev.load_state_dict(kev_lora_state, strict=False) # 加载 Laya 的 LoRA 权重 laya_lora_state = torch.load("laya_lora.bin") laya_lora_state = {f"laya_{k}": v for k, v in laya_lora_state.items()} laya.load_state_dict(laya_lora_state, strict=False)这里strict=False很重要,因为 LoRA 权重只覆盖部分参数,严格加载会报缺失键错误。
4.3 数值稳定性的额外保障
除了命名空间隔离,还有几个细节能降低 NaN 风险:
- LoRA 初始化:训练时 LoRA 的 B 矩阵通常初始化为零,A 矩阵用高斯分布。如果 A 矩阵的方差过大,训练初期容易产生大梯度,导致 NaN。建议把 A 的初始化标准差控制在 0.02 以内。
- 梯度裁剪:训练时加梯度裁剪,把梯度范数限制在 1.0 以内。这个在 LoRA 微调里几乎是标配。
- 学习率:LoRA 的学习率通常比全量微调大,但也不能太大。我用的 1e-4 到 3e-4 之间比较稳,超过 5e-4 就容易飞。
注意:如果 NaN 出现在推理阶段而不是训练阶段,优先检查 LoRA 权重加载是否正确,而不是去调学习率。推理时的 NaN 基本都是权重错位或精度溢出导致的。
5. 两个模型的调度逻辑:怎么让它们不打架
5.1 共享显存下的推理调度
两个模型同时驻留显存后,推理时怎么调度是个问题。如果同时发起两个推理请求,KV Cache 会瞬间膨胀,6GB 显存可能扛不住。我的做法是串行调度:同一时间只允许一个模型做推理,另一个处于待命状态。
具体实现上,用一个简单的队列管理请求。Kev 和 Laya 各自维护一个请求队列,调度器轮询两个队列,按优先级或到达顺序处理。处理某个模型的请求时,另一个模型的 KV Cache 会被清空,释放显存给当前推理使用。
class DualModelScheduler: def __init__(self, kev, laya): self.kev = kev self.laya = laya self.current_model = None def switch_to(self, model_name): if self.current_model == model_name: return # 清空当前模型的 KV Cache if self.current_model == "kev": self.kev.clear_kv_cache() elif self.current_model == "laya": self.laya.clear_kv_cache() torch.cuda.empty_cache() self.current_model = model_name def infer(self, model_name, input_text): self.switch_to(model_name) if model_name == "kev": return self.kev.generate(input_text) else: return self.laya.generate(input_text)这个调度器的核心是switch_to方法,它在切换模型时清空上一个模型的 KV Cache。KV Cache 是推理时显存占用的大头,清空它能立刻释放大量显存。
5.2 KV Cache 的显存占用估算
KV Cache 的大小跟序列长度、层数、隐藏维度、batch size 都有关系。粗略公式是:
KV Cache 大小 = 2 * batch_size * seq_len * num_layers * hidden_dim * dtype_size以 1.5B 模型为例,假设 24 层,隐藏维度 2048,序列长度 512,batch size 1,FP16 精度:
2 * 1 * 512 * 24 * 2048 * 2 bytes ≈ 100MB两个模型各 100MB,加起来 200MB。看起来不多,但如果序列长度拉到 2048,就是 400MB 每个模型,两个 800MB。再加上模型权重和激活值,6GB 就很紧张了。
所以调度策略里,限制单次推理的序列长度是必要的。我把最大序列长度设成 1024,超过的部分截断或分段处理。
5.3 模型切换的延迟实测
串行调度的代价是切换延迟。实测下来,从 Kev 切换到 Laya,包括清空 KV Cache、整理显存、加载 Laya 的推理状态,大概需要 200 到 400 毫秒。这个延迟在交互式场景下可以接受,但如果要做高频交替推理,就会成为瓶颈。
优化方向有两个:一是减少切换频率,把同一模型的请求批量处理;二是用更激进的显存共享策略,比如让两个模型共享部分 KV Cache 空间。后者实现复杂度高,我还没深入尝试。
6. 训练侧的翻车记录:从 loss 曲线看问题
6.1 Kev 的训练:初期 loss 震荡
Kev 的 LoRA 训练用的是 5000 条结构化决策数据,rank 设 16,alpha 设 32。前 100 步 loss 从 2.3 降到 1.8,然后开始震荡,在 1.5 到 2.0 之间来回跳。
排查后发现是数据分布不均。5000 条数据里,有 3000 条是同一类型的决策场景,另外 2000 条分散在十几个小类别里。模型在多数类上快速收敛,但在少数类上反复调整,导致整体 loss 震荡。
解决办法是重采样:对少数类过采样,对多数类欠采样,让每个类别的样本数大致均衡。重采样后 loss 曲线平滑了很多,最终收敛到 1.2 左右。
6.2 Laya 的训练:中途 NaN
Laya 的训练更折腾。前 200 步一切正常,loss 从 2.5 降到 1.6。第 201 步突然 NaN,训练直接崩了。
回滚到第 200 步的 checkpoint,逐步排查:
- 检查学习率:用的是 2e-4,不算大。
- 检查梯度:发现第 200 步的梯度范数突然飙到 50 以上,远超正常值。
- 检查数据:第 200 步对应的那条数据里,有一个字段的长度是其他样本的 10 倍,导致序列长度暴增,激活值溢出。
定位到问题后,做了两件事:一是过滤超长样本,把序列长度超过 1024 的样本直接丢掉或截断;二是加梯度裁剪,把梯度范数限制在 1.0。之后训练再没出现过 NaN。
6.3 两个模型同时训练的显存冲突
我试过同时训练 Kev 和 Laya,想省时间。结果显存直接爆了。因为训练时每个模型都要保存激活值和梯度,显存占用是推理时的好几倍。两个模型同时训练,6GB 根本不够。
最后的方案是串行训练:先训 Kev,训完保存 LoRA 权重,释放显存;再训 Laya。虽然总时间长了,但至少能跑通。
提示:如果非要同时训练,可以考虑用梯度累积模拟大 batch,同时把序列长度压到 512 以内。但即使这样,6GB 也很勉强,不建议尝试。
7. 推理输出的质量对比:Kev 和 Laya 各自适合什么场景
7.1 Kev 的输出特征
Kev 的训练数据偏向逻辑推演和结构化输出,所以它的回答通常比较长,喜欢分点论述,逻辑链条完整。适合需要详细分析和步骤拆解的场景。
但 Kev 有个毛病:过度结构化。有时候一个简单的问题,它也要分三四点来回答,显得啰嗦。而且在时间敏感的场景下,它的推理速度偏慢,因为生成的 token 数多。
7.2 Laya 的输出特征
Laya 的训练数据偏向快速判断和短决策链,所以它的回答通常很短,直接给结论,很少展开。适合需要快速决策的场景,比如分类、排序、简单问答。
Laya 的问题是解释性不足。它给的结论往往没有推理过程,如果结论错了,很难追溯原因。所以在需要可解释性的场景下,Laya 不太合适。
7.3 双模型协同的使用模式
我最后摸索出的使用模式是:Laya 做初筛,Kev 做深挖。先用 Laya 快速过一遍所有请求,把明显不需要深入分析的过滤掉;剩下的复杂请求交给 Kev 做详细推演。这样既保证了速度,又保证了质量。
具体实现上,用一个简单的路由逻辑:
def route_request(input_text): # 先用 Laya 做快速判断 laya_output = laya.generate(input_text, max_length=64) # 如果 Laya 的输出置信度低或问题复杂,转给 Kev if is_complex(laya_output) or laya_output.confidence < 0.7: return kev.generate(input_text, max_length=512) else: return laya_output这个路由逻辑的关键是is_complex的判断标准。我用的规则是:如果 Laya 的输出里出现了"不确定""可能""需要更多信息"等关键词,或者输出长度超过阈值,就转给 Kev。
8. 一些实测数据与经验总结
8.1 显存占用实测
| 阶段 | Kev 显存 | Laya 显存 | 总计 |
|---|---|---|---|
| 4-bit 加载后 | 0.9GB | 0.85GB | 1.75GB |
| 加载 LoRA 后 | 0.95GB | 0.9GB | 1.85GB |
| 推理时(seq=512) | 1.2GB | 1.15GB | 2.35GB |
| 推理时(seq=1024) | 1.5GB | 1.45GB | 2.95GB |
| 训练时(batch=1, seq=512) | 3.2GB | 3.1GB | 6.3GB(溢出) |
从表里能看出来,推理时两个模型同时驻留完全没问题,6GB 还有富余。但训练时就不行了,必须串行。
8.2 训练超参记录
| 参数 | Kev | Laya |
|---|---|---|
| LoRA rank | 16 | 8 |
| LoRA alpha | 32 | 16 |
| 学习率 | 1e-4 | 2e-4 |
| Batch size | 4 | 4 |
| 梯度累积 | 2 | 2 |
| 最大序列长度 | 1024 | 1024 |
| 训练轮数 | 3 | 3 |
| 梯度裁剪 | 1.0 | 1.0 |
Kev 的 rank 设得高一些,因为它的任务更复杂,需要更多的低秩容量。Laya 的任务相对简单,rank 8 就够了。
8.3 几个容易忽略的细节
第一个细节:LoRA 权重的保存格式。我一开始用torch.save保存整个 state_dict,后来发现加载时经常出问题。改成只保存 LoRA 相关的键值对,加载时用strict=False注入,稳定了很多。
第二个细节:4-bit 量化的校准。bitsandbytes 的 4-bit 量化需要校准数据来确定量化范围。如果校准数据跟实际推理数据分布差异大,量化误差会很明显。我用的校准数据是从训练集里随机抽的 128 条,效果还可以。
第三个细节:KV Cache 的清空时机。在切换模型时,一定要清空上一个模型的 KV Cache。我试过不清空直接切换,结果显存慢慢泄漏,跑几十次推理后就 OOM 了。
第四个细节:随机种子的固定。双模型场景下,随机种子不固定会导致每次加载的 LoRA 权重初始化略有不同,影响推理结果的一致性。我在加载前固定了torch.manual_seed(42),输出稳定了很多。
9. 后续还能怎么折腾
这套方案跑通之后,我又试了几个扩展方向。一个是把 Kev 和 Laya 的 LoRA 权重合并成一个,看能不能用一个模型模拟两种风格。结果是:合并后效果介于两者之间,但失去了各自的特色,不太实用。
另一个方向是加第三个模型,做一个三模型的决策系统。但 6GB 显存实在塞不下三个 1.5B 模型了,除非把量化压到 2-bit,但那样质量损失太大。
还有一个想法是用更小的基础模型,比如 0.5B 级别的,这样能塞更多模型。但小模型的能力上限有限,复杂决策场景下表现明显不如 1.5B。
目前最满意的还是 Kev + Laya 的双模型方案,在 6GB 显存上跑得挺稳。如果你也在做类似的小显存多模型实验,建议先从推理侧入手,把加载和调度跑通,再考虑训练。训练侧的显存压力比推理大得多,串行训练是 6GB 显存下唯一可行的路径。