第4章:元数据编码类型——字符串、整数、浮点数、bool 与数组
做 GGUF 解析,最绕不开的一块就是元数据。说白了,元数据就是模型的“身份证”加“说明书”。它告诉你这模型叫什么、用了什么量化方式、上下文长度多大、甚至训的时候用了什么数据集。
但 GGUF 的设计者没让这些信息随便写,而是规定了严格的编码类型。每种类型在二进制里存成什么样子,偏移多少,字节对齐怎么处理,都有讲究。我刚开始碰 GGUF 时,就因为在 bool 的读取上少读了几个字节,导致调试时整个模型都加载不出来。嗯,这里面有不少坑。
4.1 为什么要有固定类型
GGUF 文件的目标是“一次写入,到处加载”。不同语言、不同硬件平台,只要按同一套规则解析,元数据就能原样复原。如果类型定义模糊,C 里写个 int,Python 里以为是 long,那就乱套了。
所以 GGUF 给每种元数据分配一个唯一的类型 ID(uint32,记在 key-value 对的 value 前面)。总共 6 种基础类型 + 1 种数组类型,足够覆盖模型描述的所有场景。
关键概念: 所有元数据值都紧跟在类型 ID 之后,不填充额外字节。唯一例外是字符串和数组,它们内部有长度字段,保证解析器能确定边界。
4.2 字符串类型(type=8)
字符串是最常用的元数据类型。模型名称、作者、描述……全是字符串。
GGUF 的字符串编码很简单:
- 先写 8 字节(uint64):字符串的字节长度
len,不包含结尾\0。 - 再写
len个字节:UTF-8 编码的文本内容。 - 然后没有额外的填充或终止符。
typedef struct {
uint64_t len; // 8 bytes, little-endian
char data[len]; // UTF-8, no null terminator
} gguf_string_t;
我遇到过一个案例:用某个工具生成了带 BOM(Byte Order Mark)的 UTF-8 字符串,结果其他解析器读到前三个字节 EF BB BF,直接当成字符串的一部分,导致模型名显示成 \ufeffLlama-2。所以记得——不要加 BOM,纯 UTF-8 就好。
避坑指南: 我曾经从 JSON 转写时忘了去掉 JSON 字符串的转义符号,结果模型描述里出现了 \n 字面量而不是换行。建议写入前做一次 repr 反向转义。
4.3 整数类型(type=0~4)
GGUF 支持 4 种整数位宽,每种都对应一个单独的 type ID。这里的设计很直接:
| 类型 ID | C 类型 | 字节数 | 取值范围 |
|---|---|---|---|
| 0 | uint8 |
1 | 0 ~ 255 |
| 1 | int8 |
1 | -128 ~ 127 |
| 2 | uint16 |
2 | 0 ~ 65,535 |
| 3 | int16 |
2 | -32,768 ~ 32,767 |
| 4 | uint32 |
4 | 0 ~ 4,294,967,295 |
全部采用小端序(little-endian)。如果你写解析器,记得用 struct.unpack('<I', ...) 之类的方式,别被字节序坑了。
你可能会问:为什么没有 int32 或 int64 ?我个人观察,GGUF 的设计者希望元数据尽可能紧凑。大多数元数据(比如层数、头数)用 uint32 或 uint16 就够了。如果真的需要更大的值,可以用数组或升级到 uint64(其实是 type=5)。
注意: type=5 在官方文档里被定义为 uint64 或 int64(依据上下文)。但实际使用中很多工具只认 uint64。如果你的值可能为负数,建议用 int32 走 type=3。
4.4 浮点数类型(type=6~7)
GGUF 支持两种浮点数:32 位(float32)和 16 位(float16,也就是 half float)。
| 类型 ID | 精度 | 字节数 | C 类型 |
|---|---|---|---|
| 6 | float32 | 4 | float |
| 7 | float16 | 2 | uint16(按 IEEE 754 半精度解释) |
float32 大家都很熟。float16 常见于存储模型的一些统计值,比如 layer norm 的权重范围。如果你用 Python,可以用 struct.unpack('<e', ...) 直接解析 float16——没错,Python 3.6+ 已经支持 'e' 格式字符了。我最初不知道这个,还自己写了个半精度转换函数,后来发现标准库早就有了。
import struct
# 解析一个 float16 值
data = b'\x00\x3c' # 1.0 的 half 表示
val = struct.unpack('<e', data)[0]
print(val) # 1.0
4.5 布尔类型(type=9)
Bool 是最容易出 Bug 的类型——因为 GGUF 的 bool 占用 1 字节,且只有 0 和 1 合法。
- type ID = 9
- 紧接着写入 1 字节:0 表示 false,1 表示 true。
- 其他值(如 2、255)均视为格式错误。
为什么我特别提醒?因为我见过某些工具写 bool 时直接用了 uint8 类型(type=0),然后值设为 1。解析时看到 type=0 以为是无符号整数,结果后续所有字段的偏移都算错了。所以写入时千万要分清类型 ID——bool 就是 9,不是 0。
实操建议: 使用 C 语言写生成器时,可以定义一个 gguf_bool_writer() 函数,显式写入一个字节的 0 或 1,并检查输入值是否合法。不要依赖 !! 转换为 0/1 之外的逻辑。
4.6 数组类型(type=10)
数组是元数据里最灵活的类型。它允许把多个相同类型的值打包成一个字段。比如 tokenizer.ggml.tokens 就是一个字符串数组。
数组编码的结构如下:
- 写入类型 ID = 10(uint32)
- 写入 4 字节(uint32):数组长度
n_elements - 写入 4 字节(uint32):内部元素的基础类型 ID(比如 string=8)
- 依次写入
n_elements个该类型的值,每个值按该类型的规则编码。
注意: 数组元素类型必须是基础类型之一(0-9),不允许嵌套数组(即 type 不能是 10)。GGUF 目前不支持多维数组。
举个例子,一个包含两个字符串 ["hello", "world"] 的数组:
// 二进制示意(十六进制):
0A 00 00 00 // type=10 (uint32)
02 00 00 00 // n=2 (uint32)
08 00 00 00 // element_type=8 (string)
05 00 00 00 00 00 00 00 hello // 第一个字符串:len=5, 内容 "hello"
05 00 00 00 00 00 00 00 world // 第二个字符串:len=5, 内容 "world"
我自己写 Python 解析器时,用的是 struct.iter_unpack 加递归函数。数组元素类型从元数据读出来,然后分派到对应类型解析器。这样才能保证扩展性。
4.7 类型汇总与对照表
为了快速查阅,我把所有类型整理成一张表。建议你保存下来,写代码时直接对照。
| 类型 ID | 名称 | C 类型 | 字节数 | 备注 |
|---|---|---|---|---|
| 0 | UINT8 | uint8_t | 1 | 无符号8位 |
| 1 | INT8 | int8_t | 1 | 有符号8位 |
| 2 | UINT16 | uint16_t | 2 | 小端序 |
| 3 | INT16 | int16_t | 2 | 小端序 |
| 4 | UINT32 | uint32_t | 4 | 小端序 |
| 5 | UINT64(或 INT64) | uint64_t | 8 | 按文档约定多为无符号 |
| 6 | FLOAT32 | float | 4 | IEEE 754 单精度 |
| 7 | FLOAT16 | uint16_t | 2 | IEEE 754 半精度 |
| 8 | STRING | gguf_string_t | 8 + len | 前8字节为长度 |
| 9 | BOOL | uint8_t | 1 | 仅0或1 |
| 10 | ARRAY | 自定义 | 4 + 4 + n*element_bytes | 内部元素类型为0-9 |
4.8 元数据编码结构总图
下面这张图把整个元数据 key-value 的编码流程串起来。你可以把它当成写解析器时的“导航图”。
4.9 我常用的解析框架
有了上面的类型映射,写解析器就变成了一张“类型分发表”。我个人习惯在 Python 里用字典把 type ID 映射到读取函数:
TYPE_READERS = {
0: lambda f: struct.unpack('<B', f.read(1))[0],
1: lambda f: struct.unpack('<b', f.read(1))[0],
2: lambda f: struct.unpack('<H', f.read(2))[0],
3: lambda f: struct.unpack('<h', f.read(2))[0],
4: lambda f: struct.unpack('<I', f.read(4))[0],
5: lambda f: struct.unpack('<Q', f.read(8))[0],
6: lambda f: struct.unpack('<f', f.read(4))[0],
7: lambda f: struct.unpack('<e', f.read(2))[0],
8: lambda f: read_gguf_string(f), # 自定义函数
9: lambda f: bool(struct.unpack('<B', f.read(1))[0]),
10: lambda f: read_gguf_array(f, TYPE_READERS), # 递归
}
这样不管是读单值还是读数组,都能一行调度。我在生产环境里用这个模式跑了大半年,没出过问题。
最后再啰嗦一句: 永远不要在解析时假设字节对齐。GGUF 的元数据区是紧凑排列的,不会有 padding。如果你看到解析结果错位,99% 是因为类型 ID 或数组长度读错了。建议在关键位置打印十六进制流做交叉验证。