跳到主要内容

数据截至 (上游 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)。

9 个特殊 token

SPECIAL_TOKENSnanochat/tokenizer.py:9-18)分三组:

token用途
`<bos
`<user_start
`<assistant_start
`<python_start
`<output_start

关键细节

  • 编码走 encode_ordinarynanochat/tokenizer.py:96-118 encode):普通文本永远不会被编成特殊 token;BOS 之类靠 prepend/append 显式插入。列表输入走 encode_ordinary_batch 多线程。
  • encode_special@lru_cachenanochat/tokenizer.py:89-91):渲染循环里反复查特殊 token id 零开销。
  • token_bytes.ptscripts/tok_train.py:72-90 顺手把「每个 token 占多少字节」存成张量,特殊 token 记 0——这是 bpb 指标的分母表(见第二章与 nanochat/loss_eval.py)。

4. 对话渲染:SFT 的数据协议

它要解决的小问题

SFT 的 loss 只想算在「模型该学会说的话」上。user 的话、工具返回的输出都是「环境给的」,不该监督。所以渲染必须同时产出 token 序列和逐 token 的 mask。

渲染规则(一张表)

render_conversationnanochat/tokenizer.py:140-224)的规则汇总:

片段tokenmask
开头`<bos
user 消息`<user_start
assistant 文本`<assistant_start
assistant 工具调用`<python_start
工具输出`<output_start
assistant 收尾`<assistant_end

规则背后的判断:模型要学的不仅是「说什么」,还有「什么时候调工具、什么时候闭嘴」——所以工具调用的边界 token 和 <|assistant_end|> 都是 mask=1,而工具输出(test time 由 Python 给出)全 0,注释明说了这一点(nanochat/tokenizer.py:210-212)。

原理演示

# 示意,非源码
ids, mask = [], []
add(bos, 0)
for msg in messages: # 强制 user/assistant 交替
if msg.role == "user":
add(user_start, 0); add(enc(msg.content), 0); add(user_end, 0)
else: # assistant
add(assistant_start, 0)
for part in msg.content_parts: # 文本 | 工具调用 | 工具输出
if part.type == "text":
add(enc(part.text), 1)
elif part.type == "python":
add(python_start, 1); add(enc(part.text), 1); add(python_end, 1)
elif part.type == "python_output":
add(output_start, 0); add(enc(part.text), 0); add(output_end, 0)
add(assistant_end, 1)

关键细节 / 坑

  • system 消息被「手术」合并:协议里没有 system 的位置,直接拼进第一条 user 消息的内容前面(nanochat/tokenizer.py:153-159)。
  • 交替断言防脏数据:第 i 条消息必须是 user(i 偶)/ assistant(i 奇),否则 assert 炸(nanochat/tokenizer.py:178-181)——宁可训练前报错,不让坏数据混进去。
  • GSM8K 的 <<...>> 工具调用在任务侧就被解析成结构化 partstasks/gsm8k.py:58-75),渲染器看到的已经是 parts 列表。
  • 超长直接截到 max_tokens=2048nanochat/tokenizer.py:219-221),防 OOM。
  • 调试有 visualize_tokenizationnanochat/tokenizer.py:226-238):红 = 不训练、绿 = 训练,肉眼检查 mask 是否贴对。

5. render_for_completion:RL 与评测的「请作答」

它要解决的小问题

RL 和评测时,对话里已经有一条 assistant 的标准答案——但我们想让模型自己重新答一遍。需要把渲染停在「assistant 刚要开口」的位置。

真实实现

render_for_completionnanochat/tokenizer.py:241-250)三步:

  1. 弹掉最后一条(必须是 assistant 的)消息。
  2. 对剩余对话正常 render_conversation
  3. 末尾追加 <|assistant_start|>

返回的 ids 就是第四章 Engine 采样的 prompt;mask 在这里不需要。

下一章:02 · GPT 模型与预训练