1870 字
9 分钟
MiniCPM5导入Ollama修复记录
2026-08-12
Purely By AI

背景#

将本地的 MiniCPM5-1B Q8 GGUF 模型导入 Ollama,预期为一条命令:

ollama create minicpm5 -f Modelfile

模型成功注册,ollama list 显示 minicpm5:latest(约 1.2 GB)。但使用中文对话时,模型将输入识别为乱码字符;改用英文提问则回答正常。

英文正常、中文乱码这一现象,是后续定位问题的关键线索。

一、症状分析:问题定位到 tokenizer#

复现现象如下:

用户: 你好,用一句话介绍你自己
模型: 您输入的内容似乎包含一些特殊字符和符号……
用户: What is the capital of France?
模型: The capital of France is Paris.

判断依据:

  • 若中英文均异常,问题大概率在模型权重或聊天模板
  • 若仅中文异常,问题集中在 tokenizer

tokenizer 负责将文本切分为 token 并映射到 token id,模型权重按 token id 训练。英文在常见的两种编码方案下字节序列恰好一致,因此不受影响;中文为多字节字符,编码方案一旦错位即整体出错。

二、排查聊天模板(排除)#

ollama show minicpm5 --modelfile 显示模板为裸的 {{ .Prompt }},说明 Ollama 未能从 GGUF 中识别聊天模板。使用 Python 解析 GGUF 元数据后,发现文件内嵌了完整的 tokenizer.chat_template:Qwen 风格 ChatML 模板(<|im_start|> / <|im_end|>),包含 tools 与 thinking 分支。

据此手工编写简化版 ChatML 模板并重新导入,中文仍乱码——排除模板因素。

三、根因:字节级 BPE 的 pre 字段错配#

编写脚本解析 GGUF 元数据,提取 tokenizer.ggml.tokenstokenizer.ggml.merges

GGUF 规范的一个细节:metadata 的 value type 为 4 字节 uint32,而非 1 字节。解析时若按 1 字节读取,字段偏移会整体错位。

提取结果暴露出矛盾:

token 示例: 'Ġhello' (Ġ = U+0120,GPT2 空格标记)
'好' (好 的字节级美观表示)
tokenizer.ggml.pre = "llama-bpe"

即:

  • tokens 存储为 GPT2「美观字节格式」——将每个字节映射为对应 Unicode 字符(空格 → Ġ,UTF-8 高字节 → 拉丁字符)
  • pre = "llama-bpe" 指示 llama.cpp 将这些 token 按原始字节处理

二者相互矛盾。llama.cpp 依据 pre 决定是否执行 byte↔unicode 映射:

  • pre = "gpt-2":对输入执行字节编码后与美观格式的 vocab 匹配,结果正确
  • pre = "llama-bpe":按原始字节匹配,输入 (字节 E5 A5 BD)无法命中 vocab 中的 好(字节 C3 A5 C2 A5 C2 BD),中文全部错位

ASCII 在两种编码下字节相同,因此英文不受影响。该 GGUF 大概率由转换工具配错了 pre 字段。

使用 HuggingFace tokenizers 库以 GPT2 字节编码重建 vocab/merges 验证:「你好,用一句话介绍你自己」可正确切分为 vocab 内的真实 token id 且回译一致——数据本身自洽,只是 llama.cpp 被告知了错误的 pre

四、修复:重建 GGUF#

修复方案为将 tokenizer.ggml.prellama-bpe 改为 gpt-2。实现过程中遇到两个问题:

问题 1:支持的取值是 gpt-2,而非 gpt2

首次尝试 gpt2 时,llama-server 直接拒绝加载:

error loading model vocabulary: unknown pre-tokenizer type: 'gpt2'

从 Ollama 内置的 llama.dll 中提取 pre 类型字符串表,确认支持的写法为 gpt-2

问题 2:修改 metadata 需要重建整个文件。

GGUF 布局为「header → metadata → tensor info → tensor data」。metadata 位于文件头部,其长度变化会改变后续所有 tensor 的偏移量,无法原地修改。

可行的技巧:tensor data 区域整体原样复制;tensor 的 offset 相对 data 区域起点定义,metadata 长度变化不影响相对偏移。 因此只需重写 metadata,保持 tensor info 与 data 区域不变。

重建脚本 fix_gguf_pre.py(见附录)流程如下:

1. 解析全部 metadata(34 条 KV)与 219 个 tensor info
2. 仅替换 pre 字段为 gpt-2,其余 metadata 原样写回
3. tensor info 原样写回(offset 不变)
4. 按 32 字节对齐后,将原文件 tensor data 区域整体复制

对比修复前后 sha256:除 metadata 段外,tensor 数据逐字节一致,模型权重未受影响。

验证(llama-server /tokenize 接口):

输入: 你好,用一句话介绍你自己
tokens: [75828, 337, 1066, 49667, 13498, 62100]
回译: 你好,用一句话介绍你自己 ✅ 完全一致

五、遗留问题:推理模型与终端编码#

tokenizer 修复后,通过 Ollama 测试仍偶发空回复或乱码。排查确认这两项均非模型问题:

1. 模型为推理模型。 MiniCPM5 会先在 <think> 块内生成推理内容,再输出最终答案。Ollama / llama-server 将推理内容单独放入 thinking / reasoning_content 字段,content 字段仅含最终答案。若 max_tokens 不足,推理内容会消耗全部生成预算,导致 content 为空:

content: 中国的首都是**北京**。
thinking: 1. Identify the core question: The user is asking "中国的首都是哪里?"……

2. Windows 终端默认使用 GBK 编码。 通过命令行 curl 发送中文时,字符被编码为 GBK 字节,模型收到即为乱码。改用 Python requests 以 UTF-8 发送后恢复正常:

# curl(GBK)→ 模型: "您的消息似乎被转写错了……"
# Python requests(UTF-8)→ 模型: "中国的首都是**北京**。" ✅

最终验证:

用户: 用一句话介绍你自己
模型: 我是MiniCPM系列模型,由面壁智能(ModelBest)和OpenBMB开源社区开发的。
用户: 12乘以8等于多少?
模型: 12乘以8等于96。

六、经验总结#

① 症状驱动定位。 「英文正常、中文乱码」直接指向 tokenizer;中英文均异常才考虑权重或模板。

② 字节级 BPE 的 pre 字段是隐蔽陷阱。 GPT2 风格 tokenizer 的 tokens 可以是「美观字节格式」或「原始字节」两种形态,tokenizer.ggml.pre 决定 llama.cpp 的解释方式。该字段常被转换工具配错,且 ASCII 恰好掩盖问题,仅多字节文本(如中文)会暴露。

③ GGUF 规范需仔细阅读。 metadata value type 为 4 字节 uint32,这一细节可能导致解析偏移错位。

④ 命令行测试中文前确认编码。 Windows 下 curl 默认使用系统代码页(GBK),应使用 Python 或显式指定 UTF-8,避免复现并不存在的 bug。

结论:若模型「英文正常、中文乱码」,优先检查 tokenizer.ggml.pre 是否错配。

附录:fix_gguf_pre.py#

import struct
SRC = r'D:\AI\MiniCPM5-1B-Q8_0.gguf'
DST = r'D:\AI\MiniCPM5-1B-Q8_0-fixed.gguf'
NEW_PRE = b'gpt-2'
ALIGN = 32
SZ = {0:1,1:1,2:2,3:2,4:4,5:4,6:4,7:1,8:0,9:0,10:8,11:8,12:8}
with open(SRC, 'rb') as f:
head = f.read(24)
assert head[:4] == b'GGUF', 'not gguf'
version = struct.unpack('<I', head[4:8])[0]
n_tensors = struct.unpack('<Q', head[8:16])[0]
n_kv = struct.unpack('<Q', head[16:24])[0]
def read_str():
n = struct.unpack('<Q', f.read(8))[0]
return f.read(n)
def read_val(vtype):
if vtype == 8: # STRING
return read_str()
if vtype == 9: # ARRAY
et = struct.unpack('<I', f.read(4))[0]
cnt = struct.unpack('<Q', f.read(8))[0]
return (et, [read_val(et) for _ in range(cnt)])
if vtype == 7: # BOOL
return f.read(1)
return f.read(SZ[vtype])
# metadata(值保留原始字节,保证无损回写)
kvs = []
for _ in range(n_kv):
key = read_str()
vtype = struct.unpack('<I', f.read(4))[0]
val = read_val(vtype)
if key == b'tokenizer.ggml.pre':
val = NEW_PRE
kvs.append((key, vtype, val))
# tensor info
tensors = []
for _ in range(n_tensors):
name = read_str()
n_dims = struct.unpack('<I', f.read(4))[0]
dims = f.read(8 * n_dims)
ttype = f.read(4)
offset = f.read(8)
tensors.append((name, n_dims, dims, ttype, offset))
data_start = f.tell()
pad0 = (ALIGN - data_start % ALIGN) % ALIGN
data_region_begin = data_start + pad0
f.seek(0, 2)
data_len = f.tell() - data_region_begin
def wstr(buf, b):
buf.write(struct.pack('<Q', len(b)))
buf.write(b)
def wval(buf, vtype, val):
if vtype == 8:
wstr(buf, val)
elif vtype == 9:
et, items = val
buf.write(struct.pack('<I', et))
buf.write(struct.pack('<Q', len(items)))
for it in items:
wval(buf, et, it)
else:
buf.write(val)
with open(DST, 'wb') as out:
out.write(b'GGUF')
out.write(struct.pack('<I', version))
out.write(struct.pack('<Q', n_tensors))
out.write(struct.pack('<Q', n_kv))
for key, vtype, val in kvs:
wstr(out, key)
out.write(struct.pack('<I', vtype))
wval(out, vtype, val)
for name, n_dims, dims, ttype, offset in tensors:
wstr(out, name)
out.write(struct.pack('<I', n_dims))
out.write(dims)
out.write(ttype)
out.write(offset)
pos = out.tell()
out.write(b'\x00' * ((ALIGN - pos % ALIGN) % ALIGN))
with open(SRC, 'rb') as src:
src.seek(data_region_begin)
remaining = data_len
while remaining:
chunk = src.read(min(8 * 1024 * 1024, remaining))
if not chunk:
break
out.write(chunk)
remaining -= len(chunk)
这篇文章对你有帮助吗?
🐈
Hi,我是 YuBlogAI