你从 HuggingFace 上下载一个模型,拿到的是一堆 .safetensors 权重分片,加上一个 config.json、一个 tokenizer。可 llama.cpp 从头到尾只认一种文件:.gguf。中间那一步"把 HF 模型转成 GGUF",就是这一课的主角——它由仓库根目录那个 convert_hf_to_gguf.py 负责。很多人把它当成一个黑盒脚本:跑一条命令、等一会儿、出一个 .gguf。这一课要把这个黑盒拆开,看清它到底做了哪三件事。
这三件事其实很朴素:(1) 认出这是什么架构——读 config.json 里的 architectures,分发到对应的转换类(Llama 走 LlamaModel、Qwen 走 Qwen2Model);(2) 把每个张量改名、对齐——HF 把注意力的 Q 投影叫 model.layers.0.self_attn.q_proj.weight,GGUF 要叫 blk.0.attn_q.weight,还要把超参(层数、维度、RoPE 设置)写成 GGUF 的元数据;(3) 按 GGUF 的字节格式落盘——先写文件头,再写一段段元数据键值,再写张量信息表,最后对齐了写真正的权重。看懂这三步,你不仅会"用"这个脚本,还能在它不支持你的模型时,知道该去改哪里。
有一个结构上的变化值得先说:convert_hf_to_gguf.py 以前是个几千行的大文件,现在已经被重构成一个薄薄的命令行入口(约 300 行),真正干活的代码搬进了一个独立的 conversion/ 包——每个架构一个模块。所以这一课讲的是"一个包怎么协作",不是"一个大文件里有什么"。路线图:先看一条命令背后的分发流程(配一张追踪图),再看注册表怎么把"架构名"接到"转换类",然后看张量改名与超参,最后拆开 GGUF 文件本身的字节布局。
先跟着一条命令走一遍。你敲下 python convert_hf_to_gguf.py /path/to/hf-model,这个薄薄的入口脚本做的第一件事不是转换,而是认门:它打开模型目录里的 config.json,读出 architectures 字段——比如 "LlamaForCausalLM"。这就是模型的"身份证"。拿到身份后,它去一个注册表里查一句话:"谁负责转这种架构?"查到的是一个 Python 类(LlamaModel),把它实例化,剩下的全交给这个类的 write() 方法。整个 main() 短得出乎意料:
# 简化自 convert_hf_to_gguf.py 的 main() hparams = ModelBase.load_hparams(dir_model) # 读 config.json arch = get_model_architecture(hparams, model_type) # 取 architectures[0] model_class = get_model_class(arch) # 注册表里查到对应子类 model = model_class(dir_model, output_type, fname_out, ...) model.write() # 真正的转换 + 写盘都在这里
这段代码的关键,是入口自己几乎什么都不做——load_hparams 读配置、get_model_architecture 取出架构名、get_model_class 去注册表查类,然后实例化、调 write()。真正的转换逻辑(怎么读张量、怎么改名、怎么写超参)全在那个查到的类里。这种"入口只管分发、细节交给插件"的结构,正是它能从几千行瘦成 300 行的原因:每种架构的特殊处理,都被搬进了 conversion/ 包里各自的模块,互不打扰。那 write() 里到底发生了什么?它依次跑 prepare_tensors(逐张量改名、定量化类型、塞进 writer)和 prepare_metadata(把超参、词表收成元数据),再调 GGUFWriter 写文件头、写 KV、写张量数据,最后 close 收尾——前面那张追踪图的后半截,几乎全压缩在这一个 write() 调用里了。
把这条分发流水定格成一张图最直观:从一个 HF 目录进去,经过"认架构 -> 查类 -> 实例化 -> 转换写盘",最后落出一个 .gguf。看图时抓住一个对比:左边进去的是"按 PyTorch 习惯散落的一堆文件",右边出来的是"按 GGUF 规范收拢的一个文件",中间每一站做的都是同一件事——翻译 + 收拢。
上一步那句"去注册表里查谁负责",是整个 conversion 包的枢纽。注册表本身就是一个普通的字典:架构名(字符串)-> 转换类。神奇的地方在于怎么往里填——靠一个叫 register 的类方法当装饰器。每个架构模块在定义自己的类时,头顶都挂一行 @ModelBase.register("LlamaForCausalLM", ...);Python 加载这个模块时就执行这行,把"这些架构名 -> 这个类"登记进字典。一个类可以认领多个 HF 架构名(Llama / Mistral / Mixtral 共用一套转换逻辑),所以一行 register 往往列着好几个名字:
# conversion/base.py: 注册表 + 装饰器工厂 class ModelBase: _model_classes = {ModelType.TEXT: {}, ModelType.MMPROJ: {}} # 架构名 -> 类 @classmethod def register(cls, *names): # 传入若干 HF 架构名 def func(modelcls): for name in names: cls._model_classes[model_type][name] = modelcls # 登记 return modelcls return func # conversion/llama.py: 一个架构 = 一个注册子类 @ModelBase.register("LlamaForCausalLM", "MistralForCausalLM", "MixtralForCausalLM") class LlamaModel(TextModel): model_arch = gguf.MODEL_ARCH.LLAMA def set_gguf_parameters(self): ... # 写层数/维度/RoPE 等 def modify_tensors(self, data, name, bid): ... # 改名/permute
读懂这段,你就掌握了"给 llama.cpp 加一个新模型"的入口:不用改任何主干代码,只要在 conversion/ 里新建一个模块,写一个 @ModelBase.register("你的架构名") 的子类,实现 set_gguf_parameters(写超参)和 modify_tensors(按需调整张量),它就自动被分发系统认领。官方的 docs/development/HOWTO-add-model.md 走的正是这条路。这就是"注册表 + 插件"的威力:几十种架构,就是 conversion/ 下几十个互不影响的小文件,谁都能照着已有的一个改出下一个——这也是为什么 llama.cpp 能跟上社区里层出不穷的新模型。
分发到 LlamaModel 之后,真正的转换分两条线并行:一条写超参,一条搬张量。超参由 set_gguf_parameters 负责——它把 config.json 里的层数、隐藏维度、注意力头数、RoPE 设置等,一项项写成 GGUF 的元数据键值(比如 llama.block_count、llama.embedding_length)。张量这条线靠 modify_tensors:它遍历每个权重,调 map_tensor_name 把 HF 的命名翻成 GGUF 的规范名,必要时再调整张量本身的形状或排布。核心就这么几行:
# conversion/base.py: 改名 (基类默认实现) def map_tensor_name(self, name): new_name = self.tensor_map.get_name(key=name, try_suffixes=(".weight", ".bias")) if new_name is None: raise ValueError(f"Can not map tensor {name!r}") return new_name def modify_tensors(self, data_torch, name, bid): return [(self.map_tensor_name(name), data_torch)] # 默认: 只改名, 数据照搬
改名靠的是一张映射表 TensorNameMap:它把各家 HF 模型五花八门的命名,统一收敛到 llama.cpp 的规范名上。比如注意力的几个投影,HF 叫 self_attn.{q,k,v}_proj,GGUF 一律叫 blk.N.attn_{q,k,v};名字里的层号按层展开。Llama 还有一个绕不过的坑:它的 Q/K 权重要先 permute 一下——HF 存 Q/K 的行序,和 llama.cpp 的 RoPE 实现所假设的不一样,不重排的话位置编码会算错。正是这种"每家模型一两个特例"的处理,让每个架构都需要自己一个子类。这也解释了一个常见报错 "Can not map tensor ...":它往往不是模型坏了,而是当前架构的 TensorNameMap 还没收录这个张量名,需要在对应子类里补一条映射、或加一点特例处理。下面这张对照表,就是改名这一步最直观的样子:
| HF 名(PyTorch 习惯) | GGUF 规范名 | 说明 |
|---|---|---|
| model.layers.0.self_attn.q_proj.weight | blk.0.attn_q.weight | 注意力 Q 投影(需 permute) |
| model.layers.0.self_attn.k_proj.weight | blk.0.attn_k.weight | 注意力 K 投影(需 permute) |
| model.layers.0.mlp.gate_proj.weight | blk.0.ffn_gate.weight | FFN 门控投影 |
| model.embed_tokens.weight | token_embd.weight | 词嵌入表 |
张量改好名、超参写成 KV 之后,最后一步是把这一切按 GGUF 的字节格式落盘——这活儿交给 gguf-py 里的 GGUFWriter。它写盘严格分四段,顺序不能乱:先文件头(一眼能看出有多少东西),再元数据 KV 段(模型的说明书),再张量信息表(每个权重的目录),最后张量数据段(真正的权重字节)。把这几段竖着叠起来,就是一个 .gguf 文件从头到尾的样子:
文件头是最简单也最关键的一段,就四个定长字段,write_header_to_file 几行写完——读它的人(llama.cpp 的加载器)也是先读这四个数,才知道后面该读多少:
# gguf-py/gguf/gguf_writer.py: 写文件头 (四个字段) fout.write(self._pack("<I", GGUF_MAGIC)) # 0x46554747 = "GGUF" fout.write(self._pack("I", GGUF_VERSION)) # 3 fout.write(self._pack("Q", len(tensors))) # 张量数 (u64) fout.write(self._pack("Q", len(kv_data))) # 元数据条数 (u64)
张量信息表里每条记录都带一个 offset,指明这个张量的数据从数据区的第几个字节开始。写表时偏移是累加出来的:每写完一个张量,下一个的偏移就 += ggml_pad(本张量字节数, 32)——也就是说每个张量都向上对齐到 32 字节。正是这一步让 --outtype 落到实处:你选 f16 还是 q8_0,决定的就是每个张量"占多少字节"、用哪个 GGMLQuantizationType 码写进表里(量化算法本身在 L06/L12 讲过,这里只是把结果按格式记下来)。读到这你应该已经看清:GGUF 没有任何"魔法",它就是一套把"说明书 + 目录 + 数据"对齐着码进一个文件的朴素约定——而正因为朴素,它才能被任何语言、任何平台稳稳地读出来。再补一个常被忽略的点:元数据 KV 段里每个值都是自带类型的——开头一个 GGUFValueType 标记说明它是 u8、i32、f32、字符串还是数组,读的人据此就知道该取几个字节、怎么解析。正因为值自带类型,GGUF 才能把一个整数 block_count、一长串 token 字符串数组、几个浮点的 RoPE 参数全塞进同一段里互不混淆——这正是它能"自描述"的底层支撑。
两个折叠,补两个转换时绕不开、却容易被脚本"自动处理掉"而看不见的细节。
还记得 L14 讲过 llama.cpp 用 mmap 把权重文件直接映射进内存、不做拷贝吗?mmap 要真正发挥威力(尤其是后端做 SIMD / 对齐访问时),数据在文件里的起始地址最好落在整齐的边界上。GGUF 把对齐定为 32 字节(GGUF_DEFAULT_ALIGNMENT = 32,可被元数据 general.alignment 覆盖):每个张量的数据起点都向上取整到 32 的倍数,中间用零填充。代价是文件里多出一点点 padding 字节(每个张量最多浪费 31 字节,对动辄几十 MB 的权重可忽略不计),回报是加载时能整块 mmap、后端能按对齐地址做向量化读取,省掉一次拷贝和潜在的非对齐访问惩罚。这就是为什么上面字节布局那张图里,数据段前面专门留了一截 padding——它不是凑数,是在为运行时的零拷贝映射铺路。一个细节串起两课:L14 讲的是"加载时怎么 mmap 进来",这一课讲的是"转换时怎么把文件码得能被那样 mmap"——同一个对齐约定的两端,一个写、一个读,严丝合缝。顺带一提,对齐这件事在 ggml 里无处不在——张量内存分配、后端缓冲区、KV cache,背后都有类似的"补齐到某个边界"的考量;GGUF 只是把这套思路也带到了磁盘文件上,让"文件里的布局"和"内存里的布局"天然合拍。
模型的权重只是一半,另一半是词表——没有它,模型吐出的 token id 没法变回文字(回顾 L20)。HF 的词表躺在 tokenizer.json / tokenizer.model 里,转换时由 set_vocab 读出来,把每个 token 的字符串、分数(score)、类型(普通 / 控制 / 未知 / 字节)一并写进 GGUF 的元数据。llama.cpp 支持几大类 tokenizer——SentencePiece(Llama 系常用)、BPE(GPT 系)等,set_vocab 会按模型选对应的读法。除了 token 本身,还有一批特殊 token 也要写:BOS / EOS、padding,以及 chat 模板(还记得 L22 吗?聊天模型的对话格式就存在 GGUF 的 tokenizer.chat_template 里)。所以一个 .gguf 是真正"自带电池"的:词表、特殊 token、chat 模板全在里面,加载后不需要再去找任何原始 tokenizer 文件——这也是为什么你只要下载一个 gguf,就能直接跑起一个会聊天的模型,而不必把 HF 仓库里那一堆配套文件也凑齐。给个实感:HF 仓库里那几十个文件(多个 .safetensors 分片、config.json、一堆 tokenizer.*),转换后浓缩成一个 .gguf;这种"一个文件就能跑"的体验,背后正是 set_vocab 这类步骤把零散信息一点点收进元数据的功劳。
You download a model from HuggingFace and get a pile of .safetensors weight shards, plus a config.json and a tokenizer. But llama.cpp speaks exactly one file format end to end: .gguf. That middle step - "convert an HF model to GGUF" - is the star of this lesson, handled by convert_hf_to_gguf.py at the repo root. Many treat it as a black box: run one command, wait a bit, out comes a .gguf. This lesson pries the box open to see the three things it actually does.
Those three things are plain: (1) recognize the architecture - read architectures from config.json and dispatch to the matching converter class (Llama goes to LlamaModel, Qwen to Qwen2Model); (2) rename and align every tensor - HF calls the attention Q projection model.layers.0.self_attn.q_proj.weight, GGUF wants blk.0.attn_q.weight, and the hyper-parameters (layer count, dims, RoPE settings) must be written as GGUF metadata; (3) serialize in GGUF's byte format - write the header, then the metadata key-values, then the tensor-info table, then the actual weights after alignment. Understand these three steps and you can not just "use" the script but, when it does not support your model, know where to go fix it.
One structural change is worth flagging first: convert_hf_to_gguf.py used to be a multi-thousand-line monolith; it has been refactored into a thin command-line entry (about 300 lines), and the real work moved into a standalone conversion/ package - one module per architecture. So this lesson is about "how a package collaborates", not "what is inside one big file". Roadmap: first the dispatch flow behind one command (with a trace), then how the registry wires an "architecture name" to a "converter class", then tensor renaming and hyper-parameters, and finally the byte layout of the GGUF file itself.
Follow one command through. You type python convert_hf_to_gguf.py /path/to/hf-model, and the first thing this thin entry script does is not convert but identify: it opens config.json in the model directory and reads the architectures field - say "LlamaForCausalLM". That is the model's ID card. With the identity in hand it asks a registry one question: "who converts this architecture?" The answer is a Python class (LlamaModel); it instantiates that class and hands everything else to its write() method. The whole main() is surprisingly short:
# simplified from main() in convert_hf_to_gguf.py hparams = ModelBase.load_hparams(dir_model) # read config.json arch = get_model_architecture(hparams, model_type) # take architectures[0] model_class = get_model_class(arch) # look up the subclass in the registry model = model_class(dir_model, output_type, fname_out, ...) model.write() # the real conversion + serialization live here
The key to this code is that the entry does almost nothing itself - load_hparams reads the config, get_model_architecture pulls out the architecture name, get_model_class looks up the class in the registry, then instantiate and call write(). The real conversion logic (how to read tensors, how to rename, how to write hyper-params) all lives in the looked-up class. This "the entry only dispatches, the details go to plugins" structure is exactly why it could slim from thousands of lines to 300: each architecture's special handling moved into its own module under the conversion/ package, out of each other's way. So what happens inside write()? It runs prepare_tensors (rename each tensor, pick its quant type, hand it to the writer) then prepare_metadata (gather hyper-params and vocab into metadata), then calls GGUFWriter to write the header, the KV, and the tensor data, and finally close - the second half of that trace above is almost entirely compressed into this one write() call.
The dispatch flow is clearest frozen into one diagram: in goes an HF directory, through "identify arch -> look up class -> instantiate -> convert and serialize", and out comes a .gguf. As you read the diagram, hold one contrast: what goes in on the left is "a pile of files scattered to PyTorch habits", what comes out on the right is "one file gathered to the GGUF spec", and every station in between does the same one thing - translate plus gather.
That line "look up the registry for who is responsible" is the hinge of the whole conversion package. The registry itself is just an ordinary dictionary: architecture name (a string) -> converter class. The clever part is how it gets filled - through a classmethod called register used as a decorator. Each architecture module, when it defines its class, hangs one line above it: @ModelBase.register("LlamaForCausalLM", ...). Python runs that line when it loads the module, registering "these architecture names -> this class" into the dictionary. One class can claim several HF architecture names (Llama / Mistral / Mixtral share one conversion path), so a single register line often lists several names:
# conversion/base.py: the registry + decorator factory class ModelBase: _model_classes = {ModelType.TEXT: {}, ModelType.MMPROJ: {}} # arch name -> class @classmethod def register(cls, *names): # takes some HF architecture names def func(modelcls): for name in names: cls._model_classes[model_type][name] = modelcls # register it return modelcls return func # conversion/llama.py: one architecture = one registered subclass @ModelBase.register("LlamaForCausalLM", "MistralForCausalLM", "MixtralForCausalLM") class LlamaModel(TextModel): model_arch = gguf.MODEL_ARCH.LLAMA def set_gguf_parameters(self): ... # write layer count / dims / RoPE def modify_tensors(self, data, name, bid): ... # rename / permute
Read this and you hold the entry point for "adding a new model to llama.cpp": without touching any trunk code, you just create a module under conversion/, write a subclass decorated with @ModelBase.register("YourArchName"), implement set_gguf_parameters (write hyper-params) and modify_tensors (adjust tensors as needed), and it is automatically claimed by the dispatch system. The official docs/development/HOWTO-add-model.md walks exactly this path. That is the power of "registry plus plugins": dozens of architectures are dozens of independent little files under conversion/, and anyone can copy an existing one into the next - which is how llama.cpp keeps up with the steady stream of new community models.
After dispatch to LlamaModel, the real conversion runs two lines in parallel: one writes hyper-parameters, the other moves tensors. Hyper-params are handled by set_gguf_parameters - it writes the layer count, hidden dim, attention head count, RoPE settings and so on from config.json as GGUF metadata key-values (such as llama.block_count, llama.embedding_length). The tensor line runs through modify_tensors: it walks each weight, calls map_tensor_name to translate the HF name into the GGUF canonical name, and reshapes or re-lays-out the tensor itself when needed. The core is just a few lines:
# conversion/base.py: renaming (base-class default) def map_tensor_name(self, name): new_name = self.tensor_map.get_name(key=name, try_suffixes=(".weight", ".bias")) if new_name is None: raise ValueError(f"Can not map tensor {name!r}") return new_name def modify_tensors(self, data_torch, name, bid): return [(self.map_tensor_name(name), data_torch)] # default: rename only, data as-is
Renaming relies on a mapping table, TensorNameMap: it converges the wildly varied naming across HF models onto llama.cpp's canonical names. For example the attention projections that HF calls self_attn.{q,k,v}_proj all become blk.N.attn_{q,k,v} in GGUF, with the layer number expanded per layer. Llama also has one unavoidable wrinkle: its Q/K weights must first be permuted - the row order HF stores Q/K in differs from what llama.cpp's RoPE implementation assumes, and without the re-permute the positional encoding computes wrong. It is exactly this "one or two special cases per model" handling that makes each architecture need its own subclass. It also explains a common error "Can not map tensor ...": usually it is not that the model is broken, but that this architecture's TensorNameMap has not yet recorded this tensor name, and you need to add a mapping or a small special case in the matching subclass. The table below is the most direct picture of this renaming step:
| HF name (PyTorch habit) | GGUF canonical name | note |
|---|---|---|
| model.layers.0.self_attn.q_proj.weight | blk.0.attn_q.weight | attention Q projection (needs permute) |
| model.layers.0.self_attn.k_proj.weight | blk.0.attn_k.weight | attention K projection (needs permute) |
| model.layers.0.mlp.gate_proj.weight | blk.0.ffn_gate.weight | FFN gate projection |
| model.embed_tokens.weight | token_embd.weight | token embedding table |
Once the tensors are renamed and the hyper-params written as KV, the last step is to serialize all of it in GGUF's byte format - the job of GGUFWriter in gguf-py. It writes in four sections, strictly in order: first the header (so you can see at a glance how much is inside), then the metadata KV section (the model's manual), then the tensor-info table (a directory of every weight), and finally the tensor-data section (the actual weight bytes). Stack those sections vertically and you have a .gguf file end to end:
The header is the simplest yet most crucial section - just four fixed-length fields, written in a few lines by write_header_to_file. Its reader (llama.cpp's loader) likewise reads these four numbers first to know how much follows:
# gguf-py/gguf/gguf_writer.py: write the header (four fields) fout.write(self._pack("<I", GGUF_MAGIC)) # 0x46554747 = "GGUF" fout.write(self._pack("I", GGUF_VERSION)) # 3 fout.write(self._pack("Q", len(tensors))) # tensor count (u64) fout.write(self._pack("Q", len(kv_data))) # metadata entry count (u64)
Each record in the tensor-info table carries an offset stating which byte of the data area this tensor's data starts at. The offset is accumulated as the table is written: after each tensor, the next offset goes += ggml_pad(this tensor's byte size, 32) - that is, each tensor is rounded up to a 32-byte boundary. This is exactly where --outtype lands: choosing f16 versus q8_0 decides how many bytes each tensor occupies and which GGMLQuantizationType code is written into the table (the quantization algorithm itself was covered in L06/L12; here we only record the result per the format). By now you can see GGUF has no "magic" at all: it is a plain convention for laying "manual + directory + data" into one aligned file - and precisely because it is plain, it can be read back reliably from any language on any platform. One more easily-missed point: every value in the metadata KV section is self-typed - a leading GGUFValueType tag says whether it is a u8, i32, f32, string, or array, so the reader knows how many bytes to take and how to parse them. Because values carry their own type, GGUF can pack an integer block_count, a long array of token strings, and a few floating-point RoPE parameters all into the same section without confusion - exactly the underpinning that lets it be "self-describing".
Two accordions, filling in two details that conversion cannot avoid yet the script "auto-handles" so quietly you never see them.
Recall L14, where llama.cpp uses mmap to map the weight file straight into memory with no copy. For mmap to truly pay off (especially when the backend does SIMD / aligned access), the data's start address in the file should land on a tidy boundary. GGUF sets alignment to 32 bytes (GGUF_DEFAULT_ALIGNMENT = 32, overridable via the general.alignment metadata): each tensor's data start is rounded up to a multiple of 32, with zero padding in between. The cost is a few extra padding bytes in the file (at most 31 wasted per tensor, negligible against weights that run to tens of MB); the payoff is that loading can mmap whole blocks and the backend can do vectorized reads at aligned addresses, saving a copy and a potential misaligned-access penalty. That is why the byte-layout diagram above leaves a stretch of padding before the data section - it is not filler, it paves the way for zero-copy mapping at runtime. One detail ties two lessons together: L14 was about "how to mmap it in at load time", this lesson is about "how to lay the file out at conversion time so it can be mmap'd that way" - the two ends of one alignment convention, one writing, one reading, fitting exactly. Incidentally, alignment is everywhere in ggml - tensor memory allocation, backend buffers, the KV cache all carry a similar "round up to some boundary" consideration; GGUF merely carries that idea onto the on-disk file too, so that "the layout in the file" and "the layout in memory" naturally agree.
The weights are only half; the other half is the vocabulary - without it the token ids the model emits cannot turn back into text (recall L20). HF's vocab lives in tokenizer.json / tokenizer.model; at conversion time set_vocab reads it out and writes each token's string, score, and type (normal / control / unknown / byte) into the GGUF metadata. llama.cpp supports a few tokenizer families - SentencePiece (common for the Llama line), BPE (the GPT line), and others - and set_vocab picks the matching reader per model. Beyond the tokens themselves, a batch of special tokens must be written too: BOS / EOS, padding, and the chat template (remember L22? a chat model's conversation format is stored in GGUF's tokenizer.chat_template). So a .gguf is genuinely "batteries included": vocab, special tokens, chat template all inside, and after loading you need not hunt down any original tokenizer file - which is why downloading one gguf is enough to run a chatting model, without rounding up the pile of companion files from the HF repo. To make it concrete: the dozens of files in an HF repo (several .safetensors shards, config.json, a heap of tokenizer.*) condense after conversion into one .gguf; that "one file and it runs" experience is precisely the work of steps like set_vocab gathering scattered information bit by bit into the metadata.