想象你收到一个写着“模型文件”的快递箱,撕开胶带后,箱子里除了一堆参数,还夹着一张纸条:“请先执行下面的代码,再取货。”正常快递绝不会这么干,但老牌序列化格式 pickle 偏偏喜欢这样:它在加载时会按“说明书”重新构造 Python 对象,而说明书里可能藏着恶意代码。黑客把这样的模型文件发到开源社区,谁加载谁中招。Safetensors 的出现,就是为了终结这种“夹带纸条”的乱象。它像一个标准集装箱:箱壁贴一张统一规格的货物清单(JSON 头),箱内是排列整齐的纯二进制张量字节,码头龙门吊只读清单、定位货物、直接吊装,全程不执行任何多余指令。安全、高效、按需取货——这就是本文要讲的故事。
对 AI 工程师而言,掌握 Safetensors 格式规范,意味着模型加载更安全、部署更快、内存更可控,是进入大模型工程化必备的一课。
可视化演示
围绕格式规范展开深入探讨,从基本概念出发逐步深入到高级应用。内容涵盖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 字节”,全程不需要拆开别的货物。
- 读取头部长度文件前 8 字节按小端规则解析,得到 JSON 头的字节长度 N。
- 计算头部总占用真正的 JSON 头前面还有一个 8 字节长度前缀,因此头部总共占了 8 + N 个字节。
- 定位张量数据每个张量在数据区的起点是 begin_i,加上头部占用的空间,就是它在整个文件中的绝对偏移量。
序列化写入
装箱员的工作分三步:先清点货物,再写清单,最后按清单把货一件件码进箱内。Safetensors 的写入流程与此完全一致。
第一步收集所有张量的 dtype、shape 和字节数;第二步将这些元数据编码成 JSON 头,并预留 8 字节长度字段;第三步把张量数据按顺序拼接。重点是共享去重:如果多个键引用同一个底层 Tensor 对象,Safetensors 只写入一份字节,多个键在 JSON 头中指向同一段 data_offsets。
为了后续读取高效,数据区起点还要做 8 字节对齐。JSON 头长度不一,需要在头部末尾填充空白字节,使数据区从 8 的倍数位置开始。计算公式如下。
像集装箱要求四角对齐,张量数据也对齐码放,叉车才能一下插中重心。
- 计算头部原始长度文件最前面的 8 字节长度字段和 JSON 头构成头部总原始长度。
- 向上对齐到 8 的倍数将 h 除以 8 后向上取整,再乘回 8,得到数据区起始位置。
反序列化读取
过去卸货要把整艘船的集装箱都搬进仓库,再拆包找货。Safetensors 的加载方式更像“把集装箱挂在仓库门口”,需要哪件取哪件。
核心实现是 mmap(内存映射)。safe_open 调用 mmap 将整个文件映射到进程虚拟地址空间,返回一个 SafeTensors 句柄;get_tensor(key) 时,解析 JSON 头里的 data_offsets,直接在映射区域切片,得到内存视图。
这意味着惰性加载:打开文件时并不把全部数据读进物理内存,只有真正访问某个张量的页面,操作系统才会按页调入。对 70B 大模型,可以只加载推理需要的部分权重,避免 OOM。张量字节长度计算如下。
按需卸货让 70B 模型的加载不再“一把梭”,这也是大模型推理服务能秒级启动的秘密之一。
- 读取开始偏移JSON 头的 data_offsets 第一个元素 begin_i,表示张量在数据区的起始位置。
- 读取结束偏移data_offsets 第二个元素 end_i,表示张量数据结束后的位置。
- 求字节长度两者相减得到张量占用的字节数,mmap 视图按这个长度切片。
对齐与优化
集装箱码放讲究边角对齐,歪歪扭扭会让叉车反复调整。Safetensors 在格式层面同样强调对齐与连续。
头部之后的填充字节使数据区起始地址按 8 字节对齐;所有张量数据连续存放,不夹杂多余描述信息;读取时配合 mmap 和零拷贝,可以直接把文件里的字节当作内存中的张量,不需要逐块复制。
对齐的意义在底层:x86/ARM 对非对齐访问可能需要多次载入指令,对齐后单次访存即可;SSD 顺序读取大块连续数据也比随机小 IO 快得多。需要填充的字节数公式如下。
就像叉车插入托盘的位置被标准化,Safetensors 通过对齐让每一次取货都快、稳、省。
- 头部原始长度求模计算 8 + headerLen 除以 8 的余数,看看离下一个 8 字节边界还差多少。
- 补足到 8用 8 减去余数得到需要填充的字节数;若余数恰好为 0,再取模保证 padding 为 0。
工程应用
如今全球大多数开源大模型的“码头”都已换装这种标准集装箱。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 演示
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()
header_len = int.from_bytes(fh.read(8), "little")读取文件前 8 字节,按小端解析出 JSON 头长度。header = json.loads(fh.read(header_len))按长度读取并解析 JSON 头,得到每张量的元数据。save_file(tensors, "demo.safetensors")把张量字典写入一个 safetensors 文件。"w_copy": shared让两个 key 引用同一个底层数组,验证只写一份数据的去重机制。safe_open("demo.safetensors", framework="numpy")用 mmap 惰性打开文件,不立即读入全部字节。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”的核心含义是什么?
- A.文件加密防止参数泄露
- B.加载时无任意代码执行← 正确答案
- C.占用内存空间更小
- D.支持断点续传加载
答案:B。Safetensors 的 safe 主要针对安全性,它只保存元数据和纯字节,没有 pickle 那样的代码执行通道,所以加载时不会运行任意代码。加密、内存大小、断点续传都不是它的命名原意。
Q2. Safetensors 文件最前面的 8 个字节表示什么?
- A.JSON 元数据的总长度← 正确答案
- B.文件总字节数大小
- C.张量数据总长度
- D.张量的总个数
答案:A。文件头部前 8 字节是小端无符号整数,记录 JSON 头的字节长度 N。加载器先读 N,再读取后面 N 个字节的 JSON,紧接着才是数据区。这是格式规范的固定起点。
Q3. JSON 头中的 data_offsets 表示什么?
- A.张量在数据区的字节范围← 正确答案
- B.张量存储的索引编号
- C.文件整体的起止位置
- D.头部 JSON 字段范围
答案:A。data_offsets 是 [begin, end],表示张量在数据区中的开始和结束字节偏移。解析时把它加上头部长度,就能定位到文件中真实的张量字节。
Q4. 写入时遇到两个 key 共享同一个张量对象会发生什么?
- A.每个张量都复制一份
- B.只写一份,多个 key 引用同一段区域← 正确答案
- C.共享张量会被直接跳过
- D.只保留最后一个 key 的数据
答案:B。Safetensors 支持共享去重。多个 key 指向同一个底层对象时,数据区只保存一份字节,JSON 头里多个 key 的 data_offsets 指向同一段区间,从而节省磁盘空间。
Q5. 关于 Safetensors 的惰性加载,下列说法正确?
- A.使用 mmap 映射整个文件← 正确答案
- B.只有访问张量时才读入页面← 正确答案
- C.打开文件会复制全部数据
- D.适合超大模型按需加载← 正确答案
答案:ABD。safe_open 依赖 mmap 把文件映射到虚拟内存,物理页按需调入,因此访问哪个张量才读哪部分数据。C 是错误说法,复制全部数据恰恰是惰性加载要避免的。
Q6. 关于 pickle 与 Safetensors 的对比,正确的是?
- 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 可能是?
- 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 字节的目的包括?
- A.让数据区按 8 字节对齐← 正确答案
- B.在头部后补充空白字节← 正确答案
- C.提升对齐访问的性能← 正确答案
- D.对张量做加密处理
答案:ABC。padding 是为了把数据区起始位置对齐到 8 的倍数,空白字节不参与计算。对齐后 CPU 访存更高效。它没有任何加密作用,数据仍然是明文原字节。
Q9. safe_open(..., framework="numpy") 中的 framework 参数作用是什么?
- A.指定张量返回 NumPy 数组← 正确答案
- B.选择计算硬件后端
- C.决定文件保存格式
- D.启动 GPU 加速读取
答案:A。framework 参数告诉库把底层字节解释成哪种框架的张量类型,例如 numpy、pt、tf、flax。它不改变文件格式,也不选择硬件后端,只是控制返回对象的类型。
Q10. 大型模型希望只加载部分权重,最合适的做法是?
- A.加载全部再删除不需要的 key
- B.用 safe_open 按 key 动态获取← 正确答案
- C.用 torch.load 加载后再切片
- D.先转成 Npy 格式再分段读取
答案:B。safe_open 配合 mmap 可以按 data_offsets 直接定位并访问指定张量,不触及其他页面。A 和 C 都会先加载全部数据,D 则绕远路且破坏统一格式,都不是最优解。