跳到正文
LLM 推理
S02模型本身·232

分词

模型从来看不到文本。BPE 分词器把字节压缩进一个固定词表,你报出的每个延迟数字都以 token 计,而不是以字符计。

  • BPE
  • merge 规则
  • 字节兜底
  • 词表
  • 流式解码

为什么重要

问题

S01 里我们写下 tokenizer.encode(prompt) 就往下走了。这一次调用决定了三件引擎日后再也改不了的事:请求要花多少钱、需要多少次前向,以及输出流到底是不是合法文本。

两种显而易见的做法都不行。字符级词表很小,但序列会长出四到五倍;decode 又是一个 token 一次前向,于是直接换来 4–5 倍的减速。词级词表能让序列很短,却无法表示没见过的词,而「所有词」本来就不是一个封闭集合。

字节对编码(BPE)恰好把两头的好处都占全了。从原始字节出发,任何东西都可表示;再反复把出现频率最高的相邻对合并成一个新符号,直到词表达到你想要的大小。

核心思路

解法

训练在相邻对频率上循环,编码在 merge 优先级上循环。两者都只有二十行左右。

BPE 的核心思想python
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,而不是按位置

手写 BPE 最常见的 bug,就是从左到右扫描、遇到能合的就合。正确的做法是:在整个词中找到优先级编号最小的那条可用 merge,在所有匹配位置上应用它,然后重复。写错了,token id 会悄悄与模型训练时的分布对不上。输出质量往下掉,却不会有任何一条报错。

工作原理

工作原理

示意图字节对编码,端到端
编码文本"batching"utf-8 字节永不失败应用合并按 rank 反复合并token id[318, 1092]模型embedding 查表合并阶梯 —— RANK 最小的永远优先bytesbatchingmerge #3 (i,n)batchingmerge #7 (in,g)batchingmerge #12 (_b,a)␣batchingmerge #21 (t,ch)␣batching一个词 → 3 个 token。高频子串会变成单个 token;低频的仍被拆开。流式解码token 是字节,不是字符。一个多字节字形可能横跨两个 token,所以服务端必须缓冲到这些字节构成合法 UTF-8 为止 ——否则 SSE 流里就会吐出乱码。\xf0\x9f\x8c\x8a🌊此刻才输出字节回退(byte fallback)正是好的 tokenizer 永远不会返回「未知 token」的原因:0–255 的每个字节值本来就在词表里。
左侧:编码是一架 merge 的梯子。右侧:解码并不是它的逆运算。token 是字节串,字节要先缓冲到能构成合法 UTF-8 才能输出。

引擎翻车多半发生在流式解码

编码只跑一次。解码要在每个生成的 token 上以流的形式跑,而且带着一个讨厌的性质:token 是字节序列,一个字符可能横跨 token 边界。 一个天真的服务端如果对每个 token 调用 decode([token]) 并把结果推进 SSE 连接,只要某个字形的 UTF-8 字节被拆到多个 token 上,就会吐出替换字符。日常情形掩盖了这一点:现代词表会把常见的 emoji 和中日韩字符编成单个 token。但较冷门的字形和字节回退情形照样常被拆开,一次拆分就足以翻车。

增量解码器python
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,六十条时只剩两三个。

模拟器字节对编码
示例文本
分词结果 · 29 个 token
thecatsaresittingonmats

每个 token 都是一个计费单位,也是 decode 阶段的一次完整前向。压缩率就是同一个模型下英文比日文便宜的原因。

字符数
23
token
29
字符 / token
0.79
词表大小
86
merge 回放 · 词 “␣the”
the
merge 0 / 0

起点:整个词被拆成单个字符。还没有任何合并,零条 merge 的词表产出的就是这个样子。

merge 表(前 12 / 共 60 条)
#0 +t#1 h+e#2 a+t#3 e+n#4 ␣t+o#5 e+r#6 ␣t+he#7 i+n#8 ␣to+k#9 ␣tok+en#10 o+n#11 +c

试试「语料外」那个样本。语料中从未出现过的词会碎成近似单字符。把一段泰语丢给一个主要用英语训练的模型,发生的就是这件事,同样一句话也因此贵三倍。

亲手实现

实现

编码路径才是热点。朴素版本对每个词是 O(n²),因为每次合并后都要重新扫描找最优对;词短的时候这没什么关系。生产级分词器用两种不同的办法绕开它:tiktoken 保留了线性的最小 rank 重扫,最坏情形仍是二次方,但它依靠正则预切分把每个片段控制在几个字节以内,最坏情形永远打不中。HuggingFace 的 Rust 分词器则用链表加优先队列做到 O(n log n)。

code/s02_tokenizer.py(节选)python
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 注入漏洞。
在本地运行
在一小段语料上训练一个字节级 BPE,对包含 emoji 和中日韩文字的多个字符串做编码与往返测试,并演示流式解码器如何把一个跨两个 token 的多字节字形缓冲起来。
$ python code/s02_tokenizer.py
预期输出: 一张 merge 表、每个样本的 token 数与「字符/token」比值、对所有样本 decode(encode(x)) == x 的断言,以及一段流式追踪:在字形完整之前只输出空字符串。

只需要 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. 1
    实现 decode,并在几百个随机 Unicode 字符串上断言 decode(encode(s)) == s。字节级 BPE 应该完美往返;如果你的不行,说明信息丢在了空格处理上。
  2. 2
    把 O(n²) 的扫描换成基于相邻对的优先队列。在一份一万字符的文档上测一下加速比。
  3. 3
    加上带显式白名单的特殊 token 处理,然后写一个测试,证明含有 <|im_start|> 的用户文本被编码成了字面字符,而不是那个控制 token。

继续学习

接下来

现在 token id 进得去,文本也出得来。夹在中间的 model.forward(ids) 仍是黑箱。S03 会把它打开:RMSNorm、旋转位置编码、分组查询注意力和 SwiGLU,用 NumPy 写成,一次就能读完。

自测

习题

先作答,再看解析。答错比答对更有价值,因为解析会指出你该回头重读哪一部分。

自测 1 题 / 共 6

对一个词编码时,应该先应用哪一条 merge?

得分 0/6