云端 Mac 上的 MLX 提示词契约:分词器与聊天模板验收

云端 Mac 上的 MLX 提示词契约:分词器与聊天模板验收

把同一段对话搬到云端 Mac 后,MLX 的回答突然多出角色标签,未必是权重出了问题。分词器版本、聊天模板或重复添加的特殊标记,都可能改变实际输入。先固定“消息到 token”的转换,再比较生成结果,才能把输入漂移与采样随机性分开。

先界定要验收的输入契约

本实践只检查推理前处理,不加载权重,也不测 tokens/s。准备 Python 虚拟环境,以及经过确认的本地模型目录;目录须包含该模型需要的分词器配置、词表和聊天模板。不要从另一个同规模模型复制这些文件。

一次验收记录三层内容:原始消息数组、模板渲染文本、完整 token ID 序列。只比较 token 数量不够,两个等长序列仍可能表达不同内容。普通词表相同,也不代表角色边界和结束标记相同。

聊天模板属于模型输入协议,不是显示样式。能成功分词,只说明转换完成,不说明模型收到了正确的角色结构。

将以下脚本保存为 check_prompt.py,在装有 transformers 及该分词器所需依赖的环境运行。先执行 python -m pip freeze > requirements.lock.txt 留存环境;这里的清单是版本记录,不保证跨平台安装结果完全一致。

固定本地文件与消息样本

脚本显式禁用联网下载和自定义远程代码。若模型依赖自定义实现,应先审核实现并建立单独的运行方案,不要为绕过报错临时放开执行权限。

样本包含中文、换行、尖括号以及多轮对话,分别检查是否添加生成前缀。它们使用常见的 user、assistant 交替结构;system 角色和工具调用需在模型明确支持后另加样本。

import argparse
import json
from pathlib import Path
from transformers import AutoTokenizer

parser = argparse.ArgumentParser()
parser.add_argument("model_dir")
parser.add_argument("baseline")
parser.add_argument("--record", action="store_true")
args = parser.parse_args()

tokenizer = AutoTokenizer.from_pretrained(
    args.model_dir,
    local_files_only=True,
    trust_remote_code=False,
)
cases = [
    [{"role": "user", "content": "只回答数字:2+3\n<note>保留原文</note>"}],
    [
        {"role": "user", "content": "记住代号:实验甲"},
        {"role": "assistant", "content": "已记录。"},
        {"role": "user", "content": "代号是什么?"},
    ],
]
rows = []
for messages in cases:
    for generation in (False, True):
        rendered = tokenizer.apply_chat_template(
            messages,
            tokenize=False,
            add_generation_prompt=generation,
        )
        ids = tokenizer.apply_chat_template(
            messages,
            tokenize=True,
            add_generation_prompt=generation,
        )
        encoded = tokenizer.encode(
            rendered, add_special_tokens=False
        )
        if ids != encoded:
            raise SystemExit("Tokenization paths differ")
        rows.append({
            "messages": messages,
            "generation": generation,
            "rendered": rendered,
            "ids": ids,
        })

path = Path(args.baseline)
if args.record:
    with path.open("x", encoding="utf-8") as stream:
        json.dump(rows, stream, ensure_ascii=False, indent=2)
else:
    expected = json.loads(path.read_text(encoding="utf-8"))
    if rows != expected:
        raise SystemExit("Prompt contract changed")

建立基线,但不要自动批准

首次运行 python check_prompt.py ./model ./prompt-baseline.json --record。录制采用独占创建模式,已有文件会报错,避免在故障现场覆盖证据。之后去掉 --record,命令无输出且退出码为零才表示通过。

逐项阅读 JSON:用户内容是否完整;角色分隔是否符合模型说明;assistant 历史回复后是否有合理边界;生成模式是否准备好了下一次回答的位置。某些模板忽略生成前缀开关,因此不能强制要求两种结果一定不同。

将基线、脚本、依赖清单和所用分词器文件的校验值一起纳入版本管理。样本只能用合成数据,不要把真实聊天、访问令牌或内部文档作为可提交的基线内容。

沿实际 MLX 调用链定位漂移

脚本通过后,还要检查应用入口是否复用了相同模板和参数。有的调用层接收消息数组,有的接收已渲染字符串;把字符串再次交给聊天封装,可能套两遍模板。应在进入生成函数前截取合成样本的最终 token ID,与基线逐项比较。

避免二次添加特殊标记

模板往往已经写入起始或结束标记,所以脚本在渲染后编码时使用 add_special_tokens=False。不要额外手动拼接 BOS,也不要凭解码后“看起来正常”判定通过:隐藏特殊标记的解码选项可能掩盖重复输入。

遇到差异,先比较 rendered,再比较 ids。文本不同优先查模板与消息预处理;文本相同而 ID 不同,查词表、添加词元及分词器实现。只有实际输入一致之后,才继续检查采样参数和权重版本。

把失败变成可复查的检查项

增加空内容、尾随空格、表情和长中文样本时,要说明每项验证什么,不要只堆随机字符串。长输入还应在应用层检查上下文预算:本脚本不会替生成任务预留输出空间,也不验证截断策略。

交付前确认四件事:离线运行没有下载文件;普通检查不会改写基线;模板与 token 两层差异都能阻断任务;依赖升级必须经过人工审核。更新基线时另存候选文件并提交差异,不在失败分支自动重新录制。

租期内保留的是一套可复跑的输入证据,而不是某次回答截图。这样换到另一台云端 Mac 时,可以先证明模型收到的内容没变,再讨论回答为什么不同。

常见问题

提示词回归测试需要加载模型权重吗?

不需要。模板渲染与分词检查只加载本地分词器文件;它们验证输入契约,不验证模型生成质量。

升级依赖后 token ID 变化,可以直接更新基线吗?

不应直接覆盖。先检查分词器文件、模板与依赖版本差异,确认变化符合预期,再经人工审核建立新基线。

按实验时长租用 Mac

独享 Mac mini M4,16GB 统一内存 / 256GB SSD。按天、周、月、季预付,到期不续即停机。模型与运行环境由用户自行准备,实际可用状态以控制台实时返回为准。

按天租用 Mac

先核对配置,再开始实验

确认模型权重、运行峰值与租期,再选择周期。需要连接步骤时,可先查阅帮助中心。