Chat-Template 不匹配导致本地 LLM 输出乱码

本地 LLM 把你的问题原样吐回、在回复里夹着 [INST] 或 <|im_start|> 标签、或者一句话重复个没完,这都是 chat-template 不匹配。教你找到模型真正的模板,并强制引擎使用它。

你在 llama-server 里加载 Mistral-7B-Instruct-v0.3 并发一条聊天消息,结果模型不是干净地回答,而是把你整条消息原样吐回来,在回复中间夹着 [INST] 标记,接着开始自己编下一轮”用户提问”。又或者你加载 Llama 3,输出直接以纯文本 <|im_start|>assistant 开头。这些都是 chat-template 不匹配的症状:tokenizer 和推理引擎对”如何包装这段对话”没有达成一致,模型收到的 token 序列看起来像是被人从对话中间注入的,于是只能生成噪声。

最快的修复方式(截至 2026 年 6 月):llama-server 加上 --jinja 参数。它会让 llama.cpp 使用 GGUF 文件里内嵌的 Jinja chat template(由其内置的 minja 引擎解析),而不是去猜一个命名模板。--jinja 在较新版本里默认开启,但建议显式写出,避免旧配置悄悄回退到错误格式。如果 GGUF 里根本没有内嵌模板,直接看 Step 1 读出模型的真实模板并传入正确的命名模板。

先判断你属于哪一类

在动手改之前,先把你看到的具体症状对应到最可能的原因。

你看到的症状最可能的原因跳转到
回复里出现纯文本 [INST]<|im_start|><|eot_id|>给模型家族用错了命名模板原因 1 / Step 2
输出连贯但停不下来,或一句话反复重复该模板没注册正确的 stop/EOS token原因 6 / Step 5
模型只是续写你的文字,从不”回答”你下载的是 base 版而非 instruct原因 2
Ollama 里正常,裸 llama.cpp 里乱码引擎没套任何模板(走了 raw completion)原因 3
第一个 token 乱码或语言不对BOS token 被重复添加原因 5 / Step 4
系统提示词漏到了可见回答里system 消息被放在模板结构之外原因 4

常见原因

按命中率从高到低排列。

1. 给模型家族用错了命名模板

llama.cpp、llama-server、Ollama 各自带了一份内置模板名清单。如果你给 Llama 3 模型传 --chat-template llama2,引擎会把消息包进 [INST] ... [/INST],而不是 Llama 3 训练时用的 <|begin_of_text|>...<|eot_id|> 格式。模型从没见过 Llama 2 的包装方式,自然输出混乱。

2026 年常见的一个坑:模板名比大家想象的更”具体”。Mistral-7B-Instruct-v0.3 要用 mistral-v3不是 mistral(根本没有这个名字)。Qwen2.5 用 chatml,没有 qwen2 这个值。Mistral Nemo 用 mistral-v3-tekken;Mistral Large 2411 用 mistral-v7

怎么判断:运行 ./llama-cli --chat-template-help(或查看官方支持模板的 wiki),把这些名字和模型 HuggingFace tokenizer_config.json 里的 chat_template Jinja 字符串逐一对比。

2. 你下载的是 base 版,不是 instruct 版

Base(预训练)权重没有经过 SFT/RLHF,对任何 chat template 都不响应。它只会续写你喂进去的 token,读起来就是一段无视你问题的胡言乱语。

怎么判断:检查 GGUF 文件名和 HuggingFace 仓库名里有没有 InstructChat-it。如果写的是 base 或没有后缀,就是拿错了权重。base 模型再怎么换模板也救不回来。

3. GGUF 里没有内嵌模板,引擎用了通用模板

convert_hf_to_gguf.py 会把 tokenizer 的 chat_template 复制进 GGUF 元数据。如果转换时用了旧脚本或第三方转换器,模板可能丢失,引擎只能回退到一个和模型对不上的通用格式。这种情况在裸 llama-cli(默认不做模板包装)里会出乱码,但在 Ollama(自己套模板)里不会。

怎么判断

python3 -c "import gguf; r = gguf.GGUFReader('model.gguf'); print(r.fields.get('tokenizer.chat_template'))"

如果输出是 None,说明 GGUF 里没有模板,你必须显式补上。

4. 系统提示词被放在模板结构之外

某些模型(Mistral v0.3、Qwen2.5)期望把系统提示嵌进第一个/最后一个 [INST] 块里,而不是作为独立的 system 轮次。如果你的客户端发了 {"role": "system", "content": "..."},而模板又不处理独立的 system 角色,系统文本就会漏到可见回答的错误位置。

怎么判断:去掉 system 消息,只发 user 消息。如果输出变好了,问题就出在 system 角色的位置上。

5. BOS(序列起始)token 被加了两次

有些封装层在调用 tokenizer 之前手动加了一次 BOS,而 tokenizer 自己也会加;Jinja 模板可能再加第三次。双重 BOS 会扰乱位置 0 的位置编码,症状是第一个 token 乱码、或者前缀冒出错误的语言。较新的 llama.cpp 会打印类似 added a BOS token to the prompt as the prompt already starts with a BOS token 的警告。

怎么判断:用 --verbose 启动 llama-server 看这行警告,或者检查开头的 token ID。如果模型的 BOS id(Llama 系是 1)在开头出现了两次,就有东西在重复添加它。

6. stop/EOS token 没注册,或对话历史角色顺序错了

如果引擎不知道模型的”轮次结束”token,模型答完之后会继续往下写,自己幻想出下一轮用户提问,或者把一句话循环到底。另外,如果你的客户端把角色发成 [assistant, user, assistant] 而不是严格交替的 [user, assistant, user],模型会看到一个没有提问就出现的 assistant 轮次,于是输出混乱、重复。

怎么判断:确认 --jinja 或命名模板注册了正确的 stop token(Llama 3 是 <|eot_id|>、ChatML 是 <|im_end|>、Mistral 是 </s>),并把发给 /v1/chat/completions 的实际 JSON 打出来,核对 role 序列是否严格交替。

最短修复路径

Step 1:读出模型真正的 chat template

权威来源是模型自己的 tokenizer,不是你的记忆。

# 从 HuggingFace hub 读取(公开仓库可用)
python3 - << 'EOF'
from transformers import AutoTokenizer
tok = AutoTokenizer.from_pretrained("meta-llama/Llama-3.1-8B-Instruct")
print(tok.chat_template)
EOF

对于需要登录的 gated 模型(Llama 3 需要 HuggingFace token),直接下载配置文件:

hf download meta-llama/Llama-3.1-8B-Instruct tokenizer_config.json
python3 -c "import json; d=json.load(open('tokenizer_config.json')); print(d['chat_template'])"

读出你这个 GGUF 里实际烧进去的模板:

python3 - << 'EOF'
import gguf
r = gguf.GGUFReader("model-Q4_K_M.gguf")
for f in r.fields.values():
    if "chat_template" in f.name:
        print("=== GGUF chat_template ===")
        print(bytes(f.parts[-1]).decode("utf-8"))
EOF

Step 2:在 llama-server 中强制使用正确模板

优先用 --jinja,它直接使用 GGUF 内嵌模板,是 2026 年最可靠的做法:

./llama-server -m model-Q4_K_M.gguf --jinja

如果内嵌模板缺失或不对,就传一个显式的命名模板。请使用精确的现行名字(已对照 llama.cpp 2026 年 6 月核实):

# Llama 3 / 3.1 / 3.2
./llama-server -m models/Llama-3.1-8B-Instruct-Q4_K_M.gguf --chat-template llama3

# Mistral 7B Instruct v0.3(不是 "mistral")
./llama-server -m models/Mistral-7B-Instruct-v0.3-Q4_K_M.gguf --chat-template mistral-v3

# Mistral Nemo(tekken tokenizer)
./llama-server -m models/Mistral-Nemo-Instruct-2407-Q4_K_M.gguf --chat-template mistral-v3-tekken

# Qwen 2 / 2.5(用 ChatML,没有 "qwen2" 这个名字)
./llama-server -m models/Qwen2.5-7B-Instruct-Q4_K_M.gguf --chat-template chatml

# Gemma
./llama-server -m models/gemma-2-9b-it-Q4_K_M.gguf --chat-template gemma

如果你的模型需要一个不在内置清单里的模板,直接指向原始 Jinja 文件:

./llama-server -m model.gguf --jinja --chat-template-file ./my_template.jinja

Step 3:修正 Ollama Modelfile

在 Ollama Modelfile 里,TEMPLATE 块和 stop 参数必须和训练格式完全一致。以 Llama 3.1 为例:

FROM llama3.1:8b

TEMPLATE """{{ if .System }}<|start_header_id|>system<|end_header_id|>

{{ .System }}<|eot_id|>{{ end }}{{ range .Messages }}<|start_header_id|>{{ .Role }}<|end_header_id|>

{{ .Content }}<|eot_id|>{{ end }}<|start_header_id|>assistant<|end_header_id|>

"""

PARAMETER stop "<|eot_id|>"
PARAMETER stop "<|end_of_text|>"

保存为 Modelfile.llama31,运行 ollama create myllama31 -f Modelfile.llama31,再用 ollama show myllama31 --modelfile 验证,并把 TEMPLATE 块对照该模型在 Ollama 官方库页面上的版本。别凭记忆手写模板,从官方来源复制。

Step 4:制止双重 BOS

如果 --verbose 打出了双 BOS 警告,就不要再自己加 BOS。在 Python 封装里,让聊天路径处理特殊 token:

from llama_cpp import Llama

llm = Llama(
    model_path="model-Q4_K_M.gguf",
    n_gpu_layers=35,
    n_ctx=8192,
    chat_format="llama-3",   # 或 "chatml"、"mistral-instruct"
    verbose=False,
)
# 不要手动加 BOS;chat_format 只会插入一次。
resp = llm.create_chat_completion(
    messages=[{"role": "user", "content": "1 加 1 等于几?"}]
)
print(resp["choices"][0]["message"]["content"])

llama-server 里可以覆盖元数据标志,但注意它对少数模型家族会被忽略(截至 2026 年 6 月的已知 bug,见 llama.cpp issue #21786):

./llama-server -m model.gguf --jinja \
  --override-kv tokenizer.ggml.add_bos_token=bool:false

llama-server 上没有 --no-bos 参数;那个参数只存在于部分较旧的 llama-cli 版本里。

Step 5:手动套用模板以便检查

当你不确定引擎到底发了什么,就自己渲染一遍 prompt 并读出来:

from transformers import AutoTokenizer

tok = AutoTokenizer.from_pretrained("path/to/model/or/hub/id")
messages = [
    {"role": "system", "content": "You are a helpful assistant."},
    {"role": "user", "content": "Explain Docker networking."},
]
prompt = tok.apply_chat_template(messages, tokenize=False, add_generation_prompt=True)
print(repr(prompt))  # 进引擎之前的精确字符串,含所有特殊 token

apply_chat_template 就是事实标准:BOS、角色 token、末尾的 generation prompt 都应当存在且顺序正确。

Step 6:确认确实修好了

发一条确定性的测试 prompt,检查响应体而不只是 HTTP 状态码:

curl -s http://localhost:8080/v1/chat/completions \
  -H "Content-Type: application/json" \
  -d '{
    "messages": [{"role": "user", "content": "1 加 1 等于几?只回答数字。"}],
    "max_tokens": 10,
    "temperature": 0
  }' | python3 -m json.tool | grep content

满足以下条件即为修复:

  • 回复是 2(干净、首尾没有杂物);
  • 回复里没有以纯文本出现的角色标记([INST]<|im_start|><|eot_id|>);
  • 生成自己停下来,而不是一直写到 max_tokens
  • 服务端日志里没有双 BOS 警告。

如果还能看到角色 token 或被回吐的输入,说明模板仍然不对,回到 Step 1。

预防建议

  • 默认加 --jinja,让引擎读 GGUF 内嵌模板,而不是去猜一个名字。
  • 跑新模型前先读 tokenizer_config.jsonchat_template(或 GGUF 元数据),再与引擎的模板名对齐。
  • 如果内嵌模板缺失或过时,用最新的 convert_hf_to_gguf.py 重新转换。
  • Ollama Modelfile 里的 TEMPLATE 块要从官方库页面复制,绝不凭记忆手写。
  • 部署时锁定 llama.cpp 版本;模板和 Jinja 解析行为会随版本变化。
  • 给部署里的每个模型维护一份模型到模板的映射文档。

常见问答 (FAQ)

Q:为什么 /completion 端点能正常工作,/v1/chat/completions 却输出乱码? A:/completion 接收原始文本、不套任何 chat template;/v1/chat/completions 会用配置的模板包装你的消息。如果那个模板对模型来说是错的,就只有聊天端点坏掉。用 apply_chat_template(Step 5)渲染 prompt,和你发给 /completion 的内容对比一下。

Q:该信任 Ollama 的自动探测吗? A:从 Ollama 官方库拉取的模型可以信任——模板是随模型一起带来的。对于你从 HuggingFace 自己转换的 GGUF,用 ollama show model --modelfile 验证并对照 tokenizer 配置;当 GGUF 没有内嵌模板时,自动探测会退化成启发式猜测。

Q:Mistral 7B Instruct v0.3 在 llama.cpp 里用哪个模板? A:mistral-v3。没有叫 mistral 的模板名。v0.3 引入了原生工具调用控制 token;如果你套了 v1/v0.1 的模板,[TOOL_CALLS] 之类的 token 会以纯文本形式冒出来。Mistral Nemo 用 mistral-v3-tekken,Large 2411 用 mistral-v7

Q:输出不乱码,但模型一句话重复个没完,这也是模板问题吗? A:往往是,而且是 stop token 在作怪。如果模板没注册模型的轮次结束 token,生成就永远停不下来。确认正确的 stop token 已设置(Llama 3 是 <|eot_id|>、ChatML 是 <|im_end|>、Mistral 是 </s>),可以在 --jinja 输出或 Modelfile 的 PARAMETER stop 行里检查。

Q:错误的模板会悄悄绕过安全护栏吗? A:会。instruct/RLHF 对齐是针对特定角色 token 训练的。如果模板把内容放进错误的角色,模型可能不再把某条请求识别为用户轮次,其拒答行为就会失灵——这不是越狱,只是格式混乱。

相关阅读

标签: #local-llm #llama-cpp #排查