2026/10/27 4:07:51

safetensors、ckpt、LoRA权重格式详解:加载、转换与避坑指南

safetensors、ckpt、LoRA权重格式详解:加载、转换与避坑指南 做模型部署和微调这些年我踩过最多的坑不在训练脚本里而在权重文件上。你可能也遇到过这种场景从某个模型仓库下载了一堆文件文件夹里躺着.safetensors、.ckpt、.bin还有一个叫adapter_model.bin的小文件加载脚本一跑就报 KeyError 或者 shape mismatch折腾半天才发现是格式没对上、键名没对齐、或者 LoRA 权重被当成了完整模型去加载。这类问题不涉及什么高深算法但足够让人卡上一整天。这篇内容就围绕safetensors、ckpt、LoRA这三种最常见的预训练权重格式展开讲清楚它们各自是什么、内部结构长什么样、为什么会有这些格式、在什么场景下该用哪个、以及实际加载和转换时有哪些坑。不管你是刚接触模型权重的新手还是已经在做微调和部署的老手都能从中找到可以直接抄作业的操作步骤和排查思路。我会尽量用生活化的类比把格式差异讲透同时给出可复现的代码片段和参数说明让你看完就能上手验证。1. 三种权重格式的定位与设计逻辑拆解1.1 先搞清楚它们解决的不是同一个问题很多人把 safetensors、ckpt、LoRA 并列成三种格式这个说法其实不够准确。严格来讲safetensors 是一种序列化容器格式ckpt 是一种训练检查点惯例而 LoRA 是一种参数高效微调方法它的产物是一种增量权重。三者处在不同层面但因为经常在同一个项目文件夹里同时出现所以被大家习惯性地放在一起比较。打个比方safetensors 像是标准化的密封罐ckpt 像是整个厨房的现场快照LoRA 像是只记录改动了哪几味调料的便签。密封罐解决的是怎么安全地装快照解决的是怎么完整还原现场便签解决的是怎么用最小代价复现改动。理解这个层次差异后面所有的选型和排查都会顺很多。从实际使用角度看你需要回答三个问题第一这个文件是完整权重还是增量权重第二它的序列化方式是否安全、是否支持懒加载第三它是否携带了优化器状态、学习率调度器等训练元信息这三个问题的答案组合起来就决定了你该用什么方式加载它。1.2 为什么 safetensors 会成为默认选择早期大家用得最多的是 PyTorch 的torch.save产物也就是.bin或.pt文件。它本质上是 Python 的 pickle 序列化灵活但有个致命问题反序列化时可以执行任意代码。你从网上下载一个权重文件加载的那一刻就可能中招。这不是危言耸听pickle 的设计本身就允许在__reduce__里塞入可执行逻辑。safetensors 的出现就是为了堵这个口子。它的文件结构非常朴素开头是一段 JSON 头部描述每个张量的名称、数据类型、形状和字节偏移后面是连续的原始字节数据。加载时只做内存映射和字节读取不执行任何代码因此天然免疫 pickle 类攻击。同时因为头部记录了偏移量它支持按需加载单个张量不用把整个文件读进内存这对大模型部署非常友好。我实测过一个 7B 级别的模型用 safetensors 做分片加载时峰值内存比一次性torch.load低了将近一半启动速度也快了不少。原因就在于内存映射让操作系统按页调度真正用到的张量才被换入。这个特性在显存和内存都紧张的环境里价值很大。1.3 ckpt 到底特殊在哪ckpt 是 checkpoint 的缩写它本身不是一种文件格式规范而是一种训练状态的完整快照。一个典型的 ckpt 里通常包含模型参数state_dict、优化器状态optimizer、学习率调度器状态、当前的 epoch 和 step、随机数种子状态有时还有 AMP 的 scaler 状态。它的目的是断点续训——中断之后能原样恢复连优化器的动量都不丢。这就决定了 ckpt 通常比纯权重文件大得多。一个模型参数如果是 7B纯权重约 14GBfp16而带优化器状态的 ckpt 可能膨胀到 40GB 以上因为 Adam 类优化器要为每个参数维护一阶和二阶动量。所以 ckpt 适合训练阶段用不适合直接拿去做推理部署。我见过不少新手把训练产出的 ckpt 直接丢给推理服务结果加载报一堆 unexpected keys其实就是没做权重提取。ckpt 的序列化方式在不同框架里不一样。PyTorch Lightning 默认用 pickle 存成.ckpt里面是一个大字典有些框架会用 safetensors 作为底层容器再包一层元信息。所以看到.ckpt后缀第一反应应该是这是个训练快照我得先看看里面装了什么而不是这是个能直接加载的模型。1.4 LoRA 权重的本质是低秩增量LoRALow-Rank Adaptation的思路很巧妙冻结原模型的所有参数只在特定的线性层旁边挂两个小矩阵 A 和 B用它们的乘积去近似权重更新量。假设原权重是W形状 d×kLoRA 不去改W而是学习ΔW B A其中 A 是 r×kB 是 d×r秩 r 通常取 4 到 64。这样可训练参数从 d×k 降到 r×(dk)在 r 远小于 d、k 时能省下几个数量级。所以 LoRA 产出的权重文件里只有 A 和 B 这两个小矩阵外加可能的缩放系数。文件体积通常只有几 MB 到几十 MB跟原模型动辄十几 GB 完全不是一个量级。这也解释了为什么加载 LoRA 时必须先加载基础模型——增量权重本身不含完整信息脱离底座毫无意义。实际项目里LoRA 权重常见的命名是adapter_model.safetensors或adapter_model.bin配合一个adapter_config.json描述秩、目标模块、缩放系数等超参。这个配置文件千万别丢没有它你连 A、B 该往哪层挂都不知道。2. 文件内部结构与关键字段解析2.1 safetensors 的头部布局与读取方式safetensors 文件的前 8 个字节是一个小端序的 uint64表示头部 JSON 的长度 N紧接着 N 个字节是 JSON 文本再往后就是张量数据区。JSON 里每个键对应一个张量名值包含dtype、shape和data_offsets相对数据区起点的起止字节。这种设计让解析器可以先读头部、再按需定位数据不需要顺序扫描。用 Python 直接窥探头部很简单import json import struct def peek_safetensors_header(path): with open(path, rb) as f: length_bytes f.read(8) header_len struct.unpack(Q, length_bytes)[0] header json.loads(f.read(header_len)) # 去掉 __metadata__ 后打印前几个张量 tensors {k: v for k, v in header.items() if k ! __metadata__} for i, (name, info) in enumerate(tensors.items()): if i 5: break print(name, info[dtype], info[shape]) print(total tensors:, len(tensors)) return header peek_safetensors_header(model.safetensors)这段代码不加载任何张量数据只读头部几毫秒就能跑完。排查文件里到底有哪些键时这招比load_file再遍历快得多也不会吃内存。__metadata__字段里可能存有格式版本、训练框架等信息值得顺手看一眼。2.2 ckpt 里的字典结构怎么拆PyTorch 系的 ckpt 加载后通常是一个嵌套字典。最常见的结构是{state_dict: {...}, optimizer: {...}, epoch: ..., global_step: ...}但不同训练框架会有差异有的把模型参数直接放在顶层有的用model、optimizer_states等键名。所以第一步永远是先加载再打印键别假设结构。import torch ckpt torch.load(train.ckpt, map_locationcpu) print(type(ckpt)) if isinstance(ckpt, dict): for k in ckpt.keys(): v ckpt[k] print(k, type(v), (len(v) if hasattr(v, __len__) else ))这里有个重要细节map_locationcpu必须加。默认行为是按保存时的设备恢复如果 ckpt 是在多卡环境存的直接加载可能尝试分配 GPU 显存甚至报设备不匹配。先落到 CPU 再按需搬运是最稳的做法。另外如果 ckpt 是 pickle 格式且来源不明加载本身就有风险建议在隔离环境里先做结构检查。提取纯权重的典型写法是从state_dict里拿并处理常见的module.前缀多卡训练 DDP 会加这个前缀state ckpt[state_dict] if state_dict in ckpt else ckpt cleaned {} for k, v in state.items(): new_key k[len(module.):] if k.startswith(module.) else k cleaned[new_key] v torch.save(cleaned, weights_only.bin)2.3 LoRA 权重的键名规律与配置依赖LoRA 权重的键名通常长这样base_model.model.layers.0.self_attn.q_proj.lora_A.weight和对应的lora_B.weight。前缀base_model.model.是 PEFT 库包装后的产物lora_A、lora_B标识两个低秩矩阵最后的.weight是矩阵本体。不同库PEFT、diffusers 内置 LoRA、某些自研实现的命名会有出入所以加载前先打印键名是铁律。adapter_config.json里的关键字段包括r秩、lora_alpha缩放分子、target_modules挂载的层名列表、lora_dropout、bias处理方式。实际生效的缩放系数是lora_alpha / r这个比值决定了增量对输出的影响强度。有人只拷贝了权重文件却丢了配置结果只能靠猜秩和目标层非常被动。提示LoRA 权重和基础模型必须严格配套。基础模型换了版本、换了精度fp16 转 fp32、甚至换了分词器对应的 embedding都可能导致 LoRA 效果异常。加载前确认基础模型的身份标识一致是最省事的做法。2.4 三种格式的关键属性对照属性safetensorsckptLoRA 权重本质序列化容器格式训练状态快照惯例增量权重方法产物是否含完整参数通常是通常是否仅低秩增量是否含优化器状态否通常含否反序列化安全性高不执行代码取决于底层pickle 有风险取决于底层容器支持按张量懒加载是一般否视容器而定典型体积与参数量成正比远大于参数量几 MB 到几十 MB主要用途推理部署、权重分发断点续训微调后增量应用这张表建议存下来遇到任何权重文件先对号入座能省掉大量试错。3. 实操加载、转换与合并的完整流程3.1 环境准备与依赖确认先把基础依赖装齐。safetensors 官方库负责读写transformers 和 peft 负责模型与 LoRA 的加载合并torch 是底座。版本上要注意peft 较新版本对 LoRA 合并的支持更完善transformers 与 peft 的版本要匹配否则会出现merge_and_unload找不到方法之类的报错。pip install torch safetensors transformers peft装完之后先做一次版本自检把版本号打印出来记录到项目文档里。我吃过版本漂移的亏同一份脚本在本地能跑换台机器因为 peft 版本低了两个小版本LoRA 加载直接失败。把版本钉死写进依赖文件是团队协作的基本功。import torch, safetensors, transformers, peft print(torch, torch.__version__) print(safetensors, safetensors.__version__) print(transformers, transformers.__version__) print(peft, peft.__version__)3.2 safetensors 的读写与分片处理单文件读写很直接。保存时注意张量必须连续且在同一设备上否则会报错读取时用load_file一次性拿全部或用safe_open做惰性遍历。from safetensors.torch import save_file, load_file from safetensors import safe_open # 保存 save_file({w: torch.randn(4, 4)}, demo.safetensors) # 全量读取 tensors load_file(demo.safetensors) # 惰性读取只取需要的张量 with safe_open(demo.safetensors, frameworkpt, devicecpu) as f: keys list(f.keys()) w f.get_tensor(keys[0])大模型常见的是分片存储文件名形如model-00001-of-00004.safetensors配套一个model.safetensors.index.json记录每个张量在哪个分片。加载时不要手动拼直接用 transformers 的from_pretrained指向目录即可它会读索引自动路由。如果你要自己实现加载器就解析索引 JSON 的weight_map按张量名去对应分片里取。保存分片时有个容易忽略的点每个分片单独调用save_file不要试图把不同分片的张量混在一个文件里再切。分片边界应该按张量整体划分不能把一个张量拆到两个文件否则加载器无法还原。3.3 从 ckpt 提取可用权重的完整步骤假设拿到一个训练产出的 ckpt目标是得到能直接推理的权重。步骤是加载到 CPU、定位参数字典、清理键名前缀、剔除优化器相关键、按目标格式保存。import torch from safetensors.torch import save_file ckpt torch.load(train.ckpt, map_locationcpu) state ckpt.get(state_dict, ckpt) cleaned {} for k, v in state.items(): if not torch.is_tensor(v): continue if k.startswith(module.): k k[len(module.):] # 剔除优化器/调度器残留键如果混在 state_dict 里 if k.startswith((optimizer., lr_scheduler.)): continue cleaned[k] v.contiguous() save_file(cleaned, extracted.safetensors) print(saved tensors:, len(cleaned))这里contiguous()不能省。有些张量是转置视图或切片视图底层内存不连续safetensors 保存时会直接报错。加上它虽然可能触发一次拷贝但能保证保存成功。另外如果 ckpt 里参数是 fp32 而你想部署 fp16转换要在保存前做v.half()注意 embedding 和 norm 层是否也需要降精度有些模型对这两类层敏感混精度保存后效果会掉。3.4 LoRA 加载、验证与合并进基础模型加载 LoRA 的标准路径是用 peft 的PeftModel包一层基础模型。先加载基础模型再挂 adapter然后可以验证输出是否正常最后决定是否合并。from transformers import AutoModelForCausalLM from peft import PeftModel base AutoModelForCausalLM.from_pretrained(./base_model, torch_dtypetorch.float16) model PeftModel.from_pretrained(base, ./lora_adapter) model.eval() # 验证跑一次前向确认没有 shape 报错 # 合并把增量写回基础权重得到独立模型 merged model.merge_and_unload() merged.save_pretrained(./merged_model, safe_serializationTrue)merge_and_unload的数学本质是把W (alpha/r) * B A算出来写回W然后丢掉 A、B。合并后模型体积回到基础模型量级推理时没有额外开销适合部署。但要注意合并是不可逆的如果之后还想调 LoRA 或换 adapter必须保留原始基础模型和 adapter 文件。我的习惯是合并产物单独放一个目录原始文件只读不改。还有一个细节合并时精度会损失。如果基础模型是 fp16BA 的计算和累加也在 fp16 下进行低秩增量本身数值较小容易被舍入吃掉。对精度敏感的场景可以先把基础模型转 fp32 合并再转回目标精度代价是内存翻倍。3.5 一次典型的格式转换现场记录我最近处理过一个混合场景基础模型是 safetensors 分片微调产出是 ckpt另外还有一个第三方给的 LoRA。流程是先把 ckpt 提取成 safetensors 并核对键名与基础模型一致再加载基础模型挂 LoRA 验证效果最后合并导出。核对键名这一步用集合差集最快from safetensors import safe_open def keys_of(path): with safe_open(path, frameworkpt) as f: return set(f.keys()) base_keys keys_of(base_model/model-00001-of-00002.safetensors) # 分片要合并所有分片的键这里示意单分片 ckpt_keys set(cleaned.keys()) print(missing in ckpt:, list(base_keys - ckpt_keys)[:10]) print(extra in ckpt:, list(ckpt_keys - base_keys)[:10])差集为空或只有少量预期差异比如某些 buffer才说明权重对得上。这一步花两分钟能避免后面加载时一堆 unexpected keys 的困惑。4. 常见问题排查与避坑经验实录4.1 加载报错的典型症状与定位路径权重加载的报错看似五花八门归因其实就几类键名不匹配、形状不匹配、精度不匹配、容器格式不匹配。下面这张速查表是我这些年攒下来的基本覆盖了九成以上的现场问题。报错/症状可能原因排查与解决Unexpected key(s)键名前缀差异module.、base_model.打印键名对比批量去前缀Missing key(s)权重不完整或分片未全加载检查 index.json 与分片是否齐全size mismatch模型版本或结构不一致对比 shape确认基础模型身份加载后输出乱码精度转换错误或 LoRA 未生效检查 dtype 与 alpha/r 缩放显存瞬间爆掉一次性全量加载大 ckpt改 map_locationcpu 或分片懒加载反序列化报安全警告pickle 容器来源不明隔离环境检查优先转 safetensors定位的核心手法永远是先看结构再看数据先打印文件里有什么键、什么形状、什么精度再和模型期望的做对比。别一上来就改代码试先让数据说话。4.2 键名前缀与命名差异的处理技巧前缀问题极其常见。DDP 训练加module.peft 包装加base_model.model.有些导出脚本还会加model.。处理方式不是一个个手改而是写一个通用的前缀剥离函数按已知前缀列表依次尝试。PREFIXES (module., base_model.model., model.) def strip_prefix(key): changed True while changed: changed False for p in PREFIXES: if key.startswith(p): key key[len(p):] changed True return key用循环而不是单次判断是因为有些权重叠了两层前缀。剥完之后再做差集核对通常就能对上。注意别剥过头比如model.layers.0里的model.如果是模型结构本身的一部分剥掉就错了。所以前缀列表要基于实际键名观察来定不能拍脑袋。4.3 精度与设备相关的隐蔽坑精度问题最隐蔽因为不报错只是效果变差。几个高频场景ckpt 存的是 fp32加载后直接喂给期望 fp16 的模型某些算子会隐式转换但数值路径变了LoRA 的 A、B 是 fp32 而基础模型是 fp16合并时类型不一致报错或静默降精度embedding 层被误转 fp16 导致罕见 token 表示失真。我的做法是加载后统一做一次 dtype 审计把每类层的 dtype 统计出来from collections import Counter dtypes Counter(str(v.dtype) for v in model.state_dict().values()) print(dtypes)如果发现 embedding 或 norm 层和主体不一致就针对性处理。设备方面map_location一定要显式指定多卡环境加载大 ckpt 时先落 CPU 再to(device)避免默认分配导致的显存峰值。4.4 我踩过的三个真实教训第一个教训是关于 LoRA 配置丢失。有次只备份了adapter_model.safetensors没备份adapter_config.json恢复时不知道秩和目标层只能从键名反推 target_modules秩则靠试。从那以后我的备份脚本强制把 adapter 目录整体打包配置文件和权重永远同进同出。第二个教训是合并后没做回归验证。合并完直接上线结果某类输入的输出分布明显偏移回头查发现是合并时 fp16 精度损失累积。后来我固定加一步合并前后各跑一批固定输入对比 logits 的余弦相似度低于阈值就改用 fp32 合并。这个检查脚本只有几十行但拦下过好几次问题。第三个教训是分片索引和实际文件不一致。下载中断导致某个分片不完整索引却还在加载时报张量缺失。现在我的下载流程末尾一定校验分片数量和索引里声明的分片数是否一致不一致就重下不抱侥幸。注意任何权重转换操作原始文件一律只读保留。转换产物另存目录命名带格式和精度标识比如model_fp16_merged.safetensors。出问题时能快速回退这是最便宜的安全网。4.5 选型建议什么场景用什么格式训练阶段用 ckpt因为它要保断点续训的完整性分发和推理用 safetensors安全且支持懒加载微调产出用 LoRA 增量省存储、便于多任务切换。如果要把 LoRA 用于生产推理且不想带额外依赖就合并成完整 safetensors。多套 LoRA 服务同一底座时保持增量形态、运行时动态挂载比合并多份完整模型省得多。还有一个实践中的折中对外分发时同时提供 safetensors 完整权重和 LoRA 增量前者给不想折腾的部署方后者给需要二次微调的团队。文件命名和目录结构写清楚能省掉大量沟通成本。最后分享一个我常用的小检查拿到任何权重目录先跑一个三看脚本——看文件后缀分布、看 safetensors 头部键数、看是否存在 adapter_config。三十秒内就能判断这批文件该怎么用比翻文档快得多。这套习惯帮我省下的时间远比写脚本本身多。