数据截至 (上游 commit 92d63d4e8bb4)
01 · 分词器与对话渲染
这一章讲什么: nanochat 的分词器怎么训(rustbpe)、怎么跑(tiktoken),以及它最重要的副产品——
render_conversation如何把一段对话变成「token 序列 + 哪些 token 要训练」的二元组。后者就是 SFT 的数据协议,第三章会直接消费它。
1. 它要解决的小问题
分词器有两个互相拉扯的需求:
- 训练要快:BPE 训练要扫几十亿字符,纯 Python 跑不动。
- 推理要快且稳:编码/解码要多线程、批处理、字节级可逆。
nanochat 还有第三个需求:对话数据不是纯文本。多轮对话里只有 assistant 的话该被训练;工具调用的输入该训、工具返回的输出不该训。这需要一个「渲染协议」,把对话结构编码成 token 序列和逐 token 的 0/1 mask。
2. 思路:两头都借力,中间自己补
- 训练:借
rustbpe(Karpathy 自己的 Rust BPE 库,pyproject.toml里依赖rustbpe>=0.1.0)。 - 推理:借
tiktoken(OpenAI 的 Rust 编码器)——把 rustbpe 训出的合并表倒进tiktoken.Encoding。 - 对话协议:自己定义 9 个特殊 token + 一套渲染规则。
直觉:BPE 训练的产物本质上就是一张 merge 表(bytes → rank),它和运行时编码器是解耦的——所以训练可以用一家、推理用另一家。
3. 双拼分词器
原理演示
这段演示「训练产物如何移交」:
# 示意,非源码
def train(text_iter, vocab_size):
trainer = rustbpe.Tokenizer()
# 词表先扣掉 9 个特殊 token 的位子,特殊 token 不参与 BPE 训练
trainer.train_from_iterator(text_iter, vocab_size - 9, pattern=SPLIT_PATTERN)
merges = trainer.get_mergeable_ranks() # [(token_bytes, rank), ...]
enc = tiktoken.Encoding(
pat_str=trainer.get_pattern(),
mergeable_ranks=dict(merges), # 合并表直接移交
special_tokens={name: len(merges) + i # 特殊 token 续号
for i, name in enumerate(SPECIAL_TOKENS)},
)
return enc
重点看:特殊 token 不从文本里合并出来,训练完按偏移量续号——它们永远由代码显式插入。
真实实现
nanochat/tokenizer.py:42-58 RustBPETokenizer.train_from_iterator:先按 vocab_size - len(SPECIAL_TOKENS) 调 rustbpe 训练,再 get_mergeable_ranks() 取表构造 tiktoken.Encoding,特殊 token 从 tokens_offset = len(mergeable_ranks) 起续号。
存盘就是 pickle 这个 Encoding 对象(nanochat/tokenizer.py:132-137 save),加载走 from_directory;也可以 from_pretrained 直接包一个现成 tiktoken 编码(nanochat/tokenizer.py:63-72)。
分割模式与词表
分割正则沿用 GPT-4 风格,但有一处刻意偏离,注释里写了理由(nanochat/tokenizer.py:23-25):数字用 \p{N}{1,2} 而不是 GPT-4 的 {1,3}——32K 小词表不想在数字上浪费 token,作者实测 2 是甜点。
默认词表 32768 = 2^15(scripts/tok_train.py:19),训练数据约 2B 字符、每文档截 10K 字符(scripts/tok_train.py:28-44)。