【Bug已解决】Detail: removal of `.peft_config` when performing `.unload()`? 解决方案
【Bug已解决】Detail: removal of.peft_configwhen performing.unload()? 解决方案
一、现象长什么样
PEFT 的PeftModel.unload()用来“卸载所有 adapter,把模型还原回纯基座”。但在实际使用中有个让人困惑的细节:
model = get_peft_model(base, cfg) model.unload() # 期望还原成 base之后会出现这些情况:
unload()之后,对象上仍然挂着.peft_config属性(一个存了LoraConfig的字典),但模型结构已经不是 PeftModel 了——这个残留的peft_config让后续判断“这到底是不是已卸载”变得含糊;- 卸载后你想再次
add_adapter/get_peft_model重新挂 adapter,因为.peft_config没被清干净,状态机处于“既不是纯 base、又不是正常 PeftModel”的中间态,导致KeyError或“重复注入”; - 检查
isinstance(model, PeftModel)为 False(结构还原了),但hasattr(model, "peft_config")仍为 True(属性残留),两份真相互相矛盾; merge_and_unload()之后再unload(),残留状态更乱,有时peft_config里还留着已被合并掉的 adapter 名;- 序列化/复制这个“半卸载”的对象时,残留的
peft_config被一起 pickle,加载回来结构又错。
一句话:unload()在“还原模型结构”和“清理内部状态(尤其是.peft_config)”这两件事上没有完全对齐,留下了一个语义模糊的中间态。
二、背景
要理解这个细节,得看 PEFT 的PeftModel内部状态:
self.peft_config:一个dict,键是adapter_name(如"default"),值是对应PeftConfig。它记录了“当前挂了哪些 adapter、各自什么配置”。self.base_model:被包装的基座(里面各lora.Linear替换了原nn.Linear)。unload()做的事:遍历所有被替换的模块,把lora.Linear还原回原始nn.Linear(取出base_layer),然后通常把self就地改回 base_model 的结构。
问题在于:unload()还原结构时,不一定同步清掉self.peft_config。于是出现“结构已还原、但peft_config字典还在”的残留。这个残留本身是 PEFT 内部状态,对外表现为“我到底是卸载了还是没卸载”。
更微妙的是:有些版本unload()返回的是 base model 本身(不再有 PeftModel 方法和属性),有些版本是就地修改self使其表现得像 base。行为不一致让.peft_config的存废也不一致。
下面用可运行代码演示这个“结构还原但peft_config残留”的细节,并给出干净的清理方式。
三、根因
根因一句话:unload()还原了模型结构(lora.Linear → 原 Linear),但没有同步、显式地清理/归一化内部状态peft_config,留下“结构已还原但状态未还原”的语义模糊中间态。
展开:
- 结构还原 ≠ 状态还原:
unload()重挂base_layer,但peft_config字典可能仍持有着已不存在的 adapter 配置。 - 行为不一致:不同 PEFT 版本
unload()是“返回新 base”还是“就地改 self”不同,导致peft_config残留与否也不确定。 - 后续操作踩坑:基于残留
peft_config的判断(是否已挂 adapter、能否再 add)会得出错误结论。
修复方向:在unload()之后,显式把peft_config归一化(清空或保留为只含 base 的空状态),并统一“卸载后对象身份”的约定,避免中间态。
四、最小可运行复现
下面用一个自包含的迷你 PeftModel 仿制,把“结构还原但peft_config残留”的细节演示清楚(不依赖 PEFT 内部版本差异,聚焦状态机逻辑)。
import torch import torch.nn as nn class MiniPeftModel(nn.Module): """简化版:结构还原后 peft_config 是否残留,复现该细节。""" def __init__(self, base, cfg): super().__init__() self.base_model = base self.peft_config = {"default": cfg} # 记录已挂 adapter def unload(self, clear_config=True): # 这里 base 已是原结构(真实 PEFT 会先把 lora.Linear 还原) if clear_config: self.peft_config = {} # 显式清空 -> 干净 # 真实 PEFT 在此把 self 还原为 base_model;我们用返回值模拟 return self.base_model def is_loaded(self): return len(self.peft_config) > 0 torch.manual_seed(0) base = nn.Linear(16, 16) cfg = {"r": 4, "peft_type": "LORA"} # 情况 A:unload 不清 peft_config -> 残留(对应部分版本行为) m = MiniPeftModel(base, cfg) restored = m.unload(clear_config=False) print("卸载后 peft_config 残留:", m.peft_config) # {'default': {...}} 仍在 print("is_loaded 误判为已加载:", m.is_loaded()) # True(错误!) # 情况 B:unload 显式清 peft_config -> 干净 m2 = MiniPeftModel(base, cfg) m2.unload(clear_config=True) print("卸载后 peft_config 清空:", m2.peft_config) # {} print("is_loaded 正确为 False:", m2.is_loaded()) # False运行后:情况 A 显示peft_config残留并导致“误判为已加载”;情况 B 显示清空后状态一致。这正是该 issue 想澄清的细节——unload应当把peft_config一并清理。
五、解决方案(第一层:最小直接修复)
修复 1:unload 后显式清空 peft_config
如clear_config=True分支:self.peft_config = {},让“结构还原”与“状态还原”同步。
修复 2:用is_loaded()这类统一判断,不依赖 hasattr
不要写if hasattr(model, "peft_config"),而是提供明确的状态查询:
def is_adapter_active(model): pc = getattr(model, "peft_config", None) return bool(pc) # 空字典 = 未挂修复 3:unload 后若要复用,先确认干净
model.unload() assert not is_adapter_active(model), "unload 后仍有残留 adapter 状态" # 现在可以安全地重新 add_adapter六、解决方案(第二层:结构性改进)
改进 1:封装一个“确定性 unload”
def safe_unload(peft_model): """统一行为:卸载并清空所有 adapter 状态。""" restored = peft_model.unload() # 兜底:确保 peft_config 被清 if hasattr(restored, "peft_config"): restored.peft_config = {} return restored改进 2:重载后再 add_adapter 前重置状态机
def reset_for_reload(model): if hasattr(model, "peft_config"): model.peft_config.clear() if hasattr(model, "active_adapter"): model.active_adapter = None return model # 用法 model = safe_unload(model) reset_for_reload(model) model.add_adapter("fresh", new_cfg) # 干净起步改进 3:统一“卸载后对象身份”约定
项目里约定:unload()之后一律通过返回值拿 base,不再用原引用;并对返回值做“无 peft_config”断言:
base = model.unload() assert not hasattr(base, "peft_config"), "base 不应残留 peft_config"七、解决方案(第三层:断言 / CI 守护)
import torch import torch.nn as nn import pytest class MiniPeftModel(nn.Module): def __init__(self, base, cfg): super().__init__() self.base_model = base self.peft_config = {"default": cfg} def unload(self, clear_config=True): if clear_config: self.peft_config = {} return self.base_model def is_loaded(self): return len(self.peft_config) > 0 def test_unload_clears_config(): base = nn.Linear(16, 16) m = MiniPeftModel(base, {"r": 4}) m.unload(clear_config=True) assert m.peft_config == {} assert m.is_loaded() is False def test_unload_without_clear_leaves_residue(): base = nn.Linear(16, 16) m = MiniPeftModel(base, {"r": 4}) m.unload(clear_config=False) assert m.is_loaded() is True # 确认残留确实存在(复现 bug 行为) def test_reload_after_clean_unload(): base = nn.Linear(16, 16) m = MiniPeftModel(base, {"r": 4}) m.unload(clear_config=True) # 模拟重新 add m.peft_config["fresh"] = {"r": 8} assert m.is_loaded() is True def test_returned_base_has_no_peft_config(): base = nn.Linear(16, 16) m = MiniPeftModel(base, {"r": 4}) restored = m.unload(clear_config=True) # 真实场景里 restored 是 base;这里 base 本就没有 peft_config assert not hasattr(base, "peft_config")这四个测试守护“unload 清空 config、不留清则残留、清后可重载、返回的 base 无残留”。
八、排查清单
遇到unload()后状态混乱时按序查:
- 检查
peft_config是否残留:hasattr(model, "peft_config")且len > 0即说明没清干净。 - 统一用
is_loaded()判断:别用hasattr猜,用“peft_config 是否为空”作唯一真相。 - unload 后显式清空:
model.peft_config = {}兜底。 - 用返回值拿 base:
base = model.unload(),原引用不再信任。 - 重载前 reset 状态机:
peft_config.clear()+active_adapter=None。 - reload 后确认干净:
assert not is_adapter_active(model)。 - 版本差异:不同 PEFT 版本
unload()行为不同,封装safe_unload屏蔽差异。 - 序列化前清理:pickle 前确保无残留
peft_config,避免加载回来结构错。
九、小结
removal of .peft_config when performing .unload()的细节是:unload()还原了模型结构(lora.Linear → 原 Linear),却没有同步、显式地清理内部状态peft_config,留下“结构已还原但状态未还原”的语义模糊中间态,导致“究竟卸载没卸载”的判断自相矛盾、重载时踩坑。**
最小修复是**unload()后显式清空peft_config,并用统一的is_loaded()(看 peft_config 是否为空)代替hasattr判断**;结构性改进是封装safe_unload、重载前reset_for_reload清状态机、统一“卸载后通过返回值拿 base”的约定;最后用测试守护“unload 清空 config、不留清则残留、清后可重载、返回 base 无残留”。把结构与状态同步清理,unload 的语义就干净了。
