← 返回笔记列表
📝 学习笔记 · LLM 基础

LLM Tokenizer 方法总结

从字符到 Token 的完整演化路径:BPE / WordPiece / Unigram / SentencePiece / tiktoken

涵盖方法
BPE · WordPiece · Unigram · SentencePiece · BBPE · tiktoken
代表模型
GPT-2/4 · BERT · T5 · LLaMA · Gemini
难度
⭐⭐⭐ 中等
工程重点
vocab size 选择 · OOV · 多语言 · 数字处理
💬
一句话总结
LLM 的 Tokenizer 本质是「将任意文本映射到有限词表的整数序列」。主流方法沿着 BPE(频率驱动的贪心合并)→ WordPiece(最大化语言模型概率的合并)→ Unigram(从大词表剪枝,概率最优)→ SentencePiece(语言无关的统一框架)→ BBPE/tiktoken(字节级杜绝 OOV) 这条路演进;选哪种取决于语言范围、数字/代码比例、服务延迟等工程约束。
🎯
§1 为什么需要 Tokenizer?

语言模型的本质是:给定一段历史序列,预测下一个元素的概率。这个"元素"可以是:

  • 字符级(Char-level):每个字母/汉字都是一个 token。序列太长,模型难以学习长程依赖。
  • 单词级(Word-level):每个完整词语是一个 token。词表极大(英文 100 万+),出现生词(OOV)会崩溃。
  • 子词级(Subword-level):介于两者之间,频率高的词保持完整,稀有词拆成子词。✅ 主流选择
核心矛盾:词表越大,模型表达能力越强,但参数量(embedding table)越大、OOV 风险越小;词表越小,序列越长,注意力计算开销越大。Tokenizer 就是在这个 trade-off 上求最优解的工具。
💡 举例:同一段文字在不同粒度下的序列长度差异

文本:"Tokenization is important!"

  • 字符级(27 token):T o k e n i z a t i o n i s i m p o r t a n t !
  • 单词级(3 token):Tokenization | is | important!
  • BPE 子词级(5 token):Token | ization | is | important | !

序列长度直接影响 Transformer 的计算量($O(n^2)$ attention),所以子词级方案在保持语义完整的同时,大幅减少序列长度。

Tokenizer 的两个阶段

训练阶段(构建词表)

在大型语料库上统计词频/共现,通过特定算法(BPE/WordPiece/Unigram)构建一个固定大小的词表(vocabulary),词表大小通常 32K–128K。这是一次性的离线过程。

推理阶段(编码/解码)

给定任意文本,按照词表规则将文本切分为 token id 序列(encode),或将模型输出的 id 序列还原为文本(decode)。推理时 tokenizer 是固定的,不再更新。

🔤
§2 BPE:字节对编码(Byte Pair Encoding)

来源:1994 年提出用于数据压缩,2016 年被 Sennrich 等人引入 NMT(神经机器翻译),现为 GPT 系列(GPT-1/2/3/4-o)的基础方案。

核心思想

从字符级词表出发,反复合并语料库中最高频的相邻字节对,直到词表达到目标大小。这是一种贪心算法——每一步都选择当前最优的合并操作。

算法步骤(详细版)

Step 1:初始化字符词表
每个字符=一个 token,加上特殊词尾符 ◁
Step 2:统计语料中所有 bigram 频率
Step 3:合并最高频 bigram
将它作为新 token 加入词表
Step 4:更新语料中所有被合并的 pair
Step 5:重复 Step2-4
直到词表大小达到预设 K
💡 完整示例:BPE 在 3 个词上构建词表

语料(含词频):「low」×5,「lower」×2,「newest」×6,「widest」×3

初始词表(字符级,加词尾符):

📄 BPE 词表迭代过程Python
low</w>: 5    low e r</w>: 2    n e w e s t</w>: 6    w i d e s t</w>: 3

迭代 1:统计所有 bigram 频率:e s 出现 6+3=9 次 → 合并为 es

📄 BPE 词表迭代过程Python
low</w>: 5    low e r</w>: 2    n e w es t</w>: 6    w i d es t</w>: 3

迭代 2:es t 出现 6+3=9 次 → 合并为 est

📄 BPE 词表迭代过程Python
low</w>: 5    low e r</w>: 2    n e w est</w>: 6    w i d est</w>: 3

迭代 3:l o 出现 5+2=7 次 → 合并为 lo

以此类推,最终词表将包含:lolowestnewnewest 等子词。

关键点:遇到新词 lower,会先切成字符 l o w e r,然后按学到的合并规则从前往后合并:lolowlow e r(若 er 也被合并过就继续合并)。

推理时编码(Encode)过程

训练阶段学到的是一个有序合并规则列表(merge rules),推理时按顺序应用这些规则:

📄 伪代码:BPE 编码(Encode)Python
# 伪代码:BPE 编码
def bpe_encode(word, merge_rules):
    tokens = list(word) + ['</w>']       # 字符级初始化
    for pair in merge_rules:              # 按训练顺序应用合并规则
        tokens = apply_merge(tokens, pair)
    return tokens
BPE 的优缺点:
✅ 简单直观,高频词不会被拆分;能处理稀有词(拆成子词);推理速度快(一次遍历合并规则)
❌ 贪心合并不保证全局最优;同一个词可能有多种合并方式(歧义);对数字处理不友好(如 12345 会被拆成 12 34 5,丢失数值大小关系)

代表性模型

GPT-1/2/3(原始 BPE)

词表大小 50,257(GPT-2)。操作在字符级别(Unicode 字符),空格被当作普通字符处理。│hellohello 是不同 token(开头有空格与否不同)。

GPT-4 / LLaMA(BBPE / tiktoken)

升级为字节级 BPE(详见 §6),解决 Unicode 稀有字符的 OOV 问题。cl100k 词表 100,256 tokens,显著提升代码/数学/多语言能力。

🧩
§3 WordPiece:BERT 的选择

来源:Google 在 2012 年用于语音识别(Schuster & Nakamura),后被 BERT(2018)广泛推广。

与 BPE 的核心区别

BPE 选择"频率最高的 pair"合并,WordPiece 选择"合并后使语言模型对训练数据的似然概率最大的 pair"合并。

判断是否合并 pair $(x, y)$ 的标准:

$$\text{score}(x, y) = \frac{P(xy)}{P(x) \cdot P(y)}$$
符号说明
  • $P(xy)$:片段 $xy$ 在语料中出现的频率(合并后)
  • $P(x), P(y)$:片段 $x$ 和 $y$ 分别独立出现的频率
  • 该分数本质是 互信息(Pointwise Mutual Information, PMI):越高说明 $x$ 和 $y$ 共同出现的频率远高于独立出现,值得合并为一个整体
💡 举例:为什么 PMI 比纯频率更聪明?

假设语料中:

  • un 出现 1000 次,happy 出现 800 次,unhappy 出现 50 次
  • the 出现 5000 次,cat 出现 200 次,the cat 出现 100 次

BPE 逻辑(纯频率):可能先合并 th + ethe(高频),这当然对,但 the cat 也因为出现 100 次而有合并倾向。

WordPiece PMI 逻辑:
score(the, cat) = P(the cat) / (P(the) × P(cat)) ≈ 很小(因为 thecat 各自高频,联合出现只是巧合)
score(un, happy) = 明显更高(unhappy 的出现几乎都是 un+happy 组合,不是随机的)

PMI 准确捕捉了"这两个片段确实应该在一起"的情况,而不只看绝对频率。

WordPiece 的子词前缀规则

BERT 的 WordPiece 有一个重要约定:非开头的子词用 ## 前缀标记,表示这个子词紧跟在前面的 token 后面(没有空格)。

💡 举例:BERT 如何 tokenize "unaffordable"

词表中没有 unaffordable 这个完整词,会被拆分为:

un + ##aff + ##ord + ##able

解码时看到 ## 前缀就知道拼接,重建原词:un + aff + ord + able = unaffordable

对比没有 ## 的 BPE:BPE 直接输出 un afford able,通过位置信息而非前缀来表示连续性。

WordPiece vs BPE 对比:
✅ PMI 打分比纯频率更语义合理,更少产生"凑巧共现"的 token 合并
## 前缀使得 decode 非常简单清晰
❌ PMI 计算需要额外统计,训练速度比 BPE 慢;词表构建期间不能像 BPE 那样直接用二维频率表

代表性模型

BERT / BERT-multilingual / DistilBERT / ALBERT 均使用 WordPiece,词表大小通常 30,000。

🎲
§4 Unigram LM:概率视角的 Tokenizer

来源:Kudo(2018)提出,被 T5、XLNet、mBART 等 Google/Facebook 模型采用。

核心思想:反向剪枝

BPE 和 WordPiece 都是从小词表出发向上合并,Unigram LM 反其道而行之:

  1. 从一个非常大的初始词表出发(包含所有常见字符串,可达 10 万+)
  2. 对每个 token,计算"如果删掉这个 token,语料库的似然损失多少"
  3. 删掉使损失最小的一部分 token(通常每轮删掉 10-20%)
  4. 重复直到词表达到目标大小

概率模型

假设语料中每个句子 $W$ 的最优分词方式为 $x^* = \arg\max_x P(x)$,其中:

$$P(x) = \prod_{i=1}^{n} p(x_i)$$
符号说明
  • $x = (x_1, x_2, \ldots, x_n)$:句子的一种分词方案
  • $p(x_i)$:子词 $x_i$ 在语言模型中的概率(unigram 概率,独立同分布假设)
  • Unigram:字面意思是"一元语言模型",即每个子词的概率与上下文无关
  • 训练目标:最大化整个语料的对数似然 $\sum_{s} \log P(s)$

训练时用 EM(Expectation-Maximization)算法迭代:

  • E 步:给定当前词表,用 Viterbi/前向-后向算法找每个句子的最优分词
  • M 步:用最优分词计算每个子词的频率,更新 $p(x_i)$
💡 举例:Unigram 如何处理"unaffordable"的多种分词方案

这个词有多种分词方式,Unigram 会给每种分配一个概率:

  • 方案 A:un + afford + able → P = p(un) × p(afford) × p(able)
  • 方案 B:u + naff + ordable → P = p(u) × p(naff) × p(ordable)
  • 方案 C:unafford + able → P = p(unafford) × p(able)

如果 affordun 在词表中都有较高概率,方案 A 胜出。如果 naffordable 从未见过,其概率极低,方案 B 被淘汰。

关键优势:Unigram 不像 BPE 那样强制执行固定合并顺序,而是给出所有可能分词的概率分布,可以用于子词正则化(Subword Regularization)——训练时随机按概率采样不同分词方案,增强模型鲁棒性。

子词正则化(Subword Regularization):Unigram 的独特优势——训练语言模型时,不是固定用最优分词,而是按概率分布随机采样分词方案。同一个词在不同 epoch 可能被分成不同的子词,迫使模型学会从多种子词组合中理解语义,显著提升模型对罕见词和拼写变体的鲁棒性。BPE 无法做到这一点,因为它的分词是确定性的。
🔧
§5 SentencePiece:语言无关的统一框架

来源:Google Kudo & Richardson(2018),开源工具库,支持 BPE 和 Unigram 两种算法。

核心创新:消除空格假设

BPE 和 WordPiece 都先用空格分词,再在词内部做子词分割——这对英语很自然,但对中文、日文等无空格语言失效,对泰语等粘着语也不友好。

SentencePiece 的做法:把空格作为特殊字符 (U+2581),直接在原始字符流上做分词,不预先按空格切分。

💡 举例:SentencePiece 如何处理同一句话

输入:"Hello world"

传统 BPE(先分词再子词):先切成 ["Hello", "world"],再分别做 BPE → ["He", "##llo", "world"]

SentencePiece(原始字符流):将空格替换为 后得到 "▁Hello▁world",直接在这个字符串上做分词 → ["▁Hello", "▁world"]["▁He", "llo", "▁world"]

中文处理:输入 "你好世界" 直接分词为 ["▁你好", "世界"]["▁你", "好", "世", "界"],不需要分词预处理。

SentencePiece 的四大特性

① 纯数据驱动

不依赖任何语言特定的预处理(不需要空格分词、形态分析、汉字分割等)。只需要原始文本,算法自己学习分词边界。

② 可逆编解码

通过 标记,encode 和 decode 是完全可逆的,没有任何信息损失(包括空格位置)。

③ 两种算法可选

--model_type=bpe 或 unigram,框架统一,算法可插拔。T5 用 Unigram,LLaMA/Gemini 也用 SentencePiece+BPE。

④ 处理特殊字符

支持自定义特殊 token([PAD], [UNK], [BOS], [EOS]),支持 byte fallback 模式(对 OOV 字符用 <0xHH> 表示)。

代表性模型

模型算法词表大小特殊处理
T5SentencePiece Unigram32,000无 byte fallback
XLNetSentencePiece BPE32,000同 T5
LLaMA 1/2SentencePiece BPE32,000byte fallback ON
Gemini / PaLMSentencePiece BPE256,000多语言扩展词表
§6 BBPE + tiktoken:字节级方案

BBPE:Byte-level BPE

问题背景:原始 BPE 以 Unicode 字符为基本单位,遇到罕见 Unicode 字符(如生僻汉字、emoji、特殊符号)会触发 OOV(out-of-vocabulary)——模型完全不认识这个 token,只能用 [UNK] 替代,丢失信息。

BBPE 的解法:将基本单位从"Unicode 字符"换为"字节"(byte)。任何文本都可以表示为字节序列(UTF-8 编码),256 个字节足以表示所有 Unicode 字符,因此初始词表只有 256 个 token,绝无 OOV。

💡 举例:BBPE 如何表示生僻汉字 "𠀀"(U+20000)

第一步:理解"字节"是什么

计算机存储文字时,最终都是一串 0-255 的整数(称为"字节")。 UTF-8 就是一套规则,规定"每个 Unicode 字符应该用哪几个字节来表示"。

常见例子:

  • 字母 A → 只需 1 个字节65
  • 汉字 → 需要 3 个字节228, 189, 160
  • 生僻字 𠀀 → 需要 4 个字节240, 160, 128, 128
  • emoji 🌍 → 需要 4 个字节240, 159, 140, 141

第二步:那 0xF0 0xA0 0x80 0x80[240, 160, 128, 128] 是什么关系?

完全是同一组数字,只是写法不同——一个用十六进制(0x 开头),一个用十进制:

十六进制(0x)十进制
0xF0240
0xA0160
0x80128
0x80128

所以 𠀀 这个字,就是 4 个字节:240, 160, 128, 128

第三步:BBPE 怎么处理这 4 个字节?

BBPE 的词表里有 256 个基础 token,就是 0-255 这 256 个字节 id,相当于 256 块乐高积木。任何文字最终都能被拆成这 256 块积木的组合,永远不会 OOV

# 传统字符级 BPE
"𠀀" → 词表里没有 → [UNK](信息丢失!)

# BBPE(字节级)
"𠀀" → UTF-8 字节 → [240] [160] [128] [128](4 个 token,保留全部信息)

# 如果 𠀀 在语料中频繁出现,BPE 会把这 4 个字节合并成 1 个 token
[240, 160, 128, 128] → 合并 → [token_99999](效率更高)

GPT-2 的处理:256 个字节基础 token + 常见字节序列合并 → 最终 50,257 词表。

tiktoken:OpenAI 的高性能实现

tiktoken 是 OpenAI 开源的 BPE tokenizer 实现(Rust 编写,Python bindings),采用 BBPE,是 GPT-3.5/GPT-4 系列的标准 tokenizer。

cl100k_base(GPT-4)

词表大小 100,256 tokens。改进了数字处理(每个数字最多 3 位一组,如 123412 34,减少数学任务的困难)。对代码和多语言表现更好。

p50k_base(GPT-3)

词表大小 50,281 tokens。与 GPT-2 的词表基本兼容但有扩展。代码处理能力介于 GPT-2 和 GPT-4 之间。

⚠️ 数字 Tokenization 的工程痛点:为什么早期 GPT 数学很差?

根本问题:模型做数学,本质是在做"token 序列操作"。token 切法不对,加减乘除就全乱了。

先看一个具体例子:计算 1234 + 5678。人类的做法是逐位对齐,从个位开始加:

  1 2 3 4
+ 5 6 7 8
─────────
  6 9 1 2

要做到这个,模型必须能"看到"每一位数字。如果 tokenizer 把 1234 当成一个 token,模型就看不到"千位是1、百位是2"这件事,自然就不会算了。

GPT-2 BPE 的问题:数字切法完全随机

GPT-2 的 BPE 词表是纯粹按语料频率训练的,数字没有任何特殊处理。结果:

数字文本GPT-2 实际切出的 token问题
100[100] → 1 个 token凑巧,词表里有这个词
101[101] → 1 个 token凑巧也有
1000[10][00][1000]随语料频率决定,无规律
1234567[1234][567][12][345][67] ……切法不固定,模型无法学到数位结构
3.14159[3][.][14][159]整数部分和小数部分分界也乱

模型在大量算术训练后,面对 1234 + 5678,它看到的是:[1234] [+] [5678],只有 3 个 token。它没有办法从 [1234] 这一个 token 里解析出"千位是1、百位是2、十位是3、个位是4"——就像你让人一眼看出"1234"的每一位、脑子里完全不经过分解一样,很难。

GPT-4 cl100k 的改进:强制 3 位一组

GPT-4 的 tiktoken 在构建词表时,用正则表达式对数字做了预切分规则:连续数字最多合并成 3 位一组,超过 3 位一定切开。

数字文本GPT-4 切出的 token效果
7[7]1位数,1个token
42[42]2位数,1个token
100[100]3位数,1个token
1234[123][4][1][234]4位数,切成3+1
1234567[123][456][7]7位数,切成3+3+1,有规律
1,234,567[1][,][234][,][567]千分位逗号也被合理处理

现在模型计算 1234 + 5678,看到的是 [123][4] [+] [567][8]。虽然还不是每位一个 token,但结构已经规律很多,模型通过大量训练可以学会进位计算。

为什么不干脆每个数字一个 token(最理想方案)?

理论上最好的切法是:09 各一个 token,让模型真正逐位计算。但这有代价:

# 3位一组(GPT-4 方案)
"1234567" → 3 个 token:[123][456][7]

# 每位一个(理想方案)
"1234567" → 7 个 token:[1][2][3][4][5][6][7]

# 代价:序列长度变为 2.3 倍,attention 计算量变为 5+ 倍

对于包含大量数字的任务(如代码、金融数据、数学证明),序列长度翻倍意味着推理速度和成本都显著变差。所以 3 位一组是一个工程上的妥协方案。

结论:数字切法是 tokenizer 设计中的隐性决策,直接影响数学能力。GPT-2 之所以数学差,很大程度上是因为 BPE 随机切数字导致模型根本"看不见"数位结构。GPT-4 通过正则预切分规则修复了这个问题,这是其数学能力提升的重要工程原因之一。
📊
§7 横向对比
方法合并策略初始单位OOV多语言代表模型
BPE 频率最高 bigram Unicode 字符 GPT-1/2, RoBERTa
WordPiece PMI 最高 bigram Unicode 字符 BERT, DistilBERT
Unigram LM 从大词表剪枝 Unicode 字符 有(可缓解) T5, XLNet, mBART
SentencePiece BPE 或 Unigram(可选) 原始字符流(含空格) byte fallback 可选 LLaMA, Gemini, PaLM
BBPE / tiktoken 频率最高 bigram 字节(256 个) 最强 GPT-3/4, GPT-4o

主流大模型分词算法速查

OpenAI 系列

模型算法词表大小词表名备注
GPT-2BPE(字符级)50,257gpt2首个大规模 BPE 语言模型
GPT-3BPE(字符级)50,281p50k_base与 GPT-2 基本兼容
GPT-3.5 / GPT-4BBPE(字节级 BPE)100,256cl100k_base数字 3 位一组;多语言效率提升
GPT-4oBBPE200,019o200k_base词表扩到 20 万;中日韩效率大幅提升

Google 系列

模型算法词表大小备注
BERTWordPiece30,522## 前缀标记非首子词
T5SentencePiece + Unigram LM32,000支持子词正则化(训练时随机采样分词)
PaLM / Gemini / GemmaSentencePiece + BPE256,000超大词表;多语言覆盖最全

Meta LLaMA 系列

模型算法词表大小备注
LLaMA 1 / 2 / Code LLaMASentencePiece + BPE32,000byte fallback 模式;中文效率较低
LLaMA 3 / 3.1 / 3.2BBPE(tiktoken 风格)128,256从 SP 迁移到 tiktoken;词表扩 4×;中文效率大幅提升

国产及其他主流模型

模型算法词表大小备注
Claude 1/2/3(Anthropic)BBPE(类 tiktoken)~100K架构未完全公开;与 cl100k 类似
Mistral / MixtralSentencePiece + BPE32,000同 LLaMA 2 词表
BLOOM(BigScience)BBPE250,680250 种语言;字节级覆盖
Qwen / Qwen2(阿里)BBPE(tiktoken)~151,936特别扩充中日韩字符;中文效率高
DeepSeek v2/v3BBPE100,000+中英双语优化
Yi(零一万物)SentencePiece + BPE64,000中英双语扩展词表
Baichuan 2SentencePiece + BPE125,696中文优化;扩充汉字 token
三大演进趋势:
  1. BBPE 取代字符级 BPE — LLaMA 3、Qwen 都从 SentencePiece 迁移到了 tiktoken 风格的字节级 BPE,零 OOV。
  2. 词表持续扩大 — 32K → 100K → 256K。每翻一倍,同等文本的 token 数减少,推理成本下降,中文/代码/数学能力提升。
  3. WordPiece 退出主流 — 新模型几乎不再用,只有 BERT 系老模型还在用;PMI 打分的优势在 BBPE 面前不再显著。

词表大小选择指南

词表太小的问题

  • 稀有词 / 专有名词被过度拆分 → 序列变长
  • 长序列增加推理延迟和内存
  • 模型更难学习词汇级语义

词表太大的问题

  • Embedding table 参数量激增(32B 模型 128K 词表的 embedding 层 ≈ 10B 参数)
  • 低频 token 训练不充分,embedding 学不好
  • softmax 计算开销大

实践中:英语专用模型用 32K–50K;多语言模型用 100K–256K;代码/数学专用可能用 64K 但做特殊数字处理。

🛠️
§8 工程实践与常见坑

坑 1:特殊 token 污染

Tokenizer 通常有特殊 token(如 [CLS], [SEP], <eos>)。如果训练数据中的文本恰好包含这些字符串,它们会被当作特殊 token 处理而不是普通字符——需要在预处理时转义。

坑 2:不同 tokenizer 的不可互换性

BERT 的 WordPiece tokenizer 和 GPT-4 的 tiktoken 完全不兼容——同一段文字会产生不同的 token ID 序列、不同的长度。不能把 BERT 的 token id 直接喂给 GPT 模型。每个预训练模型必须配套使用其对应的 tokenizer。

坑 3:首尾空格导致 token 变化

💡 举例:" hello" vs "hello" 是不同 token
📄 示例:空格位置影响 token idtiktoken
import tiktoken
enc = tiktoken.get_encoding("cl100k_base")
enc.encode("hello")     # [15339]
enc.encode(" hello")    # [24748]  ← 不同!开头空格使 token 变化

在构建 prompt 时,字符串拼接的空格位置会影响 tokenization 结果,进而影响模型对内容的理解。这也是为什么 chat template 要精心设计 system/user/assistant token 的格式。

坑 4:多语言 token 效率不均

英语语料训练的 tokenizer 对中文/日文/阿拉伯文效率极低——同样一段话,中文可能需要 4-5× 更多 token 才能表达(因为中文字符在英语 tokenizer 的词表中极少合并,每个汉字可能被拆成 2-3 个 byte token)。这导致多语言模型的"token 效率"(同等信息量的 token 数)极不均匀。

语言文本tiktoken cl100k token 数
English"Hello, how are you today?"6 tokens
中文"你好,你今天怎么样?"11 tokens(约 1.8× 英文)
日文「こんにちは、お元気ですか?」18 tokens(约 3× 英文)
阿拉伯语مرحبا، كيف حالك اليوم؟约 20 tokens(约 3.3× 英文)

这也是为什么 LLaMA 2 到 LLaMA 3 的词表从 32K 扩展到 128K,很大一个目标是提升多语言 token 效率。

坑 5:代码中连续空格/缩进问题

Python 缩进(4 个空格)在某些 tokenizer 里会被分成 4 个 space token,大幅增加代码序列长度。GPT-4 的 cl100k 对连续空格有专门的合并规则(如 4 个空格合并为一个 token),显著提升代码处理效率。

💡
§9 总结与选型建议

演化路径回顾

字符级
(序列太长)
词级
(OOV 问题)
BPE
(频率驱动合并)
WordPiece
(PMI 更语义)
Unigram LM
(概率最优,可随机采样)
SentencePiece
(语言无关统一框架)
BBPE / tiktoken
(字节级,零 OOV)

选型建议

✅ 英语 + 代码 + 数学

首选 BBPE + tiktoken(cl100k 或更大词表),配合数字 3 位一组处理。GPT-4 / CodeLLaMA 的选择。

✅ 多语言场景

首选 SentencePiece + BPE(词表 100K+),不依赖空格分词,中文/阿拉伯文/日文效率更均衡。LLaMA 3 / Gemini 的选择。

✅ 需要 subword regularization

SentencePiece + Unigram LM,训练时随机采样不同分词方案,显著提升低资源语言和拼写变体的鲁棒性。T5 的选择。

✅ 下游微调 / 接续预训练

必须使用与预训练模型完全相同的 tokenizer,不能替换。不同 tokenizer 的 token 空间完全不同,换了 tokenizer 相当于换了语言。

LLM 的 Tokenizer 本质是「将任意文本映射到有限词表的整数序列」。主流方法沿着 BPE(频率驱动的贪心合并)→ WordPiece(最大化语言模型概率的合并)→ Unigram(从大词表剪枝,概率最优)→ SentencePiece(语言无关的统一框架)→ BBPE/tiktoken(字节级杜绝 OOV) 这条路演进;选哪种取决于语言范围、数字/代码比例、服务延迟等工程约束。
💻
§10 完整代码实现(从零训练到推理)
本节提供三种核心算法的完整 Python 实现(不依赖第三方 tokenizer 库),以及"有词表后如何做 encode/decode"的通用工具代码。代码可直接运行。

BPE 完整实现(训练 + 编码 + 解码)

包括:① 从语料库训练 BPE 词表;② 用学到的 merge rules 对新文本编码;③ 将 token ids 解码回文本。

📄 BPE 完整实现:训练 + 编码 + 解码Python
from collections import Counter, defaultdict
from typing import Dict, List, Tuple


# ═══════════════════════════════════════════════════
#  BPE 训练:从语料 → 词表 + 合并规则
# ═══════════════════════════════════════════════════
def get_vocab(corpus: List[str]) -> Dict[str, int]:
    """把语料转换为字符级词频词典(每个词加 </w> 结尾标记)"""
    vocab = Counter()
    for text in corpus:
        for word in text.split():
            # 字符间加空格,末尾加 </w>
            vocab[' '.join(list(word)) + ' </w>'] += 1
    return dict(vocab)


def get_stats(vocab: Dict[str, int]) -> Dict[Tuple[str, str], int]:
    """统计词典中所有相邻 bigram 的频率"""
    pairs = Counter()
    for word, freq in vocab.items():
        symbols = word.split()
        for i in range(len(symbols) - 1):
            pairs[(symbols[i], symbols[i + 1])] += freq
    return dict(pairs)


def merge_vocab(pair: Tuple[str, str], vocab: Dict[str, int]) -> Dict[str, int]:
    """将词典中所有词里的 pair 合并为一个新 token"""
    bigram = ' '.join(pair)
    replacement = ''.join(pair)
    return {
        word.replace(bigram, replacement): freq
        for word, freq in vocab.items()
    }


def train_bpe(
    corpus: List[str],
    target_vocab_size: int
) -> Tuple[Dict[str, int], List[Tuple[str, str]]]:
    """
    BPE 训练主函数
    Returns:
        token2id: 词表(token → id)
        merge_rules: 有序合并规则列表
    """
    vocab = get_vocab(corpus)
    
    # 初始词表:所有出现过的字符 + 特殊符号
    all_chars = set()
    for word in vocab:
        all_chars.update(word.split())
    token2id = {ch: i for i, ch in enumerate(sorted(all_chars))}
    
    merge_rules = []
    
    while len(token2id) < target_vocab_size:
        pairs = get_stats(vocab)
        if not pairs:
            break
        
        # 选频率最高的 pair
        best_pair = max(pairs, key=pairs.get)
        
        # 合并并更新词典
        vocab = merge_vocab(best_pair, vocab)
        
        # 记录合并规则 + 新 token
        new_token = ''.join(best_pair)
        merge_rules.append(best_pair)
        token2id[new_token] = len(token2id)
    
    return token2id, merge_rules


# ═══════════════════════════════════════════════════
#  BPE 编码:文本 → token id 序列
# ═══════════════════════════════════════════════════
def bpe_tokenize_word(word: str, merge_rules: List[Tuple[str, str]]) -> List[str]:
    """对单个词应用 BPE 合并规则,返回 token 字符串列表"""
    tokens = list(word) + ['</w>']
    
    for pair in merge_rules:
        i = 0
        new_tokens = []
        while i < len(tokens):
            # 如果当前位置匹配目标 pair,则合并
            if i < len(tokens) - 1 and (tokens[i], tokens[i+1]) == pair:
                new_tokens.append(tokens[i] + tokens[i+1])
                i += 2
            else:
                new_tokens.append(tokens[i])
                i += 1
        tokens = new_tokens
    
    return tokens


def bpe_encode(
    text: str,
    token2id: Dict[str, int],
    merge_rules: List[Tuple[str, str]]
) -> List[int]:
    """文本 → token id 列表(未知 token 用 UNK id=0)"""
    unk_id = token2id.get('<unk>', 0)
    ids = []
    for word in text.split():
        tokens = bpe_tokenize_word(word, merge_rules)
        ids.extend(token2id.get(t, unk_id) for t in tokens)
    return ids


# ═══════════════════════════════════════════════════
#  BPE 解码:token id 序列 → 文本
# ═══════════════════════════════════════════════════
def bpe_decode(
    ids: List[int],
    id2token: Dict[int, str]
) -> str:
    """token id 列表 → 原始文本(去掉 </w> 标记,恢复空格)"""
    tokens = [id2token.get(i, '<unk>') for i in ids]
    # </w> 代表词尾,后面应该跟空格(除了最后一个 token)
    text = ''.join(tokens).replace('</w>', ' ').strip()
    return text


# ═══════════════════════════════════════════════════
#  使用示例
# ═══════════════════════════════════════════════════
if __name__ == '__main__':
    corpus = [
        "low low low low low",
        "lower lower",
        "newest newest newest newest newest newest",
        "widest widest widest",
    ]
    
    token2id, merge_rules = train_bpe(corpus, target_vocab_size=30)
    id2token = {v: k for k, v in token2id.items()}
    
    # 编码
    text = "low newest"
    ids = bpe_encode(text, token2id, merge_rules)
    print(f"encode('{text}') = {ids}")
    # 例如: encode('low newest') = [14, 22, 23]
    
    # 解码
    decoded = bpe_decode(ids, id2token)
    print(f"decode({ids}) = '{decoded}'")
    # 例如: decode([14, 22, 23]) = 'low newest'
    
    print(f"学到的合并规则(前10条): {merge_rules[:10]}")
    print(f"词表大小: {len(token2id)}")

WordPiece 实现(BERT 风格)

WordPiece 的推理阶段(给定词表如何分词)使用最长匹配优先(MaxMatch / Greedy Longest Match),训练阶段用 PMI 打分。这里实现最常用的推理侧 tokenizer:

📄 WordPiece 实现(BERT 风格)Python
from typing import Dict, List, Optional


# ═══════════════════════════════════════════════════
#  WordPiece 训练:从语料构建词表(PMI 打分版)
# ═══════════════════════════════════════════════════
def train_wordpiece(
    corpus: List[str],
    target_vocab_size: int,
    special_tokens: List[str] = None
) -> List[str]:
    """
    简化版 WordPiece 训练
    Returns: 词表列表(按 token 字符串排列)
    """
    if special_tokens is None:
        special_tokens = ['[PAD]', '[UNK]', '[CLS]', '[SEP]', '[MASK]']
    
    # Step 1:构建字符级初始词表
    char_counts = Counter()
    word_counts = Counter()
    for text in corpus:
        for word in text.lower().split():
            word_counts[word] += 1
            for i, ch in enumerate(word):
                # 非首字符加 ## 前缀
                symbol = ch if i == 0 else '##' + ch
                char_counts[symbol] += 1
    
    # 词表从特殊 token + 字符开始
    vocab = set(special_tokens) | set(char_counts.keys())
    
    # Step 2:迭代合并(PMI 打分)
    # 将每个词表示为 symbol 序列
    word_symbols = {}
    for word in word_counts:
        symbols = [word[0]] + ['##' + ch for ch in word[1:]]
        word_symbols[word] = symbols
    
    while len(vocab) < target_vocab_size:
        # 计算每个 symbol 的出现频率
        sym_freq = Counter()
        pair_freq = Counter()
        for word, count in word_counts.items():
            syms = word_symbols[word]
            for s in syms:
                sym_freq[s] += count
            for i in range(len(syms) - 1):
                pair_freq[(syms[i], syms[i+1])] += count
        
        if not pair_freq:
            break
        
        total = sum(sym_freq.values())
        # PMI 打分:P(AB) / (P(A) * P(B))
        best_pair, best_score = None, -1
        for (a, b), ab_count in pair_freq.items():
            score = (ab_count / total) / ((sym_freq[a] / total) * (sym_freq[b] / total))
            if score > best_score:
                best_score, best_pair = score, (a, b)
        
        if best_pair is None:
            break
        
        # 合并 best_pair → 新 token
        a, b = best_pair
        # 去掉 b 开头的 ## 前缀(如果 b 是 ##x 则合并后是 ax)
        new_token = a + (b[2:] if b.startswith('##') else b)
        vocab.add(new_token)
        
        # 更新所有词的 symbol 序列
        for word in word_symbols:
            syms = word_symbols[word]
            merged = []
            i = 0
            while i < len(syms):
                if i < len(syms) - 1 and syms[i] == a and syms[i+1] == b:
                    merged.append(new_token)
                    i += 2
                else:
                    merged.append(syms[i])
                    i += 1
            word_symbols[word] = merged
    
    return sorted(vocab)


# ═══════════════════════════════════════════════════
#  WordPiece 推理:最长匹配(BERT 官方算法)
# ═══════════════════════════════════════════════════
class WordPieceTokenizer:
    """BERT 风格的 WordPiece tokenizer"""
    
    def __init__(self, vocab: List[str], unk_token: str = '[UNK]'):
        self.vocab = set(vocab)
        self.token2id = {t: i for i, t in enumerate(vocab)}
        self.id2token = {i: t for i, t in enumerate(vocab)}
        self.unk_token = unk_token
        self.unk_id = self.token2id.get(unk_token, 0)
    
    def _tokenize_word(self, word: str) -> List[str]:
        """
        对单个词用最长匹配分词
        - 首字符从头开始匹配
        - 非首子串加 ## 前缀匹配
        """
        tokens = []
        start = 0
        word_len = len(word)
        is_bad = False
        
        while start < word_len:
            end = word_len
            found = None
            
            while start < end:
                substr = word[start:end]
                # 非开头子串加 ## 前缀
                candidate = substr if start == 0 else '##' + substr
                
                if candidate in self.vocab:
                    found = candidate
                    break
                end -= 1
            
            if found is None:
                # 没有任何匹配 → 整个词用 [UNK]
                is_bad = True
                break
            
            tokens.append(found)
            start = end
        
        return [self.unk_token] if is_bad else tokens
    
    def encode(self, text: str, add_special_tokens: bool = True) -> List[int]:
        """文本 → token id 列表(可选加 [CLS]/[SEP])"""
        tokens = []
        if add_special_tokens:
            tokens.append('[CLS]')
        
        for word in text.lower().split():
            tokens.extend(self._tokenize_word(word))
        
        if add_special_tokens:
            tokens.append('[SEP]')
        
        return [self.token2id.get(t, self.unk_id) for t in tokens]
    
    def decode(self, ids: List[int], skip_special_tokens: bool = True) -> str:
        """token id 列表 → 文本(合并 ## 前缀)"""
        special = {'[CLS]', '[SEP]', '[PAD]', '[MASK]'}
        tokens = [self.id2token.get(i, '[UNK]') for i in ids]
        
        words = []
        current = ''
        for token in tokens:
            if skip_special_tokens and token in special:
                if current:
                    words.append(current)
                    current = ''
                continue
            if token.startswith('##'):
                current += token[2:]      # ## 前缀直接拼接,不加空格
            else:
                if current:
                    words.append(current)
                current = token
        if current:
            words.append(current)
        
        return ' '.join(words)
    
    def tokenize(self, text: str) -> List[str]:
        """文本 → token 字符串列表(调试用)"""
        result = []
        for word in text.lower().split():
            result.extend(self._tokenize_word(word))
        return result


# 使用示例
if __name__ == '__main__':
    # 用自定义词表创建 tokenizer
    vocab = [
        '[PAD]', '[UNK]', '[CLS]', '[SEP]', '[MASK]',
        'un', '##aff', '##ord', '##able',
        'hello', 'world', '##ing', '##ed',
        'a', 'b', 'c', 'd', 'e', 'f', 'g'
    ]
    tokenizer = WordPieceTokenizer(vocab)
    
    # tokenize
    tokens = tokenizer.tokenize("unaffordable hello")
    print(f"tokenize: {tokens}")
    # → ['un', '##aff', '##ord', '##able', 'hello']
    
    # encode(含 [CLS]/[SEP])
    ids = tokenizer.encode("hello world")
    print(f"encode: {ids}")
    
    # decode
    text = tokenizer.decode(ids)
    print(f"decode: '{text}'")
    # → 'hello world'

Unigram LM 实现(Viterbi 最优分词)

给定 Unigram 词表和每个 token 的对数概率,用 Viterbi 算法找最优分词,并支持多候选采样(子词正则化)。

📄 Unigram LM 实现(Viterbi 最优分词)Python
import math
import random
from typing import Dict, List, Tuple, Optional


# ═══════════════════════════════════════════════════
#  Unigram 词表格式:token → log_prob(对数概率)
# ═══════════════════════════════════════════════════
class UnigramTokenizer:
    """
    Unigram LM Tokenizer
    vocab: dict[token_str → log_prob](log_prob 均为负数或 0)
    """
    
    def __init__(self, vocab: Dict[str, float], unk_token: str = '<unk>'):
        self.vocab = vocab
        self.unk_token = unk_token
        self.token2id = {t: i for i, t in enumerate(sorted(vocab.keys()))}
        self.id2token = {i: t for t, i in self.token2id.items()}
        if unk_token not in self.token2id:
            uid = len(self.token2id)
            self.token2id[unk_token] = uid
            self.id2token[uid] = unk_token
    
    def _viterbi_segment(self, text: str) -> List[str]:
        """
        Viterbi 算法:找使 Unigram 概率最大的分词方案
        时间复杂度 O(n²),n 为文本长度
        """
        n = len(text)
        NEG_INF = float('-inf')
        
        # best_score[i]:前 i 个字符的最优对数概率
        best_score = [NEG_INF] * (n + 1)
        best_score[0] = 0.0
        # back[i]:最优路径的回溯指针
        back = [0] * (n + 1)
        
        for i in range(1, n + 1):
            for j in range(i):
                chunk = text[j:i]
                if chunk in self.vocab:
                    score = best_score[j] + self.vocab[chunk]
                    if score > best_score[i]:
                        best_score[i] = score
                        back[i] = j
        
        # 回溯路径
        tokens = []
        i = n
        while i > 0:
            j = back[i]
            tokens.append(text[j:i])
            i = j
        
        return tokens[::-1]
    
    def _sample_segment(self, text: str, alpha: float = 0.1) -> List[str]:
        """
        子词正则化:按概率分布随机采样一种分词方案(用于训练数据增强)
        alpha:温度系数,越大越随机;alpha=0 等价于贪心最优
        """
        n = len(text)
        # 用前向算法计算每个位置的所有可能分词及其分数
        candidates = []
        
        def _segment_recursive(pos, path):
            if pos == n:
                candidates.append((path[:], sum(self.vocab.get(t, -20) for t in path)))
                return
            for end in range(pos + 1, n + 1):
                chunk = text[pos:end]
                if chunk in self.vocab:
                    path.append(chunk)
                    _segment_recursive(end, path)
                    path.pop()
        
        # 只采样少量候选以控制复杂度(实际生产用 lattice sampling)
        _segment_recursive(0, [])
        
        if not candidates:
            return self._viterbi_segment(text)  # fallback
        
        # softmax 采样
        scores = [s * (1 / (alpha + 1e-9)) for _, s in candidates]
        max_s = max(scores)
        weights = [math.exp(s - max_s) for s in scores]
        total = sum(weights)
        probs = [w / total for w in weights]
        
        r = random.random()
        cumsum = 0.0
        for (tokens, _), p in zip(candidates, probs):
            cumsum += p
            if r < cumsum:
                return tokens
        return candidates[-1][0]
    
    def tokenize(self, text: str, sample: bool = False) -> List[str]:
        """文本 → token 字符串列表;sample=True 启用子词正则化"""
        result = []
        # 按空格预切分(实际 SentencePiece 版本不需要这步)
        for word in text.split():
            if sample:
                result.extend(self._sample_segment(word))
            else:
                result.extend(self._viterbi_segment(word))
        return result
    
    def encode(self, text: str, sample: bool = False) -> List[int]:
        """文本 → token id 列表"""
        tokens = self.tokenize(text, sample=sample)
        unk_id = self.token2id[self.unk_token]
        return [self.token2id.get(t, unk_id) for t in tokens]
    
    def decode(self, ids: List[int]) -> str:
        """token id 列表 → 文本"""
        return ' '.join(self.id2token.get(i, self.unk_token) for i in ids)


# 使用示例
if __name__ == '__main__':
    # 模拟词表(token → log_prob)
    vocab = {
        'un': -2.0, 'afford': -3.5, 'able': -2.5,
        'unafford': -6.0, 'unaffordable': -8.0,
        'u': -5.0, 'n': -5.0, 'a': -4.0,
        'hello': -2.0, 'world': -2.1,
        '<unk>': -20.0,
    }
    tokenizer = UnigramTokenizer(vocab)
    
    text = "unaffordable"
    
    # 最优分词(Viterbi)
    best = tokenizer.tokenize(text, sample=False)
    print(f"Viterbi 最优: {best}")
    # → ['un', 'afford', 'able']
    
    # 子词正则化(随机采样,训练时用)
    samples = [tokenizer.tokenize(text, sample=True) for _ in range(5)]
    print(f"随机采样 5 次: {samples}")
    # 可能输出: [['un','afford','able'], ['u','n','afford','able'], ...]
    
    # encode / decode
    ids = tokenizer.encode("hello world")
    print(f"encode: {ids}")
    print(f"decode: {tokenizer.decode(ids)}")

核心:有词表后如何 Encode / Decode

这是实际工程中最常见的需求:已有一个训练好的词表文件,如何用它做编解码?以下给出一个通用的 VocabMatcher,支持加载词表文件、encode、decode、批量处理,并处理 OOV。

📄 VocabMatcher:通用词表匹配器Python
import json
from typing import Dict, List, Union
from pathlib import Path


# ═══════════════════════════════════════════════════
#  通用词表匹配器
# ═══════════════════════════════════════════════════
class VocabMatcher:
    """
    通用 Token ↔ ID 双向映射器
    支持从 dict / json 文件 / txt 文件加载词表
    """
    
    def __init__(
        self,
        vocab: Union[Dict[str, int], None] = None,
        unk_token: str = '[UNK]',
        pad_token: str = '[PAD]',
        bos_token: str = '[BOS]',
        eos_token: str = '[EOS]',
    ):
        self.unk_token = unk_token
        self.pad_token = pad_token
        self.bos_token = bos_token
        self.eos_token = eos_token
        
        if vocab is not None:
            self.token2id = vocab
        else:
            self.token2id = {}
        
        self.id2token = {v: k for k, v in self.token2id.items()}
        
        # 确保特殊 token 存在
        for tok in [pad_token, unk_token, bos_token, eos_token]:
            if tok not in self.token2id:
                new_id = len(self.token2id)
                self.token2id[tok] = new_id
                self.id2token[new_id] = tok
    
    # ─── 加载词表 ───────────────────────────────────────
    
    @classmethod
    def from_json(cls, path: str, **kwargs) -> 'VocabMatcher':
        """从 vocab.json 加载(格式:{"token": id, ...})"""
        with open(path, 'r', encoding='utf-8') as f:
            vocab = json.load(f)
        return cls(vocab=vocab, **kwargs)
    
    @classmethod
    def from_txt(cls, path: str, **kwargs) -> 'VocabMatcher':
        """
        从 vocab.txt 加载(格式:每行一个 token,行号即 id)
        BERT 词表就是这种格式
        """
        with open(path, 'r', encoding='utf-8') as f:
            tokens = [line.rstrip('\n') for line in f]
        vocab = {tok: i for i, tok in enumerate(tokens)}
        return cls(vocab=vocab, **kwargs)
    
    @classmethod
    def from_list(cls, tokens: List[str], **kwargs) -> 'VocabMatcher':
        """从 token 列表构建(顺序即 id)"""
        vocab = {tok: i for i, tok in enumerate(tokens)}
        return cls(vocab=vocab, **kwargs)
    
    # ─── 属性 ───────────────────────────────────────────
    
    @property
    def vocab_size(self) -> int:
        return len(self.token2id)
    
    @property
    def unk_id(self) -> int:
        return self.token2id[self.unk_token]
    
    @property
    def pad_id(self) -> int:
        return self.token2id[self.pad_token]
    
    # ─── 单条编解码 ─────────────────────────────────────
    
    def token_to_id(self, token: str) -> int:
        """单个 token → id(OOV 返回 unk_id)"""
        return self.token2id.get(token, self.unk_id)
    
    def id_to_token(self, id: int) -> str:
        """单个 id → token(越界返回 unk_token)"""
        return self.id2token.get(id, self.unk_token)
    
    def encode(
        self,
        tokens: List[str],
        add_bos: bool = False,
        add_eos: bool = False,
        max_length: Optional[int] = None,
        padding: bool = False,
    ) -> List[int]:
        """
        token 列表 → id 列表
        - tokens: 已经由分词器切好的 token 字符串列表
        - add_bos/add_eos: 是否在首尾加特殊 token
        - max_length: 截断长度(None 不截断)
        - padding: 是否补 PAD 到 max_length
        """
        ids = [self.token_to_id(t) for t in tokens]
        
        if add_bos:
            ids = [self.token2id[self.bos_token]] + ids
        if add_eos:
            ids = ids + [self.token2id[self.eos_token]]
        
        if max_length is not None:
            ids = ids[:max_length]
            if padding and len(ids) < max_length:
                ids += [self.pad_id] * (max_length - len(ids))
        
        return ids
    
    def decode(
        self,
        ids: List[int],
        skip_special_tokens: bool = True,
        join_str: str = ' ',
    ) -> str:
        """
        id 列表 → token 字符串(空格连接)
        - skip_special_tokens: 跳过 PAD/BOS/EOS/UNK 等
        - join_str: token 间分隔符(BPE 解码后通常不需要空格,根据算法决定)
        """
        special = {self.pad_token, self.unk_token, self.bos_token, self.eos_token}
        tokens = [
            self.id_to_token(i) for i in ids
            if not (skip_special_tokens and self.id_to_token(i) in special)
        ]
        return join_str.join(tokens)
    
    # ─── 批量编码 ───────────────────────────────────────
    
    def batch_encode(
        self,
        batch_tokens: List[List[str]],
        max_length: int,
        padding: bool = True,
        add_eos: bool = True,
    ) -> Dict[str, List[List[int]]]:
        """
        批量编码(返回 input_ids + attention_mask)
        batch_tokens: 多条样本的 token 列表
        """
        input_ids = []
        attention_masks = []
        
        for tokens in batch_tokens:
            ids = self.encode(
                tokens,
                add_eos=add_eos,
                max_length=max_length,
                padding=padding,
            )
            # attention_mask:非 PAD 位置为 1,PAD 位置为 0
            mask = [0 if i == self.pad_id else 1 for i in ids]
            input_ids.append(ids)
            attention_masks.append(mask)
        
        return {'input_ids': input_ids, 'attention_mask': attention_masks}
    
    # ─── 工具方法 ───────────────────────────────────────
    
    def save_vocab(self, path: str):
        """保存词表到 JSON 文件"""
        with open(path, 'w', encoding='utf-8') as f:
            json.dump(self.token2id, f, ensure_ascii=False, indent=2)
    
    def contains(self, token: str) -> bool:
        """检查 token 是否在词表中"""
        return token in self.token2id
    
    def oov_rate(self, tokens: List[str]) -> float:
        """计算 OOV 比例"""
        oov = sum(1 for t in tokens if t not in self.token2id)
        return oov / len(tokens) if tokens else 0.0
    
    def __repr__(self):
        return f"VocabMatcher(vocab_size={self.vocab_size}, unk='{self.unk_token}')"


# ═══════════════════════════════════════════════════
#  使用示例
# ═══════════════════════════════════════════════════
if __name__ == '__main__':

    # ── 方式 1:从 dict 构建 ──────────────────────────
    vocab_dict = {
        '[PAD]': 0, '[UNK]': 1, '[CLS]': 2, '[SEP]': 3,
        'hello': 4, 'world': 5, 'un': 6, '##aff': 7,
        '##ord': 8, '##able': 9, 'token': 10,
    }
    matcher = VocabMatcher(vocab=vocab_dict)
    print(matcher)                    # VocabMatcher(vocab_size=13, unk='[UNK]')
    
    # ── 方式 2:从 vocab.txt 加载(BERT 格式)──────────
    # matcher = VocabMatcher.from_txt('/path/to/vocab.txt')
    
    # ── 方式 3:从 vocab.json 加载(HuggingFace 格式)──
    # matcher = VocabMatcher.from_json('/path/to/vocab.json')
    
    # ── 单条 encode ───────────────────────────────────
    tokens = ['un', '##aff', '##ord', '##able']
    ids = matcher.encode(tokens)
    print(f"encode: {ids}")            # [6, 7, 8, 9]
    
    # 带截断和 padding
    ids_padded = matcher.encode(tokens, add_eos=True, max_length=8, padding=True)
    print(f"encode(padded): {ids_padded}")   # [6, 7, 8, 9, <eos_id>, 0, 0, 0]
    
    # ── decode ────────────────────────────────────────
    text = matcher.decode(ids)
    print(f"decode: '{text}'")           # 'un ##aff ##ord ##able'
    
    # ── 批量 encode ──────────────────────────────────
    batch = [['hello', 'world'], ['un', '##aff', '##ord']]
    result = matcher.batch_encode(batch, max_length=6, padding=True)
    print("batch input_ids:", result['input_ids'])
    print("batch attention_mask:", result['attention_mask'])
    # input_ids:   [[4, 5, 3, 0, 0, 0], [6, 7, 8, 3, 0, 0]]
    # attention:   [[1, 1, 1, 0, 0, 0], [1, 1, 1, 1, 0, 0]]
    
    # ── OOV 率检查 ───────────────────────────────────
    oov_tokens = ['hello', 'xyz123', 'world', 'foobar']
    print(f"OOV 率: {matcher.oov_rate(oov_tokens):.1%}")   # 50.0%

tiktoken 实际使用(GPT-4 / GPT-3.5)

如果你直接用 OpenAI 模型,tiktoken 是最实用的工具:

📄 tiktoken 完整使用指南Python
import tiktoken


# ═══════════════════════════════════════════════════
#  tiktoken 完整使用指南
# ═══════════════════════════════════════════════════

# 1. 加载编码器
enc = tiktoken.get_encoding("cl100k_base")      # GPT-4 / GPT-3.5
# enc = tiktoken.get_encoding("p50k_base")          # GPT-3
# enc = tiktoken.get_encoding("r50k_base")          # GPT-2

# 2. 基础编解码
text = "Hello, 你好世界!🌍"
ids = enc.encode(text)
print(f"token ids : {ids}")
# [9906, 11, 220, 57668, 53901, 11375, 6447, 127, 246, 9468, 242, 101]

decoded = enc.decode(ids)
print(f"decoded   : {decoded}")                # 完全还原

# 3. 查看每个 token 对应的文本
tokens_bytes = [enc.decode_single_token_bytes(t) for t in ids]
print(f"token bytes: {tokens_bytes}")
# [b'Hello', b',', b' ', b'\xe4\xbd\xa0', ...]

# 4. 不切分特殊 token(用于 Chat 格式)
ids_no_special = enc.encode(text, disallowed_special=())
# 阻止 <|endoftext|> 等被误识别为特殊 token

# 5. 计算 token 数(不实际编码,更快)
num_tokens = len(enc.encode(text))
print(f"token 数: {num_tokens}")

# 6. 模型专属 encoding(自动对应正确词表)
enc_gpt4 = tiktoken.encoding_for_model("gpt-4")
enc_gpt35 = tiktoken.encoding_for_model("gpt-3.5-turbo")


# ═══════════════════════════════════════════════════
#  实用工具:计算 Chat Messages 的 token 数
#  参考 OpenAI 官方文档 "How to count tokens with tiktoken"
# ═══════════════════════════════════════════════════
def num_tokens_from_messages(messages: list, model: str = "gpt-4") -> int:
    """
    计算 messages 列表的 token 数(兼容 gpt-3.5-turbo / gpt-4)
    messages 格式:[{"role": "user", "content": "..."}]
    """
    try:
        encoding = tiktoken.encoding_for_model(model)
    except KeyError:
        encoding = tiktoken.get_encoding("cl100k_base")
    
    # gpt-3.5-turbo / gpt-4 格式
    tokens_per_message = 3  # <|im_start|> role \n content <|im_end|>
    tokens_per_name = 1
    
    num_tokens = 0
    for message in messages:
        num_tokens += tokens_per_message
        for key, value in message.items():
            num_tokens += len(encoding.encode(str(value)))
            if key == "name":
                num_tokens += tokens_per_name
    num_tokens += 3  # 每次回复前有 <|im_start|>assistant
    return num_tokens


# 使用示例
messages = [
    {"role": "system", "content": "You are a helpful assistant."},
    {"role": "user",   "content": "Hello, how are you?"},
]
n = num_tokens_from_messages(messages)
print(f"这段对话占 {n} tokens")    # ~22 tokens


# ═══════════════════════════════════════════════════
#  实用工具:截断文本到指定 token 数
# ═══════════════════════════════════════════════════
def truncate_text(text: str, max_tokens: int, model: str = "gpt-4") -> str:
    """截断文本到 max_tokens(完整 token 边界截断,不会截断 UTF-8 字节)"""
    enc = tiktoken.encoding_for_model(model)
    ids = enc.encode(text)
    if len(ids) <= max_tokens:
        return text
    return enc.decode(ids[:max_tokens])

# 超长文本截断到 100 tokens
long_text = "这是一段很长的文本 " * 100
truncated = truncate_text(long_text, max_tokens=100)
print(f"截断后 token 数: {len(tiktoken.encoding_for_model('gpt-4').encode(truncated))}")
💡 与 HuggingFace Transformers 的对比
上面是从零实现的版本,适合学习原理。实际项目推荐直接用:
from transformers import AutoTokenizer

tokenizer = AutoTokenizer.from_pretrained("bert-base-uncased")

ids = tokenizer.encode("hello world", add_special_tokens=True)
tokens = tokenizer.convert_ids_to_tokens(ids)
text = tokenizer.decode(ids, skip_special_tokens=True)
batch = tokenizer(["hello", "world"], padding=True, return_tensors="pt")
原理相同,接口更完善,但理解了本文的从零实现,你就知道 HuggingFace 内部在干什么了。