↓快轉到主要內容
  1. 教學文章/

MLX-LM Chat Template 驗收:角色、特殊 Token 與停止邊界

·8 分鐘· loading · loading · ·
MLX-LM Chat-Template Tokenizer LLM Testing Apple-Silicon
每日拍拍
作者
每日拍拍
科學家 X 科技宅宅
目錄
科技觀點 - 本文屬於一個選集。
§ 35: 本文

一. 前言:明明有套模板,為什麼還在接錯話?
#

嗨,我是拍拍君。 你把 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。

八. 升級時,要保留哪幾份證據?
#

拍拍君會保留這四層資料,避免「昨天還可以」只剩回憶:

  1. 輸入規格:支援角色、內容型別、輪次與生成模式。
  2. Tokenizer fixture:revision、模板摘要、文字、IDs 與相關套件版本。
  3. 引擎紀錄:MLX / MLX-LM 版本、有效停止集合、finish_reason 與最後 token。
  4. 獨立品質測試:真實任務的預期答案與評分規則,不混進協定 Gate。

前兩層可以在沒有 Metal 的測試環境驗證 tokenizer; 本篇 smoke 則需要能跑 MLX 的 Apple Silicon 環境,不能在一般 CI 假裝跑過。 若只想檢查角色,還可以把 validate 拆成不下載模型的獨立模組。

這個範例只驗兩個固定 prompt,不代表全面支援所有歷史長度與語言。 換到 tool calling、reasoning 或多模態模型,要增加相應 fixture 和規格, 不應刪掉驗證器、讓所有角色無條件通過。 模型檔案怎麼搬移和封存,則留給 離線產物篇。

結語:把「看起來有對話」變成可檢查的契約
#

先驗訊息,再套模板;文字和 IDs 一起看。 新回答與續寫分開測,控制 token 不重複補。 最後用引擎事件區分 EOS 停止和長度耗盡。

當模型或 tokenizer 升級,你就能指出到底是哪一層變了。 這比反覆調 temperature,祈禱模型不要接錯話,有用得多。

延伸閱讀
#

科技觀點 - 本文屬於一個選集。
§ 35: 本文

相關文章

MLX-LM 回歸評測:版本化題庫、逐題差異與 CI Gate
·8 分鐘· loading · loading
MLX-LM LLM Regression-Testing Evaluation Ci Apple-Silicon
MLX-LM 結構化 JSON:Schema、Pydantic 驗證與 Repair/Retry
·7 分鐘· loading · loading
MLX-LM LLM Structured Output JSON Schema Pydantic Outlines Apple-Silicon Quality Assurance
MLX-LM Speculative Decoding:Draft Model、加速條件與品質 Benchmark
·10 分鐘· loading · loading
Mlx MLX-LM LLM Speculative Decoding Draft Model Apple-Silicon Benchmark Local AI
MLX-LM 長文本聊天實戰:Context、KV Cache 與記憶體取捨
·10 分鐘· loading · loading
Mlx MLX-LM LLM Long Context KV Cache Apple-Silicon Local AI
MLX-LM 模型轉換與量化:4/8-bit、Mixed Quant 與品質評測
·9 分鐘· loading · loading
Mlx MLX-LM LLM Quantization Apple-Silicon Model Conversion Local AI
MLX-LM 本機 API Server:OpenAI 相容介面、Prompt Cache 與 Tool Calling
·8 分鐘· loading · loading
Mlx MLX-LM LLM OpenAI API Prompt Cache Tool Calling Apple-Silicon