登录
推荐 文章 Go 技术 课程 下载 专题 AI
首页 >  科技周边 >  人工智能

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() 直接集成方式的参数。

LoRA 配置、冻结基础模型和适配器导出之间的静态关系
图1:保存范围结构图。模型组成区包括冻结基础模型与 LoRA 增量;保存约定区包括适配器配置及其权重、配置文件。连线表示组成与保存关联,不表示执行顺序,也不是训练截图。

训练结束后从 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 权重,目录小不应以遗漏它的改动为代价。

基础模型快照、适配器文件、分词器和加载对象的依赖结构
图2:加载依赖结构图。共享资产区的基础模型快照、分词器资产与适配器交付区的权重和配置共同关联模型组装区。图示强调依赖要齐全,不代表执行过程或实际测试结果。

哪些情况不能追求最小文件

为什么适配器还是很大?先看是否有 modules_to_save、偏置训练、嵌入层或多个适配器。这些可能是任务所需的正确状态。不能删掉后再宣称保存优化成功。

只筛选 requires_grad 的参数可以吗?可以得到一个自定义张量字典,但它不是天然符合 PEFT 加载格式的交付物。配置、键名映射及额外模块都有语义,常规导出直接交给 PEFT。

能不能合并以后再存?merge_and_unload() 是另一种部署目标:适配器合入基础模型后保存通常面向完整模型。要保留独立适配器时,应在合并前导出,不把两种包的大小放在同一栏比较。

Adapter 包能不能代替训练断点?单独的 save_pretrained() 不保存全部训练运行状态。需要精确续训时,保留训练框架的 checkpoint;只做推理交付时,独立适配器更适合复用同一基础模型。

对我来说,这个方案适合“一个基础模型配多份任务增量”的交付方式。最终要追求的是文件范围明确、基础模型可定位、加载依赖齐全,而不是单看一个漂亮的体积百分比。

声明:本文转载于:17golang原创 如有侵犯,请联系study_golang@163.com删除
相关阅读
更多>
最新阅读
更多>
课程推荐
更多>