第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 就是一个字符串数组。

数组编码的结构如下:

  1. 写入类型 ID = 10(uint32)
  2. 写入 4 字节(uint32):数组长度 n_elements
  3. 写入 4 字节(uint32):内部元素的基础类型 ID(比如 string=8)
  4. 依次写入 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 类型 字节数 备注
0UINT8uint8_t1无符号8位
1INT8int8_t1有符号8位
2UINT16uint16_t2小端序
3INT16int16_t2小端序
4UINT32uint32_t4小端序
5UINT64(或 INT64)uint64_t8按文档约定多为无符号
6FLOAT32float4IEEE 754 单精度
7FLOAT16uint16_t2IEEE 754 半精度
8STRINGgguf_string_t8 + len前8字节为长度
9BOOLuint8_t1仅0或1
10ARRAY自定义4 + 4 + n*element_bytes内部元素类型为0-9

4.8 元数据编码结构总图

下面这张图把整个元数据 key-value 的编码流程串起来。你可以把它当成写解析器时的“导航图”。

元数据 Key-Value 编码结构 Key(字符串) Value(变长) 先写 Key 字符串 再写 Value Key 结构 uint32 type = 8 uint64 strlen char str[strlen] Value 结构 uint32 value_type(0~10) 然后根据 value_type 读对应数据 例如:type=9 → 1 byte bool;type=6 → 4 byte float 若 type=10:数组内嵌类型+长度 全部小端序 无填充对齐

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 或数组长度读错了。建议在关键位置打印十六进制流做交叉验证。