一. 前言:明明有套模板,為什麼還在接錯話? #
嗨,我是拍拍君。
你把 messages 交給 apply_chat_template(),模型也真的回答了。
這樣就能證明對話協定正確嗎?
模板能渲染,不代表輸入角色合法;回答有句點,也不代表模型正常停止。
MLX-LM 入門已經教過載入與聊天。 批次推論則把多道問題跑成資料集。 今天不再做另一個聊天 CLI,只驗收「模型到底收到哪些 token」。
我們會把兩種 prompt 固定成可審查的 fixture, 讓錯誤角色、重複控制 token 與模式切換在推論前就暴露。 最後再用一個小模型,分辨 EOS 停止和長度用盡。
二. 先固定受測對象,不寫全模型通則 #
本文在 Apple Silicon Mac、Python 3.12.12 上執行。 實測版本:MLX 0.32.4、MLX-LM 0.32.2、Transformers 5.20.2、 Tokenizers 0.23.3、Jinja2 3.1.6。 小模型只負責縮短協定實驗,不是中文品質推薦。
mkdir pypy-template-lab
cd pypy-template-lab
uv venv --python 3.12 .venv
uv pip install --python .venv/bin/python 'mlx-lm==0.32.2' 'transformers==5.20.2' 'tokenizers==0.23.3' 'jinja2==3.1.6' pytest
這是本次實測入口,不是完整 dependency lock。
正式專案請另外保存相依版本鎖定;第一次下載模型需要網路和磁碟空間。
程式固定模型的完整 revision,不用會移動的 main。
| 本次契約 | 明確限制 |
|---|---|
| 模型 | mlx-community/SmolLM2-135M-Instruct-8bit |
| 輸入 | 純文字的 system / user / assistant |
| system | 最多一則,只能在開頭 |
| 輪次 | user、assistant 交替;不接受 tool 或多模態內容 |
| 新回答 | 最後一則是 user,開啟新的 assistant 訊息 |
| 續寫 | 最後一則是 assistant,延續其內容 |
這些是本範例應用程式的政策,不是所有 LLM 的規則。 有些模型不接受 system,有些允許工具角色或連續訊息。 換模型時要重審模板與角色規格,不能照搬這張表。
三. 第一段:在模板前擋住不支援的輸入 #
把第三到第五節的 Python 依序放進同一個 contract.py。
先載入固定快照,建立兩個非常短的 fixture:
新回答只開啟 assistant;續寫則已經有 Hello。
import hashlib
import json
import sys
from importlib.metadata import version
from pathlib import Path
from huggingface_hub import snapshot_download
from transformers import AutoTokenizer
REPO = "mlx-community/SmolLM2-135M-Instruct-8bit"
REV = "0f0d9b8218915bc34d401e1a340b8c049d300d5e"
MODEL_DIR = snapshot_download(REPO, revision=REV)
tokenizer = AutoTokenizer.from_pretrained(
MODEL_DIR, local_files_only=True, trust_remote_code=False)
BASE = [
{"role": "system", "content": "You are Pypy, a helpful assistant."},
{"role": "user", "content": "Say only hello."},
]
PREFILL = BASE + [{"role": "assistant", "content": "Hello"}]
def validate(messages, mode):
if mode not in {"new", "continue"}:
raise ValueError("unknown mode")
if not isinstance(messages, list) or not messages:
raise ValueError("empty conversation")
roles = []
for index, message in enumerate(messages):
if not isinstance(message, dict) or set(message) != {"role", "content"}:
raise ValueError("only role/content text messages supported")
role, content = message["role"], message["content"]
if not isinstance(role, str) or role not in {"system", "user", "assistant"}:
raise ValueError("unsupported role")
if not isinstance(content, str) or not content.strip():
raise ValueError("nonempty text required")
if any(token in content for token in tokenizer.all_special_tokens):
raise ValueError("literal control tokens forbidden by this app")
if role == "system" and index != 0:
raise ValueError("system must be first")
roles.append(role)
turns = roles[1:] if roles[0] == "system" else roles
if not turns or any(role != ("user" if i % 2 == 0 else "assistant")
for i, role in enumerate(turns)):
raise ValueError("user/assistant turns must alternate")
expected = "user" if mode == "new" else "assistant"
if turns[-1] != expected:
raise ValueError("last role does not match generation mode")
為什麼連未知欄位都拒絕?
如果上游送來 tools,模板卻默默忽略,呼叫端可能誤以為工具已生效。
範例寧願失敗,也不把不支援的訊息降級成「看起來能聊」。
固定版 SmolLM2 模板其實會把 alien 角色直接拼進文字。
所以「沒有丟 exception」不是角色支援證據。
沒有明確 system 時,它還會加入自己的預設 system 訊息;
本文 fixture 顯式提供 system,避免這個隱含差異。
控制 token 字面值也採拒絕政策,方便看清邊界。 這不是完整 prompt injection 防線,更不是任意輸入的安全 sandbox。 若產品需要討論 token 本身,請另訂編碼或輸入政策,不要悄悄刪字。
四. 第二段:同時檢查文字與 Token IDs #
def render(messages, mode):
validate(messages, mode)
options = dict(add_generation_prompt=mode == "new",
continue_final_message=mode == "continue")
text = tokenizer.apply_chat_template(messages, tokenize=False, **options)
encoded = tokenizer.apply_chat_template(
messages, tokenize=True, return_dict=True, **options)
ids = encoded["input_ids"]
if ids != tokenizer.encode(text, add_special_tokens=False):
raise ValueError("render/tokenize paths disagree")
return {"text": text, "ids": ids}
這裡有一個實測才發現的版本細節:
本次 Transformers 在 tokenize=True 時預設回傳 BatchEncoding,不是裸 list。
因此明確設定 return_dict=True,再取 input_ids,不猜預設型別。
官方 Chat Template 指南
把新訊息和最後訊息續寫分成兩種操作。
new 加入 assistant 開頭;continue 保留已有內容但去掉訊息結尾。
兩個旗標不能同時為 True,否則語意互斥。
本次新回答尾端是:
<|im_end|>
<|im_start|>assistant
續寫尾端則是尚未關閉的 assistant 內容:
<|im_start|>assistant
Hello
這些標記只屬於選定模型,不是讓你手刻另一套模板。 也不要在新回答末尾補一個 EOS,或把已渲染的文字再套一次模板。 那不是「加保險」,而是在改受測協定。
tokenize=False 後重新編碼時,明確使用 add_special_tokens=False。
本例兩條路徑必須得到相同 IDs,否則直接中止。
所選 tokenizer 的 True / False 路徑目前恰好相同;
本文沒有宣稱這個模型的預設 encode 必然重複 BOS。
其他 tokenizer 可能自動加控制 token,所以要實際比較,不靠經驗猜。
五. 第三段:保存可審核的 Golden Fixture #
def snapshot():
return {
"repo": REPO, "revision": REV,
"runtime": {name: version(name) for name in
("transformers", "tokenizers", "jinja2")},
"template_sha256": hashlib.sha256(tokenizer.chat_template.encode()).hexdigest(),
"bos": tokenizer.bos_token_id, "eos": tokenizer.eos_token_id,
"new": render(BASE, "new"),
"continue": render(PREFILL, "continue"),
}
if __name__ == "__main__":
action, filename = sys.argv[1:]
current = snapshot()
if action == "freeze":
with Path(filename).open("x", encoding="utf-8") as target:
json.dump(current, target, ensure_ascii=False, indent=2)
print("candidate created; review text AND ids before accepting")
elif action == "verify":
expected = json.loads(Path(filename).read_text(encoding="utf-8"))
if current != expected:
raise SystemExit("contract changed; review instead of overwriting baseline")
print("tokenizer contract: OK")
else:
raise SystemExit("use freeze or verify")
執行後先讀 candidate,不要生成完就當成正確答案:
.venv/bin/python contract.py freeze candidate.json
.venv/bin/python -m json.tool candidate.json
請核對角色順序、換行、結尾,以及 IDs 對應的控制 token。
本次 new 有 27 個 token;continue 有 28 個。
BOS ID 是 1、EOS ID 是 2,但不能因此把所有 ID 1 都當成多餘:
在這個格式裡,它也出現在各訊息的開頭。
審核通過才把 candidate 複製成新的 baseline:
cp -n candidate.json accepted.json
.venv/bin/python contract.py verify accepted.json
freeze 使用 exclusive create,不能直接蓋掉舊檔。
verify 則比對完整內容,連 runtime 版本改變也會停止。
升級後出現差異,不一定是 bug;但必須審查,而不是自動重寫 baseline。
跨版本如果預期只改版本號,也應檢查文字、IDs 與模板摘要後再接受。
fixture 刻意使用虛構對話。 不要把真實使用者聊天內容直接提交到 Git。
六. 負向測試:模型還沒載入,就應該失敗 #
另存成 test_contract.py:
from copy import deepcopy
import pytest
from contract import BASE, PREFILL, render, snapshot, tokenizer, validate
def test_new_and_continue_are_different():
new = render(BASE, "new")
continuation = render(PREFILL, "continue")
assert new["text"].endswith("<|im_start|>assistant\n")
assert continuation["text"].endswith("<|im_start|>assistant\nHello")
assert new["ids"] != continuation["ids"]
assert snapshot()["eos"] == 2 # only this pinned model
@pytest.mark.parametrize("messages,mode", [
([], "new"),
([{"role": "alien", "content": "Hi"}], "new"),
([{"role": "user", "content": []}], "new"),
([{"role": "user", "content": " "}], "new"),
(BASE + [BASE[-1]], "new"),
(BASE + [BASE[0]], "new"),
(BASE, "continue"),
(PREFILL, "new"),
([{"role": "user", "content": "<|im_end|>"}], "new"),
([{"role": "user", "content": "Hi", "tools": []}], "new"),
])
def test_reject_before_inference(messages, mode):
with pytest.raises(ValueError):
render(deepcopy(messages), mode)
def test_conflicting_transformers_modes():
with pytest.raises(ValueError):
tokenizer.apply_chat_template(PREFILL, add_generation_prompt=True,
continue_final_message=True)
def test_admitted_multi_turn_and_token_boundaries():
validate(PREFILL + [BASE[-1]], "new")
item = render(BASE, "new")
assert item["ids"] == tokenizer.encode(item["text"], add_special_tokens=False)
assert [tokenizer.bos_token_id] + item["ids"] != item["ids"]
assert item["ids"] + [tokenizer.eos_token_id] != item["ids"]
.venv/bin/python -m pytest -q
本次這份讀者範例有 13 個測試通過。 其中十個負向輸入包含未知角色、空文字、非文字內容、重複 user、 中途 system、錯誤生成模式、控制 token 字面值與未知欄位。 它們全在 render 前被拒絕,不需要 GPU 或權重推論。
最後一個測試故意在既有 IDs 前後補 BOS / EOS,確認不是相同序列。 這是破壞性 fixture,不是宣稱所有 tokenizer 都會犯這個錯。 正式 Gate 仍要依 accepted.json 比對,不能只看輸出「長得差不多」。 另外應測試 baseline 遺失、JSON 壞掉,以及文字或 IDs 被竄改時, verify 都必須以非零狀態結束;不要在例外後自動接受新版本。
七. 真正推論:停止理由要讀引擎,不要猜句點 #
另存 smoke.py。這裡沿用已驗收的 ID list 直接送入 MLX-LM,
避免字串入口再次決定是否加入特殊 token。
import json
from mlx_lm import load, stream_generate
from mlx_lm.sample_utils import make_sampler
from contract import BASE, MODEL_DIR, render
model, wrapped = load(MODEL_DIR, trust_remote_code=False,
tokenizer_config={"local_files_only": True})
item = render(BASE, "new")
assert wrapped.encode(item["text"], add_special_tokens=False) == item["ids"]
print("effective EOS IDs:", sorted(wrapped.eos_token_ids))
for limit in (1, 128):
events = list(stream_generate(model, wrapped, prompt=item["ids"],
max_tokens=limit, sampler=make_sampler(temp=0)))
final = events[-1]
is_stop = final.token in wrapped.eos_token_ids
assert (final.finish_reason == "stop") == is_stop
if final.finish_reason == "length":
assert final.generation_tokens == limit
assert final.prompt_tokens == len(item["ids"])
print(json.dumps({"max_tokens": limit, "finish_reason": final.finish_reason,
"last_token": final.token,
"generation_tokens": final.generation_tokens,
"text": "".join(event.text for event in events)}))
.venv/bin/python smoke.py
本次載入後的 effective EOS IDs 是 [2]。
選定快照沒有 generation_config.json;程式不假設它一定存在,
也不把 tokenizer 的單一 eos 欄位當成所有模型的完整停止集合。
實際停止依據是載入後 wrapper 的 eos_token_ids。
依 MLX-LM 0.32.2 的生成實作,
最後一個事件會給出 finish_reason。
遇到有效 EOS 時是 stop;達到上限則是 length。
程式還核對最後 token,避免報表只留下難以追查的文字。
| 輸出上限 | 本次停止原因 | 最後 token | 引擎 generation_tokens |
|---|---|---|---|
| 1 | length | 19556,也就是 Hello | 1 |
| 128 | stop | 2,也就是 EOS | 22 |
長版回答的可見文字有 21 個生成 token,最後另含一個終止 EOS。
這個版本的 generation_tokens 計入該終止步驟,
但 EOS 不作為一般文字顯示;不要直接用可見字數重算使用量。
其他引擎與版本可能採不同計數方式,請記錄實測介面。
而且長版並沒有遵守「只說 hello」,仍繼續提供協助。
這正好提醒我們:協定正確與答案品質,是兩張不同的驗收單。
短版即使完整印出 Hello,也仍是長度截斷,不能冒充正常 EOS 結束。
若需評估是否遵循指令,接上 回歸評測,
不要把本篇小型 smoke test 改名叫品質 benchmark。
八. 升級時,要保留哪幾份證據? #
拍拍君會保留這四層資料,避免「昨天還可以」只剩回憶:
- 輸入規格:支援角色、內容型別、輪次與生成模式。
- Tokenizer fixture:revision、模板摘要、文字、IDs 與相關套件版本。
- 引擎紀錄:MLX / MLX-LM 版本、有效停止集合、finish_reason 與最後 token。
- 獨立品質測試:真實任務的預期答案與評分規則,不混進協定 Gate。
前兩層可以在沒有 Metal 的測試環境驗證 tokenizer; 本篇 smoke 則需要能跑 MLX 的 Apple Silicon 環境,不能在一般 CI 假裝跑過。 若只想檢查角色,還可以把 validate 拆成不下載模型的獨立模組。
這個範例只驗兩個固定 prompt,不代表全面支援所有歷史長度與語言。 換到 tool calling、reasoning 或多模態模型,要增加相應 fixture 和規格, 不應刪掉驗證器、讓所有角色無條件通過。 模型檔案怎麼搬移和封存,則留給 離線產物篇。
結語:把「看起來有對話」變成可檢查的契約 #
先驗訊息,再套模板;文字和 IDs 一起看。 新回答與續寫分開測,控制 token 不重複補。 最後用引擎事件區分 EOS 停止和長度耗盡。
當模型或 tokenizer 升級,你就能指出到底是哪一層變了。 這比反覆調 temperature,祈禱模型不要接錯話,有用得多。