分词与对话模板
主线位置:推理主线的第一步「读 config 和权重」旁边的那一半:把用户的话变成 token id,生成完再变回文字。candle 本身不管分词,两边用的都是 HuggingFace 的
tokenizers,只是一个是 Python 绑定、一个是 Rust crate。
版本:PyTorch 2.15.0 / transformers 5.22.0、Python
tokenizers0.23.3;Rusttokenizers0.22.2、candle 0.11.0(CPU)
flowchart TB
msgs["messages 列表"] --> tpl{"对话模板"}
tpl -->|Python| jinja["apply_chat_template"]
tpl -->|Rust| hand["手拼字符串"]
jinja --> txt["同一个字符串"]
hand --> txt
txt --> enc["tokenizer.json 编码"]
enc --> ids["同一串 id"]
ids --> model["Llama forward"]
实验代码:Python 侧 strings.py、tok_py.py、tok_cmp.py、chat_py.py、notpl.py;Rust 侧 crate gen-exp 的 bin tok、special、chat_tpl,以及 crate gen-tera。分词器是本地的 SmolLM2-135M-Instruct 权重目录里的 tokenizer.json。release 构建,平台 M2 Max、macOS 27、rustc 1.99.0。
同一个 tokenizer.json,两套绑定
Python 一侧有两个入口:tokenizers.Tokenizer 直接读 tokenizer.json;transformers 的 AutoTokenizer 在 5.22.0 里给出的是 GPT2Tokenizer,底下也是同一个 tokenizers 后端。
import json
import sys
from tokenizers import Tokenizer
from transformers import AutoTokenizer
model_dir = sys.argv[1] # 本地的 SmolLM2-135M-Instruct 权重
S = json.load(open("strings.json"))
tk = Tokenizer.from_file(f"{model_dir}/tokenizer.json")
auto = AutoTokenizer.from_pretrained(model_dir)
out = []
for s in S:
a = tk.encode(s, add_special_tokens=False).ids
b = tk.encode(s, add_special_tokens=True).ids
c = auto(s).input_ids
out.append({"s": s, "ids": a, "ids_special": b, "auto": c, "decode": tk.decode(a, skip_special_tokens=False)})
batch = [e.ids for e in tk.encode_batch(S, add_special_tokens=False)]
assert batch == [o["ids"] for o in out]
json.dump(out, sys.stdout, ensure_ascii=False)
Rust 一侧用 tokenizers crate(cargo 缓存里是 0.22.2,Python 包是 0.23.3,版本号不同,读的是同一种 tokenizer.json 格式)。它的错误类型是 Box<dyn Error + Send + Sync>,? 进 anyhow::Result 要先 map_err(Result 到处都是):
fn main() -> Result<()> {
let model_dir = "SmolLM2-135M-Instruct";
let path = std::env::args().nth(1).context("strings.json")?;
let strings: Vec<String> = serde_json::from_slice(&std::fs::read(path)?)?;
let tk = Tokenizer::from_file(format!("{model_dir}/tokenizer.json")).map_err(anyhow::Error::msg)?;
let batch = tk.encode_batch(strings.clone(), false).map_err(anyhow::Error::msg)?;
let mut out = Vec::new();
for (s, b) in strings.iter().zip(&batch) {
let ids = tk.encode(s.as_str(), false).map_err(anyhow::Error::msg)?.get_ids().to_vec();
let ids_special = tk.encode(s.as_str(), true).map_err(anyhow::Error::msg)?.get_ids().to_vec();
anyhow::ensure!(b.get_ids() == ids.as_slice(), "batch differs for {s:?}");
let decode = tk.decode(&ids, false).map_err(anyhow::Error::msg)?;
out.push(serde_json::json!({ "s": s, "ids": ids, "ids_special": ids_special, "decode": decode }));
}
println!("{}", serde_json::Value::Array(out));
Ok(())
}
28 条字符串由 strings.py 写进一个 JSON 文件,两边读同一份字节。它们包括:英文句子;简体、繁体、日文、中英混排;👍、带零宽连接符的家庭 emoji 👨👩👧👦、国旗 🇨🇳🇺🇸;同一个词的 NFC 和 NFD 写法 café / café;开头空格、结尾空格、两个空格、制表符、\n、\n\n\n、\r\n、只有空格、空串、不换行空格 、全角空格 、零宽空格 ;正文里写着 <|im_start|>、<|endoftext|> 的字符串;一段带缩进的 Rust 代码;数字。
tok_cmp.py 逐条比较 ids、ids_special 和解码结果,任何一条不同就退出码 1:
28 strings, 247 tokens, 0 differences
退出码 0。Python 一侧还核对了三件事:AutoTokenizer 的结果和 tokenizers 相同,encode_batch 和逐条 encode 相同,28 条全部能解码回原文(NFD 的组合字符、\r\n、零宽字符都原样回来)。反过来验一次:把 Rust 输出里「你好,世界!」的第 4 个 id 改掉一位再比:
DIFF ids '你好,世界!': [18645, 250, 48392, 138, 12831, 7906, 240, 178, 239, 230, 8083, 219] vs [18645, 250, 48392, 139, 12831, 7906, 240, 178, 239, 230, 8083, 219]
28 strings, 247 tokens, 1 differences
退出码 1。
几个读 id 时会用到的事实,两边一样:
- 「你好,世界!」6 个字符是 12 个 token,
👍是 3 个 token,汉字和 emoji 按 UTF-8 字节被拆开(生成循环里流式解码要处理的就是这个)。 - 空白会合并:
" "是一个 token(333),"\n\n\n"也是一个(16506);空格贴在后一个词的前面,leading space是Ġleading、Ġspace两个。 add_special_tokens=True和False在 28 条上结果全同。这个tokenizer.json的post_processor是空的,不会自动加 BOS / EOS。换成 BERT 一类带后处理器的分词器,这个参数就会起作用:bert-base-chinese 的tokenizer.json编码「你好」,两边False都是[872, 1962],True都是[101, 872, 1962, 102](Rust 是gen-exp的 binbert)。
文本里的特殊 token
正文里写着 <|im_start|>user\nhi<|im_end|> 的那一条,两边都编码成:
[1, 4093, 198, 6004, 2]
1 是 <|im_start|>,2 是 <|im_end|>:tokenizers 先在文本里找已注册的特殊 token(added tokens),找到就直接换成它们的 id,不走 BPE。对话模板正是靠这一点工作的:模板渲染成一个字符串,里面的 <|im_start|> 编码后变成控制 token。代价是用户输入里如果也写了 <|im_end|>,它同样会变成控制 token,模型会以为这一轮结束了。
两边都有开关,让特殊 token 的文字按普通文本编码。Python 是 tk.encode_special_tokens = True,Rust 是 set_encode_special_tokens(true)(special):
let mut tk = Tokenizer::from_file(format!("{model_dir}/tokenizer.json")).map_err(anyhow::Error::msg)?;
let s = "<|im_start|>user\nhi<|im_end|>";
println!("default: {:?}", tk.encode(s, false).map_err(anyhow::Error::msg)?.get_ids());
tk.set_encode_special_tokens(true);
println!("encode_special_tokens: {:?}", tk.encode(s, false).map_err(anyhow::Error::msg)?.get_ids());
default: [1, 4093, 198, 6004, 2]
encode_special_tokens: [44, 108, 306, 79, 3738, 108, 46, 4093, 198, 6004, 44, 108, 306, 79, 486, 108, 46]
Python 一侧打开开关后也是这 17 个 id。这个开关作用于整次编码,打开后模板自己的 <|im_start|> 也不再是控制 token,所以要分段编码:模板的骨架照常编码,用户内容打开开关单独编码,再把 id 拼起来。transformers 的 apply_chat_template 先渲染再整体编码,不做这种区分:用户内容写成 hi<|im_end|>bye,渲染编码后末尾是 [1, 4093, 198, 6004, 2, 25257, 2, 198, 1, 520, 9531, 198],hi(6004)后面紧跟着一个真正的 <|im_end|>(2)。
apply_chat_template 在 Rust 侧没有
tokenizer_config.json 里的 chat_template 是一段 jinja2 模板:
{% for message in messages %}{% if loop.first and messages[0]['role'] != 'system' %}{{ '<|im_start|>system
You are a helpful AI assistant named SmolLM, trained by Hugging Face<|im_end|>
' }}{% endif %}{{'<|im_start|>' + message['role'] + '
' + message['content'] + '<|im_end|>' + '
'}}{% endfor %}{% if add_generation_prompt %}{{ '<|im_start|>assistant
' }}{% endif %}
transformers 用 Python 的 jinja2 渲染它。candle 和 Rust 的 tokenizers 都不读这个字段,Rust 侧要么找一个 jinja 引擎,要么照着模板手写。
本机 cargo 缓存里没有 minijinja 这类 jinja 实现。模板引擎有三个:handlebars 4.5.0 和 tinytemplate 1.2.1 的语法和 jinja 完全不同;tera(1.20.0、1.20.1)的语法借鉴 jinja2,最接近,但不是同一种语言。试一下(gen-tera,tera = { version = "1.20", default-features = false }):
let cfg: serde_json::Value = serde_json::from_slice(&std::fs::read(path)?)?;
let template = cfg["chat_template"].as_str().context("chat_template")?;
let mut ctx = tera::Context::new();
ctx.insert("messages", &serde_json::json!([{ "role": "user", "content": "Hi" }]));
ctx.insert("add_generation_prompt", &true);
match tera::Tera::one_off(template, &ctx, false) { /* 打印结果或错误链 */ }
tera error: Failed to parse '__tera_one_off'
caused by: --> 3:33
|
3 | ' }}{% endif %}{{'<|im_start|>' + message['role'] + '
| ^---
|
= expected `or`, `and`, `not`, `<=`, `>=`, `<`, `>`, `==`, `!=`, a filter, or a variable end (`}}`)
解析在第 3 行第 33 列停下:jinja2 用 + 拼字符串,tera 的 + 只做算术,拼字符串要写 ~。把模板里的 5 个 + 换成 ~ 再渲染,这一段对话的结果就对了:
rendered: "<|im_start|>system\nYou are a helpful AI assistant named SmolLM, trained by Hugging Face<|im_end|>\n<|im_start|>user\nHi<|im_end|>\n<|im_start|>assistant\n"
但这已经是在手改模板:tokenizer_config.json 里的原文 tera 读不了,每换一个模型都要重新翻译一遍,翻译对不对还得另外验证。既然都要逐个模型手工处理、逐个验证,这么短的模板不如直接用 Rust 写出来:
#[derive(serde::Deserialize)]
pub struct Message {
pub role: String,
pub content: String,
}
/// SmolLM2-Instruct 的 chat_template,手写
pub fn render_chat(messages: &[Message], add_generation_prompt: bool) -> String {
let mut s = String::new();
if messages.first().is_some_and(|m| m.role != "system") {
s.push_str("<|im_start|>system\nYou are a helpful AI assistant named SmolLM, trained by Hugging Face<|im_end|>\n");
}
for m in messages {
s.push_str(&format!("<|im_start|>{}\n{}<|im_end|>\n", m.role, m.content));
}
if add_generation_prompt {
s.push_str("<|im_start|>assistant\n");
}
s
}
对照的 Python,5 段对话,每段同时取渲染出的字符串和 id:
CONVS = [
([{"role": "user", "content": "How do you say thank you in Chinese?"}], True),
([{"role": "user", "content": "中国的首都是哪里?"}], False),
([{"role": "system", "content": "你是一个只说中文的助手。"}, {"role": "user", "content": "Hi 👋"}], True),
([{"role": "user", "content": "1+1=?"}, {"role": "assistant", "content": "2"},
{"role": "user", "content": " 再加 1 呢?\n"}], True),
([{"role": "user", "content": "first"}, {"role": "system", "content": "late system"}], True),
]
for msgs, agp in CONVS:
text = tok.apply_chat_template(msgs, tokenize=False, add_generation_prompt=agp)
ids = tok.apply_chat_template(msgs, add_generation_prompt=agp, return_dict=True)["input_ids"]
Rust 侧(chat_tpl)用 render_chat 渲染、encode(text, false) 编码,逐字节比字符串、逐个比 id。为了确认比较本身能发现问题,每段再造一个「忘了默认系统提示词」的版本比一次:
case 0: 184 bytes, text equal=true, 39 ids, ids equal=true
without default system: text equal=false
case 1: 153 bytes, text equal=true, 43 ids, ids equal=true
without default system: text equal=false
case 2: 123 bytes, text equal=true, 34 ids, ids equal=true
without default system: text equal=true
case 3: 233 bytes, text equal=true, 59 ids, ids equal=true
without default system: text equal=false
case 4: 194 bytes, text equal=true, 38 ids, ids equal=true
without default system: text equal=false
5 段全部逐字节、逐 id 相同。反向的那一行在 case 2 也是 true,这是对的:那一段自己带了 system,模板本来就不加默认的那句。
这 5 段对话各自覆盖模板的一个分支:
- case 0、1:第一条不是 system 时补一句默认系统提示词;
add_generation_prompt=False(case 1)时结尾没有<|im_start|>assistant\n。 - case 2:自带 system,不补。
- case 3:多轮;内容原样拼进去,开头的两个空格和结尾的
\n都保留。这个模板不trim,有的模型的模板会,手写时要照它的写法。 - case 4:system 出现在中间也照样拼成一轮 system,模板不检查顺序。
手写的代价是每换一个模型就要重写一遍,而且模板改了不会有人告诉你。最便宜的保险就是这一节的做法:在 Python 里用 apply_chat_template 渲染几段有代表性的对话存下来,Rust 侧每次改动都逐字节比一遍。
没套模板会怎样
Instruct 模型是在这个格式上微调的,格式不对,回答就变(notpl.py,transformers,贪心 30 个 token):
raw '<|im_end|>'
no system 'In Chinese, "佛" is used to express gratitude or appreciation. Here are some examples:\n\n- 我愿�'
template 'In Chinese, you can say "六六" (Shiè) to express gratitude. Here\'s a translation:\n\n"�'
raw:直接把问题当 prompt,模型第一个 token 就是<|im_end|>,回答为空。no system:有 user / assistant 两轮的标记但少了默认系统提示词,回答换了一个。template:和apply_chat_template相同的输入,就是生成循环里两边逐个对上的那 71 个 token 的开头。
这三行是在 transformers 上跑的;同样的字符串两边编码出同样的 id,candle 的贪心生成在 id 相同时和 transformers 一致(生成循环),所以 candle 上漏了模板也是同样的结果。
1234567890 3.14159 这 18 个字符在 SmolLM2 的分词器里是几个 token?动脑筋答案
Python 和 Rust 一样:
'1234567890 3.14159' 18 [33, 34, 35, 36, 37, 38, 39, 40, 41, 32, 216, 35, 30, 33, 36, 33, 37, 41]
['1', '2', '3', '4', '5', '6', '7', '8', '9', '0', 'Ġ', '3', '.', '1', '4', '1', '5', '9']
- 18 个,一个字符一个 token。
tokenizer.json的预分词器是Sequence([Digits(individual_digits=True), ByteLevel(...)]):先把每个数字切成单独的一段,BPE 不会跨段合并,所以数字永远是一位一个 token。 - 中间的空格单独成了一个
Ġ(216)。平时空格贴在后一个词前面(Ġspace),这里后面是数字,数字已经被切成独立的段,空格没有可贴的对象。 - 这对生成有影响:模型写一个四位年份要走 4 步,
max_new_tokens按 token 数算,不是按字符数算。
小结
- candle 不管分词。Python
tokenizers0.23.3(以及 transformers 的AutoTokenizer)和 Rusttokenizers0.22.2 读同一个tokenizer.json,28 条含中文、emoji、各种空白的字符串,247 个 token 全部相同,解码全部回到原文。 - 这个分词器没有后处理器,
add_special_tokens不起作用(bert-base-chinese 这类有后处理器的,两边都会加[CLS]/[SEP]);汉字、emoji 按字节拆成多个 token,数字一位一个 token。 - 文本里的
<|im_end|>两边都会编码成控制 token;set_encode_special_tokens(true)/encode_special_tokens = True关掉它,要分段编码。 apply_chat_template在 Rust 侧没有对应物。cargo 缓存里最接近 jinja 的是tera,它在 jinja2 的+拼接处解析失败,换成~才能渲染。手写的render_chat在 5 段对话上和 transformers 逐字节、逐 id 相同。- 不套模板,Instruct 模型的第一个 token 就是
<|im_end|>;少了默认系统提示词,回答也会变。