Skip to content

4.1 分词算法

在第三章中,我们构建了 Transformer 架构的核心组件——自注意力机制、前馈网络、层归一化等。然而,所有这些模块都假设输入已经是整数序列(token ID),并通过嵌入层映射为连续向量。那么,原始文本究竟是如何变成这些整数的?这正是分词(Tokenization) 要解决的问题。

分词是大语言模型的"第零层"——它不可训练、不可微分,却直接决定了模型能看到什么、怎么看。正如 Andrej Karpathy 所言,分词器是"语言模型中唯一一个用 C++/Rust 实现的层"。一个差的分词方案会让模型在还没开始学习之前就丢失关键信息。本节将从最朴素的分词思路出发,逐步推进到现代大语言模型普遍采用的子词分词算法,并给出 BPE 的从零实现。

4.1.1 从字符到子词:分词粒度的演进

分词的本质是在两个矛盾的目标之间寻找平衡:词汇表大小序列长度。词汇表越大,每个 token 承载的信息越多,序列越短;但词汇表过大会增加 softmax 层的计算负担,且大量低频 token 难以获得充分训练。反之,词汇表越小,序列越长,自注意力的 O(n2) 复杂度就会成为瓶颈。

词级分词(Word-based)。 最直觉的方案是按空格和标点切分。例如 "Hello, world." 被拆为 ["Hello", ",", "world", "."]。优点是每个 token 对应一个有语义的词,缺点是致命的:

  • 词汇表爆炸:英语中 "love"、"loved"、"loving"、"lover" 被视为四个不同的词,屈折变化和复合词会让词汇表膨胀到数十万。
  • 未登录词(OOV)问题:拼写错误、新造词、专有名词等不在词典中的词只能映射为 <UNK>,信息完全丢失。
python
import re

text = "Hello, world. Is this-- a test?"
tokens = re.split(r'([,.:;?_!"()\']|--|\s)', text)
tokens = [t.strip() for t in tokens if t.strip()]
print(tokens)
# ['Hello', ',', 'world', '.', 'Is', 'this', '--', 'a', 'test', '?']

字符级分词(Character-based)。 走向另一个极端:将文本拆为单个字符。英文字母加数字和常用符号只有一百多个,词汇表极小,也完全不存在 OOV 问题。但代价同样沉重:

  • 序列过长"Hello" 变成 5 个 token,一句话动辄上百个 token,远超词级方案。
  • 语义碎片化:单个字符几乎不携带独立语义,模型必须从头学习字符如何组合成词,训练效率极低。

子词级分词(Subword-based)。 现代大语言模型几乎无一例外地采用子词分词,它在上述两个极端之间找到了最佳折中:

  • 高频词保留为完整 token:如 "the"、"is"、"你好"。
  • 低频词被拆分为有意义的子词单元:如 "tokenization" → "token" + "ization"。
  • 任意未知词都能被逐步分解,最终退化为字节级表示,从根本上消除 OOV 问题。

主流子词算法有三种:

算法代表模型核心策略
BPE(Byte-Pair Encoding)GPT-2/3/4, LLaMA自底向上合并最高频相邻对
WordPieceBERT合并能最大化训练数据似然的相邻对
Unigram LMT5, SentencePiece自顶向下裁剪对似然影响最小的 token

本节重点讲解 BPE,因为它是 GPT 系列和 LLaMA 系列共同采用的方案,也是理解其他子词算法的基础。

4.1.2 UTF-8 编码基础

UTF-8 编码转换过程

图 4-1:UTF-8 编码原理。不同 Unicode 码位范围使用 1-4 个字节表示,ASCII 字符保持单字节兼容。

在进入 BPE 之前,需要先理解一个前置知识:计算机如何表示文本字符。现代 BPE 算法的起点不是字符,而是字节(byte)——这一选择的基础正是 UTF-8 编码。

UTF-8 的变长规则。 UTF-8 是一种变长编码方案,用 1 到 4 个字节表示一个 Unicode 码点。编码规则如下:

码点范围字节数字节格式(二进制)示例
U+0000 — U+007F10xxxxxxxA0x41
U+0080 — U+07FF2110xxxxx 10xxxxxxé0xC3 0xA9
U+0800 — U+FFFF31110xxxx 10xxxxxx 10xxxxxx0xE4 0xB8 0xAD
U+10000 — U+10FFFF411110xxx 10xxxxxx 10xxxxxx 10xxxxxx😀0xF0 0x9F 0x98 0x80

规则非常简洁:首字节的高位 1 的个数标识了该字符占多少字节,后续字节一律以 10 开头。这意味着在任意位置截断字节流后,解码器都能重新同步——找到下一个不以 10 开头的字节即可。

python
text = "A中😀"
byte_seq = text.encode("utf-8")
print(list(byte_seq))
# [65, 228, 184, 173, 240, 159, 152, 128]
# A=1字节, 中=3字节, 😀=4字节

为什么 BPE 从字节开始。 既然一个字节只有 256 种取值(0x00 到 0xFF),以全部 256 个字节值作为初始词汇表,就能无损地表示任意语言的任意文本。这比以字符作为初始词汇表更优雅:中文常用字就有数千个,如果用字符初始化,初始词汇表就已经很大;而字节初始化将所有语言统一到同一个起点。

Overlong 编码与安全风险。 UTF-8 的一个安全隐患值得了解。例如字符 /(U+002F)正常编码为单字节 0x2F,但如果故意用两字节编码为 0xC0 0xAF,仍然能被某些有缺陷的解码器解析为 /。这种"过长编码(Overlong Encoding)"曾被用于绕过安全过滤器——攻击者用过长编码的 ../ 穿越目录,而过滤器只检查正常编码的 ../。现代 UTF-8 标准已明确禁止 overlong 编码,合规的解码器会拒绝此类输入。在实现分词器时,只要使用标准库(如 Python 的 str.encode('utf-8'))就不会产生这一问题。

4.1.3 BPE 算法原理

BPE(Byte-Pair Encoding)最初由 Philip Gage 于 1994 年提出,是一种数据压缩算法。其核心思想极其简单:反复找到序列中出现频率最高的相邻对,将其合并为一个新符号

训练过程。 给定一个文本语料库和目标词汇表大小 V,BPE 训练分三步:

  1. 初始化:将语料库中的所有文本转换为字节序列,初始词汇表为 256 个字节值(ID 0–255)。
  2. 迭代合并:重复以下步骤 V256 次(每次向词汇表中新增一个 token):
    • 统计当前序列中所有相邻 token 对的出现频率。
    • 找到频率最高的一对 (a,b)
    • 创建新 token ab,分配下一个可用 ID。
    • 在整个语料库中,将所有相邻的 (a,b) 替换为 ab
    • 记录合并规则 (a,b)ab
  3. 完成:最终词汇表 = 256 个字节 + V256 个合并产生的 token。

一个具体的例子。 假设训练文本为 the cat in the hat

  • 初始序列:[t, h, e, ' ', c, a, t, ' ', i, n, ' ', t, h, e, ' ', h, a, t]
  • 第 1 次合并(t, h) 出现 3 次,频率最高 → 新建 token th(ID 256)
    • 序列变为:[th, e, ' ', c, a, t, ' ', i, n, ' ', th, e, ' ', h, a, t]
  • 第 2 次合并(th, e) 出现 2 次,频率最高 → 新建 token the(ID 257)
    • 序列变为:[the, ' ', c, a, t, ' ', i, n, ' ', the, ' ', h, a, t]
  • 第 3 次合并(the, ' ') 出现 2 次 → 新建 token the·(ID 258)
    • 序列变为:[the·, c, a, t, ' ', i, n, ' ', the·, h, a, t]
  • ……以此类推,直到达到目标词汇表大小。

合并过程的贪心性质使得高频子串被优先"凝聚"为单个 token,低频子串则保持拆分状态。

编码过程。 训练完成后,对新文本进行编码时:将输入转为字节序列,然后按训练时合并规则的顺序依次扫描并执行匹配的合并。先学到的规则优先级更高——这是因为先合并的对在训练时频率更高,理应优先应用。

解码过程。 解码极其简单:将 token ID 序列中的每个 ID 映射回其对应的字节序列,拼接后用 UTF-8 解码即可还原原始文本。

BPE 算法训练过程示意图:从字节序列出发,每轮统计最高频相邻对并合并,逐步构建词汇表

图 4-2:BPE 训练过程。左侧为当前 token 序列,中间统计相邻对频率,右侧执行合并并更新词汇表。每轮新增一个 token,重复直至词汇表达到目标大小。

4.1.4 BPE 从零实现

下面给出一个完整的、自包含的 BPE 分词器 Python 实现,包含训练(train)、编码(encode)和解码(decode)三个核心方法。这是一个教学版实现,优先考虑可读性而非性能。

python
from collections import Counter, deque


class BPETokenizer:
    """一个教学用的 BPE 分词器,从字节级开始训练。"""

    def __init__(self):
        # token_id -> 字节序列的映射
        self.vocab = {i: bytes([i]) for i in range(256)}
        # 合并规则:(token_id_a, token_id_b) -> merged_token_id
        self.merges = {}

    # ---- 训练 ----

    def train(self, text: str, vocab_size: int):
        """在给定文本上训练 BPE,直到词汇表达到 vocab_size。

        Args:
            text: 训练语料(字符串)。
            vocab_size: 目标词汇表大小,必须 >= 256。
        """
        assert vocab_size >= 256, "词汇表大小不能小于 256(字节数)"
        # 将文本转为字节 ID 列表
        ids = list(text.encode("utf-8"))
        num_merges = vocab_size - 256

        for new_id in range(256, 256 + num_merges):
            # 1. 统计所有相邻对的频率
            pair_counts = self._count_pairs(ids)
            if not pair_counts:
                break  # 序列长度 <= 1,无法继续合并

            # 2. 找到最高频的对
            best_pair = max(pair_counts, key=pair_counts.get)

            # 3. 执行合并:将序列中所有 best_pair 替换为 new_id
            ids = self._merge(ids, best_pair, new_id)

            # 4. 记录合并规则和新 token 的字节表示
            self.merges[best_pair] = new_id
            self.vocab[new_id] = self.vocab[best_pair[0]] + self.vocab[best_pair[1]]

    @staticmethod
    def _count_pairs(ids: list[int]) -> dict[tuple[int, int], int]:
        """统计列表中所有相邻元素对的出现次数。"""
        return Counter(zip(ids, ids[1:]))

    @staticmethod
    def _merge(ids: list[int], pair: tuple[int, int], new_id: int) -> list[int]:
        """将 ids 中所有出现的 pair 替换为 new_id。"""
        result = deque(ids)
        merged = []
        while result:
            current = result.popleft()
            if result and (current, result[0]) == pair:
                merged.append(new_id)
                result.popleft()  # 跳过被合并的第二个元素
            else:
                merged.append(current)
        return merged

    # ---- 编码 ----

    def encode(self, text: str) -> list[int]:
        """将字符串编码为 token ID 列表。"""
        ids = list(text.encode("utf-8"))

        # 按合并规则的学习顺序依次应用
        for pair, new_id in self.merges.items():
            ids = self._merge(ids, pair, new_id)

        return ids

    # ---- 解码 ----

    def decode(self, ids: list[int]) -> str:
        """将 token ID 列表解码回字符串。"""
        byte_seq = b"".join(self.vocab[i] for i in ids)
        return byte_seq.decode("utf-8", errors="replace")

使用示例:

python
# 训练
corpus = "the cat in the hat sat on the mat"
tokenizer = BPETokenizer()
tokenizer.train(corpus, vocab_size=276)  # 学习 20 条合并规则

# 编码
ids = tokenizer.encode("the cat in the hat")
print(ids)
# 输出类似 [259, 99, 97, 116, 32, 105, 110, 259, 104, 97, 116]
#          "the " "c" "a" "t" " " "i" "n" "the " "h" "a" "t"

# 解码
print(tokenizer.decode(ids))
# "the cat in the hat"

# 验证往返一致性
assert tokenizer.decode(tokenizer.encode("the cat in the hat")) == "the cat in the hat"

关键实现细节解析:

  1. _count_pairs:用 Counter(zip(ids, ids[1:])) 一行完成相邻对计数,简洁且高效。zip(ids, ids[1:]) 生成 (ids[0], ids[1]), (ids[1], ids[2]), ... 的迭代器。
  2. _merge:使用 dequepopleft() 实现 O(n) 的单遍扫描替换。注意合并后可能产生新的可合并对(例如合并 (a,b) 后,如果前一个元素恰好和 ab 构成另一条规则中的对),但在当前轮次中不会继续合并——这些新对会在后续轮次中被发现。
  3. encode 中的规则应用顺序:遍历 self.merges 字典时,Python 3.7+ 保证字典按插入顺序迭代,因此合并规则天然按学习顺序排列。先学到的规则对应更高频的对,理应优先应用。

4.1.5 BPE 性能优化

分词与嵌入的完整流水线

图 4-3:从原始文本到嵌入向量的完整流水线。文本经分词器切分为 token 序列后,通过嵌入层映射为密集向量表示。

上述教学版实现的训练复杂度为 O(nk),其中 n 是语料库的字节数,k 是合并次数(即 V256)。每轮合并都要重新遍历整个序列来统计频率和执行替换。当语料库规模达到 GB 级别时(如 GPT-2 的训练数据),这个实现会慢到不可接受。

瓶颈分析。 用 Python 的 cProfile 或更现代的 Scalene 进行性能分析,会发现两个热点:

  1. _count_pairs:每轮都对整个序列做一次 O(n) 遍历。但实际上,大多数对的频率在一次合并后并不会改变——只有涉及被合并对的局部区域才会变化。
  2. _merge:同样需要 O(n) 遍历,且创建了大量临时列表。
python
# 使用 cProfile 分析
import cProfile
cProfile.run('tokenizer.train(large_corpus, vocab_size=1000)')

# 使用 Scalene(推荐,支持逐行 CPU/内存分析)
# 命令行: scalene train_tokenizer.py

优化策略一:增量式计数器更新。 核心思路是维护一个全局的相邻对频率计数器,每次合并后只更新受影响的局部区域,而不是重新统计全部:

python
def _update_counts_after_merge(counts, ids, merged_positions, pair, new_id):
    """合并后增量更新计数器,而非重新全局统计。

    只需处理每个合并位置的左邻居和右邻居:
    - 旧的对 (left, a), (a, b), (b, right) 频率各减 1
    - 新的对 (left, ab), (ab, right) 频率各加 1
    """
    for pos in merged_positions:
        a, b = pair
        # 减去旧的对
        if pos > 0:
            left = ids[pos - 1]
            counts[(left, a)] -= 1
        if pos + 2 < len(ids):
            right = ids[pos + 2]
            counts[(b, right)] -= 1
        counts[(a, b)] -= 1

        # 执行合并
        ids[pos] = new_id
        ids[pos + 1] = None  # 标记删除

        # 加上新的对
        if pos > 0:
            left = ids[pos - 1]
            counts[(left, new_id)] = counts.get((left, new_id), 0) + 1
        if pos + 2 < len(ids):
            right = ids[pos + 2]
            counts[(new_id, right)] = counts.get((new_id, right), 0) + 1

    # 清除标记为 None 的位置
    ids[:] = [x for x in ids if x is not None]

这将每轮合并的代价从 O(n) 降低到 O(m),其中 m 是该轮被合并的位置数。在大语料库上,mn,加速效果显著。

优化策略二:使用优先队列。 为了避免每轮都遍历整个计数器寻找最大值,可以用最大堆(优先队列)维护对的频率,将查找最高频对的复杂度从 O(|pairs|) 降低到 O(log|pairs|)

工程级实现。 在实际生产中,这些优化通常用 Rust 或 C++ 实现。OpenAI 的 tiktoken 库和 Google 的 SentencePiece 库都采用了类似的策略。HuggingFace 的 tokenizers 库底层同样用 Rust 编写,在多线程加持下能在几分钟内完成 GB 级语料的 BPE 训练。

4.1.6 预分词与正则表达式

实际的 BPE 分词器在训练和编码时,并不是直接在整个文本的字节序列上运行 BPE。它们会先进行一步预分词(Pre-tokenization):用正则表达式将文本切分为"单词"级别的块,然后在每个块内部独立运行 BPE。

预分词的作用是防止 BPE 学习到跨越词边界的合并。例如,如果不做预分词,BPE 可能会把 "the dog" 中间的 "e ""d" 合并为 "e d",产生一个跨越空格的无意义 token。

GPT-2 使用的预分词正则表达式为:

python
import re

GPT2_PAT = re.compile(
    r"""'s|'t|'re|'ve|'m|'ll|'d"""
    r"""| ?\p{L}+"""         # 可选空格 + 连续字母
    r"""| ?\p{N}+"""         # 可选空格 + 连续数字
    r"""| ?[^\s\p{L}\p{N}]+"""  # 可选空格 + 连续符号
    r"""|\s+(?!\S)"""        # 尾部空白
    r"""|\s+"""              # 其余空白
)

GPT-4 进一步改进了这个正则表达式,增加了对大小写混合词、多位数字等的更细致处理。预分词的设计直接影响分词器的行为——相同的 BPE 合并规则,配合不同的预分词正则,会产生截然不同的编码结果。

4.1.7 SentencePiece 与 Tiktoken 对比

在工程实践中,开发者通常不会从零实现 BPE,而是使用成熟的分词器库。两个最主流的选择是 Google 的 SentencePiece 和 OpenAI 的 Tiktoken

三种分词器实现的对比:教学版 BPE、SentencePiece 和 Tiktoken 在功能、性能和使用场景上的差异

图 4-4:三种分词器方案的定位对比。教学版适合理解原理,SentencePiece 适合从零训练自定义分词器,Tiktoken 适合加载和使用预训练好的分词器。

SentencePiece 是一个独立于语言的分词工具,支持 BPE 和 Unigram 两种算法:

  • 直接处理原始文本:不依赖预分词,将空格视为普通字符(用特殊符号 标记词的起始位置),因此能统一处理中文、日文等不以空格分词的语言。
  • 支持训练:给定语料库和目标词汇表大小,可以从零训练出分词器。LLaMA、T5 等模型都使用 SentencePiece 训练分词器。
  • C++ 实现:性能远高于纯 Python 实现。
python
import sentencepiece as spm

# 训练
spm.SentencePieceTrainer.train(
    input="corpus.txt",
    model_prefix="my_tokenizer",
    vocab_size=32000,
    model_type="bpe"
)

# 使用
sp = spm.SentencePieceProcessor(model_file="my_tokenizer.model")
ids = sp.encode("Hello world", out_type=int)
text = sp.decode(ids)

Tiktoken 是 OpenAI 开源的分词器库,核心算法用 Rust 实现:

  • 仅支持编码/解码:不支持训练,只能加载预训练好的词汇表和合并规则。
  • 极致性能:在编码速度上比 SentencePiece 快 3–6 倍(取决于文本类型和长度)。
  • 预训练分词器丰富:内置 GPT-2(gpt2,50,257 个 token)、GPT-4(cl100k_base,100,256 个 token)和 GPT-4o(o200k_base,199,997 个 token)的分词器。
python
import tiktoken

enc = tiktoken.get_encoding("cl100k_base")  # GPT-4 分词器
ids = enc.encode("Hello world")
text = enc.decode(ids)
print(ids)   # [9906, 1917]
print(text)  # "Hello world"

两者的关键差异总结如下:

维度SentencePieceTiktoken
核心语言C++Rust
支持训练
算法BPE / UnigramBPE
空格处理 标记词首正则预分词
典型用途从零训练分词器加载预训练分词器进行推理
使用模型LLaMA, T5, MiniMindGPT-2, GPT-3, GPT-4

在实际项目中,如果需要为自己的语料库训练一个定制化的分词器,SentencePiece 是首选;如果只是使用 GPT 系列模型或需要一个高性能的编码器,Tiktoken 更合适。HuggingFace 的 tokenizers 库则提供了一个折中方案:底层用 Rust 实现,同时支持训练和高速编码,且与 HuggingFace 的模型生态无缝集成。

4.1.8 小结

本节的核心要点如下:

  1. 分词粒度的取舍:词级分词语义清晰但词汇表爆炸、OOV 严重;字符级分词无 OOV 但序列过长;子词级分词在两者之间取得最佳平衡,是当前所有主流大模型的选择。
  2. UTF-8 为 BPE 提供了统一的字节级起点:256 个字节值构成初始词汇表,任何语言的任意文本都能被无损表示。理解 UTF-8 的变长编码规则有助于理解为什么 BPE 能处理多语言文本。
  3. BPE 的训练过程是一个贪心压缩过程:每轮合并最高频的相邻对,逐步从字节构建出子词和完整词。编码时按学习顺序应用合并规则,解码时直接查表还原字节序列。
  4. 性能优化的核心是避免重复全局遍历:增量式计数器更新和优先队列能将训练加速一到两个数量级,但工程级实现通常需要 Rust/C++ 来进一步榨取性能。
  5. SentencePiece 和 Tiktoken 代表了两种不同的使用范式:前者面向"训练新分词器",后者面向"使用已有分词器",两者各有所长。

理解了分词的原理和实现后,下一节我们将讨论如何将 token ID 映射为连续向量——即嵌入层(Embedding Layer)的设计,这是将离散的 token 序列送入 Transformer 之前的最后一步。