📌 好文共赏 | Editor’s Pick
原文:What’s in a GGUF, besides the weights — and what’s still missing? 作者:NobodyWho 团队(一个用 Rust + Godot 做"游戏里跑本地 LLM"的小工作室) 发布:2026-05-14 阅读时长:约 12 分钟 多模评分:Opus 8.6 / Sonnet 8.5 / Gemini 8.4(综合 8.5/10) 一句话推荐:把 GGUF 当成
.safetensors + tokenizer.json的合订本?这篇会让你明白:单文件模型格式真正的难点,不在权重,在那 200 行 jinja 脚本里。
为什么值得读
如果你只把 GGUF 当成"llama.cpp 用的那个文件格式"——一个把权重和 quantization 元信息压缩到 4-bit 的存档——那你只看到了它一半的价值。
NobodyWho 这篇博文的角度非常稀缺:作者不是 GGUF 标准的制定者,也不是 llama.cpp 的核心维护者,而是一个独立推理引擎的实现者。他们要从 GGUF 里读出"足够多的元数据"来让一个用户提供的 GGUF 文件能在自己引擎里直接跑通——不需要为 Qwen3 / Gemma / LFM2 各写一个 special case。
从这个消费者视角看 GGUF 标准,会看到一个完全不同的故事:
- GGUF 不只是权重。chat template(jinja2 脚本)、special token、sampler chain、tokenizer 全在里面,而且每一项都各自隐藏着设计权衡。
- GGUF 还不够。tool calling 语法、think token、projection model、feature flag——这四块缺失,正是 2026 年本地推理引擎日常要写"模型家族特化代码"的根因。
- GGUF 是开放标准,社区可以参与扩展——这是它和 ollama 的 OCI layout、HuggingFace 的
safetensors+JSON散文件方式最大的区别。
这篇文章是少数你可以同时读出"工程实现细节"和"格式标准演进路线图"的博文。它和我之前介绍的 《Quack:DuckDB 重新设计数据库 wire 协议》 几乎是镜像问题——一个是序列化模型权重,一个是序列化查询结果——但都指向同一个深层问题:一个开放格式怎么在保持简单的同时,覆盖足够多的下游需求。
如果你正在做以下任何一件事,这篇文章是必读:
- 给团队选本地大模型推理栈(vLLM / llama.cpp / NobodyWho / mistral.rs / candle)。
- 写自己的 GGUF 解析器或 inference loop。
- 维护 model card / 上传量化版本到 HuggingFace。
- 关心 LLM 生态的"协议层"如何演化。
核心观点深度解读
一、为什么"单文件"是 GGUF 最大的卖点
要理解 GGUF 的价值,先看它的两个主要竞争者:
HuggingFace + safetensors:一个模型仓库通常是这样的:
| |
这是研究/训练场景的最优解——每个 JSON 都是独立可读、可 diff、可编辑的。但对部署/分发场景,意味着用户要一次性下对 6-7 个文件,少一个就跑不通。
Ollama 的 OCI 模型:把模型包成一个容器镜像,由 layers + template(go template)+ JSON manifest 组成。优点是用 docker 思路可以做版本化、增量更新;缺点是 ollama 自定义了 chat template 格式(go template 而非 jinja),无法和 HuggingFace 生态直接兼容。
GGUF 的选择:把所有需要的东西——权重、tokenizer、chat template、metadata——压成一个二进制文件。一个 URL,一次 download,一个文件 cache。
原文:
The really neat thing about GGUF is that it’s just one file. … The contents are roughly the same, but GGUF makes it more ergonomic by keeping all this stuff in a single file.
这种"单文件"的设计,看似只是 ergonomics 上的小改进,但它直接决定了 llama.cpp 生态的分发模型:你可以在 HuggingFace 上挂一个 qwen3-7b-q4_k_m.gguf 链接,用户用 curl 或 wget 下载完即可使用,不需要 git clone、不需要解压、不需要"是否选对了 4 个 JSON 配套版本"的焦虑。
这点也呼应了我之前写的 《Redis 的野心代价》 里讨论的"边界设计"哲学:好的格式不是塞最多功能,而是让 90% 的使用路径压缩到最少步骤。
二、Chat Template:藏在文件里的 250 行 jinja2 程序
GGUF metadata 里有一个叫 tokenizer.chat_template 的键,存的是 jinja2 模板源码。原文给的例子很直观:
| |
这是 Gemma 4 的对话格式。但作者警告,这只是"基础例子"——一旦你需要支持 reasoning blocks、tool descriptions、tool call、tool response、多模态输入,模板会迅速膨胀到 200-300 行。
原文:
Jinja2 is a programming language, no doubt about it — it has loops, conditionals, assignments, lists, dictionaries, etc. — so any conversational LLM application must ship a programming language interpreter capable of running programs like the ~250 line jinja script that gemma ships with, every time a new message is added.
这里有一个对很多人来说反直觉的事实:任何严肃的本地 LLM 应用都必须捆绑一个 jinja2 解释器。不是为了渲染网页,是为了把 [{role: user, content: ...}, {role: assistant, content: ...}] 这种数组渲染成模型实际见过的 prompt 格式。
不同实现的选择:
| 工具 | jinja 实现 | 备注 |
|---|---|---|
| HuggingFace Transformers | Python jinja2 | 经典实现,最权威 |
| llama.cpp | 自研 C++ jinja | 与 minja / 老的 hardcoded llama_chat_apply_template 并存的一段混乱史 |
| NobodyWho | minijinja (Rust) | jinja 原作者 Armin Ronacher 自己写的 Rust 重写版 |
为什么有多个实现?因为 jinja2 是 Python 库,把它嵌进 C++/Rust 应用要么走 PyO3 / Python embed(依赖巨大),要么自己实现。
chat-template-benchmark 显示这些实现之间的性能差异是数量级的——但作者很诚实地补了一句:
原文:
But chat templating isn’t exactly the performance bottleneck in a local LLM application, so it’s not worth bickering about.
这是工程师的成熟态度——知道哪里值得 micro-optimize,哪里只是工程癖好。
但这件事的真正含义比性能更深:“模型的输入格式由模型自己规定” 这个原则,意味着推理引擎本质上不能再是"无状态的 forward pass",它必须有能力执行一段图灵完备的程序来构造输入。这是 LLM 时代 inference engine 边界的扩张,本质上和 GPU shader 中的可编程化是同构的——硬件/系统层不能继续假设"我只接受一种格式"。
三、Special Tokens:模型自己说"我说完了"
文章用一张表清晰地展示了 Gemma 4 的特殊 token:
| Token ID | 文本表示 | 用途 |
|---|---|---|
| 1 | <eos> | End-of-sequence,模型用这个停止生成 |
| 2 | <bos> | Beginning-of-sequence,prepend 到输入开头 |
| 46 | `< | tool_call>` |
| 47 | `<tool_call | >` |
| 105 | `< | turn>` |
| 106 | `<turn | >` |
注意几个细节:
<eos>和<bos>不是字符串匹配——它们是单一 token id,模型在 vocabulary 里专门为它们分配了一个槽位。- 这些 token “通常不显示给用户”,但有文本表示——这是给 debug 和 logging 用的。
- tool_call / turn 这种 token 表明:现代模型已经把对话结构和工具调用结构内化进了 tokenizer,不再是上层 prompt 的事。
旧的 inference 引擎(包括 llama_chat_apply_template 这个 API)会在 C++ 里 hardcode 一系列模型家族的格式,每出一个新模型就要 PR 加一个分支。GGUF 把这些 metadata 标准化的过程,本质上是把硬编码从源码迁移到数据文件——这是任何成熟生态都会经历的演化。
类似的演化我之前在 《Cloudflare 的 14ms 死亡螺旋》 中描述过——CUBIC 拥塞控制最早也是 hardcode 在内核里的常数,后来才一步步暴露成 sysctl 参数。
四、Sampler Chain:被忽视的输出"调音台"
模型的 forward pass 输出的是"下一个 token 的概率分布"。从分布到具体 token 的过程叫 sampling。最简单的是按概率随机采样,但实践中我们会用一堆变换:
- Temperature:把 logit 除以 T,T<1 让分布更尖锐(更确定),T>1 更平缓(更随机)。
- Top-K:只保留概率前 K 个 token,其余设为 0。
- Top-P / Nucleus:保留累积概率到 P 的最小 token 集合。
- Min-P:保留概率 ≥ P × max 的 token。
- Repetition Penalty:对最近出现过的 token 降低概率。
- DRY、Mirostat、Typical Sampling……
这些操作的顺序会显著影响最终分布。比如先做 Temperature 还是先做 Top-P,结果差异巨大。
原文:
It’s frustrating to me that most sampler config formats (including ollama images’ json files and HF’s generation_config.json) don’t have any way of specifying the order of sampling steps. … GGUF standard for this includes the
general.sampling.sequencefield, which lets you specify the order.
这是个非常具体的"格式优势"——GGUF 在 2026 年某次更新中加入了 sampler chain sequence 字段,让模型作者可以指定"先做 X 再做 Y"。HuggingFace 的 generation_config.json 和 ollama 都不支持这个。
NobodyWho 团队为此一度自己搞了一套 sampler 配置格式上传到 HuggingFace,但当 GGUF 原生支持后,他们立刻放弃了自己的格式。这里有个隐藏的价值观:优秀的工具作者愿意废弃自己的方案以拥抱社区标准。这呼应了 《Quack 的 DuckDB 协议》 里 DuckDB 团队选择把自己的 wire 协议设计开放出来的姿态——格式之争的终局不是赢家通吃,是收敛到一两个有共识的标准。
五、Still Missing #1:Tool calling 语法
到了文章后半段的"missing"部分,故事变得有趣起来。
每个模型家族的 tool call 格式都不一样:
Qwen3:
| |
Qwen3.5:
| |
Gemma 4:
| |
这意味着每个 inference 引擎都必须维护一个模型 → 解析器的查找表。每出新模型都要写新 parser。这是非常糟糕的可扩展性。
NobodyWho 的提议:让 GGUF 把 grammar(语法)直接放进文件,下游引擎能从 grammar 推导出 parser。
更妙的是,他们已经把这思路推到一步:
原文:
In NobodyWho, we go one extra (somewhat unique?) step wrt. tool calling, because we generate a unique constraining grammar for the specific tools passed. This means that we can guarantee type-safety for the tool calls.
也就是说,对每次调用,他们根据 user 传入的 tool schema 动态生成 GBNF grammar,然后让 llama.cpp 的 grammar-constrained sampling 来强制模型只能输出符合该 schema 的 token 序列。这给小模型(1B 以下)带来巨大可靠性收益——它们经常会"打 float 进 int 字段"。
这个特性可以理解为 JSON Schema → grammar → constrained decoding 的完整链路,是 OpenAI 的 response_format: { type: "json_schema" } 在本地世界的开源版本。它是把 LLM 输出类型安全化的关键工程。
六、Still Missing #2:Think Token
OpenAI o1 / Claude 3.5 Sonnet thinking / DeepSeek R1 之后,“reasoning model” 已经成为标配。模型在输出最终答案前会先输出一段"思考",通常被 <think>...</think> 或类似 tag 包围。
UI 上一般要把 think 部分单独渲染(折叠 / 灰色 / 流式 spinner),不能直接和 final answer 混在一起。这需要引擎知道"哪个 token 标志着 think 开始/结束"。
原文:
The upstream huggingface repos have begun to include a
think_tokenfield. … Somewhy, the downstream GGUF conversions typically don’t include this one. This makes GGUF-based inference engines incapable of separating the think streams from the main output, without having to write specific codepaths for specific model-families.
这是个纯转换流水线 bug——上游有,下游 GGUF 转换工具没传递。作者说这是最容易补的一个 missing,只要 GGUF 转换脚本加一行映射即可。
但它揭示了一个更深的问题:GGUF 转换是一个由志愿者维护的 best-effort 过程。模型作者发 safetensors,社区里 TheBloke / bartowski / Qwen 等人手动做量化和 GGUF 转换。他们的脚本没有官方化的、版本化的"完整性检查",所以会漏字段。
这点暴露了GGUF 作为开放标准的代价——它依赖社区的好心人维护转换链路,而上游变化时容易丢信息。
七、Still Missing #3:Projection Models(多模态投影)
多模态 LLM(看图、听音)需要一个额外的 projection model——把 vision encoder(如 CLIP / SigLIP)的特征投影到 LLM 的 embedding 空间。
当前实践:用户要下两个 GGUF 文件——主模型 + projection 模型。这破坏了"一个文件"的承诺。
原文:
The projection model is often ~1GB in size — enough of an overhead that we definitely want to skip it when it’s not used. But I think it’s reasonable to provide two variants of the GGUF: one with projection weights, and one without.
作者的妥协方案很现实:发布两个变体,一个带 projection 一个不带,但每个变体仍然是单文件。这把"两个文件"的复杂度移到上游一次(发布时),下游永远只看到一个文件。
类比:Linux distribution 的 -dev 包 vs 普通包——同一个上游源码,两个不同的下游产物。
八、Still Missing #4:Feature Flags
这是最深的一个 missing。
原文:
Some models support image ingestion, some don’t. The best way to handle this right now, is to assume support for images when a projection model is passed in. Some models natively support tool calling, some don’t. The best way to handle this right now, is to do substring matching on the chat template, to see if it tries to render the list of tool json schemas. This is obviously hacky.
NobodyWho 在生产中只能用字符串匹配 chat template来推断"这个模型支不支持 tool calling"。这种 capability detection 完全是经验启发式,不是声明式的。
作者建议加 feature flag——一组明确的布尔字段标注模型支持什么:supports_tools: true、supports_vision: true、supports_thinking: true、max_context: 32768、vocab_size: ...。
这看似是小事,但本质上是capability negotiation 的标准化——和 HTTP Accept-Encoding、X11 extension protocol、OpenGL extension list 完全同构。任何"客户端要根据服务端能力调整行为"的协议都需要这一层。
这是 GGUF 从"序列化格式"演化到"模型 ABI"的关键一步。
延伸阅读图谱
NobodyWho 团队的其他作品 / 相关讨论
- NobodyWho 项目主页 — Godot 引擎的本地 LLM 插件,让游戏开发者能直接给 NPC 接 LLM。
- Chat Template Benchmark (Asbjørn Olling) — 同团队的成员对各种 jinja2 实现做的性能对比,是看待 chat template 不能忽视的辅助资料。
- NobodyWho 文档 — 整体架构、grammar-constrained sampling 用法。
GGUF 标准生态
- GGUF 官方规范 — ggml 仓库里的格式定义文档,技术细节最权威来源。
- llama.cpp 仓库的 convert_hf_to_gguf.py — 上游 HuggingFace 模型转 GGUF 的官方脚本,看它如何处理 metadata 的转换。
- ggml-org/llama.cpp 的 jinja 子目录 — 自研 C++ jinja 实现源码。
相关数据结构 / 协议
- safetensors 规范 — HuggingFace 主推的张量序列化格式,理解 GGUF 是在反对什么的关键。
- ONNX Runtime 模型元数据 — 另一种"模型 + 元数据"打包思路,对比可以看出 GGUF 的开放性。
- Mike McCandless: Building a Better Inverted Index — Lucene 用 FST 重写 term dictionary 的故事,这与我们之前写的《FST 300x 压缩》同源。
反方观点 / 现实复杂性
- “GGUF Considered Annoying” (HN 讨论合集) — 一些资深 ML 工程师认为 GGUF 把太多 concern 塞到一个文件里,反而让"格式演化"变难。
- VLLM 团队为何不用 GGUF — vLLM 主要用 AWQ / GPTQ / FP8 等 in-memory quantization,不依赖 GGUF。
历史脉络
- ggml 项目首次提出 GGUF 的提案 — 2023 年的原始讨论,可以看到当时的设计目标和争论。
编辑延伸思考:单文件格式的递归困境
读完这篇文章我最大的感受不是 GGUF 有多好,而是"单文件主义"作为一种工程哲学的递归困境。
GGUF 的核心承诺是 “everything in one file”。但作者列出的四个 missing:tool calling grammar、think token、projection model、feature flag——每一个都在测试这个承诺的边界。
Projection model 显然是最大的挑战。一个 7B 主模型 + 1GB projection 模型,是不是要塞到一个文件里?作者给出的妥协是"两个变体"——但这其实已经偷偷违背了原则,只是把违背藏到了上游。
这让我想起 Unix 早期的争论:可执行文件该不该把所有 shared library 静态链接进来?最后大家的选择是动态链接 + RPATH 机制——既要"独立部署",又要"组件共享"。GGUF 现在面临同样的问题:是要 fat binary(含所有可选组件),还是要 thin core + side files?
这不是 GGUF 一个人的问题。它是任何成功的"自包含格式"在演化中必然遇到的:
- PDF:原本是排版的"打印就绪"格式,后来塞进了表单、JavaScript、3D 模型、附件——最终变成了一个虚拟机。
- Docker 镜像:原本是"运行环境的 snapshot",后来塞进了 multi-arch manifest、SBOM、provenance、signature——OCI 标准每年扩。
- WebAssembly modules:原本是"沙箱执行单元",正在塞进 component model、WASI preview2、interface types。
每一次扩展都让格式更强大,也让"完全支持这个格式"变得更难。一个新出的 GGUF 解析器现在不仅要会读权重,还要会跑 jinja2 解释器、要会做 grammar-constrained sampling、未来可能还要跑 projection model 的另一个 graph executor。
这是开放格式的成功税。
NobodyWho 团队的文章其实是一份社区运营文档——它在做四件事:
- 表达对 GGUF 现状的赞美(强信号)。
- 列出具体的痛点(积极反馈)。
- 提出可行的改进建议(建设性)。
- 邀请别人加入讨论(社区呼吁)。
这种"先表达共同利益,再提出技术请愿"的姿态,是开源标准演化的最佳沟通模板。我之前在 《Ghostty 离开 GitHub 转向自托管 forge》 中提到的"开源治理需要把工程行为变成政治行为",在这里有了一个非常温和的例证——不是退出而是参与,不是 fork 而是 contribute。
配套资料导览
本文配套了以下额外资料(同目录下):
- mindmap.svg:GGUF 现有功能 + 缺失功能 + 实现选择三维思维导图。
- concept-cards.md:6 张概念卡片,覆盖 jinja2 chat template、sampler chain、grammar-constrained decoding、projection model 等关键概念。
- glossary.md:本地大模型推理英中术语对照表(30+ 条),涵盖 GGUF / 量化 / sampler / tool calling / multimodal 五大门类。
谁应该读
- ✅ 用 llama.cpp / Ollama / LM Studio / vLLM 跑过本地模型,但不清楚 GGUF 到底装了什么的开发者。
- ✅ 在产品里集成本地 LLM 的应用工程师(Tauri/Electron/游戏引擎/CLI 工具)。
- ✅ 在选型时困惑"为什么我换个模型就要改一堆代码"的工程负责人。
- ✅ 关心 AI 开放生态如何演化的研究者 / 写作者。
- ❌ 只用 ChatGPT API、从不下载模型到本地的应用开发者。
- ❌ 在大公司云端做 LLM 推理优化的 ML infra 工程师(你们的关注点在 vLLM / TGI / SGLang,不在 GGUF)。
NobodyWho 团队结尾的那句"This post was written entirely by a human. No words were made up by the machine." —— 在 2026 年这么写一句,已经成了某种作者的签名仪式。它是技术博客圈对 AI 内容泛滥的温和反抗。
我们这篇导读也是人写的(with 编辑工具的辅助)。技术细节都对原文做了核对。
如果你读完想继续深挖,请按顺序:先读原文 → 再读 BurntSushi 的 jinja/sampler 相关博文 → 最后翻 ggml 仓库的 issue tracker。这是从"接受者"过渡到"参与者"的最短路径。