Skip to content

Tokenizer 分词器详解

分词器(Tokenizer)是 LLM 与人类语言之间的翻译官——它把文本切分成模型能处理的最小单元(token),再把模型输出的 token 还原成文字。它是 LLM 应用生态概览的基础设施层,所有 LLM 的输入输出都离不开它。本页梳理从字符级到 BPE、WordPiece、Unigram 等主流分词算法的完整谱系。

Tokenizer = 把句子拆成积木块。

LLM 不认识文字,只认识数字。Tokenizer 就是”拆字+编号”的翻译官:

  • 字符级(Character-level):逐个字母切分。“hello” 拆成 h-e-l-l-o。永远不会遇到生词,但序列太长——一个单词 5 个 token,模型难以学到单词级别的语义。
  • 词级(Word-level):按空格/标点切分成完整词。“I love cats” 拆成 [“I”, “love”, “cats”]。序列短,但词汇表爆炸——英语有数十万词,还有各种变体(run/runs/running/unran),生词(OOV,Out-of-Vocabulary,即词汇表中不存在的词)直接抓瞎。
  • 子词级(Subword):折中方案。常见词保留完整(“love”),罕见词拆成有意义的片段(“unhappiness” → “un” + “happiness” 或 “un” + “happy” + “ness”)。既不会序列太长,也几乎不会遇到生词——这是当今所有 LLM 的标准做法。

核心哲学:高频词完整保留,低频词拆成碎片。这样词汇表大小可控(通常 3 万到 20 万),同时能表示任意文本——即使是从没见过的新词,也能用已有子词拼出来。

理解 Tokenizer 在整个 LLM 流水线中的角色非常重要:

用户输入文本
│
▼
┌──────────────┐ ┌─────────────────┐ ┌──────────────┐
│ Tokenizer │─────▶│ Token ID 序列 │─────▶│ Embedding │
│ (分词+编号) │ │ [15496, 995, …] │ │ (查表→向量) │
└──────────────┘ └─────────────────┘ └──────┬───────┘
│
▼
┌──────────────┐
│ Transformer │
│ (模型主体) │
└──────┬───────┘
│
▼
┌──────────────┐ ┌─────────────────┐ ┌──────────────┐
│ Tokenizer │◀─────│ Token ID 序列 │◀─────│ 输出概率分布 │
│ (ID→文本) │ │ [输出 IDs] │ │ (softmax) │
└──────────────┘ └─────────────────┘ └──────────────┘

关键点:Tokenizer 决定了模型的”阅读能力”边界。如果一个中文字在词汇表中不存在,模型就永远无法”看到”这个字——它只能以更碎的字节片段来理解。Tokenizer 的质量直接影响模型的多语言能力、代码理解能力、以及推理成本。

BPE(Byte Pair Encoding,字节对编码)

Section titled “BPE(Byte Pair Encoding,字节对编码)”

BPE 是最主流的子词分词算法,GPT 系列、LLaMA 等都使用它的变体。核心思想极其朴素:反复合并出现频率最高的相邻字节对。BPE 最初是 1994 年提出的一种数据压缩算法,2016 年被 Sennrich 等人引入 NLP 领域。

训练过程(在语料上):

  1. 初始化:把所有词拆成单个字符序列,词尾加特殊标记(如 </w> 表示词尾)。比如 “low” 拆成 l o w </w>。同时统计每个词在语料中出现的次数。
  2. 统计频率:统计所有相邻字符对的频率,找到出现次数最多的一对。
  3. 合并:把这对字符合并成一个新 token,更新所有词。
  4. 重复:重复步骤 2-3,直到达到预设的合并次数(即词汇表大小)。

一个完整的训练示例(假设语料为:low low low low low lower lower newest newest newest newest newest newest widest widest):

初始状态(每个字符独立):
l o w </w> ×5
l o w e r </w> ×2
n e w e s t </w> ×6
w i d e s t </w> ×2
第 1 轮:频率最高的对是 (e, s) 出现 8 次 → 合并为 es
l o w </w> ×5
l o w e r </w> ×2
n e w es t </w> ×6
w i d es t </w> ×2
第 2 轮:频率最高的对是 (es, t) 出现 8 次 → 合并为 est
l o w </w> ×5
l o w e r </w> ×2
n e w est </w> ×6
w i d est </w> ×2
第 3 轮:频率最高的对是 (est, </w>) 出现 8 次 → 合并为 est</w>
l o w </w> ×5
l o w e r </w> ×2
n e w est</w> ×6
w i d est</w> ×2
第 4 轮:频率最高的对是 (l, o) 出现 7 次 → 合并为 lo
lo w </w> ×5
lo w e r </w> ×2
n e w est</w> ×6
w i d est</w> ×2
第 5 轮:(lo, w) 出现 7 次 → 合并为 low
low </w> ×5
low e r </w> ×2
...

几轮之后,常见词 low 和后缀 est 已经被合并为完整的 token。经过数千轮合并,词汇表就包含了所有高频词、常见词根和词缀。

编码过程(对新文本):按训练时学到的合并规则表(一个有序的 (pair → merged_token) 列表),从单字符开始,按优先级(训练时的合并顺序)逐一尝试合并,直到不能再合并。

输入:"lowest"
初始: l o w e s t
规则: (l,o) → lo → lo w e s t
规则: (lo,w) → low → low e s t
规则: (e,s) → es → low es t
规则: (es,t) → est → low est
结果: ["low", "est"]

如果输入包含训练时没见过的字符,BPE 会输出 [UNK](未知 token)——这就是经典 BPE 的局限性,也是 Byte-level BPE 要解决的问题。

Google 为 BERT 设计,与 BPE 非常相似,区别在于选择标准:

  • BPE 选择频率最高的字节对。
  • WordPiece 选择使语料似然函数最大化的字节对——即合并后整体语言模型概率提升最大的那一对。

具体而言,WordPiece 的评分公式为:

score(pair)=freq(xy)freq(x)×freq(y)\text{score}(\text{pair}) = \frac{\text{freq}(xy)}{\text{freq}(x) \times \text{freq}(y)}

这个分数衡量的是 x 和 y 共现(一起出现)的”密度”——如果 x 和 y 总是成对出现,分数高;如果 x 和 y 各自也常单独出现,分数被稀释。这与 PMI(Pointwise Mutual Information,逐点互信息,衡量两个事件关联程度的指标)的思想一致。

实际效果上,WordPiece 和 BPE 分词结果差别不大,但 WordPiece 更”贪心”地选择对整体概率贡献最大的合并。BERT、DistilBERT 等使用 WordPiece。

WordPiece 和 BPE 的编码结果都用 ## 前缀标记子词片段的后缀部分,如 “playing” 可能被切分为 ["play", "##ing"],其中 ##ing 表示它是接在前面的词后面的。

SentencePiece 库支持的另一种策略,思路”反着来”:

  1. 初始化:先初始化一个巨大的候选词汇表(所有可能的子串,可能有数百万个)。
  2. 估计概率:用 EM 算法(Expectation-Maximization,期望最大化算法,一种迭代求解最大似然估计的经典方法)估计每个子词的概率,构建一个 Unigram 语言模型。
  3. 逐步删减:逐步删掉对语料似然贡献最小的子词,缩小词汇表到目标大小。
  4. 编码:编码时,对同一文本选择概率最高的分词方式(使用 Viterbi 算法动态规划求解最优切分)。

优点:对同一文本可能有多种分词方式,编码时自然选择概率最高的——这在某些语言(日语、中文等无空格语言)上更灵活。

Unigram 与 BPE 的本质区别:BPE 是贪心增长的(从字符出发逐步合并),Unigram 是贪心缩减的(从大全出发逐步删除)。BPE 每一步只考虑局部频率,Unigram 每一步考虑全局似然——理论上更优,但训练更慢。

子词正则化(Subword Regularization):Unigram 的一个独特优势是可以采样多种分词方式,在训练时随机选不同的分词结果,起到数据增强的效果。这被称为”子词正则化”,由 Kudo (2018) 提出,能提升模型对分词噪声的鲁棒性。

SentencePiece 不是分词算法,而是一个分词框架/工具库,它把文本当作原始字节流处理,不依赖空格分词。它支持 BPE 和 Unigram 两种算法。

关键优势:语言无关。英语靠空格分词,但中文、日语、韩语没有空格——SentencePiece 直接在 Unicode 字节流上操作,对所有语言一视同仁。LLaMA、T5、ChatGLM 等多语言模型都用 SentencePiece。

核心设计决策:

  1. 将空格编码为特殊符号(▁,U+2581):这样空格信息不会在分词过程中丢失,解码时可以完美还原原文。
  2. 直接在 Unicode 字符上操作:不依赖任何语言的空格规则,对中文、日语等无空格语言同样适用。
  3. 可逆性保证:decode(encode(text)) == text,不丢失任何信息。
文本: "Hello 世界"
预处理: "▁Hello▁世界" (空格被替换为 ▁)
分词: ["▁Hello", "▁世界"] 或 ["▁He", "llo", "▁世", "界"]
解码: "▁Hello▁世界" → "Hello 世界" (▁ 替换回空格)

OpenAI 为 GPT 系列开发的高效 BPE 分词器库。核心改进:直接在 UTF-8 字节上操作(Byte-level BPE),而非 Unicode 字符。好处是任何文本——包括 emoji、生僻字、代码符号——都能被分词,不会出现”无法表示的字符”。

Byte-level BPE 为什么重要:经典 BPE 在 Unicode 字符层面操作,如果遇到训练时没见过的字符(如某个生僻汉字),就只能输出 [UNK]。Byte-level BPE 先将所有文本转换为 UTF-8 字节(每个字符变成 1-4 个字节),然后在字节层面做 BPE。UTF-8 只有 256 种字节值,所以永远不可能遇到”未知字节”——从根本上消除了 OOV 问题。

GPT 系列编码演进:

模型编码名称词汇表大小特点
GPT-2 / GPT-3r50k_base / p50k_base~50KByte-level BPE
GPT-3.5 (ChatGPT)cl100k_base~100K扩充词汇表,优化多语言和代码
GPT-4oo200k_base~200K大幅优化非英语语言,中文效率提升约 1.7 倍

GPT-4o 的 o200k_base 是一个重要里程碑——它将中文的 token 效率(每个 token 承载的中文字符数)相比 cl100k_base 提升了约 1.7 倍,意味着同样的中文文本在 GPT-4o 下消耗的 token 更少、成本更低。

不同模型的 Tokenizer 设计差异很大,直接影响其在不同语言和任务上的效率:

模型算法词汇表大小中文效率备注
GPT-4 (cl100k)Byte-level BPE~100K一般英文优化
GPT-4o (o200k)Byte-level BPE~200K好中文效率大幅提升
LLaMA-2SentencePiece BPE32K差词汇表太小,中文一个字常需 2-3 token
LLaMA-3tiktoken BPE128K好大幅扩充词汇表
Qwen 2.5tiktoken BPE152K优秀中文优化,每字约 0.6 token
DeepSeek-V2Byte-level BPE100K优秀中文优化
Claude 3专有~100K好Anthropic 专有实现

中文效率差异的影响:LLaMA-2 的 32K 词表中中文占比极少,导致中文文本被切碎成大量单字甚至字节,一个中文字常需 2-3 个 token。这直接导致中文场景的成本翻倍、上下文窗口缩小。Qwen、DeepSeek 等国产模型在词表中专门加入了大量中文 token,大幅降低了中文场景的 token 消耗。

使用 tiktoken 查看 GPT 的分词结果

Section titled “使用 tiktoken 查看 GPT 的分词结果”
import tiktoken
# 加载 GPT-4 的分词器编码
enc = tiktoken.encoding_for_model("gpt-4")
# 查看分词结果(数字 ID)和对应的文本片段
text = "我喜欢学习人工智能和LLM"
tokens = enc.encode(text)
for tid in tokens:
print(f"ID={tid:6d} 片段={enc.decode_single_token_bytes(tid)}")
print(f"\n总 token 数: {len(tokens)}")
# 中文和英文的 token 效率对比
zh = enc.encode("我爱自然语言处理")
en = enc.encode("I love natural language processing")
print(f"中文: {len(zh)} tokens") # 通常比英文多
print(f"英文: {len(en)} tokens")
import tiktoken
# 加载不同编码
enc_cl100k = tiktoken.get_encoding("cl100k_base") # GPT-4
enc_o200k = tiktoken.get_encoding("o200k_base") # GPT-4o
text_zh = "人工智能是计算机科学的一个分支,它致力于研究、开发用于模拟、延伸和扩展人的智能的理论、方法、技术及应用系统。"
text_en = "Artificial intelligence is a branch of computer science that aims to create machines that can think and act like humans."
text_code = "def quicksort(arr):\n if len(arr) <= 1:\n return arr\n pivot = arr[len(arr)//2]\n left = [x for x in arr if x < pivot]\n return quicksort(left) + [pivot] + quicksort(right)"
for label, text in [("中文", text_zh), ("英文", text_en), ("代码", text_code)]:
c = len(enc_cl100k.encode(text))
o = len(enc_o200k.encode(text))
print(f"{label}: cl100k={c:4d} tokens, o200k={o:4d} tokens, 提升={c/o:.1%}")

用 HuggingFace tokenizers 训练 BPE 分词器

Section titled “用 HuggingFace tokenizers 训练 BPE 分词器”
from tokenizers import Tokenizer
from tokenizers.models import BPE
from tokenizers.trainers import BpeTrainer
from tokenizers.pre_tokenizers import Whitespace
# 创建 BPE 分词器,按空格预切分
tokenizer = Tokenizer(BPE(unk_token="[UNK]"))
tokenizer.pre_tokenizer = Whitespace()
trainer = BpeTrainer(
vocab_size=5000, # 目标词汇表大小
special_tokens=["[PAD]", "[UNK]", "[CLS]", "[SEP]", "[MASK]"]
)
# 在一组文本文件上训练(这里用列表模拟)
texts = ["hello world", "machine learning is fun", "tokenizers split text"]
tokenizer.train_from_iterator(texts, trainer)
# 编码与解码
output = tokenizer.encode("hello machine learning")
print(f"Tokens: {output.tokens}")
print(f"IDs: {output.ids}")
print(f"解码: {tokenizer.decode(output.ids)}")
# 前提:pip install sentencepiece
import sentencepiece as spm
# 训练 Unigram 模型(适合中文等无空格语言)
spm.SentencePieceTrainer.train(
input="corpus.txt", # 训练语料文件
model_prefix="m", # 输出模型前缀
vocab_size=32000, # 词汇表大小
model_type="unigram", # 也可选 "bpe"
character_coverage=0.9995, # 覆盖多少比例的 Unicode 字符
input_sentence_size=1000000,
)
# 加载并使用
sp = spm.SentencePieceProcessor(model_file="m.model")
tokens = sp.encode("自然语言处理很有趣", out_type=str)
ids = sp.encode("自然语言处理很有趣", out_type=int)
print(f"分词: {tokens}") # ['▁', '自然', '语言', '处理', '很', '有趣']
print(f"ID: {ids}")
print(f"解码: {sp.decode(ids)}") # 完美还原原文
  • Token 数量直接决定成本和延迟:API 按 token 计费,上下文窗口按 token 限制。中文一个字通常是 1-2 个 token(比英文贵),代码和 emoji 的 token 开销更大。估算时:1 个英文单词约 1.3 个 token,1 个中文字约 1.5-2 个 token(GPT-4 cl100k 编码),GPT-4o(o200k 编码)中文约 0.7-1 个 token/字。
  • 不同模型的 Tokenizer 不通用:GPT-4 的 “hello” 是 token 31373,但在 Claude 的 Tokenizer 里是完全不同的 ID。混用会得到乱码。务必使用目标模型对应的 Tokenizer 做长度估算。
  • 词汇表大小是多语言权衡:词汇表越大,每个 token 承载的信息越多(序列短、推理快),但 Embedding 层(将 token ID 映射为向量的查找表)参数更多,训练数据更容易稀疏。GPT-4 约 10 万词表,LLaMA-2 仅 3.2 万(因此中文效率差),Qwen/DeepSeek 等中文优化模型通常扩充到 15 万+。
  • 特殊 token 的处理:[BOS](序列开头)、[EOS](序列结尾)、[PAD](填充)、[UNK](未知词)等特殊 token 的设计因模型而异。微调时如果添加了新的特殊 token(如 <|im_start|>),需要正确扩展 Embedding 层。
  • 压缩率是核心指标:好的 Tokenizer 能用更少的 token 表示同样的文本,直接降低成本、增大有效上下文。评估 Tokenizer 时关注 bytes-per-token(每 token 承载的字节数)——越高越好。一个好的多语言 Tokenizer 的 bytes-per-token 应在 4-6 之间。
  • Tokenizer 决定了模型的能力上限:如果词汇表中缺少某些语言/领域的 token,模型在这些领域的表现会受限。这就是为什么 LLaMA-2 在中文任务上先天不如 Qwen——不是因为模型架构差,而是因为 Tokenizer 没有中文优化,大量信息在字节层面丢失了。
  • Tokenizer 是模型不可分割的一部分:一个训练好的 LLM 和它的 Tokenizer 是绑定的。不能给一个模型换另一个 Tokenizer——Token ID 和 Embedding 层的对应关系是固定的。如果必须换 Tokenizer(如合并两个模型),需要重新训练 Embedding 层。
  • Pre-tokenization 的影响:在子词合并之前,文本通常会先被空格或正则规则预切分(pre-tokenization)。不同的预切分策略会显著影响最终分词效果。例如 GPT-4 的预切分将 'hello 和 world 分开,但不会拆开 don't 中的 't——这些细节影响模型的实际行为。
  • 所有 LLM 的输入输出处理:从 GPT 到 LLaMA 到 Claude,每一次对话都先经过 Tokenizer 编码再送入模型,输出再解码。详见LLM 推理优化。
  • Token 计费与配额管理:OpenAI/Anthropic 等 API 按 token 计费,应用层必须用 Tokenizer 预估 token 数来控制成本和上下文窗口。详见上下文工程。
  • 数据预处理与词表对齐:微调模型时,训练数据的 Tokenizer 必须和基础模型完全一致,否则 Embedding 层对不上。详见LLM 微调技术。
  • Embedding 模型:嵌入模型的输入也需先分词。详见嵌入模型。
  • 多语言模型评估:评估模型在不同语言上的表现时,需要考虑 Tokenizer 效率差异。同样长度的中文和英文文本,LLaMA-2 消耗的 token 数差异可达 3-5 倍——这直接影响公平比较。
  • Tokenizer 压缩研究:2024-2025 年的研究热点之一是”Tokenizer 压缩”——在不降低模型性能的前提下减小词汇表,从而减小 Embedding 层参数量和模型文件体积。byte-level 训练和动态词表(如 MegaByte、Mamba 的选择性机制)是两个方向。
类库语言说明
tiktokenPythonOpenAI 开源的高效 BPE 分词器,支持 GPT-3.5/4 的 cl100k_base 和 GPT-4o 的 o200k_base 编码
sentencepiecePython/C++Google 开源的分词框架,支持 BPE 和 Unigram,语言无关,LLaMA/T5/ChatGLM 使用
tokenizersPython/RustHuggingFace 开源的高性能分词库,Rust 实现,支持 BPE/WordPiece/Unigram
transformers AutoTokenizerPythonHuggingFace Transformers 内置的统一接口,自动匹配模型对应的 Tokenizer
js-tiktokenJavaScripttiktoken 的 JavaScript 移植,适合前端/Node.js 场景的 token 估算
术语英文解释
分词器Tokenizer将文本切分为 token(子词单元)并映射到数字 ID 的组件
词元Token分词器输出的最小文本单元,可能是完整词、子词或单个字符
词汇表Vocabulary分词器所有可用 token 的集合,每项对应一个唯一 ID
字节对编码BPE, Byte Pair Encoding反复合并频率最高的相邻字节对来构建词汇表的子词算法
词片WordPiece基于似然最大化选择合并对的子词算法,BERT 使用
Unigram 语言模型Unigram LM通过概率模型选择最优分词方案的算法,支持多种分词方式
句子片段SentencePiece语言无关的分词框架,直接在 Unicode/字节流上操作
词外词OOV, Out-of-Vocabulary词汇表中不存在的词,词级分词器会遇到的问题,子词分词器基本解决
字节级 BPEByte-level BPE直接在 UTF-8 字节上运行 BPE,能表示任意字符(GPT-4 使用)
压缩率Compression Rate每 token 承载的字节数,衡量 Tokenizer 效率的核心指标
预切分Pre-tokenization子词合并前对文本的初步切分(如按空格),影响最终分词效果
子词正则化Subword Regularization训练时随机采样不同分词方式,增强模型鲁棒性的技术
  • Sennrich et al.,「Neural Machine Translation of Rare Words with Subword Units」(ACL 2016):BPE 分词的原始论文,将字节对编码从数据压缩领域引入 NLP,奠定了现代子词分词的基础。
  • Kudo,「SentencePiece: A simple and language independent subword tokenizer」(2018):SentencePiece 的论文,提出语言无关的分词框架,T5/LLaMA 等模型的标准工具。
  • Kudo,「Subword Regularization: Improving Neural Network Translation Models」(ACL 2018):Unigram 语言模型分词 + 子词正则化,提出多种分词采样提升鲁棒性。
  • OpenAI tiktoken 文档:tiktoken 开源仓库 README,包含 GPT 系列编码方式说明和性能对比,practical 使用首选。
  • HuggingFace NLP Course - Chapter 2:Tokenizer 章节,通过代码示例讲解 BPE/WordPiece/Unigram 的实际操作,入门首选。
  • Petrov et al.,「Language Model Models Are Secretly Rabbits」(2024):深入分析 Tokenizer 对模型行为的隐含影响,揭示分词策略如何微妙地影响模型的推理能力。
  • GPT-4o tokenizer 改进:OpenAI 在 GPT-4o 中将词汇表扩展到约 20 万 token,大幅优化非英语语言的 token 效率,官方博客有详细说明。