一、什么是Tokenizer?为什么需要它?

1.1 基本概念

原始文本(人类能读):"我爱吃北京烤鸭"
        ↓  Tokenizer
Token序列(模型能处理):["我","爱","吃","北京","烤鸭"]
        ↓  转成数字ID
ID序列:[101, 2769, 4263, 1166, 1266, 2110, 102]
        ↓  Embedding
向量矩阵(模型真正的输入)

Tokenizer就像翻译官兼速记员,把人类的语言(文字)先切割成有意义的片段(Token),再把每个片段翻译成模型能理解的数字编号(ID)。

1.2 什么是Token?

Token = 模型处理文本的最小单元

可以是:
- 一个词:     "烤鸭"1个Token
- 一个字符:   "鸭"1个Token
- 一个子词:   "play"+"ing"2个Token
- 一个字节:   某个Unicode字符的字节表示

不同的Tokenizer,切割方式不同!

1.3 三类Tokenizer的演进

文本粒度从粗到细:

Word-based    Character-based    Subword-based
(词级别)       (字符级别)        (子词级别)
   ↓               ↓                  ↓
词表太大         序列太长           两者折中!✓
信息丰富         信息稀疏           主流方案

Tokenizer的作用:

  • 将文本序列转化为数字序列(token编号),作为transformer的输入
  • 是训练和微调LLM必不可少的一部分

二、Word-based Tokenizer(基于词的分词)

2.1 基本原理

将文本按照词语边界切割:

英文(按空格切割):
"I love roast duck"["I", "love", "roast", "duck"]

中文(按词语切割):
"我爱吃北京烤鸭"["我", "爱吃", "北京", "烤鸭"]

2.2 词表的构建

收集大量文本,提取所有出现过的词:

词表示例:
ID 0[UNK](未知词)
ID 1[PAD](填充符)
ID 2"the"
ID 3"I"
ID 4"love"
ID 5"duck"
...
ID 50000"烤鸭"

词表大小:通常 50,000 ~ 100,000 个词

2.3 核心缺陷

缺陷一:相同意思的词被拆分为不同Token

以下这些词,语义相似,但被视为完全不同的Token:

"run"    → ID 1024
"runs"   → ID 3721   ← 明明是同一个词的变形!
"running" → ID 8832  ← 完全不同的三个ID
"ran"    → ID 2156

模型需要分别学习这四个词的含义,
而无法知道它们其实共享同一个词根!

缺陷二:词表过于巨大

问题的连锁反应:

词表大(10万个词)
    ↓
Embedding矩阵巨大:
    10万词 × 768= 7680万个参数!
    ↓
空间复杂度爆炸 ❌(存储消耗大)
时间复杂度爆炸 ❌(训练速度慢)
    ↓
还有大量词是低频词,学习效果差

缺陷三:未登录词(OOV问题)

训练时没见过的词:

训练词表里有:"iPhone"
但没有:"iPhone15"

处理"iPhone15"时:
→ 不在词表中!→ 只能用[UNK]替代
→ 信息完全丢失 ❌

新词层出不穷,Word-based完全无法应对!

三、Character-based Tokenizer(基于字符的分词)

3.1 基本原理

将文本切割为最小的字符单元:

英文(按字母切割):
"duck"["d", "u", "c", "k"]

中文(按单字切割):
"烤鸭"["烤", "鸭"]

3.2 词表结构

词表非常小,只需覆盖基本字符:

英文词表(约100~200个字符):
a, b, c, ..., z        ← 26个字母
A, B, C, ..., Z        ← 26个大写字母
0, 1, 2, ..., 9        ← 数字
!, ?, ., ,, ...        ← 标点符号

中文词表(约数千个常用字):
我、你、他、的、是...

对比Word-based:
Word-based词表:50,000+
Character-based词表:几百~几千
词表缩小了100倍!✓

3.3 核心缺陷

缺陷一:信息量极低,性能差

"duck"被拆成 ["d", "u", "c", "k"]

单个字母本身没有任何语义!
模型需要从零学习:
"d"+"u"+"c"+"k"组合在一起才是"鸭子"

就像要你从26个字母重新学习英语一样困难!
模型性能大幅下降 ❌

缺陷二:Token序列过长

Word-based:
"I love eating roast duck in Beijing"7个Token

Character-based:
"I love eating roast duck in Beijing"35个Token(包括空格)

序列长度增加5倍!

后果:
① Transformer的计算复杂度是O()
   序列长度5倍 → 计算量25倍!❌
② 长距离依赖更难学习
③ 训练时间大幅增加

四、Subword-based Tokenizer(基于子词的分词)

4.1 核心思想

聪明的折中方案:

高频词    → 保持为整体Token(不再细分)
           "the", "I", "is" → 各自1个Token

低频词    → 拆分为高频子词
           "unhappiness"["un", "happiness"]["un", "happy", "ness"]

未登录词  → 拆分为字符或字节
           "iPhone15"["i", "Phone", "15"]

原则:
✅ 常见的不拆,保留语义完整性
✅ 罕见的拆开,避免OOV问题
✅ 子词之间共享信息("play","plays","playing"共享"play"

4.2 BPE:字节对编码(Byte Pair Encoding)

4.2.1 算法原理

BPE是目前最广泛使用的Subword方法,GPT系列都在使用
核心思想

从字符级词表出发,反复合并“最常出现的相邻字符对,直到达到设定的词表大小!
4.2.2 详细步骤图解

假设我们有以下语料库(corpus):

语料库:
"low"   出现5次
"lower" 出现2次
"newest"出现6次
"widest"出现3次

第0步:初始化(Character-based词表)

将每个词拆成字符,并加上词尾标记</w>"low"    → l o w </w>        (频次5)
"lower"  → l o w e r </w>    (频次2)
"newest" → n e w e s t </w>  (频次6)
"widest" → w i d e s t </w>  (频次3)

初始词表:{l, o, w, e, r, n, s, t, i, d, </w>}
共11个字符

第1步:统计相邻Token对的词频

统计所有相邻字符对出现的次数:

(e, s):出现在newest(6) + widest(3) = 9次  ← 最高!
(s, t):出现在newest(6) + widest(3) = 9(e, s)先出现,选择合并(e, s)

第2步:合并最高频的Token对

"e"+"s"合并为新Token "es""low"    → l o w </w>           (频次5)
"lower"  → l o w e r </w>       (频次2)
"newest" → n e w es t </w>      (频次6)  ← es合并
"widest" → w i d es t </w>      (频次3)  ← es合并

更新词表:{l, o, w, e, r, n, es, t, i, d, </w>}
新增了"es"

第3步:继续迭代

第2轮:最高频对 = (es, t) 出现9次
合并后:
"newest" → n e w est </w>      (频次6)
"widest" → w i d est </w>      (频次3)
词表新增:"est"

第3轮:最高频对 = (l, o) 出现7次
合并后:
"low"    → lo w </w>           (频次5)
"lower"  → lo w e r </w>       (频次2)
词表新增:"lo"

第4轮:最高频对 = (lo, w) 出现7次
合并后:
"low"    → low </w>            (频次5)
"lower"  → low e r </w>        (频次2)
词表新增:"low"

...持续迭代,直到达到设定的词表大小...

最终词表效果:

最终词表(部分):
字符级:l, o, w, e, r, n, s, t, i, d, </w>
子词级:es, est, lo, low, new, newest, ...

测试未见过的词 "lowest":
→ 在词表中找不到整词
→ 拆分:low + est + </w>[low][est][</w>]  ← 成功分解,无OOV问题 ✓

对比Word-based:
"lowest"[UNK]  ← 完全丢失信息 ✗
4.2.3 BPE的完整算法流程图
输入:语料库corpus,目标词表大小V

① 初始化词表 = 字符级词表
      ↓
② 将语料库中每个词拆成字符序列
      ↓
③ 循环(直到词表大小达到V):
   ┌─────────────────────────────────┐
   │ a. 统计所有相邻Token对的出现频次  │
   │ b. 找出频次最高的Token对(x, y)   │
   │ c. 将(x, y)合并为新Token "xy"   │
   │ d. 更新语料库中所有(x,y)"xy"  │
   │ e. 将"xy"加入词表                │
   └─────────────────────────────────┘
      ↓
④ 输出最终词表
4.2.4 BPE的优缺点
优点:
✅ 词表大小可控(预先设定)
✅ 有效解决OOV问题
✅ 高频词保持完整,低频词拆分为子词
✅ 子词之间可以共享参数(play/plays/playing共享"play")

缺点:
❌ 初始词表基于字符,对非拉丁语系(如中文)效率低
❌ 同一个词可能有多种分割方式
❌ 纯贪心合并,不一定是全局最优

4.3 Byte-level BPE(BBPE) :字节级BPE

4.3.1 为什么需要BBPE?
BPE的问题——初始词表太大:

普通BPE初始词表:
英文:26个字母 + 标点 ≈ 几百个字符
中文:数千个常用汉字!
日文:平假名 + 片假名 + 汉字 ≈ 几千个字符
Emoji: 😀😂🎉... 数百个

多语言混合时:
初始词表 = 所有语言字符的并集 → 巨大!❌
4.3.2 BBPE的核心思路
关键洞察:
所有字符(不管哪种语言)最终都是由字节组成的!

Unicode编码 → UTF-8字节表示:

"A" (ASCII)1个字节:0x41
"中" (中文)3个字节:0xE4 0xB8 0xAD
"😀" (Emoji)4个字节:0xF0 0x9F 0x98 0x80

统一用字节作为基础Token:
初始词表 = 256个字节(0x00 ~ 0xFF)
不管什么语言,都只需要256个基础Token!✓
4.3.3 BBPE vs BPE对比
BPE:                      BBPE:
初始词表:字符              初始词表:字节(固定256个)
"中"1个字符token         "中"3个字节token
"A"1个字符token         "A"1个字节token

多语言支持:
BPE:需要把所有语言的字符加入初始词表(可能数万个)
BBPE:固定256个字节,天然支持所有语言 ✓

OOV问题:
BPE:遇到罕见字符可能OOV
BBPE:理论上不存在OOV(任何字符都能用字节表示)✓

4.4 WordPiece Tokenization

4.4.1 基本介绍

WordPiece是BERT使用的分词方法,与BPE思路相似,但合并规则不同

BPE vs WordPiece 的核心区别:

BPE合并标准:
→ 选择出现频次最高的相邻Token对合并

WordPiece合并标准:
→ 选择合并后能最大化语言模型似然概率的Token对合并

通俗理解:
BPE:合并出现最多的 (看频率)
WordPiece:合并最有意义的  (看语言学价值)
4.4.2 WordPiece的合并规则详解
WordPiece的评分公式:

score(A, B) = 频率(AB) / (频率(A) × 频率(B))

含义:
- 如果AB经常一起出现(频率(AB)大)→ 分数高 → 值得合并
- 但如果A和B各自也很常见 → 分母大 → 分数降低

直觉解释:
AB一起出现,但A和B单独出现不多 → 说明AB组合有独特含义 → 应该合并!
AB一起出现,但A和B单独出现也很多 → 说明只是偶然相邻 → 不应该合并!
4.4.3 WordPiece的标记方式
WordPiece用"##"标记子词(不是词的开头):

"unhappiness" 可能被分词为:
["un", "##happy", "##ness"]
  ↑       ↑           ↑
词开头   子词         子词
     (##表示我不是词的开头)

对比BPE:
BPE:["un", "happiness"]["un", "happy", "ness"]
(没有##标记,顺序拼接)

WordPiece用##明确标注了词内部的边界信息!

4.5 Unigram Tokenization

4.5.1 与BPE的根本区别
BPE和WordPiece的方向:
从小到大(Bottom-up)
字符 → 逐步合并 → 子词 → 词

Unigram的方向:
从大到小(Top-down)
大词表 → 逐步删减 → 最优词表 ✓
4.5.2 算法详细步骤

第一步:初始化超大词表

包含所有可能的子词:

从语料库中提取所有可能的子词:
- 所有单字符:a, b, c, d, ...
- 所有二字符子串:ab, bc, cd, ...
- 所有三字符子串:abc, bcd, ...
- 所有完整词:apple, book, running, ...

初始词表:可能有几十万个条目
(远超最终目标词表大小)

第二步:Unigram语言模型假设

假设:每个词独立出现(Unigram = 一元语言模型)

整个词的概率 = 各子词概率的乘积:

"unhappy" 的一种分割:["un", "happy"]
P("un", "happy") = P("un") × P("happy")

"unhappy" 的另一种分割:["u", "n", "h", "a", "p", "p", "y"]
P = P("u") × P("n") × P("h") × ... × P("y")

选择概率最大的分割方式!
(通常较少的、较长的子词概率更高)

第三步:计算每个词的最优分割

对语料库中的每个词,找概率最大的分割:

"playing":
方案1: ["play", "ing"]         → P = 0.05 × 0.08 = 0.004  ← 最大!
方案2: ["play", "i", "n", "g"] → P = 0.05 × 0.01 × 0.02 × 0.03 = 0.0000003
方案3: ["p", "l", "a", "y", "ing"] → P更小

选择方案1:["play", "ing"] ✓

(通常使用动态规划Viterbi算法高效计算)

第四步:迭代删除子词

目标:把大词表压缩到目标大小
每轮:
① 尝试删除词表中每一个子词
② 计算删除后,整个语料库的"损失"
   (负对数似然 Negative Log Likelihood)
③ 删除那个导致损失增加最小的子词
   (损失增加最小 = 这个子词最不重要)
④ 重复,直到词表大小达到目标

为什么是"负对数似然损失"?
P最大 ↔ log(P)最大 ↔ -log(P)最小
删除某子词后,-log(P)增加越少
→ 说明这个子词越不重要
→ 优先删除它!

直观理解:

删除某子词的后果评估:

情况A:删除"playing"(低频完整词)
→ 可以用["play","ing"]替代
→ 替代方案概率高,损失小 → 可以删除

情况B:删除"the"(超高频词)
→ 只能用["t","h","e"]替代
→ 替代方案概率极低,损失大 → 不能删除!

Unigram会优先删除"可被替代"的子词!

第五步:批量删除

每轮不只删1个,而是删除最不重要的p%(通常10%~20%):

当前词表:100,000个子词
每轮删除10%:
第1轮:100,000 → 90,000
第2轮:90,000  → 81,000
第3轮:81,000  → 72,900
...
直到达到目标大小(如32,000)
4.5.3 Unigram的特点
优点:
✅ 可以给一个词提供多种分割方案(概率分布)
✅ 分割结果有理论基础(最大似然)
✅ 在某些语言(如日语)上效果更好
✅ 可以计算每种分割的概率,用于数据增强

缺点:
❌ 算法复杂,计算量大
❌ 需要EM算法估计子词概率
❌ 训练速度慢于BPE

4.6 SentencePiece:跨语言统一分词框架

4.6.1 SentencePiece是什么?
SentencePiece不是一种新算法,
而是一个支持多种算法的统一框架!

支持的算法:
✅ BBPE(Byte-level BPE)
✅ Unigram

代表模型:
- LLaMA(使用SentencePiece + BPE)
- T5(使用SentencePiece + Unigram)
- ALBERT(使用SentencePiece + Unigram)
4.6.2 SentencePiece的核心创新

创新一:直接处理原始文本

传统Tokenizer的流程:
文本 → 预处理(分词、去空格)→ 子词分割 → Token

SentencePiece的流程:
文本 → 直接子词分割 → Token

优点:
✅ 不依赖任何语言相关的预处理
✅ 空格也是Token的一部分(用"▁"表示)
✅ 真正的语言无关(Language-agnostic)

例子:
"Hello World"["▁Hello", "▁World"]
(▁表示词前有空格,保留了空格信息)

创新二:统一的多语言处理

传统方案:
中文:需要专用中文分词工具
英文:按空格分割
日文:需要专用日文分词工具
每种语言需要不同的处理方式 ❌

SentencePiece:
统一用字节/字符序列处理
所有语言用同一套流程!✓

结合BBPE:
所有字符转字节(256种)→ BPE合并
天然支持任何语言 ✓

五、实战:在代码中使用Tokenizer

5.1 使用HuggingFace Transformers库

from transformers import AutoTokenizer

# 加载预训练的Tokenizer
# "uer/roberta-base-finetuned-dianping-chinese" 是中文情感分析模型
tokenizer = AutoTokenizer.from_pretrained(
    "uer/roberta-base-finetuned-dianping-chinese"
)

# 待处理的文本
sen = "弱小的我也有大梦想!"

# 进行分词(padding填充到max_length=15)
inputs = tokenizer(sen, padding="max_length", max_length=15)
print(inputs)

5.2 输出结果分析

# 输出:
{
    "input_ids": [101, 2483, 2207, 4638, 2769, 738, 3300, 
                  1920, 3457, 2682, 106, 102, 0, 0, 0],
    "token_type_ids": [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0],
    "attention_mask": [1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 0, 0, 0]
}
  1. input_ids(Token ID序列)
[101, 2483, 2207, 4638, 2769, 738, 3300, 1920, 3457, 2682, 106, 102, 0, 0, 0]

可视化对应关系:
ID    Token    说明
101   [CLS]    ← BERT的句子开始标记
2483  弱       ← "弱小""弱"
2207  小       ← "弱小""小"
4638  的       ← "的"
2769  我       ← "我"
738   也       ← "也"
3300  有       ← "有"
1920  大       ← "大梦想""大"
3457  梦       ← "梦"
2682  想       ← "想"
106   !       ← 感叹号
102   [SEP]    ← BERT的句子结束标记
0     [PAD]    ← 填充符(凑足max_length=150     [PAD]    ← 填充符
0     [PAD]    ← 填充符

原文11个字符 + [CLS] + [SEP] = 13个有效Token
再加3个[PAD]填充 = 15个Token ✓
  1. token_type_ids(句子类型标识)
[0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0]
 ↑ 全部是0

含义:标记每个Token属于第几个句子

单句任务:全部为0
句子对任务(如问答、自然语言推断):
  句子A的Token → 0
  [SEP]分隔符  → 0
  句子B的Token → 1

例子(句子对):
"今天天气好" + "适合出门"
token_type_ids:
[0, 0, 0, 0, 0, 0, | 1, 1, 1, 1, 1]
  ←── 句子A ──→       ←── 句子B ──→

3.attention_mask(注意力掩码)

[1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 0, 0, 0]
 ↑_________________________真实Token_____↑   ↑__填充__↑

值为1:真实有效的Token,模型应该关注 ✓
值为0:填充符[PAD],模型应该忽略 ✗

可视化:
位置:  1  2  3  4  5  6  7  8  9 10 11 12 13 14 15
Token: CLS 弱 小 的 我 也 有 大 梦 想  !  SEP PAD PAD PAD
mask:   1  1  1  1  1  1  1  1  1  1  1   1   0   0   0

Self-Attention计算时:
mask=0的位置会被加上-inf → Softmax后变成0 → 被完全忽略
这样填充符就不会影响模型对真实内容的理解!

5.3 常用Tokenizer操作

from transformers import AutoTokenizer

tokenizer = AutoTokenizer.from_pretrained(
    "uer/roberta-base-finetuned-dianping-chinese"
)

# ① 基本分词
text = "弱小的我也有大梦想!"
tokens = tokenizer.tokenize(text)
print(tokens)
# ['弱', '小', '的', '我', '也', '有', '大', '梦', '想', '!']

# ② 转换为ID
ids = tokenizer.convert_tokens_to_ids(tokens)
print(ids)
# [2483, 2207, 4638, 2769, 738, 3300, 1920, 3457, 2682, 106]

# ③ ID转回Token(解码)
decoded = tokenizer.decode(ids)
print(decoded)
# "弱小的我也有大梦想!"

# ④ 批量处理(多个句子)
sentences = ["弱小的我也有大梦想!", "今天天气真好!"]
batch_inputs = tokenizer(
    sentences,
    padding=True,        # 自动填充到最长句子
    truncation=True,     # 超长截断
    max_length=20,       # 最大长度
    return_tensors="pt"  # 返回PyTorch张量
)
print(batch_inputs['input_ids'].shape)  # torch.Size([2, 20])

# ⑤ 查看特殊Token
print(tokenizer.special_tokens_map)
# {'unk_token': '[UNK]', 'sep_token': '[SEP]',
#  'pad_token': '[PAD]', 'cls_token': '[CLS]',
#  'mask_token': '[MASK]'}

# ⑥ 查看词表大小
print(tokenizer.vocab_size)
# 21128(BERT中文词表大小)

六、特殊Token详解

[CLS] (Classification Token)
├── 位置:句子开头
├── 作用:聚合整个句子的信息,用于分类任务
└── 例:[CLS] 今天 天气 真好 [SEP] → 取[CLS]的向量做情感分类

[SEP] (Separator Token)
├── 位置:句子结尾,或两句话之间
├── 作用:标记句子边界
└── 例:[CLS] 问题 [SEP] 答案 [SEP]

[PAD] (Padding Token)
├── 位置:句子末尾填充
├── 作用:让批次中所有句子长度一致
└── attention_mask=0,模型忽略它

[UNK] (Unknown Token)
├── 位置:遇到词表外的词时
├── 作用:替代未登录词
└── 注意:子词方法基本消除了[UNK]

[MASK] (Mask Token)  ← BERT特有
├── 位置:随机替换某些Token(训练时)
├── 作用:让模型学习预测被遮盖的词
└── 例:今天 [MASK] 真好 → 模型预测[MASK]"天气"
Logo

AtomGit 是由开放原子开源基金会联合 CSDN 等生态伙伴共同推出的新一代开源与人工智能协作平台。平台坚持“开放、中立、公益”的理念,把代码托管、模型共享、数据集托管、智能体开发体验和算力服务整合在一起,为开发者提供从开发、训练到部署的一站式体验。

更多推荐