想象你收到一个写着“模型文件”的快递箱,撕开胶带后,箱子里除了一堆参数,还夹着一张纸条:“请先执行下面的代码,再取货。”正常快递绝不会这么干,但老牌序列化格式 pickle 偏偏喜欢这样:它在加载时会按“说明书”重新构造 Python 对象,而说明书里可能藏着恶意代码。黑客把这样的模型文件发到开源社区,谁加载谁中招。Safetensors 的出现,就是为了终结这种“夹带纸条”的乱象。它像一个标准集装箱:箱壁贴一张统一规格的货物清单(JSON 头),箱内是排列整齐的纯二进制张量字节,码头龙门吊只读清单、定位货物、直接吊装,全程不执行任何多余指令。安全、高效、按需取货——这就是本文要讲的故事。

岗位关联

对 AI 工程师而言,掌握 Safetensors 格式规范,意味着模型加载更安全、部署更快、内存更可控,是进入大模型工程化必备的一课。

目录
  1. 可视化演示
  2. 格式与安全背景
  3. 文件结构
  4. 序列化写入
  5. 反序列化读取
  6. 对齐与优化
  7. 工程应用
  8. AI 应用场景
  9. Python 演示
  10. 常见误区
  11. 面试题

可视化演示

围绕格式规范展开深入探讨,从基本概念出发逐步深入到高级应用。内容涵盖AI编译器与系统软件、模型格式与序列化中的... 切换模式查看不同维度。

本节配有交互动效(进入视口后自动加载,约 3KB)。

Safete:核心原理 动态展示关键流程与数据流动 交互控件调节参数实时渲染 f(x) = transform(x) 核心概念的可视化呈现

格式与安全背景

想象你收到一个写着“模型文件”的快递箱。撕开胶带,箱子里除了一堆参数,还有一张纸条:“请先执行下面的代码:删除所有数据。”正常快递不会这么做,但 PyTorch 默认的 .pt 文件基于 pickle 序列化,它在加载时会按“说明书”重建 Python 对象,说明书里可以藏着一行 __reduce__ 魔术代码,相当于让快递柜替你执行了纸条。

这就是所谓的任意代码执行(RCE)风险。黑客把恶意 pickle 文件发布到开源社区,任何人 torch.load 都会中招。Safetensors 正是为了终结这种局面:它统一采用“清单 + 货物”结构,清单是 JSON,货物是纯二进制张量,加载过程没有“执行纸条”这一步。

Safetensors(Safe Tensors)由 Hugging Face 主导设计,核心规格包含:8 字节长度前缀、UTF-8 编码的 JSON 头、按 offset 排列的数据区。它不像 pickle 保存 Python 对象图,而是保存跨框架可读的张量元数据与字节。所谓 safe,不是加密,而是从机制上移除了代码执行通道。

于是,新的集装箱从此只装货、不装人。码头工人不用读纸条,只需要对着清单取货,危险被扼杀在格式层面。

文件结构

集装箱侧壁会有一张货物清单,写明每件货在哪个位置、有多重。Safetensors 文件也有一张固定位置的清单:最开头 8 个字节记录 JSON 头的长度,紧接着是 JSON 元数据,再往后才是整整齐齐的数据区。

8 字节长度前缀是小端无符号整数,值为 N。程序先读这 8 字节,知道有多少 JSON 要解析;然后读取 JSON 头,里面为每个张量记录 dtype、shape、data_offsets。data_offsets 是 [begin, end],表示该张量在数据区中的字节范围。

例如 {"weight": {"dtype": "F32", "shape": [4, 4], "data_offsets": [0, 64]}}。实际文件中的字节位置还要加上头部占用长度,因此计算方式如下。

码头工人看一眼清单,就知道“W 在数据区第 0 到 64 字节”,全程不需要拆开别的货物。

offseti=headerBytes+begini\text{offset}_i = \text{headerBytes} + \text{begin}_i
这个公式表示:张量在数据区里的偏移量,换算成整个文件中的绝对位置时,要再加上头部占用的总字节数。
  1. 读取头部长度文件前 8 字节按小端规则解析,得到 JSON 头的字节长度 N。
    N=∑k=07Bk⋅256kN = \sum_{k=0}^{7} B_k \cdot 256^k
  2. 计算头部总占用真正的 JSON 头前面还有一个 8 字节长度前缀,因此头部总共占了 8 + N 个字节。
    headerBytes=8+N\text{headerBytes} = 8 + N
  3. 定位张量数据每个张量在数据区的起点是 begin_i,加上头部占用的空间,就是它在整个文件中的绝对偏移量。
    offseti=headerBytes+begini\text{offset}_i = \text{headerBytes} + \text{begin}_i

序列化写入

装箱员的工作分三步:先清点货物,再写清单,最后按清单把货一件件码进箱内。Safetensors 的写入流程与此完全一致。

第一步收集所有张量的 dtype、shape 和字节数;第二步将这些元数据编码成 JSON 头,并预留 8 字节长度字段;第三步把张量数据按顺序拼接。重点是共享去重:如果多个键引用同一个底层 Tensor 对象,Safetensors 只写入一份字节,多个键在 JSON 头中指向同一段 data_offsets。

为了后续读取高效,数据区起点还要做 8 字节对齐。JSON 头长度不一,需要在头部末尾填充空白字节,使数据区从 8 的倍数位置开始。计算公式如下。

像集装箱要求四角对齐,张量数据也对齐码放,叉车才能一下插中重心。

dataStart=⌈8+headerLen8⌉×8\text{dataStart} = \left\lceil \frac{8 + \text{headerLen}}{8} \right\rceil \times 8
这个公式把“8 字节长度前缀 + JSON 头”的原始长度向上取整到 8 的倍数,得到数据区真正开始的位置。
  1. 计算头部原始长度文件最前面的 8 字节长度字段和 JSON 头构成头部总原始长度。
    h=8+headerLenh = 8 + \text{headerLen}
  2. 向上对齐到 8 的倍数将 h 除以 8 后向上取整,再乘回 8,得到数据区起始位置。
    dataStart=⌈h8⌉×8\text{dataStart} = \left\lceil \frac{h}{8} \right\rceil \times 8

反序列化读取

过去卸货要把整艘船的集装箱都搬进仓库,再拆包找货。Safetensors 的加载方式更像“把集装箱挂在仓库门口”,需要哪件取哪件。

核心实现是 mmap(内存映射)。safe_open 调用 mmap 将整个文件映射到进程虚拟地址空间,返回一个 SafeTensors 句柄;get_tensor(key) 时,解析 JSON 头里的 data_offsets,直接在映射区域切片,得到内存视图。

这意味着惰性加载:打开文件时并不把全部数据读进物理内存,只有真正访问某个张量的页面,操作系统才会按页调入。对 70B 大模型,可以只加载推理需要的部分权重,避免 OOM。张量字节长度计算如下。

按需卸货让 70B 模型的加载不再“一把梭”,这也是大模型推理服务能秒级启动的秘密之一。

viewLen=endi−begini\text{viewLen} = \text{end}_i - \text{begin}_i
这个公式表示:一个张量在数据区里占用的字节数,就是结束偏移量减去开始偏移量。
  1. 读取开始偏移JSON 头的 data_offsets 第一个元素 begin_i,表示张量在数据区的起始位置。
    start=begini\text{start} = \text{begin}_i
  2. 读取结束偏移data_offsets 第二个元素 end_i,表示张量数据结束后的位置。
    end=endi\text{end} = \text{end}_i
  3. 求字节长度两者相减得到张量占用的字节数,mmap 视图按这个长度切片。
    viewLen=end−start\text{viewLen} = \text{end} - \text{start}

对齐与优化

集装箱码放讲究边角对齐,歪歪扭扭会让叉车反复调整。Safetensors 在格式层面同样强调对齐与连续。

头部之后的填充字节使数据区起始地址按 8 字节对齐;所有张量数据连续存放,不夹杂多余描述信息;读取时配合 mmap 和零拷贝,可以直接把文件里的字节当作内存中的张量,不需要逐块复制。

对齐的意义在底层:x86/ARM 对非对齐访问可能需要多次载入指令,对齐后单次访存即可;SSD 顺序读取大块连续数据也比随机小 IO 快得多。需要填充的字节数公式如下。

就像叉车插入托盘的位置被标准化,Safetensors 通过对齐让每一次取货都快、稳、省。

padding=(8−((8+headerLen) mod 8)) mod 8\text{padding} = (8 - ((8 + \text{headerLen}) \bmod 8)) \bmod 8
这个公式计算的是:为了让数据区从 8 字节边界开始,JSON 头后面还需要补多少个空白字节。
  1. 头部原始长度求模计算 8 + headerLen 除以 8 的余数,看看离下一个 8 字节边界还差多少。
    r=(8+headerLen) mod 8r = (8 + \text{headerLen}) \bmod 8
  2. 补足到 8用 8 减去余数得到需要填充的字节数;若余数恰好为 0,再取模保证 padding 为 0。
    padding=(8−r) mod 8\text{padding} = (8 - r) \bmod 8

工程应用

如今全球大多数开源大模型的“码头”都已换装这种标准集装箱。Hugging Face Hub 上的模型仓库,普遍同时提供 .safetensors 文件。

transformers 的 from_pretrained 会自动优先加载仓库中的 safetensors 版本;若只有 .bin,才会回退到 pickle。官方还提供 convert.py 等脚本,把老式 .bin / .pt 一键转换成 .safetensors。

生态上,safetensors 库提供了 save_file、load_file、safe_open 三组核心 API,并支持 PyTorch、TensorFlow、Flax、NumPy 多种框架绑定。工程最佳实践包括:发布模型时一律附带 safetensors;加载时用 safe_open + mmap 按需获取权重;训练 checkpoint 用共享去重节省磁盘。

集装箱标准一旦统一,全球码头都能无缝对接——AI 工程化也因此更安全、更高效。

AI 应用场景

LLM 在线推理服务

阶段:模型加载与部署

解决的问题:传统 torch.load 加载大模型时,既可能触发 pickle 恶意代码,又会一次性把全部权重读入内存,导致启动慢、易 OOM。Safetensors 用 mmap 惰性加载,只把需要的权重页调入内存,且没有任何代码执行通道。

具体案例:某大模型推理平台部署 70B 模型时,将 checkpoint 统一转为 safetensors,safe_open 按层加载权重,服务启动时间从 3 分钟降到 30 秒,内存峰值下降约 40%。

企业模型安全分发

阶段:模型发布与版本管理

解决的问题:模型文件在开源社区流转时,供应链攻击防不胜防。pickle 文件可以被植入后门,Safetensors 则从格式上杜绝了“附赠代码”的可能,发布方和消费方都更放心。

具体案例:某企业发布内部基础模型时,规定所有权重必须使用 .safetensors 格式,并配合哈希校验;安全团队扫描后再分发给各业务线,彻底消除了“加载即中招”的隐患。

分布式训练断点恢复

阶段:训练中断恢复与 checkpoint 管理

解决的问题:大规模训练每隔几小时保存一次 checkpoint,文件动辄几十 GB。传统格式加载整个 checkpoint 才能恢复,Safetensors 支持按需读取特定 rank 对应的权重分片,还能通过共享去重减少重复存储。

具体案例:千卡集群训练中,某节点故障后只需从 checkpoint 中恢复该节点负责的 shard,配合 safe_open 的惰性访问,恢复时间从分钟级降到秒级。

Python 演示

python27 行
import jsonimport numpy as npfrom safetensors import safe_openfrom safetensors.numpy import save_file  def main():    shared = np.random.randn(2, 3).astype(np.float32)    tensors = {        "w": shared,        "b": np.zeros(3, dtype=np.float32),        "w_copy": shared,    }    save_file(tensors, "demo.safetensors")     with open("demo.safetensors", "rb") as fh:        header_len = int.from_bytes(fh.read(8), "little")        header = json.loads(fh.read(header_len))        print(json.dumps(header, indent=2))     with safe_open("demo.safetensors", framework="numpy") as f:        print(f.keys())        print(f.get_tensor("w").shape)  if __name__ == "__main__":    main()
目的:演示 Safetensors 的完整读写流程,展示 8 字节长度前缀、JSON 头解析,以及共享张量去重后两个 key 指向同一段 data_offsets。
  1. header_len = int.from_bytes(fh.read(8), "little")读取文件前 8 字节,按小端解析出 JSON 头长度。
  2. header = json.loads(fh.read(header_len))按长度读取并解析 JSON 头,得到每张量的元数据。
  3. save_file(tensors, "demo.safetensors")把张量字典写入一个 safetensors 文件。
  4. "w_copy": shared让两个 key 引用同一个底层数组,验证只写一份数据的去重机制。
  5. safe_open("demo.safetensors", framework="numpy")用 mmap 惰性打开文件,不立即读入全部字节。
  6. f.get_tensor("w")按 key 定位 data_offsets,只取出这一个张量。

常见误区

误区把 `.pt` 重命名成 `.safetensors` 就能用,反正都是二进制文件,加载出来都是张量字典。

正解两者文件结构完全不同。.pt 是 pickle 打包的 Python 对象图,.safetensors 是“8 字节长度前缀 + JSON 头 + 数据字节区”的统一格式。直接改后缀会让加载器解析出乱码,必须用 save_file 或官方转换脚本重新生成。

为何常见:用户看到的都是“一个模型文件 + 一个加载函数”,接口相似,很容易误以为只是扩展名不同。

误区打开 safetensors 文件后,内存占用立刻等于文件大小,加载 100GB 模型会瞬间 OOM。

正解safe_open 只建立 mmap 内存映射,几乎不占物理内存。真正触发磁盘读取的是后续 get_tensor 访问对应页面。所以打开 100GB 文件可能只占几十 KB 内存,按需访问时才逐页载入。

为何常见:很多人把“映射”等同于“读入”,其实 mmap 是虚拟内存技术,物理页是按需加载的。

误区JSON 是文本格式,成千上万个张量会让文件膨胀很多,浪费存储和带宽。

正解JSON 头只保存 dtype、shape、data_offsets 等元数据,通常只有几 KB 到几百 KB;模型权重动辄数 GB,头占比可以忽略。而且 Safetensors 做了紧凑编码和 8 字节对齐,整体开销远小于 pickle 的对象图。

为何常见:JSON 文本肉眼可见地“重复”,容易高估它的体积占比,忘记了真正的大头是二进制张量数据。

误区它绑定 PyTorch,只能配合 `torch.save` 使用,其他框架用不了。

正解Safetensors 是一种跨框架的字节格式,PyTorch、TensorFlow、Flax、NumPy 都有官方绑定。底层的 JSON 头描述 dtype 和 shape,不依赖任何特定框架的 Python 对象,因此同一份文件可以被不同框架读取。

为何常见:实际场景中多在 Hugging Face Transformer 里配合 PyTorch 出现,容易让人误以为它是 PyTorch 专属格式。

面试题

Q1. Safetensors 名称中“safe”的核心含义是什么?

单选题 · easy

  • A.文件加密防止参数泄露
  • B.加载时无任意代码执行← 正确答案
  • C.占用内存空间更小
  • D.支持断点续传加载

答案:B。Safetensors 的 safe 主要针对安全性,它只保存元数据和纯字节,没有 pickle 那样的代码执行通道,所以加载时不会运行任意代码。加密、内存大小、断点续传都不是它的命名原意。

Q2. Safetensors 文件最前面的 8 个字节表示什么?

单选题 · easy

  • A.JSON 元数据的总长度← 正确答案
  • B.文件总字节数大小
  • C.张量数据总长度
  • D.张量的总个数

答案:A。文件头部前 8 字节是小端无符号整数,记录 JSON 头的字节长度 N。加载器先读 N,再读取后面 N 个字节的 JSON,紧接着才是数据区。这是格式规范的固定起点。

Q3. JSON 头中的 data_offsets 表示什么?

单选题 · mid

  • A.张量在数据区的字节范围← 正确答案
  • B.张量存储的索引编号
  • C.文件整体的起止位置
  • D.头部 JSON 字段范围

答案:A。data_offsets 是 [begin, end],表示张量在数据区中的开始和结束字节偏移。解析时把它加上头部长度,就能定位到文件中真实的张量字节。

Q4. 写入时遇到两个 key 共享同一个张量对象会发生什么?

单选题 · mid

  • A.每个张量都复制一份
  • B.只写一份,多个 key 引用同一段区域← 正确答案
  • C.共享张量会被直接跳过
  • D.只保留最后一个 key 的数据

答案:B。Safetensors 支持共享去重。多个 key 指向同一个底层对象时,数据区只保存一份字节,JSON 头里多个 key 的 data_offsets 指向同一段区间,从而节省磁盘空间。

Q5. 关于 Safetensors 的惰性加载,下列说法正确?

多选题 · mid

  • A.使用 mmap 映射整个文件← 正确答案
  • B.只有访问张量时才读入页面← 正确答案
  • C.打开文件会复制全部数据
  • D.适合超大模型按需加载← 正确答案

答案:ABD。safe_open 依赖 mmap 把文件映射到虚拟内存,物理页按需调入,因此访问哪个张量才读哪部分数据。C 是错误说法,复制全部数据恰恰是惰性加载要避免的。

Q6. 关于 pickle 与 Safetensors 的对比,正确的是?

多选题 · mid

  • A.pickle 可能执行任意代码← 正确答案
  • B.safetensors 只存元数据和字节← 正确答案
  • C.两种格式都能安全加载
  • D.safetensors 能避免 RCE 风险← 正确答案

答案:ABD。pickle 加载时会重建 Python 对象,可能触发恶意 __reduce__ 代码;Safetensors 只保存 JSON 头和二进制数据,没有执行通道。C 说两者都安全是错的,pickle 恰恰是 RCE 高发点。

Q7. 一个 shape 为 [4, 4]、dtype 为 F32 的张量,它的 data_offsets 可能是?

单选题 · hard

  • A.[0, 16]
  • B.[0, 64]← 正确答案
  • C.[16, 80]
  • D.[0, 32]

答案:B。F32 每个元素 4 字节,4×4 共 16 个元素,所以占用 64 字节。若它是数据区第一个张量,则 data_offsets 为 [0, 64],即 0 到 64 字节。

Q8. 头部末尾添加 padding 字节的目的包括?

多选题 · hard

  • A.让数据区按 8 字节对齐← 正确答案
  • B.在头部后补充空白字节← 正确答案
  • C.提升对齐访问的性能← 正确答案
  • D.对张量做加密处理

答案:ABC。padding 是为了把数据区起始位置对齐到 8 的倍数,空白字节不参与计算。对齐后 CPU 访存更高效。它没有任何加密作用,数据仍然是明文原字节。

Q9. safe_open(..., framework="numpy") 中的 framework 参数作用是什么?

单选题 · hard

  • A.指定张量返回 NumPy 数组← 正确答案
  • B.选择计算硬件后端
  • C.决定文件保存格式
  • D.启动 GPU 加速读取

答案:A。framework 参数告诉库把底层字节解释成哪种框架的张量类型,例如 numpy、pt、tf、flax。它不改变文件格式,也不选择硬件后端,只是控制返回对象的类型。

Q10. 大型模型希望只加载部分权重,最合适的做法是?

单选题 · extreme

  • A.加载全部再删除不需要的 key
  • B.用 safe_open 按 key 动态获取← 正确答案
  • C.用 torch.load 加载后再切片
  • D.先转成 Npy 格式再分段读取

答案:B。safe_open 配合 mmap 可以按 data_offsets 直接定位并访问指定张量,不触及其他页面。A 和 C 都会先加载全部数据,D 则绕远路且破坏统一格式,都不是最优解。