safetensors 转 GGUF:六步跑通与三处静默失败

结论先说:把一个 0.5B 的 safetensors 模型转成 GGUF 再导进 Ollama,全程 12 秒左右,命令两条。卡人的地方有两个,一是 brew 装的 llama.cpp 里没有转换脚本,二是量化时刷过去的 144 行降级警告。

本文所有数字都在本机实测:M4 芯片 / 16GB 统一内存,llama.cpp 走 Homebrew 的 0.9.0 build 12110,转换脚本取上游 commit 7bcbc04(2026-11-11),源模型是 Qwen2.5-0.5B-Instruct 的原始 safetensors 权重。对照组另加了 Qwen3-0.6B,用来验证量化降级的触发条件。

第一步:brew 里没有转换脚本,得从源码拿

装完 llama.cpp,两个二进制都进了 PATH,看起来工具链是齐的。

brew install llama.cpp
which llama-cli llama-quantize

但转换脚本找不到。本机实测这一句返回空:

find /opt/homebrew/Cellar/llama.cpp -name 'convert_hf_to_gguf*'
# 无输出

brew 的 bottle 只打包编译好的二进制和头文件,Python 转换脚本不在分发内容里。这一步最容易误判,因为 llama-quantize 完全正常,人会以为剩下的工具也都在。

脚本只能从源码取,浅克隆就够,本机 4 秒完成。

git clone --depth 1 https://github.com/ggml-org/llama.cpp.git
ls llama.cpp/convert_hf_to_gguf.py

第二步:依赖清单换位置了,旧命令会直接失败

网上大量教程还写着 pip install -r requirements.txt。现在的仓库根目录没有这个文件,依赖被拆进 requirements/ 下 11 个清单,转换要装的是 requirements-convert_hf_to_gguf.txt。

这个文件本身很短,实际内容只有三行:

-r ./requirements-convert_legacy_llama.txt
--extra-index-url https://download.pytorch.org/whl/cpu
torch==2.11.0

展开后 27 个包,其中三个版本关键:torch 2.11.0、transformers 4.57.6、gguf 0.19.0。装到全局环境以后很难摘干净,建议单独开虚拟环境。

python3 -m venv .venv
.venv/bin/python -m pip install -r llama.cpp/requirements/requirements-convert_hf_to_gguf.txt

本机耗时 1 分 38 秒,PyPI 默认源,不需要额外配镜像。

第三步:下载权重,目录要整份

转换脚本的第一个参数是模型目录,里面必须有 config.json 和完整的权重文件。单文件模型和分片模型都要把整份目录下全。

export HF_ENDPOINT=https://hf-mirror.com
hf download Qwen/Qwen2.5-0.5B-Instruct --local-dir models/Qwen2.5-0.5B-Instruct

这份权重 953MB,其中 model.safetensors 是 988,097,824 字节,目录里没有 tokenizer.model,只有 tokenizer.json、vocab.json、merges.txt 三个文件。镜像选择与断点续传的细节写在 hf-mirror 下载模型实测 里,这里不重复。

第四步:转换,注意默认参数不是 f16

最省事的一条命令,不传精度参数:

cd llama.cpp
../.venv/bin/python convert_hf_to_gguf.py ../models/Qwen2.5-0.5B-Instruct \
  --outfile ../out/qwen25-05b.gguf

本机 5.2 秒出结果,产物 948.1 MiB,峰值内存 1.55GB。产物精度用 gguf_dump 看 general.file_type,值是 32,对应 BF16。

显式写 --outtype bf16 得到的文件与上面这份 sha256 完全相同(6f8d546c…)。反过来写 --outtype f16,体积一字节不差,哈希变成 f781a61a…,张量类型从 BF16 变成 F16。

这一点和中文教程里的说法对不上。旧文章普遍说默认输出 f16,实际版本已经改成 bf16 了。下游如果是老版本工具链,或者某个只吃 F16 的推理框架,必须显式加参数。

../.venv/bin/python convert_hf_to_gguf.py ../models/Qwen2.5-0.5B-Instruct \
  --outtype f16 --outfile ../out/qwen25-05b-f16.gguf

第五步:量化,一次生成多个档位

转换一步只能出 f32、f16、bf16、q8_0 四种精度。K 系量化档位要第二步做,所以中间那份 f16 值得留着,一次转换可以反复量化出多个档位。

/opt/homebrew/bin/llama-quantize ../out/qwen25-05b-f16.gguf \
  ../out/qwen25-05b-Q4_K_M.gguf Q4_K_M

同一份 f16 输入,本机实测四个档位的账:

产物 体积 占 f16 比例 量化耗时 llama-cli 生成速度
源 safetensors 942.3 MiB 0.994 — 不可用
f16 转换 948.1 MiB 1.000 5.70 秒 —
Q8_0 506.5 MiB 0.534 0.56 秒 —
Q5_K_M 400.6 MiB 0.423 0.93 秒 —
Q4_K_M 379.4 MiB 0.400 0.97 秒 145.3 t/s

表里有个能省一步的地方:--outtype q8_0 直接转出来的文件,和 f16 再量化到 Q8_0 的文件 sha256 一模一样(5471df58…),体积都是 506.5 MiB。如果你只要 Q8_0,直接一步转,省掉一个 948 MiB 的中间文件。

选哪个档位取决于显存和内存预算。同一套账的推算方法在 本地大模型显存怎么算 里,0.5B 这种尺寸在 16GB 内存的机器上随便哪个档位都够。

第六步:导进 Ollama

Modelfile 只写一行就够,模板可以从 GGUF 元数据里继承。

printf 'FROM ./out/qwen25-05b-Q4_K_M.gguf\n' > Modelfile
ollama create qwen05b-local -f Modelfile

本机 0.54 秒完成。Ollama 会自己算 GGUF 的 sha256 当作层 ID,和 llama-quantize 的产物哈希对得上,说明它没有重新量化,只是原样收进模型库。

ollama show qwen05b-local

输出里三行值得记:

architecture        qwen2
context length      32768
quantization        Q4_K_M
template source     gguf_chat_template

template source 显示 gguf_chat_template,意味着聊天模板是从 GGUF 里读的,Modelfile 不必手写 TEMPLATE。上下文长度 32768 也是从元数据继承的,不是 Ollama 的默认值。模型库占空间以后要挪盘,看 Ollama 模型移到 D 盘实测。

踩坑一:Q4_K_M 会静默降级,文件比该有的大四分之一

量化时刷过的警告不要跳。拿 Q4_K_M 量化 Qwen2.5-0.5B,本机统计到 144 行降级:

grep -c 'falling back' quantize.log
# 144

具体是 132 个张量退到 q5_0,12 个退到 q8_0。这个模型一共 169 个权重张量,也就是说 144 个都没按 Q4_K 存。警告原文给的原因很明确:ncols 896 不能被 256 整除。

warning: blk.0.attn_q.weight - ncols 896 not divisible by 256 (required for type q4_K) -> falling back to q5_0

896 就是 Qwen2.5-0.5B 的 hidden size。K 系量化按 256 个元素一块打包,维度除不尽就只能退回旧的量化类型,q5_0 和 q8_0 都比 q4_K 占地方。

为了确认这条判据成立,另拉了一个 Qwen3-0.6B 做对照,它的 hidden 是 1024、intermediate 是 3072,都能被 256 整除。同一条流水线跑下来,降级 0 行。

模型 hidden 降级张量 f16 体积 Q4_K_M 体积 压缩比
Qwen2.5-0.5B 896 144 / 169 948.1 MiB 379.4 MiB 0.400
Qwen3-0.6B 1024 0 / 169 1439.4 MiB 461.8 MiB 0.321

按 0.6B 那档 0.321 的压缩比折算,0.5B 的 Q4_K_M 应该是 304 MiB,实测 379.4 MiB,多占了 75 MiB,相当于多花约四分之一的空间。这个数字是折算出来的,不是实测值,但它说明降级的代价是实打实的。

判断方法很简单,量化完先数一遍降级行数,超过 0 就去看是哪个维度除不尽。窄 hidden 的小模型(896、768、640 这些)做 K 系量化基本都会撞上,要么换 Q5_0 这类不要求整除的档位,要么接受体积损失。

踩坑二:退出码是 0,但什么都没产出

转换脚本的报错走日志,退出码还是 0。写脚本批量处理时会把它当成功。实测三种:

传单个 safetensors 文件而不是目录:

ERROR:hf-to-gguf:Error: model.safetensors is not a directory
# echo $? 结果是 0

把已经是 GGUF 的文件再喂给转换脚本,报错文本一模一样,同样返回 0。分词器文件缺失时报的是另一个错,栈底那句更难懂:

TypeError: expected str, bytes or os.PathLike object, not NoneType

这句出现在 transformers 的 tokenization_qwen2.py 里,真实原因是 vocab.json 找不到,路径参数传成了 None。看到 TypeError 往分词器目录上想,别去查 Python 版本。

第三步用错工具也会给一条好认的错。把 safetensors 直接丢给 llama-quantize:

gguf_init_from_reader: invalid magic characters: '?~??', expected 'GGUF'

这句反过来是好事,说明 magic 校验在工作。真正的判据只有一条:转换完先看输出文件在不在、体积对不对,别信退出码。

踩坑三:只删 tokenizer.json 也能转,但会丢三个键

这条坑很隐蔽。把 tokenizer.json 删掉,只留 vocab.json 和 merges.txt,转换照样成功,产物还能量化、能推理,分词结果逐个 token 与完整版一致。

差别在元数据里。两份文件体积差 224 字节,gguf_dump 的键值对从 41 条降到 36 条,少的五个键是:

general.basename
general.finetune
tokenizer.ggml.add_eos_token
tokenizer.ggml.normalizer.nfc
tokenizer.ggml.padding_token_id

后三个属于分词器行为参数,缺了 add_eos_token 以后,依赖自动补结束符的客户端可能拿不到终止 token,输出会一直往下写。padding_token_id 缺失会影响批量推理时的对齐。

所以下载权重时目录里有什么就全下,别按文件名挑。手工挑文件不会报错,只会让产物悄悄少几个键。

转完必做三个验证

第一步看结构,架构名和精度都对才算转对了:

python llama.cpp/gguf-py/gguf/scripts/gguf_dump.py --no-tensors out/qwen25-05b-Q4_K_M.gguf | grep -E 'architecture|file_type'

第二步跑一句话,确认能出 token。本机 0.5B 的 Q4_K_M 生成速度 145.3 t/s,这个数字主要看有没有异常低,绝对值和机器强相关。

llama-cli -m out/qwen25-05b-Q4_K_M.gguf -p '用一句话说明 GGUF 是什么' -n 40 --no-warmup

第三步是进 Ollama 再跑一次,看 ollama show 的 template source 是否生效。三种 GGUF 在 llama.cpp 里的跑法与算力对照,可以看 NVIDIA Dory 4B 四档算力实测,那里有不同硬件的横向数据。

FAQ

Q:转换 7B 模型要多少内存?
实测 0.5B(权重 942 MiB)的峰值内存在 1.28 到 1.55GB 之间,约为权重体积的 1.4 到 1.6 倍。按这个比例,7B 的 f16 权重约 15.2GB,峰值要按 22GB 以上准备。脚本默认延迟加载,不会一次性把整份权重读进内存,但留足余量更稳。

Q:能不能一步直接转出 Q4_K_M?
不能。--outtype 只接受 f32、f16、bf16、q8_0、tq1_0、tq2_0、auto 七个值,K 系档位一律要靠第二步 llama-quantize。唯一能合并的是 q8_0,实测一步转出的产物与两步的 sha256 完全相同,可以省掉中间文件。

Q:brew 装的 llama-quantize 和源码里的转换脚本版本不一致,会不会读不了?
本机是 Homebrew 0.9.0 build 12110 配上游 commit 7bcbc04,两边写出的 GGUF 版本号都是 3,互相读写正常。真撞上 unknown architecture 报错,说明脚本写出的架构比二进制新,把两边同时升级就行,不用去改文件头。