1. 6GB 显存跑双模型这个念头是怎么冒出来的先把背景交代清楚。我手头只有一张 6GB 显存的显卡具体型号不重要反正就是那种跑个 7B 模型做推理都得掐着显存过日子的水平。之前一直在用 LoRA 做微调实验踩过的坑不算少但这次想玩点不一样的——把两个决策模型同时装进去一个叫 Kev一个叫 Laya。为什么叫决策模型因为它们不是那种通用的对话模型而是针对特定决策场景做了定向微调的版本。Kev 偏向逻辑推演和结构化输出Laya 偏向快速判断和短决策链。两个模型各有各的脾气单独跑的时候都挺稳但我想让它们在同一张卡上共存甚至能交替调用。这个想法的来源很朴素我在实际项目里经常需要两种不同风格的决策输出做对比。以前的做法是跑完一个、卸载、再加载另一个来回切换一次至少等两三分钟。如果能让它们同时驻留显存切换成本几乎为零。但 6GB 显存要装两个模型听起来就像把两头大象塞进一辆小轿车。先说结论最后确实跑起来了但中间翻车了不止一次。NaN 损失、显存溢出、LoRA 权重加载错位、推理输出乱码能踩的坑基本踩了个遍。这篇文章就是把整个过程拆开讲包括我为什么这么设计、每一步的判断依据、以及那些文档里不会写的教训。如果你也是小显存玩家或者正在做 LoRA 微调和多模型部署的实验这篇内容应该能帮你省下几个晚上的时间。如果你只是好奇 6GB 到底能压榨到什么程度也可以当个参考案例看。2. 为什么选 LoRA 而不是全量微调显存账本要先算清楚2.1 全量微调的显存开销到底有多大很多人一上来就想做全量微调觉得效果最好。但全量微调的显存占用不是线性增长的它跟模型参数量、优化器状态、梯度、激活值都有关系。粗略估算一下一个 1B 参数的模型全量微调时显存占用大概是模型本身的 4 到 6 倍。也就是说1B 模型光权重就要 2GBFP16加上梯度 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 模型加起来不到 2GB6GB 显存应该随便装。但实际跑起来第一次加载就爆了。原因在于显存碎片。深度学习框架在加载模型时会向显存申请连续的大块空间。如果你先加载 Kev它占了一块再加载 Laya它又占一块。但中间可能因为临时变量、缓存等原因留下了一些不连续的小块。当 Laya 需要一块较大的连续空间时虽然总剩余显存够但没有一块足够大的连续区域就会报 OOM。这个问题在单模型加载时不太明显因为模型加载完就稳定了。但双模型场景下第二个模型加载时面对的是一个已经被分割过的显存空间碎片问题会被放大。3.2 加载顺序与显存整理策略我试过几种加载顺序先 Kev 后 LayaLaya 加载时 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 换成 FP32NaN 消失了但显存占用上去了。再怀疑是模型间的干扰把两个模型隔离开分别跑又都正常。最后定位到原因两个 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 {fkev_{k}: v for k, v in kev_lora_state.items()} kev.load_state_dict(kev_lora_state, strictFalse) # 加载 Laya 的 LoRA 权重 laya_lora_state torch.load(laya_lora.bin) laya_lora_state {flaya_{k}: v for k, v in laya_lora_state.items()} laya.load_state_dict(laya_lora_state, strictFalse)这里strictFalse很重要因为 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序列长度 512batch size 1FP16 精度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 设 16alpha 设 32。前 100 步 loss 从 2.3 降到 1.8然后开始震荡在 1.5 到 2.0 之间来回跳。排查后发现是数据分布不均。5000 条数据里有 3000 条是同一类型的决策场景另外 2000 条分散在十几个小类别里。模型在多数类上快速收敛但在少数类上反复调整导致整体 loss 震荡。解决办法是重采样对少数类过采样对多数类欠采样让每个类别的样本数大致均衡。重采样后 loss 曲线平滑了很多最终收敛到 1.2 左右。6.2 Laya 的训练中途 NaNLaya 的训练更折腾。前 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_length64) # 如果 Laya 的输出置信度低或问题复杂转给 Kev if is_complex(laya_output) or laya_output.confidence 0.7: return kev.generate(input_text, max_length512) else: return laya_output这个路由逻辑的关键是is_complex的判断标准。我用的规则是如果 Laya 的输出里出现了不确定可能需要更多信息等关键词或者输出长度超过阈值就转给 Kev。8. 一些实测数据与经验总结8.1 显存占用实测阶段Kev 显存Laya 显存总计4-bit 加载后0.9GB0.85GB1.75GB加载 LoRA 后0.95GB0.9GB1.85GB推理时seq5121.2GB1.15GB2.35GB推理时seq10241.5GB1.45GB2.95GB训练时batch1, seq5123.2GB3.1GB6.3GB溢出从表里能看出来推理时两个模型同时驻留完全没问题6GB 还有富余。但训练时就不行了必须串行。8.2 训练超参记录参数KevLayaLoRA rank168LoRA alpha3216学习率1e-42e-4Batch size44梯度累积22最大序列长度10241024训练轮数33梯度裁剪1.01.0Kev 的 rank 设得高一些因为它的任务更复杂需要更多的低秩容量。Laya 的任务相对简单rank 8 就够了。8.3 几个容易忽略的细节第一个细节LoRA 权重的保存格式。我一开始用torch.save保存整个 state_dict后来发现加载时经常出问题。改成只保存 LoRA 相关的键值对加载时用strictFalse注入稳定了很多。第二个细节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 显存下唯一可行的路径。