本地跑模型:看清那几百 MB 里到底是什么

LLM 原理第 2 / 2 篇

本文的每个数字都是跑出来的。 环境是 Ollama 0.33.2 / Apple Silicon,验证代码在仓库 experiments/model-files/ (gguf.mjs 直接读文件头,不经过任何推理框架;run.mjs 跑质量与 token 对照)。 全程离线,不需要 API key,共约 2.7 GB 磁盘。

把模型跑起来只要两条命令。这一篇讲的是跑起来之后的事: ollama pull 下来的那几百 MB,具体是什么。

这个问题有实际用处,因为关于量化的说法里有一批是错的, 而它们全都可以用一个 GGUF 解析器当场证伪。

一、GGUF 里存了什么

GGUF 的头部是一段自描述的 key-value,后面跟张量表,最后才是权重数据。 自己写解析器不到 150 行:magic GGUF → version → 张量数 → metadata 条数 → KV 对 → 每个张量的(名字、维度、类型、偏移)。

qwen2.5:0.5b-instruct-q4_K_M 读出来是:

GGUF v3 · 290 个张量 · 34 条 metadata · 文件 397.8 MB

qwen2.block_count              24
qwen2.embedding_length         896
qwen2.feed_forward_length      4864
qwen2.attention.head_count     14
qwen2.attention.head_count_kv  2        ← GQA:14 个 Q 头共用 2 组 KV
qwen2.context_length           32768
tokenizer.ggml.tokens          151936 个 token
tokenizer.ggml.merges          151387 条 BPE 合并规则

先验一下解析器有没有读歪,否则后面的结论全部作废:

张量名里出现 24 个层号(blk.0 … blk.23),metadata 声明 block_count=24 → 对上了。

这一步很便宜,但它是整篇文章唯一的地基。metadata 和张量表是文件里两处独立的信息, 如果我把游标读串了,几乎不可能让这两个数恰好相等。

一层里有 12 个张量:

attn_norm.weight   [896]         F32
attn_q.weight      [896,896]     Q5_0      attn_q.bias  [896]  F32
attn_k.weight      [896,128]     Q5_0      attn_k.bias  [128]  F32
attn_v.weight      [896,128]     Q8_0      attn_v.bias  [128]  F32
attn_output.weight [896,896]     Q5_0
ffn_norm.weight    [896]         F32
ffn_gate.weight    [896,4864]    Q5_0
ffn_up.weight      [896,4864]    Q5_0
ffn_down.weight    [4864,896]    Q6_K

attn_k / attn_v 是 [896,128] 而 attn_q 是 [896,896] —— 这就是 GQA 在文件里的样子,KV 的宽度只有 Q 的七分之一。 上一篇开头那张形状表, 到这里可以在文件里逐个指出来了。

层外只有两个张量。其中一个值得单独说:

token_embd.weight  [896,151936]  Q8_0   144.6 MB

⭐ 这一个张量占整个 397.8 MB 文件的 36%。 词表有 151936 个 token, 每个 token 一个 896 维向量。也就是说这个模型三分之一的体积是「词表长什么样」, 和它会不会推理无关。小模型尤其如此 —— 词表大小不随参数量缩小。

二、叫 q4 的那一档,只有 7.5% 是 q4

把所有张量按量化类型汇总:

类型 张量数 字节 占比 平均每权重
Q5_0 132 173.2 MB 44.2% 5.50 bit
Q8_0 13 146.1 MB 37.3% 8.50 bit
Q6_K 12 42.9 MB 10.9% 6.56 bit
Q4_K 12 29.4 MB 7.5% 4.50 bit
F32 121 0.3 MB 0.1% 32.00 bit

全档平均 6.35 bit/权重。 名字里的那个 4 不是这个文件的属性。

为什么?看第一节那张表:ffn_down 是 [4864,896] 用 Q6_K, 其余的 [896,*] 全部回退到 Q5_0 / Q8_0。4864 能被 256 整除,896 不能 (896 = 3×256 + 128)。K-quant 的超块是 256 个元素,行长凑不齐就用不了。

把 169 个多维量化张量交叉制表:

行长 %256==0 行长 %256≠0
用了 K-quant 24 0
没用 K-quant 0 145

零反例。但这张表还不能下结论,因为它有一个混淆项:

⚠️ 这个模型只有两种行长,而行长和张量角色完全重合 —— 4864 全是 ffn_down,896 全是别的。「256 整除决定了它」和 「只有 ffn_down 用 K-quant」在这份数据上完全无法区分。

拆开的办法是换一个维度不一样的模型。llama3.2:1b-instruct-q4_K_M 的行长是 2048 和 8192,两种都能被 256 整除:

行长 %256==0 行长 %256≠0
用了 K-quant 113 0
没用 K-quant 0 0

在 qwen 里回退掉的那些角色 —— attn_q、ffn_gate、ffn_up —— 在 llama 里全部用上了 K-quant。角色没变,结论翻转了。 ⇒ 决定因素是行长,不是角色。

这件事的后果可以直接称重:

模型 标称档位 实际平均
qwen2.5 0.5B q4_K_M 6.35 bit/权重
llama3.2 1B q4_K_M 5.18 bit/权重

同一个档位名,差 23%。 所以「我用 q4_K_M」这句话, 在不知道模型隐藏维度之前,没有确定的含义。

三、量化省的显存,比文件小的那部分少

三档各载入一次,读 Ollama 的 /api/ps:

档位 权重文件 载入后显存 差
fp16 994 MB 1525 MB +531 MB
q8_0 531 MB 1062 MB +531 MB
q4_K_M 398 MB 929 MB +531 MB

⭐ 那个固定开销三档一模一样:531 MB。 它是 KV cache 加激活,不随量化档变。

于是「换到 q4_K_M 省 60%」这句话在显存上是不成立的: 文件小了 60%,实际显存只小了 39%。模型越小,这个固定开销占比越大 —— 0.5B 这一档,它已经比权重本身还大了。

(这一轮没有改 context 长度,所以没测那 531 MB 随 context 怎么变。 它在别的 context 设置下是别的数。)

四、判据:「量化不影响质量」要给出差异数

stub 给这一篇定的判据是:

「量化不影响质量」这类说法必须给出差异数;20 题里差 0 题和差 6 题是完全不同的结论。

20 道有唯一短答案的题,temperature=0,三档各跑一遍,以 fp16 为基准:

档位 逐字节相同 答对数
fp16 20/20(自身) 14/20
q8_0 19/20 14/20
q4_K_M 14/20 13/20
  • 6/20 题里至少有一档的字节与 fp16 不同
  • 其中 1/20 题的对错发生了翻转

所以这一组上的答案是:字节差 6/20,对错差 1/20。

⭐ 为什么要同时报这两个数。只报「逐字节相同 14/20」会让 q4 看起来很糟, 但字节不同大多是措辞不同;只报「答对数 13 vs 14」又会掩盖掉 30% 的回答确实变了这件事。两个数测的是不同的东西: 一个是「输出变没变」,一个是「变得有没有代价」。

还要注意 fp16 自己也只答对 14/20 —— 这是个 0.5B 模型。 基准本身不是满分,所以「q4_K_M 答对 13」不能读成「量化让它错了 1 题」, 只能读成「在这一组题上,量化前后的正确数相差 1」。

⚠️ 这个量具测不到的东西同样要说清楚:20 道短答案题, 够不到推理链变长之后的退化。量化对长链条推理的影响是另一个实验, 这一篇没有做。

五、同名模型在不同平台上答案不一样

这是 stub 里列的第三个题目。跑完前面几节,它其实已经有三个互相独立的来源了:

  1. 文件本身就不同。 「q4_K_M」不指定一个确定的比特分配 —— 同一个档位名在两个模型上是 6.35 bit 和 5.18 bit(第二节)。
  2. 量化档之间的输出本来就有差异。 同一台机器、同一个 prompt、 temperature=0,q4_K_M 和 fp16 有 6/20 题字节不同(第四节)。
  3. 服务端配置会改结果。 这是上一篇量的: 开了 KV cache 量化之后,同一个 prompt 的输出取决于你上一个问题问了什么。

三条里没有一条需要「模型有随机性」来解释。它们全都是确定性的, 只是那些输入没有写在你的请求里。

六、tokenizer 的口径不一样

最后一件在文件里的东西:词表。qwen2.5 是 151936 个 token, llama3.2 是 128256 —— 都从 GGUF 头部读出来。

同一段文本,两边各算多少 token(已减掉 chat 模板的固定开销, qwen 是 29、llama 是 25):

样本 字符 qwen2.5 llama3.2 比值
纯中文 25 15 tok (0.60/字) 22 tok (0.88/字) 1.47×
中英混排 51 19 tok 19 tok 1.00×
纯英文 69 14 tok 14 tok 1.00×
代码 77 35 tok 35 tok 1.00×

中文差 47%,英文一个不差。 这直接决定按 token 计费时的成本 —— 同一份中文文档,换个模型可能贵一半,而这和模型贵不贵无关。

这张表第一次跑出来的时候我以为量具坏了:两个词表大小都不同的 tokenizer, 在三行上给出完全相同的数,看着像「根本没按模型重新分词」。 查原始值之后确认不是 —— 纯中文 15 vs 22,翻倍后 30 vs 44,严格成比例。 英文和代码上的相同是真的相同。

⭐ 但留下的教训是反过来的一条:「总数相同」不等于「口径相同」。 中英混排那行两边都是 19,可里面的中文部分两边切得不一样, 只是英文部分的差异恰好把它抵消了。拿一句话的总 token 数 去验证两个 tokenizer 是否等价,会得出错误结论。


本文没有回答的三个问题:

一是 safetensors。stub 里写的是「safetensors / GGUF 的结构」, 这一篇只做了 GGUF。safetensors 是另一种布局(JSON 头 + 连续张量), 本文一个字节都没读过它,不要把这里的结论套过去。

二是 那 531 MB 随 context 怎么变。三档下它是常数, 但这一轮 context 设置没动过 —— 它大概率是 context 的函数, 而本文只在一个点上采了样。

三是 量化对长链条推理的影响。第四节的 20 题都是短答案, 测得到「答案变没变」,测不到「推到第五步才崩」。 那需要另一组题和另一个判据。