分词
模型从来看不到文本。BPE 分词器把字节压缩进一个固定词表,你报出的每个延迟数字都以 token 计,而不是以字符计。
- BPE
- merge 规则
- 字节兜底
- 词表
- 流式解码
为什么重要
问题
S01 里我们写下 tokenizer.encode(prompt) 就往下走了。这一次调用决定了三件引擎日后再也改不了的事:请求要花多少钱、需要多少次前向,以及输出流到底是不是合法文本。
两种显而易见的做法都不行。字符级词表很小,但序列会长出四到五倍;decode 又是一个 token 一次前向,于是直接换来 4–5 倍的减速。词级词表能让序列很短,却无法表示没见过的词,而「所有词」本来就不是一个封闭集合。
字节对编码(BPE)恰好把两头的好处都占全了。从原始字节出发,任何东西都可表示;再反复把出现频率最高的相邻对合并成一个新符号,直到词表达到你想要的大小。
核心思路
解法
训练在相邻对频率上循环,编码在 merge 优先级上循环。两者都只有二十行左右。
def train(corpus, num_merges):
words = {w: list(w) for w in corpus.split()}
merges = []
for rank in range(num_merges):
pairs = Counter()
for word, parts in words.items():
for a, b in zip(parts, parts[1:]):
pairs[(a, b)] += corpus_freq[word]
if not pairs:
break
best = max(pairs, key=pairs.get) # most frequent adjacent pair
merges.append(best)
words = {w: apply(best, p) for w, p in words.items()}
return merges编码按优先级应用 merge,而不是按位置
工作原理
工作原理
引擎翻车多半发生在流式解码
编码只跑一次。解码要在每个生成的 token 上以流的形式跑,而且带着一个讨厌的性质:token 是字节序列,一个字符可能横跨 token 边界。 一个天真的服务端如果对每个 token 调用 decode([token]) 并把结果推进 SSE 连接,只要某个字形的 UTF-8 字节被拆到多个 token 上,就会吐出替换字符。日常情形掩盖了这一点:现代词表会把常见的 emoji 和中日韩字符编成单个 token。但较冷门的字形和字节回退情形照样常被拆开,一次拆分就足以翻车。
class StreamDecoder:
"""Emits text only when the pending bytes are valid UTF-8."""
def __init__(self, tokenizer):
self.tok = tokenizer
self.pending = b""
def push(self, token_id: int) -> str:
self.pending += self.tok.id_to_bytes(token_id)
try:
text = self.pending.decode("utf-8")
except UnicodeDecodeError:
return "" # incomplete glyph — wait for more bytes
self.pending = b""
return text真实引擎还要更进一步,因为停止字符串和工具调用分隔符都定义在文本上,却要在 token 流中被检测。vLLM 保留一小段已输出文本的滑动窗口,每步在窗口上重跑停止串匹配,一个被拆成三个 token 的停止序列也依然能被捕获。
为什么你数的 token 数总对不上账单
- 英文,字符/token
- ~4.0
- 所有容量估算默认用的数字
- 中日韩,字符/token
- ~1.5
- 每个字符的 token 数约为英文的 2.7 倍
- 代码,字符/token
- ~2.8
- 缩进和标点会把 token 切得很碎
动手观察
动手试试
这个模拟器会在你的浏览器里用一小段语料真实训练一个 BPE,然后一步一步回放 merge。拖动 merge 数量,看 batching 这个词会怎样变化:零条 merge 时它是九个 token,六十条时只剩两三个。
每个 token 都是一个计费单位,也是 decode 阶段的一次完整前向。压缩率就是同一个模型下英文比日文便宜的原因。
- 字符数
- 23
- token
- 29
- 字符 / token
- 0.79
- 词表大小
- 86
起点:整个词被拆成单个字符。还没有任何合并,零条 merge 的词表产出的就是这个样子。
试试「语料外」那个样本。语料中从未出现过的词会碎成近似单字符。把一段泰语丢给一个主要用英语训练的模型,发生的就是这件事,同样一句话也因此贵三倍。
亲手实现
实现
编码路径才是热点。朴素版本对每个词是 O(n²),因为每次合并后都要重新扫描找最优对;词短的时候这没什么关系。生产级分词器用两种不同的办法绕开它:tiktoken 保留了线性的最小 rank 重扫,最坏情形仍是二次方,但它依靠正则预切分把每个片段控制在几个字节以内,最坏情形永远打不中。HuggingFace 的 Rust 分词器则用链表加优先队列做到 O(n log n)。
def encode_word(self, word: str) -> list[str]:
parts = list(word)
while len(parts) > 1:
# find the applicable merge with the LOWEST rank
best_rank, best_i = None, None
for i in range(len(parts) - 1):
rank = self.ranks.get((parts[i], parts[i + 1]))
if rank is not None and (best_rank is None or rank < best_rank):
best_rank, best_i = rank, i
if best_i is None:
break # no merge applies — done
a, b = parts[best_i], parts[best_i + 1]
parts = merge_all(parts, a, b) # apply it everywhere, not once
return parts特殊 token 的陷阱
<|im_start|> 这类特殊 token 包裹每一轮。它们必须在 BPE 之前被匹配出来,而且绝不能允许用户文本产生它们;否则用户只要打出那个分隔符,就能伪装成 system 角色。每个生产级分词器都有 allowed_special 参数,就是为了这件事,把它默认放开等于开了一个 prompt 注入漏洞。$ python code/s02_tokenizer.py只需要 NumPy — 查看环境准备.
生产实践
在生产环境中
- tiktoken(OpenAI)—— 字节级 BPE,外加一层正则预切分,强制在词首、缩写和连续数字处切开 token。数字之所以能被稳定切分,靠的就是这层预切分。
- SentencePiece(Llama 1–2、Gemma)—— 直接在原始文本上训练、不做预分词,把空格当作符号
▁。还支持一种压根不是 BPE 的 unigram 语言模型模式。Llama 3 已经改用 tiktoken 风格的字节级 BPE,词表 12.8 万,所以同样的文本在 Llama 2 和 Llama 3 上 token 数并不一样。 - picoLM / llama.cpp —— 直接从 GGUF 元数据里读 merge 规则,约 200 行 C,不需要 Python,也没有依赖。
- vLLM —— 分词委托给 HuggingFace,但增量解码器由自己实现,因为流式输出和停止串逻辑是引擎特有的。
练习
- 1实现
decode,并在几百个随机 Unicode 字符串上断言decode(encode(s)) == s。字节级 BPE 应该完美往返;如果你的不行,说明信息丢在了空格处理上。 - 2把 O(n²) 的扫描换成基于相邻对的优先队列。在一份一万字符的文档上测一下加速比。
- 3加上带显式白名单的特殊 token 处理,然后写一个测试,证明含有
<|im_start|>的用户文本被编码成了字面字符,而不是那个控制 token。
继续学习
接下来
现在 token id 进得去,文本也出得来。夹在中间的 model.forward(ids) 仍是黑箱。S03 会把它打开:RMSNorm、旋转位置编码、分组查询注意力和 SwiGLU,用 NumPy 写成,一次就能读完。
自测
习题
先作答,再看解析。答错比答对更有价值,因为解析会指出你该回头重读哪一部分。
对一个词编码时,应该先应用哪一条 merge?