2. 分词器:文字变成整数
模型不能直接读取字符串。第一步要把文本转换成整数序列,这一步由分词器完成。
分词器有两个方向:
文本 --encode--> 词元编号
词元编号 --decode--> 文本2
nanochat 使用字节对编码(Byte Pair Encoding,BPE)。理解它不需要记住复杂的数学,只需要抓住两个动作:先切分,再合并。
为什么不能按字符直接编号
最直接的做法是给每个字符一个编号。但字符数量很多,中文、日文、表情符号和不同语言混在一起时,词表会变得很大;而且单个字符太短,模型需要很长的序列才能表达一个常见词。
另一个极端是按整词编号,但词表会无限膨胀,遇到新词就无法表示。
BPE 在两者之间取折中:先把文本拆成较小的单位,再根据语料中的出现频率,把经常相邻出现的单位合并成一个词元。最终词表大小固定,常见的词或片段只占一个编号,不常见的词则拆成多个编号。
nanochat 里的一次完整流程
读分词器训练入口nanochat/tokenizer.py第 42–61 行↗读分词器训练脚本scripts/tok_train.py第 28–58 行↗text_iterator()从 Parquet 训练数据中逐篇读取文档。- 每篇文档最多保留 10000 个字符,避免单篇长文档占据过多训练时间。
rustbpe在这些文本上训练合并规则,默认词表大小为32768。- 训练完成后,把合并规则交给
tiktoken,得到快速的编码器。 - 编码器保存到
~/.cache/nanochat/tokenizer/tokenizer.pkl。
代码里有两套库:
rustbpe负责训练 BPE 合并规则,速度较快。tiktoken负责之后大量文本的快速编码和解码。
两者共享同一套合并规则,所以训练和推理使用的词表一致。
先切分,再合并
RustBPETokenizer 使用一个正则表达式 SPLIT_PATTERN 先把文本切成片段。它的作用不是产生最终词元,而是决定哪些位置允许合并。
nanochat 的切分规则有几个值得注意的设计:
- 英文缩写如
's、'll、've会单独成片段。 - 字母片段和数字片段分开。
- 数字被切成 1 到 2 位一组。nanochat 有意不按 3 位切分,因为 3 位会占用更多词表,而 2 位在 32K 词表下效果更好。
- 标点和换行有单独的规则。
- 中文没有专门的“中文词”规则,通常会进入字节级处理,再和其他片段一起合并。
所以,“一个中文词等于一个词元”是错误直觉。一个汉字可能需要多个字节表示,常见组合可能被合并成一个词元,也可能被拆成几个。
词元不等于单词
同一段文字,可能被切成不同数量的词元:
"chat" → 可能是一个词元
"nanochat" → 可能是 "nano" + "chat"
"12345" → 可能被拆成 "12" + "345"
"你好" → 可能被拆成多个字节片段2
3
4
这会直接影响模型的行为:
- 序列长度按词元计算,不按字符或单词计算。
- 同样的文字,更换分词器后长度可能变化。
- 词表越大,常见片段越容易被合并,但输出层参数也越多。
- 数字被切得零碎时,模型做算术会更困难。
这就是为什么 nanochat 的词表固定为 32768,并且模型保存配置时会记录词表大小。加载检查点时会检查模型词表是否和当前分词器一致,不一致就直接报错。
特殊词元
除了从语料中学到的普通词元,nanochat 还定义了 9 个特殊词元:
读特殊词元定义nanochat/tokenizer.py第 9–21 行↗| 特殊词元 | 用途 |
|---|---|
| `< | bos |
| `< | user_start |
| `< | user_end |
| `< | assistant_start |
| `< | assistant_end |
| `< | python_start |
| `< | python_end |
| `< | output_start |
| `< | output_end |
这些词元不会参与 BPE 合并训练。它们是在 BPE 词表训练完成后,按固定顺序追加到词表末尾的。这样模型可以把它们当作普通编号来预测,但分词器永远不会把普通文本错误地合并成这些字符串。
<|bos|> 这个名字来自“beginning of sequence”。在训练数据里,它被放在每篇文档开头,让模型知道“这里开始一篇新内容”。有些旧模型把同一个标记叫 <|endoftext|>,容易让人误以为它只放在结尾;nanochat 统一使用更直观的 <|bos|>。
对话怎样变成词元
训练对话模型时,分词器还要负责把一条对话渲染成词元序列,并同时返回一个掩码:
mask = 1:这个位置的词元需要计算损失。mask = 0:这个位置的词元不计算损失。
例如一段真实的 SFT 对话会被渲染成:
<|bos|>
<|user_start|> 用户问题 <|user_end|>
<|assistant_start|> 助手回答 <|assistant_end|>2
3
用户部分不计算损失,助手部分计算损失。这样模型学的是“看到用户问题后该怎样回答”,而不是“复述用户问题”。
对话渲染和掩码的细节会在监督微调展开。这里先知道:分词器不仅负责转换文字,还负责构造训练格式。
亲手检查分词结果
训练分词器后,可以运行:
from nanochat.tokenizer import get_tokenizer
tokenizer = get_tokenizer()
for text in ["Hello", "nanochat", "12345", "你好,世界"]:
ids = tokenizer.encode(text)
pieces = [tokenizer.decode([i]) for i in ids]
print(text, "→", pieces, "→", ids)2
3
4
5
6
7
8
你会看到不同文本被切成的词元数量并不相同。再看一次还原:
original = "Hello, nanochat!"
ids = tokenizer.encode(original)
assert tokenizer.decode(ids) == original2
3
如果还原失败,通常是分词器文件损坏,或者使用了和训练时不同的特殊词元配置。
每字节比特数为什么要用分词器
训练损失通常按词元平均,但不同分词器的词元长度不同,直接比较平均损失不公平。nanochat 的评估脚本会读取 token_bytes.pt,记录每个词元对应多少字节,再用总损失除以总字节数,得到每字节比特数(BPB)。
这样即使换了一个词表更大的分词器,只要文本内容相同,评估指标仍然可以比较。这部分会在训练目标和评估中展开。
本章要记住的三件事
- 模型只看到整数编号,分词器负责双向转换。
- BPE 先切分,再根据语料频率合并;词元不等于单词。
- 特殊词元定义了对话和工具调用的边界,训练分词器时不会让它们参与合并。
下一章看这些整数怎样被排成训练样本:训练数据。