Rust in Everything
基于 candle 0.11.0

分词与对话模板

主线位置:推理主线的第一步「读 config 和权重」旁边的那一半:把用户的话变成 token id,生成完再变回文字。candle 本身不管分词,两边用的都是 HuggingFace 的 tokenizers,只是一个是 Python 绑定、一个是 Rust crate。

版本:PyTorch 2.15.0 / transformers 5.22.0、Python tokenizers 0.23.3;Rust tokenizers 0.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 的 bin bert)。

文本里的特殊 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 上漏了模板也是同样的结果。

❗IMPORTANT
动脑筋: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 tokenizers 0.23.3(以及 transformers 的 AutoTokenizer)和 Rust tokenizers 0.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|>;少了默认系统提示词,回答也会变。

💬 关联讨论

发起讨论
加载中...