Transformers PEFT adapter 怎样只保存可训练权重
来源:17golang原创
时间:2026-11-01 02:57:40 422浏览 收藏
要把 Transformers 模型的 PEFT 微调结果保存成小文件,使用训练完成的 PeftModel 对象的 save_pretrained(),并按需要指定 selected_adapters。它按适配器配置保存权重和配置,通常不包含冻结的基础模型。不要直接保存整个 model.state_dict(),也不要把“只保存可训练权重”理解成对所有 requires_grad=True 参数做一次通用过滤。
我更愿意把这个任务叫作“交付一份可加载的适配器”:文件小只是一个指标,另一个指标是接收方知道要配哪份基础模型。下面给出单进程、未合并 LoRA 的保存写法;示例代码供读者在自己的训练工程中使用,本文未执行训练或体积测试。
官方地址:https://huggingface.co/docs/peft/
先算清楚要省的是哪一份权重
先建立一个可计算的基线。对输入、输出维度均为 768 的线性层,秩为 8 的普通 LoRA 增量矩阵包含 8 × (768 + 768) = 12,288 个参数。如果假设 12 层、每层只改 query 和 value 两个投影,总数是 12 × 2 × 12,288 = 294,912 个参数。按每个参数 4 字节计算,裸张量约 1.125 MiB。
另一个纯算术基线是假设完整模型有 1.1 亿个 FP32 参数,那么裸参数是 440,000,000 字节。二者的比值约 0.268%。这不是某个训练任务的实测压缩率:真实 checkpoint 还有张量头、配置、可能额外保存的模块,dtype 也可能不同。LoRA 的基本结构可参见 官方 LoRA 说明。
我的判断是,先统计适配器参数量,再记录真实文件字节数,才能解释文件为什么变小。小文件并不直接代表推理更快、训练更省多少显存,更不代表微调效果更好。
把可训练范围交给 LoRA 配置表达
下面选用 BERT 风格的 query、value 模块名,构造默认名称的适配器。base 是读者已经加载的兼容基础模型;模型结构不同,target_modules 也应使用对应的真实模块名。不要把这一组名字套到所有架构。
from peft import LoraConfig, TaskType, get_peft_model
def attach_lora(base):
# 只对 BERT 风格注意力投影添加 LoRA,不额外训练偏置或完整模块。
config = LoraConfig(
task_type=TaskType.FEATURE_EXTRACTION,
target_modules=["query", "value"],
r=8,
lora_alpha=16,
lora_dropout=0.05,
bias="none",
modules_to_save=None,
)
return get_peft_model(base, config)
# 将返回的 PeftModel 交给你自己的训练代码;此处没有执行训练。
这里选择 bias="none"、不增加 modules_to_save,是为了把讨论范围限制为普通 LoRA。若任务需要训练分类头,就应把分类头纳入相应保存配置;不能为了缩小文件而漏掉它。额外完整模块的语义见 Transformers 的 PEFT 集成说明。本文后续使用 PEFT 封装对象的接口,不混用 Transformers add_adapter() 直接集成方式的参数。

训练结束后从 PeftModel 导出适配器
将训练完成、未合并且已解除分布式包装的 PeftModel 传入下面函数。使用一个全新目录,能避免以前留下的完整模型文件混入本次交付。示例只处理默认名称 default;非默认名称可能对应输出子目录,应把实际包含 adapter_config.json 的目录交给加载接口。
from pathlib import Path
from peft import PeftModel
def export_adapter(trained_model: PeftModel, output_dir: str):
if not isinstance(trained_model, PeftModel):
raise TypeError("需要训练后的 PeftModel 对象")
folder = Path(output_dir)
# 全新目录避免混入旧的完整模型文件;存在时由调用方另选输出目录。
folder.mkdir(parents=True, exist_ok=False)
trained_model.save_pretrained(
str(folder),
selected_adapters=["default"],
safe_serialization=True,
save_embedding_layers="auto",
)
return folder
# 在你自己的训练完成后调用:
# adapter_dir = export_adapter(trained_model, "exports/task_adapter_v1")
PEFT 标准导出包含 adapter_model.safetensors 和 adapter_config.json,并可能生成模型卡 README.md。权重描述适配器,配置描述它的类型和加载所需信息;基础模型仍需另外可用。文件结构详见 PEFT checkpoint 格式。
selected_adapters 控制保存哪些适配器,未指定时会保存全部。save_embedding_layers="auto" 保留库的自动处理逻辑;如果明确改变过词表或训练过嵌入层,应结合自己的配置决定是否显式保存,不能只因文件变大就强制设为 False。这些选项属于 PeftModel 保存接口。
另一个容易误会的地方是:适配器即便当前被冻结用于推理,其保存规则仍围绕适配器状态,而非简单依据此刻的 requires_grad。反过来,手工解冻一个不在 PEFT 保存约定内的基础层,也不能假定这个层会自动进入适配器包。
用同一口径统计导出体积
如果要报告节省比例,我会把“参数字节数”和“目录字节数”分开。前者是理论存储量,后者包含配置等附带文件。下面代码只给读者记录指标,不代表本文已有测试输出,也不是发布前执行的校验。
from pathlib import Path
from peft import get_peft_model_state_dict
def collect_export_metrics(model, adapter_dir: str):
# 按 PEFT 规则提取默认适配器,不手工拼接或改写权重键名。
state = get_peft_model_state_dict(
model, adapter_name="default", save_embedding_layers="auto"
)
tensor_bytes = sum(t.numel() * t.element_size() for t in state.values())
parameter_count = sum(t.numel() for t in state.values())
# 递归计入配置、模型卡等文件;不把共享的基础模型缓存算入目录。
disk_bytes = sum(
f.stat().st_size for f in Path(adapter_dir).rglob("*") if f.is_file()
)
return {
"adapter_parameters": parameter_count,
"adapter_tensor_bytes": tensor_bytes,
"adapter_directory_bytes": disk_bytes,
}
# 在与导出一致的模型状态下收集;没有虚构固定的输出数字。
比较方案时,基础模型版本、LoRA 秩、目标层、dtype、额外模块和词表处理都应相同。若比较保存耗时,还应固定存储介质、缓存条件和计时范围,多次记录;本文不给未经测试的耗时结论。对分布式或量化模型,这段简单的张量统计也不能替代框架自己的权重收集与存储口径。
| 比较项 | 本文能给出的结论 | 需要读者记录的实际值 |
|---|---|---|
| 普通 LoRA 张量量 | 示例假设下 294,912 参数、FP32 约 1.125 MiB | 实际目标层与 dtype 下的字节数 |
| 交付目录大小 | 适配器不必重复包含冻结基础模型 | 新目录文件总字节数 |
| 保存耗时与推理速度 | 不能从参数比值直接推出 | 各自独立的测量结果 |
加载时补齐基础模型与分词器
小包的代价是依赖基础模型。我会把基础模型的准确版本、所用库版本和分词器约定一起交接。下面假设 base_snapshot 是读者已经准备好的、与训练时一致的本地 BERT 模型快照目录;tokenizer_assets 是对应分词器目录,未扩词时可来自同一快照,改变分词器后则应另外保存其资产。
from peft import PeftModel
from transformers import AutoModel, AutoTokenizer
def load_for_inference(base_snapshot, adapter_dir, tokenizer_assets):
# 本地基础模型必须与训练时一致,不能随意换成另一个同名版本。
base = AutoModel.from_pretrained(base_snapshot, local_files_only=True)
model = PeftModel.from_pretrained(
base, adapter_dir, is_trainable=False
)
tokenizer = AutoTokenizer.from_pretrained(
tokenizer_assets, local_files_only=True
)
model.eval() # 明确切换推理行为;计算时仍可使用 torch.inference_mode()。
return model, tokenizer
# 继续训练时改用 is_trainable=True,并自行恢复训练所需状态。
适配器加载成功不等于恢复了优化器、调度器、随机状态或数据进度。上面函数服务于推理组装,不能冒充一次完整的训练续跑。分词器也不属于普通 adapter 权重,目录小不应以遗漏它的改动为代价。

哪些情况不能追求最小文件
为什么适配器还是很大?先看是否有 modules_to_save、偏置训练、嵌入层或多个适配器。这些可能是任务所需的正确状态。不能删掉后再宣称保存优化成功。
只筛选 requires_grad 的参数可以吗?可以得到一个自定义张量字典,但它不是天然符合 PEFT 加载格式的交付物。配置、键名映射及额外模块都有语义,常规导出直接交给 PEFT。
能不能合并以后再存?merge_and_unload() 是另一种部署目标:适配器合入基础模型后保存通常面向完整模型。要保留独立适配器时,应在合并前导出,不把两种包的大小放在同一栏比较。
Adapter 包能不能代替训练断点?单独的 save_pretrained() 不保存全部训练运行状态。需要精确续训时,保留训练框架的 checkpoint;只做推理交付时,独立适配器更适合复用同一基础模型。
对我来说,这个方案适合“一个基础模型配多份任务增量”的交付方式。最终要追求的是文件范围明确、基础模型可定位、加载依赖齐全,而不是单看一个漂亮的体积百分比。
-
501 收藏
-
501 收藏
-
501 收藏
-
501 收藏
-
501 收藏
-
248 收藏
-
253 收藏
-
165 收藏
-
211 收藏
-
424 收藏
-
435 收藏
-
265 收藏
-
249 收藏
-
471 收藏
-
329 收藏
-
120 收藏
-
284 收藏
-
- 前端进阶之JavaScript设计模式
- 设计模式是开发人员在软件开发过程中面临一般问题时的解决方案,代表了最佳的实践。本课程的主打内容包括JS常见设计模式以及具体应用场景,打造一站式知识长龙服务,适合有JS基础的同学学习。
- 立即学习 543次学习
-
- GO语言核心编程课程
- 本课程采用真实案例,全面具体可落地,从理论到实践,一步一步将GO核心编程技术、编程思想、底层实现融会贯通,使学习者贴近时代脉搏,做IT互联网时代的弄潮儿。
- 立即学习 516次学习
-
- 简单聊聊mysql8与网络通信
- 如有问题加微信:Le-studyg;在课程中,我们将首先介绍MySQL8的新特性,包括性能优化、安全增强、新数据类型等,帮助学生快速熟悉MySQL8的最新功能。接着,我们将深入解析MySQL的网络通信机制,包括协议、连接管理、数据传输等,让
- 立即学习 500次学习
-
- JavaScript正则表达式基础与实战
- 在任何一门编程语言中,正则表达式,都是一项重要的知识,它提供了高效的字符串匹配与捕获机制,可以极大的简化程序设计。
- 立即学习 487次学习
-
- 从零制作响应式网站—Grid布局
- 本系列教程将展示从零制作一个假想的网络科技公司官网,分为导航,轮播,关于我们,成功案例,服务流程,团队介绍,数据部分,公司动态,底部信息等内容区块。网站整体采用CSSGrid布局,支持响应式,有流畅过渡和展现动画。
- 立即学习 485次学习