Safetensors:用 8 个字节和一段 JSON,重新设计大模型的权重文件

Safetensors 文件格式

同一个 2.44 GiB 的模型权重文件,用 torch.load 读进来要 1.57 秒、进程常驻内存涨 2446 MiB;换成 Safetensors 的 load_file,3 毫秒、涨 1.1 MiB。

这不是”优化得好一点”的差距,而是两种格式对”权重文件是什么”给出了不同答案:一边是可以执行的 Python 对象图,另一边是一张记录字节位置的目录。

大模型基础设施里,Safetensors 解决的就是这个看起来很底层、实际决定了很多上层设计的问题——如何安全、高效地存储、读取和分发几百 GB 甚至 TB 级的权重。它由 Hugging Face 开发并开源(Apache-2.0),今天的 Transformers、Diffusers、vLLM、llama.cpp、MLX、Candle、ComfyUI 几乎都在用它。

这篇文章想讲清楚四件事:格式到底长什么样;安全性从哪来、边界在哪;”零拷贝”到底零掉了什么;以及——它为什么能成为权重编程的基础设施。

一、权重文件里究竟装了什么

先回到最朴素的问题:一个 .safetensors 文件(或 .bin、.pt)里,到底有什么?

对一个 Transformer 来说,模型参数就是一堆有名字的多维数组:

  • model.embed_tokens.weight:词表嵌入,形状 [vocab, hidden]。
  • model.layers.N.self_attn.q_proj.weight:第 N 层的 Q 投影矩阵。
  • model.layers.N.mlp.gate_proj.weight:SwiGLU 的 gate 投影。
  • model.norm.weight:最后一层 LayerNorm 的缩放参数。

这些张量本质上只需要五项信息就能被无损重建:名字、数据类型、形状、字节数、字节在哪里。除此之外的一切——模型怎么前向、有哪些自定义算子、训练到了第几步——都不属于”权重”。

PyTorch 的 state_dict 正是这样一张 name → tensor 的表。问题出在它默认的落盘方式:torch.save 用 Python 的 pickle 序列化整张表。而 pickle 的设计目标是”重建任意 Python 对象”,所以文件里不仅有张量数据,还有告诉解释器如何重建这些对象的指令。

这份”额外的能力”就是后面所有安全问题的源头。

二、一个只有三段的文件

Safetensors 的格式规范短到可以贴在一屏里。整个文件由三段组成:

┌──────────────────────────────┐
│  Header Length   8 字节       │  u64,little-endian
├──────────────────────────────┤
│  JSON Header     N 字节       │  UTF-8,必须以 { 开头
│    name / dtype / shape      │  可用空格补齐到 8 字节对齐
│    / data_offsets            │
├──────────────────────────────┤
│  Tensor Data     裸二进制     │  按 offset 连续排列
└──────────────────────────────┘

1. 前 8 个字节:一个整数

文件开头是一个无符号 64 位小端整数 N,表示 JSON header 的字节长度。

为什么需要它?因为读取方必须先知道 header 有多长,才能准确跳到数据区。这一步是 O(1) 的——不需要扫描文件、不需要解析前面的内容。

2. JSON Header:一张字节地图

紧接着是 N 字节的 UTF-8 JSON。下面是我用 save_file 写出的一个真实文件(只有两个小张量)的 header 原文:

{
  "model.embed_tokens.weight": {
    "dtype": "F32",
    "shape": [8, 4],
    "data_offsets": [0, 128]
  },
  "model.layers.0.mlp.gate_proj.weight": {
    "dtype": "F32",
    "shape": [4, 4],
    "data_offsets": [128, 192]
  }
}

三个字段各有明确职责:

字段 含义 你需要知道的细节
dtype 元素类型 F64/F32/F16/BF16/I64/I32/I16/I8/U8/BOOL,以及 F8_E4M3 等 FP8
shape 维度 行主序(C order);[] 表示标量,允许某一维为 0 的空张量
data_offsets [BEGIN, END) 字节区间 相对数据区起点,不是文件绝对位置;字节数 = END - BEGIN

data_offsets 是整个格式的核心。有了它,读取方能在不读任何张量数据的前提下,算出任意一个张量的精确位置和长度。

还有一个特殊键 __metadata__,放的是字符串到字符串的映射(Hugging Face 会写 {"format": "pt"})。注意限制:只允许字符串,不能嵌套任意 JSON。这一条不是洁癖,是为了让解析器的行为完全可预测。

3. Tensor Data:没有容器的裸数据

header 之后就是连续的二进制数据。没有 per-tensor 的头部、没有对齐填充块、没有指向上层的指针——第 k 个张量的数据就老老实实躺在 [offset_k, offset_k + size_k)。

我在实验里验证过一件容易被忽略的事:规范要求整个数据区被完整索引,不能有空洞。也就是说,所有张量的 data_offsets 必须不重叠、不遗漏地覆盖整块数据区。

这条规则挡住了一类攻击:polyglot 文件——同时是合法 .safetensors、又是合法 ELF 或 ZIP 的文件。如果允许空洞,攻击者就能在”合法权重文件”的缝隙里塞进可执行内容;不允许空洞,塞进去的字节就必须被某个张量声明,而声明了就会被当成权重读出来。

另外两个实现细节值得知道:

  • 对齐:header 允许在 JSON 后补若干空格(0x20),实现通常补到让数据区起点是 8 的倍数。这样 float32/float16 张量直接映射到内存就是天然对齐的。
  • 上限:header 最大 100 MB。否则一个声明了 10 GB JSON 的文件就能让解析器先把自己 OOM 掉。

三、安全:把”可执行”从格式里拿掉

pickle 与 safetensors 的加载路径对比

pickle 为什么危险

pickle 不是”数据格式”,而是一门小型虚拟机。它的字节流里有一条 GLOBAL 指令,作用是”导入某个模块的某个属性”;配合 REDUCE,就是”拿这些参数调用这个函数”。

所以一个对象可以在反序列化时指定”我要被重建时执行什么”:

import pickle

class Harmless:
    def __reduce__(self):
        return (print, ("[side effect] code from the checkpoint ran",))

payload = pickle.dumps({"w": 1, "x": Harmless()})
pickle.loads(payload)   # 打印:[side effect] code from the checkpoint ran

我实际跑了这段代码:485 字节的 pickle,在 loads 的那一刻执行了里面的函数。这不是漏洞,是 pickle 的设计功能——它需要靠执行代码来重建对象。

把这个能力放进”从互联网下载模型权重”的场景,结论就很清楚了:加载 .bin 等价于运行别人给你的代码。

更麻烦的是,这条防线一直在被推后移:

  • PyTorch 长期推荐 torch.load(..., weights_only=True) 作为缓解。
  • 2025 年的 CVE-2025-32434 证明这个白名单可以被绕过,torch ≤ 2.5.1 受影响,2.6.0 修复(CVSS 4.0 评分 HIGH)。
  • torch 2.6 起,torch.load 的 weights_only 默认值改为 True。

只要格式本身允许”重建对象”,安全就永远是补丁式的。

Safetensors 的做法:只描述数据在哪

Safetensors 把整条链路换成:

读 8 字节长度 → 解析 JSON → 校验偏移 → 生成张量视图

没有任何一步需要执行文件里的内容。Rust 实现会在生成视图之前做结构性校验:

  1. header 长度合法(≤ 100 MB),且必须以 { 开头;
  2. 每个张量的 dtype × shape 与 data_offsets 声明的字节数一致;
  3. 偏移不越界;
  4. 张量之间不重叠;
  5. 数据区被完整覆盖(无空洞、无截断)。

我构造了三个畸形文件来验证(结果来自实测):

构造的破坏 结果
把某个张量的 END 改大 invalid shape, data type, or offset for tensor
让两个张量区域重叠 invalid offset for tensor `model.layers.3.self_attn.o_proj.weight`
截断数据区 4 个字节 incomplete metadata, file not fully covered

三条都是整份拒绝,而不是”尽力加载”。这一点比很多人以为的更重要:一个能”部分加载”的格式,会让损坏的文件悄悄变成看起来正常的模型。

但安全边界到此为止

需要把话说清楚,避免过度承诺:

  • Safetensors 不检查数值。 NaN、Inf、异常大的权重都能合法存进去。README 里写得很明白:tensor values are not checked。
  • 格式没有校验和。 它不负责证明”这份权重就是你想要的那一份”。完整性要靠外部机制:Hugging Face 的 SHA256/Xet、Git LFS 的 OID、你自己签名的 manifest。
  • 模型供应链是另一层问题。 后门权重、投毒微调、许可证问题,格式一概管不了。

一句话:Safetensors 消除的是”序列化执行风险”,不是”模型可信问题”。

四、”零拷贝”到底零掉了什么

这是最容易被说糊的一段。先把两条路径摊开。

传统 torch.load 的链路是:

磁盘 → 读入用户态缓冲区 → 反序列化(构造 Python 对象)→ 分配张量内存 → 拷贝数据 → 模型

Safetensors 的链路是:

磁盘 → mmap 建立映射 → 按 offset 在映射区域取视图 → 模型

关键在于第二步:张量不是”被读进来的”,而是”被指向的”。数据仍然在页缓存里,进程只是拿到了一段虚拟地址。

实测

我用一份合成的 Llama 风格 checkpoint 做对比:43 个张量、2.44 GiB,同一台机器、同一份数据,分别存成 .safetensors 和 .bin。

指标 safetensors load_file torch.load(weights_only=True)
保存耗时 3.02 s 3.76 s
加载返回耗时 3.0 ms 1.57 s
加载后常驻内存增量 1.1 MiB 2446 MiB
逐个访问全部张量首尾 12.5 ms 3.6 ms

前两行说明零拷贝的价值:torch.load 必须把 2.44 GiB 完整复制进用户态堆,进程 RSS 直接涨 2.4 GB;load_file 只建立映射,RSS 几乎不动。

第三行是很多人会漏掉的细节:映射不等于数据已经在内存里。第一次访问某个页会触发缺页中断,把数据从页缓存映射进来。所以”访问全部张量”这步,safetensors 反而要花 12.5 ms——它把成本推到了真正用到数据的时候。这仍然是净赚:那 2.4 GB 里,多卡推理时你可能永远用不到别的卡负责的那部分。

内存与耗时对比

三个必须澄清的边界

  1. header 不是零拷贝。 JSON 总要被解析成结构体。但 header 通常只有几 KB 到几 MB,相对于几百 GB 的数据可以忽略。
  2. GPU 上一定有拷贝。 显存没有”磁盘页缓存”这种东西,权重必须从主机内存传到显存。Safetensors 省掉的是”CPU 上多分配一份完整副本”,不是 cudaMemcpy 本身。官方 benchmark 里 CPU 快 76.6 倍、GPU(T4)只快 2.1 倍,原因就在这里。
  3. load_file 返回的映射张量不是”可以随便改的副本”。 写入是私有映射,不会回写文件;想改文件得自己用读写模式映射——这正是第六节要做的事。

官方 README 还记录了一个著名的工程收益:BLOOM 在 8 卡上加载,从原来 PyTorch 权重的约 10 分钟降到 45 秒。这个数字来自特定环境,但它说明的是同一件事:省掉的不是 IO,而是”为了拿到一个张量而必须先构造整个对象图”。

五、懒加载:只碰你需要的那几百 MB

有了 offset 表,”只读一部分”就不需要任何特殊支持,它是格式的自然结果。

from safetensors import safe_open

with safe_open("model.safetensors", framework="pt", device="cpu") as f:
    # 只要形状,不读数据
    slice_ = f.get_slice("model.embed_tokens.weight")
    vocab, hidden = slice_.get_shape()

    # 只要前 128 列
    part = slice_[:, :128]

    # 只要一个张量
    q = f.get_tensor("model.layers.3.self_attn.q_proj.weight")

get_slice 返回的是一个可切片的视图对象,[:, :128] 只读那部分字节。实测中,读一个 45 MiB 的完整张量和读它 8 行(0.17 MiB)都在 1 ms 以内——因为两者都没有真正把数据全部搬进内存。

分片:仓库约定,不是格式规定

真正的百亿、千亿参数模型不会只有一个文件。Hugging Face 仓库里常见的是:

model-00001-of-00004.safetensors
model-00002-of-00004.safetensors
model-00003-of-00004.safetensors
model-00004-of-00004.safetensors
model.safetensors.index.json

index.json 是一张 weight_map,把张量名映射到分片文件名:

{
  "metadata": { "total_size": 16060522496 },
  "weight_map": {
    "model.embed_tokens.weight": "model-00001-of-00004.safetensors",
    "model.layers.0.self_attn.q_proj.weight": "model-00001-of-00004.safetensors",
    "model.layers.20.self_attn.q_proj.weight": "model-00003-of-00004.safetensors"
  }
}

这里要区分两件事:Safetensors 格式本身完全不知道”分片”和”index”的存在——它只是一堆独立的单文件;分片和 weight_map 是模型仓库的组织约定,由 Transformers 一类的加载器实现。

这个组合带来的实际能力是:第 0 号 GPU 只需要读 model-00001 里的 embedding 和前几层,第 2 号 GPU 只需要读 model-00003。每张卡都不需要先加载完整模型再挑自己那份。

README 里还有一个更微妙的优势,叫 layout control:光是”能懒加载”不够,还得让每个张量的数据在文件里挨在一起。如果张量的元信息分散在文件各处,即使能惰性读取,你也得随机访问大半个文件才能凑齐一个张量,磁盘到内存的拷贝次数反而变多。Safetensors 把同名张量的 header 和数据都集中放置,正是为了这一点。

六、把它当文件用:不加载模型,改掉一个 Attention Head

前面都是”读”。但格式设计同样决定了写能做到什么程度——这一点和模型权重拆分、权重编辑、Deletion Tuning 这些方向直接相关。

假设我想把第 3 层 q_proj 的某 128 行权重置零(可以理解为消掉一簇 attention head 的贡献)。传统做法是:load 整个模型 → 改内存里的张量 → save 重新写一遍。2.44 GiB 的文件,实测这条路要 28.37 秒,还要求峰值内存能装下 2.4 GB。

用 Safetensors,根本不需要加载模型:

import json, struct
import numpy as np

path = "big.safetensors"

# 1. 只读 8 字节长度 + header
with open(path, "rb") as f:
    n = struct.unpack("<Q", f.read(8))[0]
    header = json.loads(f.read(n))

# 2. 从 header 里算出目标张量的绝对偏移
key = "model.layers.3.self_attn.q_proj.weight"
begin, end = header[key]["data_offsets"]
rows, cols = header[key]["shape"]

# 3. 以读写模式映射这一块,就地修改
mm = np.memmap(
    path, dtype=np.float16, mode="r+",
    offset=8 + n + begin, shape=(rows, cols),
)
mm[1024:1152, :] = 0.0
mm.flush()

实测:5.87 ms,改的是 128 × 4096 个 float16(1 MiB)。相对 28.37 秒的完整重写,快了约 4800 倍。

改完要验证的不是”跑起来不报错”,而是只有目标区域变了:

检查项 结果
目标块全零 ✅
第 1023 行、第 1152 行未受影响 ✅
其余 42 个张量与原文逐字节相同 ✅
文件大小不变 ✅

这件事之所以成立,是四个设计决定的叠加:

  1. offset 表让你能在 O(1) 内定位任意张量,不需要解析其余数据;
  2. 8 字节对齐让 float16 张量可以直接用裸内存视图访问,不需要中转缓冲;
  3. 行主序 + 连续布局让”取某些行”是一段连续的字节,而不是散落各处的碎片;
  4. 张量之间没有共享结构,改一个张量不会牵动其他张量的偏移。

为什么这件事对”权重编程”重要

这直接解释了为什么 Deletion Tuning 那类方法能做得那么轻。那篇工作学到的适配器是每个权重 1 个比特的掩码:Llama-3-8B 的掩码 8.4 MB,而全量微调的适配器是 30,633 MB。掩码要生效,就是把 W ⊙ 1[G > 0] 落到权重上——如果每应用一次掩码都要读写一遍整个 checkpoint,这种”1 bit/weight”的优雅就会被工程成本吃掉。

有了可随机访问的格式,”应用掩码”可以退化成:读 header、定位需要改的张量块、就地写回。甚至可以只改被删除的那 0.05% 参数——它们本来就很稀疏。

同样受益的还有:

  • 权重拆分:按层/按张量把一个大文件拆成几份,只需要按 offset 复制区间,不需要反序列化再序列化。
  • 模型调试:想对比两个 checkpoint 的某一层差异,读那一个张量就够,不必把 140 GB 读进内存。
  • 增量分发:只传变化的张量块。

代价:没有事务,也没有校验和

这种”直接改文件”的能力是双刃剑:

  • 写坏了就是写坏了。格式里没有校验和,也没有事务日志,进程中途崩溃会留下一个结构合法但内容损坏的文件。
  • 实践上必须:先留副本或计算哈希、改完用 load_file 重新打开验证结构、对关键张量做逐字节比对。
  • 非 1/2/4/8 字节的 dtype(比如 FP8、4-bit 打包)会让”按元素寻址”变得麻烦,任意切片可能落到非对齐地址,官方实现会直接报错而不是悄悄读错。

七、和其他格式比,它站在哪

不同格式的设计目标不同,硬要比”谁更好”没有意义。Safetensors README 里的对照表维度选得很准,我按它的口径整理(✓ 表示支持,~ 表示部分支持):

格式 安全(不执行代码) 零拷贝 懒加载 无文件大小上限 布局可控 灵活性 BF16/FP8
pickle (PyTorch) ✗ ✗ ✗ ✓ ✗ ✓ ✓
HDF5 ✓ ✗ ✓ ✓ ~ ~ ✗
SavedModel (TF) ✓ ✗ ✗ ✓ ✓ ✗ ✓
MsgPack (Flax) ✓ ✓ ✗ ✓ ✗ ✗ ✓
Protobuf (ONNX) ✓ ✗ ✗ ✗(2 GB 上限) ✗ ✗ ✓
NumPy NPZ ✓ ✗ ✗ ✗ ✓ ✗ ✗
Safetensors ✓ ✓ ✓ ✓ ✓ ✗ ✓

“灵活性 ✗”是它的自我限定:不能存自定义代码、不能存任意对象。这不是缺陷,是它用功能换来的安全属性。

两个常见的比较对象值得单独说:

  • ONNX 存的是计算图 + 算子 + 权重,解决”跨框架交换模型”;Safetensors 只存权重,解决”安全高效地搬参数”。二者不是替代关系——ONNX 自己的外部权重也可以存成 Safetensors。
  • GGUF(llama.cpp 的格式)把量化块结构做进了格式:k-quant 的分块缩放因子、张量级的量化元数据,都是为 CPU/端侧推理服务的。Safetensors 不做量化,它存原始精度的权重,量化发生在加载时或另存为 GGUF 时。两者常常在同一条流水线上:HF 上发行 Safetensors,转换脚本读它、量化、写出 GGUF。

八、局限:它明确不做什么

把边界列清楚,比罗列优点更有用。

  1. 不保存计算图。 层与层的调用关系、Attention 的实现、自定义算子,统统不在文件里。你需要 config.json 和模型代码才能把它变成可运行的模型。
  2. 不保存任意 Python 对象。 优化器状态、训练器配置、自定义类,不能直接塞进来(只能以字符串元数据的形式记录少量信息)。
  3. 不做压缩,也不做量化。 同样的 FP16 权重存成 Safetensors 不会变小。体积取决于张量数量、形状、dtype 和数值本身;要减体积得靠量化、剪枝、低秩分解。
  4. 没有校验和、没有加密、没有签名。 完整性、来源可信、访问控制,都要在格式之外解决。
  5. 不保证跨文件系统的 mmap 行为。 在网络文件系统上,内存映射的语义和性能可能与本地磁盘差异很大;某些场景下老老实实顺序读反而更快。
  6. 张量数量极多时 header 会变大。 十万个小张量的 header 可能到几十 MB,解析成本不再可以忽略——这时应该合并小张量或调整布局。
  7. 不支持 strided 张量。 写入前必须 contiguous(),这是一个常见但很容易撞上的坑。

九、在生态里的位置

Safetensors 已经不是”一个可选格式”,而是默认选项:

  • 训练/微调:Transformers 的 save_pretrained 默认 safe_serialization=True;PEFT 的 adapter 也是 Safetensors。
  • 推理:vLLM、TensorRT-LLM、SGLang 直接读 Safetensors 分片;llama.cpp 通过转换脚本读入再量化成 GGUF。
  • 扩散模型:Diffusers、ComfyUI、Stable Diffusion WebUI 全面使用。
  • 端侧/其他语言:MLX(Apple)、Candle(Rust)、safetensors 的 Rust/Node/Python 绑定。

一个典型的 HF 模型目录长这样:

model/
├── config.json                 # 架构与超参(决定怎么解释权重)
├── generation_config.json       # 默认生成参数
├── model.safetensors            # 权重
├── tokenizer.json               # 分词器与词表
└── tokenizer_config.json        # 分词器配置

职责分离得很清楚:config.json 决定”这些张量怎么用”,Safetensors 只负责”这些张量在哪”。

从旧格式迁移

import torch
from safetensors.torch import save_file, load_file

# 1. 只在确认来源可信时,用 weights_only=True 读旧文件
state_dict = torch.load("pytorch_model.bin", map_location="cpu", weights_only=True)

# 2. 直接写 Safetensors(要求张量连续、不共享存储)
state_dict = {k: v.contiguous() for k, v in state_dict.items()}
save_file(state_dict, "model.safetensors")

# 3. 以后这样读
state_dict = load_file("model.safetensors")
model.load_state_dict(state_dict)

两个容易踩的点:

  • 共享存储的张量(比如 tied embeddings)会让 save_file 报错。要么 clone() 展开,要么用 save_model 处理共享。
  • 转换不能”洗白”来源。 把恶意 pickle 转成 Safetensors 时,恶意代码已经在你机器上执行过了。转换的价值在于此后的分发是安全的。

十、总结:把”数据”和”指令”分开

Safetensors 最值得学的地方,不是它有多快,而是它的克制。

它没有去设计一个能表达计算图、能存优化器状态、能塞自定义算子的”模型容器”。它只回答一个问题:给定一张”名字 → 字节区间”的表,能不能安全、零成本地把张量交给上层?

为此它做了几件很朴素的事:

  • 用 8 字节长度 + JSON 描述布局,任何语言几十行就能实现解析;
  • 把”可执行”从格式里彻底剥离,安全不再是补丁;
  • 让每个张量的位置可以 O(1) 计算,于是懒加载、切片、分片、原地修改全都成了自然结果;
  • 用”数据区必须被完整索引”这种约束,堵住 polyglot 这类结构性攻击。

从整个技术栈看,三个环节分工清晰:训练决定权重如何产生,Safetensors 决定权重如何保存和分发,推理框架决定权重如何被执行。

而它的 offset 设计还打开了一个更有意思的方向:当权重文件从”一个不透明的 blob”变成”一张可寻址的表”,权重就不再只能整体加载,而可以被精确地读取、比较、修改和局部替换。这正是权重拆分、模型调试、以及 Deletion Tuning 这类”只改一点点权重”的方法,能够从论文走到工程的前提。

延伸阅读

项目 / 资料 关注点
safetensors 格式规范与 Rust/Python 实现
Safetensors 文档 API、速度对比、迁移指南
Deleting Weights 1 bit/weight 的掩码式后训练
Transformers 权重加载、分片与 index 的实现
llama.cpp GGUF 格式与量化流水线
vLLM 高性能加载与分布式推理

实验环境:Windows 11 / Python 3.11.9 / torch 2.14.1+cpu / safetensors 0.8.0,2.44 GiB 合成 Llama 风格 checkpoint(43 个张量),CPU-only,计时取 3 次最优。文中所有耗时与内存数字均来自本机实测,不同硬件与文件系统会有差异。




Enjoy Reading This Article?

Here are some more articles you might like to read next: