AI 基建
0%
第一部分 · 基座模型的形成 · 第 7 章

分词

作者Changkun Ou
阅读时长约 11 分钟

第 6 章 最后落到词元 id 分片,而分词定义的正是这些 id:文本与模型实际读取的整数之间的映射。这张很早就定下的表,会决定每种语言处理起来有多贵,代码和数字怎样被切开,一篇文档会占用多少序列长度,也解释了为什么一个看似预处理的选择,会变成整个技术栈里最难事后更改的决策之一。因此,字节级 BPE 在这里是主流折中;Unigram 和无分词器方案与它并置,因为它们是对同一组约束的不同回应,而不是表面上的替代。

模型从不直接看见文本

Transformer 看不到文本。它看到的是一串整数,取自一张固定的词表,每个整数对应嵌入矩阵的一行(嵌入矩阵就是一张查找表,把每个词元 id 映射成一个学到的向量,词表里每个条目占一行)。总得有个东西把 Unicode 流转成这串序列,这个东西就是分词器(tokenizer)。它看着像不起眼的管道活,作用却接近一项架构选择。

分词器之所以不只是管道工程,是因为三项约束叠在一起。每一项单看都无害,合在一起却约束很强。

词表必须在训练开始前就有限且固定,而文本本身是开放的。新词、代码、emoji,还有设计者从未列举过的语言,都必须照样能编码,不能失败。一张词的清单永远穷尽不了,所以完备性只能从清单之外来。

分词还必须廉价。它要赶在 GPU 训练之前,在 CPU 集群上跑遍整套语料,这一点 第 6 章 会详谈。在语料这种规模上,一个优雅却缓慢的方案根本负担不起。

而且结果是永久的。嵌入矩阵与输出矩阵都按词表来定尺寸(输出矩阵,也称输出投影,负责把模型最后的向量转回每个词表条目上的一个分数),所以词表不重训模型就改不了。在偏斜语料上训出的分词器,会为语料中所有代表性不足的东西封顶,而这道天花板要等到评估时才显形。

这三项合在一起,使分词器既关乎质量,又几乎不可逆。后面的一切,都是在认下第三项约束的前提下,设法满足前两项。

子词分词的折中

主流的答案是子词分词:词表里装的是比词更小、又比字符更大的片段,规模经过调校,让常见词正好是单个词元,稀有词则分解成已知的部件。这是针对开放词表约束做出的直接折中。任何字符串都能表示,因为片段最终会落到始终存在的单位上;而高频文本仍然简短,因为常见序列被合并成了单个 id。

字节对编码(BPE)是这套构造的主力:它反复合并语料中最常见的相邻符号。从一套基础字母表出发,统计语料里相邻对的频率,把最高频的一对合并成一个新符号,如此反复,直到词表达到目标规模 (Sennrich et al. 2015)。学到的那张合并列表就是分词器;编码时只要按顺序重放这些合并。

# BPE 训练,概念版
vocab = set(base_symbols)
merges = []
while len(vocab) < target_size:
    pair = most_frequent_adjacent_pair(corpus, vocab)
    merges.append(pair)
    vocab.add(merge(pair))

走一个实例,这个循环就具体起来。图 7.2 在一个由 low 反复构成的微型语料上跑两次合并,每一轮都把最高频的相邻对合并成一个新符号,直到这个常见序列成为单个词元。

语料是 low low low;基础词表为 l、o、w。
统计相邻对,(l, o) 最高频。
把 (l, o) 合并为 lo,并追加到合并列表。
重新统计,此时 (lo, w) 最高频。
把 (lo, w) 合并为 low,这个常见序列成了单个词元。
图 7.1. BPE 一轮一轮地统计相邻对、合并最高频的那一对,由此学成。逐步走过每一轮,或让它自动播放。
bpe s0 起始:l o w  l o w  l o w vocab = { l, o, w } s1 合并 1:对 (l, o) 最高频 lo w  lo w  lo w s0->s1  统计 + 合并 s2 合并 2:对 (lo, w) 最高频 low  low  low s1->s2  统计 + 合并 s3 结果:'low' 成为一个词元 vocab = { l, o, w, lo, low } s2->s3  冻结合并列表
图 7.2. BPE 训练即反复的贪心合并。每一轮统计相邻对、把最高频的一对合并为新符号、并追加到合并列表。常见序列会合并成单个词元。仿 Sennrich 等(2015)。

把这段伪代码真跑一遍:下面在一个微型语料上训练 BPE,打印学到的合并,再展示常见词与稀有词分别切成了多少个词元。

from collections import Counter

def merge_pair(word, a, b):
    out, i = [], 0
    while i < len(word):
        if i < len(word) - 1 and word[i] == a and word[i + 1] == b:
            out.append(a + b); i += 2
        else:
            out.append(word[i]); i += 1
    return out

corpus = "low low low lower lowest newest wider".split()
words = [list(w) + ["_"] for w in corpus]
merges = []
for _ in range(6):
    pairs = Counter()
    for w in words:
        for a, b in zip(w, w[1:]):
            pairs[(a, b)] += 1
    if not pairs:
        break
    (a, b), _ = max(pairs.items(), key=lambda kv: (kv[1], kv[0]))
    merges.append((a, b))
    words = [merge_pair(w, a, b) for w in words]

print("学到的合并:", [a + "+" + b for a, b in merges])
for w, toks in zip(corpus, words):
    print(f"{w:7s} -> {' '.join(toks)}  ({len(toks)} 个词元)")

决定性的改良,是把 BPE 跑在原始字节上,而不是 Unicode 字符上。以 256 个字节值作基础字母表,任何可能的输入都能编码,词表外风险为零,哪怕是分词器从没见过的文本也一样,而且不必单独留一个未知词元来兜底。这就是字节级 BPE,正因为如此,同一个分词器才能处理英文、代码、一个新 emoji,乃至一种训练数据里完全缺席的文字。在基于字符的方案里,字节回退也能给出近乎等价的保证:把任何未见字符拆成它底层的字节。开放词表这条约束,曾让每一张词级清单失效,如今由构造本身来满足,不再依赖清单恰好足够完整。

这项折中还留着第二个问题:不是片段是什么,而是当有多种切法时,应当选择哪一种。BPE 贪心、由频率驱动,对一个字符串该怎么切并没有概率模型。Unigram 语言模型给了另一条路:把一个句子设定为一组具有独立概率的子词片段,从一个大的候选词表出发,逐个剪掉那些移除后对语料似然损害最小的片段 (Kudo 2018)。Unigram 给出的分词有原则可循;而且因为它在所有切分上定义了一个分布,便支持子词正则化,即训练时对不同切分采样,当成一种数据增广 (Kudo 2018)。

由数据流水线设定的两个参数,主宰着其余一切。词表规模在序列长度与矩阵尺寸之间做取舍;分词器自己的训练语料,则把哪些语言和领域能拿到简短高效的编码烙了进去。两者都在这里冻结,下游只能继承,无从挑选。

从词到字节

上面这套构造并非一步到位。分词从词走到字节,经过了三步,每一步都消掉了上一步的一处失败。图 7.3 梳理了这条演进路线,标出每个阶段各自带着的失败,以及它的后继消掉了哪一处。

evolution word 词级 失败:任何未见词 都变成未知词元 char 字符级 失败:序列变长, 从零重学拼写 word->char  消除未知词元 subword 子词 BPE / Unigram 开放词表, 常见文本简短 char->subword  缩短序列 byte 字节级 BPE 零词表外风险, 任何输入皆可编码 subword->byte  封闭词表外缺口
图 7.3. 从词级到字节级分词的演进路线。每条箭头都标注了下一阶段所消除的失败。字节级 BPE 彻底封闭了词表外的缺口。

词级词表最先出现,却受制于开放词表问题:任何不在表里的词都成了单个未知词元,内容就此丢掉。字符级模型消掉了未知词元,却让序列变长,还逼着模型从零重学拼写,把容量耗在了本该由词表存下的东西上。

Sennrich 等(2015)把原本为数据压缩发明的 BPE 引进神经机器翻译,当作子词折中方案,它由此成了默认选择 (Sennrich et al. 2015)。Kudo(2018)提出 Unigram 语言模型,作为带子词正则化的概率化替代 (Kudo 2018)。Kudo 与 Richardson(2018)把两者一并纳入 SentencePiece 分词器(SentencePiece),它把输入当作连空白也含在内的原始流,于是分词器与语言无关,也不需要预分词规则 (Kudo and Richardson 2018)。最后一步是字节级 BPE,它把基础字母表定为 256 个字节值,彻底封闭了词表外的缺口。当下大多数前沿分词器背后都是字节级 BPE,而 Unigram 与 SentencePiece 在多语种和非拉丁文字的场景里仍然常见。

开放的前沿是:究竟要不要分词。无分词器方案(tokenizer-free)直接在字节或字符上运作,把分词学进模型内部,而不是在一个预处理步骤里把它固定下来。这类方案去掉了那张冻结的词表和它带来的偏见,代价是更长的序列,以及处理它们所需的算力。这是个活跃的研究方向,还谈不上已成定论的替代。

争议所在

要不要存在一个固定的分词器,确实还没有定论。反对的理由是:词表是一件冻结的、依赖语料的人造物,它把语言与领域偏见烙了进去,又把数字和代码切碎、从而损害算术与程序合成,而且它是唯一一个训练之后改不了的组件。无分词器和字节级的做法则把结构学进模型内部来回应,用预处理偏见换来更长的序列、每字符更高的算力。这一派的回应如今有了具体的系统支撑。Byte Latent Transformer 直接读原始字节,把它们聚成字节块(patch),块的大小随下一个字节的可预测程度变化,把算力花在文本难的地方,在 FLOP 对齐的扩展比较里追平了基于 BPE 的模型 (Pagnoni et al. 2024)。H-Net 更进一步,把切块本身放进一个层级模型里端到端地学 (Hwang et al. 2025)。但两者的字节级层仍要处理比分词模型长数倍的序列,这正是词表被发明出来要省掉的那笔开销。字节级 BPE 实在太根深蒂固,这笔序列长度代价又实在太真,以至于这个领域至今没有收敛。把无分词器方案当作一个有前景的开放方向,别当成可以直接顶上的替代品。

那些再也收不回的决定

上面这段历史读起来像一部进步史,它确实也是。可它固定的每一个选择,都会变成一项设定:总得有人一次定下,往后只能与它共处。每一项分词器设定都是一个带拐点的平衡,其中有几项会设下一道只在后期才显形的上限。图 7.4 把最核心的那一项摆了出来:更大的词表缩短序列,却放大了按它定尺寸的矩阵,于是两项成本彼此交叉,而不是同向。

2026-06-21T23:31:05.682231 image/svg+xml Matplotlib v3.11.0, https://matplotlib.org/ 1 0 3 1 0 4 1 0 5 词表规模(词元) 2 3 4 5 6 7 相对序列长度 序列长度 嵌入与输出参数 0 2 4 6 8 相对参数量
图 7.4. 词表规模取舍的示意图:更大的词表缩短词元序列(每篇文档更廉价),同时扩大按它设定尺寸的嵌入矩阵与输出矩阵。最优值是一个拐点,而非端点。为理想化数据,非实测。
  • 词表规模。 更大的词表能缩短序列,让每篇文档的训练与推断更廉价,也改善多语种的产出率,也就是每个词元承载的字符数。它同时会放大嵌入矩阵与输出矩阵,还可能让稀有词元拿不到足够多的出现次数,训不出一个好的嵌入。最优值是语料与预算的函数,不是一个常数。
  • 分词器的训练语料。 在最终的数据配比上训练它,还是另用一套精选集?这一选择会把语言与领域的平衡永久烙进词表。一旦和真实配比对不上,词表就被浪费在了错的东西上,代表性不足的语言则会得到更长、成本更高的编码。
  • BPE 还是 Unigram。 BPE 贪心、快、无处不在,工具链强、兼容面广。Unigram 是概率化的,支持子词正则化,往往还能把多语种文本切得更均匀,代价是更重的训练流程,以及背后弱一些的生态惯性。
  • 多语种公平。 Petrov 等(2023)指出,在以英文为主的数据上调出的分词器,会把别的语言里表达同一含义的内容切成多得多的词元 (Petrov et al. 2023)。这提高了那些用户的成本与延迟,又缩短了他们实际可用的上下文窗口,是一种固化在词表本身里的结构性不公。这正是最直接为下游质量封顶的取舍,而它完全在这里定下。图 7.5 画出了这道缺口的形状:在某些语言里,同一个句子要比英文多花数倍的词元。
2026-06-21T23:31:07.027743 image/svg+xml Matplotlib v3.11.0, https://matplotlib.org/ 0 10 20 30 40 50 60 70 80 同一句话所需词元数 英语 西班牙语 德语 俄语 印地语 阿拉伯语 缅甸语 1.0x 1.3x 1.5x 2.2x 3.4x 3.8x 6.0x
图 7.5. 分词器在各语言上产出率的示意图:在以英文为主调出的分词器下,同一个句子在某些语言里要花多得多的词元,每条柱旁标出相对英文的倍数。为理想化词元数,仿 Petrov 等(2023)。

这些之所以比寻常超参数更要紧,原因在于锁定。在偏斜配比上训出的词表,会为多语种质量封顶,提高服务不足语言的词元成本,而它无法在不重训模型的前提下更改,因为嵌入矩阵与输出矩阵都按它来定尺寸。继续训练时做词表扩展,可以嫁接上一些新词元,算个部分的权宜之计,但它消不掉最初的偏见。归根结底,分词器一次定下,便要陪着模型过完一生。这也是为什么一个看似预处理的选择,值得以对待架构决策的审慎来看待。

下层约束

分词器在数据流水线里被冻结,并由此决定了上一层的一个选择。这里定下的词表规模,固定了 第 8 章 中嵌入矩阵的行数和输出投影:那一层继承词表,并按它给自己的矩阵定尺寸,它无权挑选词表。于是,一个在数据工作中定下的词元预算,就设定了模型架构的一个硬参数。

只设定一次的参数

点出锁定,并不等于轻视这项工作。实践中分词器只训练一次,伴随着少数几个一旦搞错就代价高昂的决策。除了词表规模与训练语料,还有两个反复出现的参数值得另外留意。

第一个是数字处理:把数字拆成单个数位,这样算术就不再取决于「1234」是不是碰巧合并成了一个词元。第二个是空白处理,即 SentencePiece 式的方案把空格编码成普通符号,让去分词精确且可逆 (Kudo and Richardson 2018)。构建过程本身就是上面那段概念版 BPE 循环:统计、合并、重复,然后把合并列表与词表冻结成后续阶段都要继承的人造物。

真正关键的是这件人造物。它很小,产出得很早,却把下游的一切都固定在造就它的那份语料的语言与领域平衡上。一个看似预处理的选择,结果竟是模型一生中第一项不可逆的承诺,而这正是值得带进下一层、带进继承它的那套架构里去的视角。

延伸阅读

  • Sennrich et al., “Neural Machine Translation of Rare Words with Subword Units” (BPE), 2015. arXiv:1508.07909
    本文提出将 BPE(字节对编码)用于稀有词的子词分割,使神经机器翻译无需回退词典即可实现开放词表翻译。
  • Kudo, “Subword Regularization: Improving Neural Network Translation Models with Multiple Subword Candidates” (Unigram LM), 2018. arXiv:1804.10959
    子词正则化通过在训练时对多个概率采样的子词切分方案进行边际化,并提出基于一元语言模型的分词器作为 BPE(字节对编码)的概率替代方案,以提升神经机器翻译的鲁棒性。
  • Kudo & Richardson, “SentencePiece: A Simple and Language Independent Subword Tokenizer and Detokenizer for Neural Text Processing,” 2018. arXiv:1808.06226
    SentencePiece 是一种语言无关的子词分词器,直接从原始句子训练,支持字节对编码(BPE)和 unigram 语言模型,无需预分词即可实现端到端文本处理。
  • Pagnoni et al., “Byte Latent Transformer: Patches Scale Better Than Tokens” (BLT,无分词器), 2024. arXiv:2412.09871
    BLT 去掉分词器,按下一字节的熵把原始字节聚成动态大小的字节块,在 FLOP 对齐的扩展比较中追平基于分词的模型,规模最高到 8B 参数。
  • Hwang et al., “Dynamic Chunking for End-to-End Hierarchical Sequence Modeling” (H-Net,学习式动态切块), 2025. arXiv:2507.07955
    H-Net 用一个层级模型取代分词流水线,端到端地学习依内容而定的切块边界,在算力对齐下超过字节级与 BPE Transformer。
  • Petrov et al., “Language Model Tokenizers Introduce Unfairness Between Languages,” 2023. arXiv:2305.15425
    分词器对相同文本在非英语语言中生成的词元数量最多相差15倍,导致不同语言社区在访问成本、延迟和上下文长度方面存在不公平差异。

评论

登录后评论