Skip to content

文本分词与子词算法:BPE、WordPiece 与 Unigram ​

标签
AI/llm
字数
4050 字
阅读时间
17 分钟

计算机只理解数字,喂给模型之前必须把文本转成数字序列,这个过程叫分词(Tokenization)。分词器(Tokenizer) 定义一套规则,把原始文本切成最小单元,称为词元(Token)。

先把三层概念分开 ​

「XX 模型用 BPE 还是 SentencePiece」这类问题之所以容易吵,是因为三个不同层的东西被混在一句话里:

层回答什么问题取值
算法怎么建词表BPE / WordPiece / Unigram
实现(工具 / 框架)谁来跑这个算法tiktoken(OpenAI)/ SentencePiece(Google)/ HF tokenizers
粒度在什么单位上切character-level / byte-level

SentencePiece 不是算法,是工具——它同时实现了 BPE 和 Unigram 两种(--model_type=bpe / unigram)。所以「Llama 用 SentencePiece」和「Llama 用 BPE」两句话都对:一个说的是框架,一个说的是算法。这是这一块最常见的一处混淆。

SentencePiece 还有一个关键设计:它把输入当成原始 Unicode 字符流,连空格也当普通符号(用 ▁,U+2581 表示)。标准 BPE / WordPiece 会先把文本按空格切开、假定语言是用空格分词的——那样在中文、日文、泰文上就不好使。把空格编码进 token 里还带来一个附加好处:分词完全可逆。

「XX 模型用 BPE 还是 SentencePiece」之所以容易吵,是因为三层东西被混在一句话里。把它们摊开:

层 1  算法 —— 怎么建词表
      ├─ BPE        自底向上合并,判据是频率
      ├─ WordPiece  自底向上合并,判据是「哪个合并让语料概率提升最多」
      └─ Unigram    自顶向下裁剪,判据是移除后对语料似然的损失
      │
      ▼ 由谁来实现这个算法
层 2  实现 / 工具
      ├─ tiktoken        OpenAI 的 byte-level BPE(Rust 核心 + Python 绑定)
      ├─ SentencePiece   Google 的工具,同时实现 BPE 与 Unigram
      └─ HF tokenizers   HuggingFace 的实现
      │
      ▼ 在什么单位上切
层 3  粒度
      ├─ character-level  从字符起步,字符集依赖语言
      └─ byte-level       从 256 个字节值起步,任何 UTF-8 文本都能编码

于是「Llama 用 SentencePiece」说的是层 2,「Llama 用 BPE」说的是层 1——两句话都对。

现状:谁在用哪个 ​

模型算法粒度实现
GPT-2 / 3 / 4 / GPT-5 系列BPEbyte-leveltiktoken
Llama 1 / 2 / 3BPEbyte-levelSentencePiece
Qwen 2 及之后BPEbyte-leveltiktoken 风格
Mistral / GemmaBPEbyte-levelSentencePiece
BERT / DistilBERT / ELECTRAWordPiececharacter-level(## 前缀标非词首)HF tokenizers
T5 / mT5 / mBART / ALBERT / XLNetUnigram—SentencePiece

所以「BPE 是不是主流」的答案是:是,而且是压倒性的。 GPT 系列、Llama、Qwen、Mistral、Gemma 都在 BPE 这一支上;WordPiece 基本只剩 BERT 家族在用,Unigram 主要在 T5 系。

而且在 2026 年说「BPE」基本就等于说「byte-level BPE」——下面会讲为什么。

跨算法速查:

算法建表方向核心判据词表规模
BPE自底向上合并频率几万 – 200K
WordPiece自底向上合并最大化语料似然~几万
Unigram自顶向下裁剪对语料似然的贡献几万 – 200K+

Unigram 与 BPE 的方向是反的:它从一个很大的候选词表出发,迭代剪掉那些移除后对语料总似然影响最小的 token,直到降到目标规模。由此带来一个 BPE 没有的性质——同一字符串可以有多种合法切分(BPE 是确定性的)。这个概率性可以被利用来做数据增强(subword regularization,训练时采样不同切分提升鲁棒性)。

WordPiece 则只是在 BPE 上换了合并准则:不看原始频率,看哪个合并能让语料概率提升最多。实际效果与 BPE 差别通常很小。

两个算法的建表方向相反,这决定了它们各自的性质。

BPE:自底向上合并
  起点:基本字符集(byte-level 下是 256 个字节值)
        │
        ▼
  每轮合并频率最高的一对 ──▶ 重复 ──▶ 达到目标词表规模
        └─ 结果确定性:同一个字符串只有一种切分

Unigram:自顶向下裁剪
  起点:一个很大的候选词表(几十万条候选)
        │
        ▼
  每轮删掉「移除后对语料总似然影响最小」的 token ──▶ 重复 ──▶ 降到目标规模
        └─ 结果概率性:同一个字符串可以有多种合法切分
           这个性质被利用来做数据增强 —— subword regularization,
           训练时采样不同切分,提升鲁棒性

WordPiece 夹在两者之间:方向与 BPE 相同(合并),判据换成「最大化语料似然」。
实际效果与 BPE 差别通常很小。

为什么不能按词或按字符切 ​

策略做法问题
按词(Word-based)用空格或标点切分成单词词表爆炸;未登录词(OOV)——词表外的词(如 DatawhaleAgent)无法处理;语义关联缺失——look / looks / looking 被当成三个无关词元,低频词语义学不好
按字符(Character-based)切成单个字符词表很小、无 OOV;但单字符大多不具备独立语义,模型要额外花精力学「怎么把字符组成词」,学习效率低

现代大模型普遍用子词分词(Subword Tokenization) 折中:常见词(agent)保留为完整词元,不常见的词(Tokenization)拆成有意义的子词片段(Token + ization)。既控制词表大小,又让模型能通过组合子词理解和生成新词。

OOV 那一条是分词的原始动机:词表 5 万,用户输入 untokenizable 返回 [UNK],模型拿不到这个词的任何信号。子词分词让罕见词分解成已知片段(un + token + izable),既保住信息又不必扩词表。

BPE 算法 ​

字节对编码(Byte-Pair Encoding, BPE) 是最主流的子词算法之一(Gage, A new algorithm for data compression, C Users Journal 1994),GPT 系列采用。核心是一个「贪心」的合并过程:

1. 初始化   → 词表 = 语料库中出现过的所有基本字符
2. 迭代合并 → 统计所有相邻词元对的频率,把频率最高的一对合并成新词元,加入词表
3. 重复     → 重复第 2 步,直到词表大小达到预设阈值

案例:迷你语料集 {"hug": 1, "pug": 1, "pun": 1, "bun": 1},目标词表大小 10。合并顺序是 u+g → ug、ug+</w> → ug</w>、u+n → un、un+</w> → un</w>。

训练完成后,对没见过的词 bug 的分词过程:查 bug 不在词表 → 查 bu 不在词表 → 查 b 和 ug 都在 → 切成 ['b', 'ug']。

python
import re, collections

def get_stats(vocab):
    """统计词元对频率"""
    pairs = collections.defaultdict(int)
    for word, freq in vocab.items():
        symbols = word.split()
        for i in range(len(symbols) - 1):
            pairs[symbols[i], symbols[i + 1]] += freq
    return pairs

def merge_vocab(pair, v_in):
    """合并词元对"""
    v_out = {}
    bigram = re.escape(' '.join(pair))
    p = re.compile(r'(?<!\S)' + bigram + r'(?!\S)')
    for word in v_in:
        w_out = p.sub(''.join(pair), word)
        v_out[w_out] = v_in[word]
    return v_out

# 每个词末尾加 </w> 表示结束,并切分好字符
vocab = {'h u g </w>': 1, 'p u g </w>': 1, 'p u n </w>': 1, 'b u n </w>': 1}

for i in range(4):
    pairs = get_stats(vocab)
    if not pairs:
        break
    best = max(pairs, key=pairs.get)
    vocab = merge_vocab(best, vocab)

注意 </w> 的作用:标记词边界。没有它,ug 可以在词中和词尾无差别合并,分词结果就不可逆了。

BPE 的建表过程就是反复数频率、合并、扩词表。

语料 {hug: 1, pug: 1, pun: 1, bun: 1},目标词表大小 10

初始:词表 = 语料里出现过的所有基本字符,每个词结尾加 </w> 标记边界
      h u g </w>    p u g </w>    p u n </w>    b u n </w>

合并顺序(每轮数一遍相邻对的频率,合并最高的那一对)
      第 1 轮   u + g    → ug        相邻出现 3 次,最高
      第 2 轮   ug + </w> → ug</w>   出现 2 次
      第 3 轮   u + n    → un        出现 2 次
      第 4 轮   un + </w> → un</w>   出现 2 次
      四轮之后词表从初始字符集扩到 10 个

表外词 bug 的切分是一次查表
      bug 不在词表 ──▶ bu 不在词表 ──▶ b 在、ug 在 ──▶ 切成 ['b', 'ug']

</w> 标记词边界。没有它,ug 在词中与词尾会无差别合并,分词结果就不可逆了。

byte-level BPE:现在说的「BPE」默认指它 ​

原始的 BPE 从字符起步,字符集依赖语言(英文和中文的词表起点完全不同)。byte-level BPE 从 256 个字节值起步,算法一样,只是基本单位换成了字节。

这一步换来一个关键性质:任何 UTF-8 文本都能被编码,永远不会有 [UNK]。任何语言、emoji、罕见符号,最终都能拆成字节——所以词表里不需要「未知」这个类别。这就是 2026 年几乎所有生成式 LLM 都用它的原因。

代价是非拉丁文字可能被切得更碎:一个字符在 UTF-8 下占多个字节,每个字节都要先成为 token 再逐步合并,所以同样长度下非英语内容往往消耗更多 token。

GPT-2 的词表构成可以精确拆开:

50,257 = 256(字节)+ 50,000(合并次数)+ 1(特殊 token)

tiktoken 就是 OpenAI 的 byte-level BPE 实现(Rust 核心 + Python 绑定)。它的词表在代际之间是变的:

编码名用在
gpt2GPT-2
p50k_baseGPT-3 / Codex
cl100k_baseGPT-3.5 / GPT-4
o200k_baseGPT-4o 及更新

所以不要记死映射,按模型名取(tiktoken.encoding_for_model(...))。词表从 100K 扩到 200K 主要是为了改善多语言与代码覆盖。

byte-level 把起点换成了字节,代价落在非拉丁文字上。

GPT-2 的词表构成可以精确拆开
      50,257 = 256(所有字节值)+ 50,000(合并出来的词元)+ 1(特殊 token)

从字节起步换来的一条性质
      任何 UTF-8 文本都能编码 ──▶ 永远不会有 [UNK]
      ──▶ 词表里不需要「未知」这一档,任何语言、emoji、罕见符号都有字节兜底

代价:一个字符占几个字节,就先要当几个 token 起步
      英文字母    1 字节   1 个 token 起步
      汉字        3 字节   3 个 token 起步(后续合并通常降到 1–2)
      emoji       4 字节   4 个 token 起步
      └─ 同样长度的非英语内容,token 数因此系统性更高

tiktoken 的词表在代际之间是变的,按模型名取即可
      gpt2 → p50k_base → cl100k_base → o200k_base
      从 100K 扩到 200K 主要是为了改善多语言与代码覆盖

两个后续优化 ​

算法提出方与 BPE 的差别
WordPieceGoogle,BERT 采用(Schuster & Nakajima, 2012)合并标准是「能最大化提升语料库的语言模型概率」,而不是单纯的「最高频率」——优先合并那些让整个语料库通顺度提升最大的词元对
SentencePieceGoogle 开源工具(Kudo & Richardson, 2018),Llama 系列采用把空格也视作普通字符(通常用下划线 _ 表示)。分词与解码完全可逆,且不依赖特定语言——不需要知道中文不用空格分词

对开发者实际影响的三件事 ​

理解算法细节不是目的,但这三件事直接影响智能体的性能、成本和稳定性:

一、上下文窗口限制。 窗口(8K、128K)是按 Token 数量算的,不是字符数或单词数。同样一段话,在不同语言(中英文)或不同分词器下,Token 数量可能相差巨大。精确管理输入长度是构建长时记忆智能体的基础。

二、API 成本。 多数模型 API 按 Token 计费,知道自己文本会被怎么分词是预估成本的前提。

三、模型表现的异常。 有些奇怪表现的根源就在分词:

  • 模型可能很擅长算 2 + 2,但对 2+2(无空格)就出错——后者可能被切成一个不常见的独立词元
  • 同一个词因首字母大小写不同,可能被切成完全不同的 Token 序列
  • 设计提示词和解析模型输出时把这些「陷阱」考虑进去,能提升智能体鲁棒性

编解码的工程接口 ​

用 Hugging Face transformers 时,文本与 token id 的互转走这一套:

python
# 文本 → id(含对话模板)
text = tokenizer.apply_chat_template(messages, tokenize=False, add_generation_prompt=True)
model_inputs = tokenizer([text], return_tensors="pt").to(device)

# 生成
generated_ids = model.generate(model_inputs.input_ids, max_new_tokens=512)

# 只取新生成的部分再解码,否则会把输入原样吐回来
generated_ids = [out[len(inp):] for inp, out in zip(model_inputs.input_ids, generated_ids)]
response = tokenizer.batch_decode(generated_ids, skip_special_tokens=True)[0]

apply_chat_template 负责把 {"role": ..., "content": ...} 的消息列表按模型自己的对话格式拼成字符串——不同模型的模板不一样,这一步不能手写。解码时必须先截掉输入部分,否则输出里会包含原始 prompt。skip_special_tokens=True 用来过滤 <|im_start|> 这类控制符。

生产里的五个坑 ​

分词在训练时就固定了——换 tokenizer 是类别级的改动,模型必须重训。所以这些问题只能在选型与集成阶段处理,上线后改不了。

坑表现处理
训练/服务的 tokenizer 漂移同一家族的 tokenizer 不同版本会微妙不同必须把 tokenizer 版本与模型一起锁死
特殊 token 不一致不同 chat 模型对 system / user / assistant 轮次期望不同的特殊 token 格式,发错就质量退化用模型自己的 chat template,不要自己拼字符串
多语言成本不对称英文约 4 字符/token,日文可到 1 字符/token。1000 字符的日文文档可能比同长度英文贵 10 倍按语言分别估预算;必要时按字符或句数而非固定 token 数切块
数字切分方式不同逐位切数字(对算术友好)vs 多位数当一个 token(算术差)需要算术能力的场景要确认模型是逐位切的(LLaMA 与现代模型多为逐位)
Unicode 规范化差异「同一个字符串」的合成式与分解式会产生不同的 token 序列统一走 NFC 规范化

还有一个容易低估的量级问题:同一个字符串在不同 tokenizer 下的 token 数能差 10–30%(cl100k 对比 LLaMA tokenizer)。多语言密集或代码密集的内容差距更大。所以「我的 prompt 大概多少 token」这种估算,必须用目标模型自己的 tokenizer 去数。

有些厂商(如 Anthropic、Google)不公开 tokenizer 细节,第三方实现的计数只是估算。对精度敏感的计费或截断逻辑,应该用厂商 API 自己的 token 计数端点。

相关 ​

参考 ​

  • 《Hello-Agents》第三章 §3.2.2、§3.2.3
  • Gage, P. A new algorithm for data compression. C Users Journal, 1994.
  • Schuster, M., & Nakajima, K. Japanese and korean voice search. ICASSP, 2012.
  • Kudo, T., & Richardson, J. SentencePiece. arXiv:1808.06226, 2018.

贡献者 ​

文件历史 ​