如果你实际部署过大语言模型,你会在很多地方看到,在加载大模型的参数之前,需要加载一个tokenizer。tokenizer到底是什么?在大模型的流程中到底发挥什么作用?网上很多教学贴写的云里雾里,实际上没那么玄妙,一句话概括就是把文字变成数字,便于大模型理解。
Tokenizer是什么?
Tokenizer将文本转换为数字序列,它的主要目的是将自然语言文本转换为模型可以理解的形式。直接举个例子:
假如你有一段话要问大模型:“请问天津工业大学是顶级985大学吗?”
tokenizer会将这句话转换成类似这样的数字序列:[101, 5401, 3282, 6663, 3717, 2145, 4500, 1920, 7027, 5303, 4638, 9985, 2145, 7558, 8043, 102]。
这些数字是模型能够理解和处理的形式,也就是所谓ids。每个数字代表了词汇表中的一个token。转换成这样的数字序列之后,大模型才会进行后续的处理。
Tokenizers内部流程
当你使用Tokenizers的时候,内部经过了几个阶段:
1.归一化(normalization)
- 清理文本(删除空格、变音符号等)
- 转换为小写
- Unicode标准化
from tokenizers import normalizers
from tokenizers.normalizers import NFD, StripAccents, Lowercase
normalizer = normalizers.Sequence([
NFD(), # Unicode正规化
StripAccents(), # 去除音调符号
Lowercase()]) # 转小写
normalizer.normalize_str("Héllò hõw are ù?")
tokenizer.normalizer = normalizer
2. 预分词(pre-tokenization)
预分词将文本拆分为更小的单位(“单词”),这些单位是实际token的边界。例如,将句子分割成单词或子词:
from tokenizers.pre_tokenizers import Whitespace, Digits
# 简单的空格分词器
pre_tokenizer = Whitespace()
pre_tokenizer.pre_tokenize_str("Hello! How are you? I'm fine, thank you.")
# 会按空格分割文本
# 或者组合多个预分词器
pre_tokenizer = pre_tokenizers.Sequence([
Whitespace(),
Digits(individual_digits=True)
])
pre_tokenizer.pre_tokenize_str("Call 911! How are you? I'm fine thank you") # 空格分词+数字单独处理
tokenizer.pre_tokenizer = pre_tokenizer
3. 模型(model)
决定了实际的分词算法,例如BPE(字节对编码):
tokenizer = Tokenizer(BPE(unk_token="[UNK]"))
4. 后处理(post-processing)
在分词后添加特殊token,如句子分隔符[CLS]
、[SEP]
等。
完整流程
from tokenizers import Tokenizer
from tokenizers.models import BPE
from tokenizers.normalizers import NFD, StripAccents, Lowercase, Sequence as NormSequence
from tokenizers.pre_tokenizers import Whitespace, Digits, Sequence as PreSequence
from tokenizers.processors import TemplateProcessing
# 1. 创建一个基本的分词器
tokenizer = Tokenizer(BPE(unk_token="[UNK]"))
# 2. 设置归一化组件
normalizer = NormSequence([
NFD(), # Unicode标准化
StripAccents(), # 去除音调符号
Lowercase() # 转小写
])
tokenizer.normalizer = normalizer
# 3. 设置预分词组件
pre_tokenizer = PreSequence([
Whitespace(), # 按空格分词
Digits(individual_digits=True) # 数字单独处理
])
tokenizer.pre_tokenizer = pre_tokenizer
# 4. 设置后处理组件
tokenizer.post_processor = TemplateProcessing(
single="[CLS] $A [SEP]",
pair="[CLS] $A [SEP] $B [SEP]",
special_tokens=[
("[CLS]", 1),
("[SEP]", 2),
],
)
# 假设已经训练好了这个分词器
# 对文本进行分词处理
text = "Hello! 你好123 Cómo estás?"
# 实际处理流程:
# 1. 归一化: "hello! 你好123 como estas?"
# 2. 预分词: ["hello!", "你好", "1", "2", "3", "como", "estas?"]
# 3. 模型处理: 将预分词结果转换为token IDs
# 4. 后处理: 添加特殊标记 "[CLS] ... [SEP]"
encoded = tokenizer.encode(text)
print(f"Tokens: {encoded.tokens}")
print(f"IDs: {encoded.ids}")
# tokens = ["[CLS]", "hello", "!", "你好", "1", "2", "3", "como", "estas", "?", "[SEP]"]
# ids = [1, 25, 16, 8930, 13, 14, 15, 345, 789, 19, 2]
如何训练一个新的tokenizer?
每次部署大模型,使用tokenizer的时候,似乎都是从人家训练好的tokenizer加载一个。具体如何训练一个自己的新的tokneizer?我给出一份示例代码:
from tokenizers import Tokenizer
from tokenizers.models import BPE
from tokenizers.trainers import BpeTrainer
from tokenizers.pre_tokenizers import Whitespace
from tokenizers.normalizers import NFD, Lowercase, StripAccents
from tokenizers.processors import TemplateProcessing
import os
# 步骤1: 准备训练数据
# 这里假设我们有一些文本文件用于训练
def get_training_corpus(folder_path, batch_size=1000):
# 这个函数会批量读取文本文件
files = [os.path.join(folder_path, f) for f in os.listdir(folder_path) if f.endswith('.txt')]
for file_path in files:
with open(file_path, 'r', encoding='utf-8') as f:
lines = []
for line in f:
line = line.strip()
if line: # 跳过空行
lines.append(line)
if len(lines) >= batch_size:
yield lines
lines = []
if lines: # 不要忘记最后一批
yield lines
# 步骤2: 初始化tokenizer
# 创建一个使用BPE算法的tokenizer
tokenizer = Tokenizer(BPE(unk_token="[UNK]"))
# 步骤3: 配置normalizer(归一化处理)
tokenizer.normalizer = NFD() + Lowercase() + StripAccents()
# 步骤4: 配置pre-tokenizer(预分词)
tokenizer.pre_tokenizer = Whitespace() # 基于空格的预分词
# 步骤5: 配置训练器
# 我们需要指定特殊token和词汇表大小
trainer = BpeTrainer(
vocab_size=20000, # 词汇表大小
min_frequency=2, # 最小词频
special_tokens=["[UNK]", "[CLS]", "[SEP]", "[PAD]", "[MASK]"] # 特殊token
)
# 步骤6: 训练tokenizer
# 假设我们的训练文本在'corpus'文件夹中
corpus_folder = "corpus"
# 训练过程
tokenizer.train_from_iterator(get_training_corpus(corpus_folder), trainer)
# 步骤7: 添加后处理
# 例如,自动在序列前后添加[CLS]和[SEP]
tokenizer.post_processor = TemplateProcessing(
single="[CLS] $A [SEP]",
pair="[CLS] $A [SEP] $B [SEP]",
special_tokens=[
("[CLS]", tokenizer.token_to_id("[CLS]")),
("[SEP]", tokenizer.token_to_id("[SEP]")),
],
)
# 步骤8: 保存训练好的tokenizer
tokenizer.save("custom_tokenizer.json")
# 测试我们的tokenizer
test_sentence = "这是一个用于测试的中文句子。It also contains some English words!"
encoded = tokenizer.encode(test_sentence)
print(f"Tokens: {encoded.tokens}")
print(f"IDs: {encoded.ids}")
# 测试解码
decoded = tokenizer.decode(encoded.ids)
print(f"Decoded: {decoded}")
Tokenizer训练的本质
在刚开始学的时候,很多教学贴都写的是训练,其实对新手不友好,容易把这个“训练”和深度学习的那种训练搞混。实际上与深度学习训练更新权重不同,tokenizer训练主要是一个统计学习过程,不涉及反向传播或梯度下降。
以BPE规则为例,训练过程如下:
-
初始化:
- 从单个字符/字节的词汇表开始
- 将所有文本拆分为单个字符
-
统计频率:
- 统计所有相邻字符对在语料库中的出现频率
-
选择合并:
- 选择频率最高的字符对
- 将该字符对添加到合并规则中
- 在语料库中执行该合并
-
重复步骤2-3:
- 重新统计合并后的频率
- 选择新的最高频率字符对
- 继续合并,直到达到预设的词汇量大小
-
构建最终词汇表:
-
收集所有产生的token(包括原始字符和合并后的token)
-
为每个token分配唯一ID
-
假设我们有一个微型语料库:
"人工智能", "人工合成", "智能手机" # 这是三个语料
BPE训练过程可能如下:
- 初始分词:
["人", "工", "智", "能"], ["人", "工", "合", "成"], ["智", "能", "手", "机"]
- 统计频率:
("人", "工"): 2次 ("工", "智"): 1次 ("智", "能"): 2次 ("工", "合"): 1次 ("合", "成"): 1次 ("能", "手"): 1次 ("手", "机"): 1次
- 第一次合并(选择频率最高的对):
- 合并"人工"和"智能"(都出现2次)
- 合并规则1: 人+工 → 人工
- 合并规则2: 智+能 → 智能
- 更新文本:[“人工”, “智能”], [“人工”, “合”, “成”], [“智能”, “手”, “机”]
- 再次统计频率:
("人工", "智能"): 1次 ("人工", "合"): 1次 ("合", "成"): 1次 ("智能", "手"): 1次 ("手", "机"): 1次
- 再次合并:
- 所有对都是1次,按顺序选择一个,如"合成"
- 合并规则3: 合+成 → 合成
- 更新文本:[“人工”, “智能”], [“人工”, “合成”], [“智能”, “手”, “机”]
- 最终词汇表:
"人": 0 "工": 1 "智": 2 "能": 3 "合": 4 "成": 5 "手": 6 "机": 7 "人工": 8 "智能": 9 "合成": 10
-
那么,在训练Tokenizer的时候,建立的所谓“词表”到底在哪里发挥作用呢?
- 文本标准化 (Normalization)
- 处理大小写、重音符号、Unicode标准化等
- 词表不参与此步骤
- 预分词 (Pre-tokenization)
- 将文本分割成初步的单元(如按空格或字符分割)
- 词表不参与此步骤
- 模型处理 (Model Processing)
- 应用学习到的合并规则(BPE)或分割规则(WordPiece)
- 这里使用的是合并规则,而非词表
- 合并规则决定了如何将初步分割的单元合并成实际的token
- 词表查找 (Vocabulary Lookup)
- 这是词表发挥作用的地方
- 将前面步骤生成的token转换为数字ID
- 对每个token进行查表操作,找到对应的ID
- 后处理 (Post-processing)
- 添加特殊token、截断长度等
- 词表用于查找特殊token的ID
可见,词表的作用是在token已经生成之后,把token映射成id。也就是说,tokenizer内部记录了两个主要的东西:一个是合并规则,一个是词表。
如何查看一个tokenizer的合并规则?
from transformers import AutoTokenizer
import json
import os
# 方法1:加载预训练模型的tokenizer并查看合并规则
def inspect_tokenizer_merge_rules(model_name):
# 加载tokenizer
tokenizer = AutoTokenizer.from_pretrained(model_name)
# 检查是否是BPE类型的tokenizer
if hasattr(tokenizer, 'bpe_ranks'):
print(f"BPE合并规则 (前20个):")
for i, (pair, rank) in enumerate(sorted(tokenizer.bpe_ranks.items(), key=lambda x: x[1])):
if i >= 20:
break
print(f"规则 {i}: {pair[0]}+{pair[1]} -> {''.join(pair)} (优先级: {rank})")
else:
# 尝试另一种方式访问 - 对于较新的Tokenizers
vocab_file = tokenizer.vocab_file if hasattr(tokenizer, 'vocab_file') else None
merges_file = tokenizer.merges_file if hasattr(tokenizer, 'merges_file') else None
print(f"Tokenizer类型: {type(tokenizer).__name__}")
print(f"词汇表文件: {vocab_file}")
print(f"合并规则文件: {merges_file}")
# 如果有merges文件,尝试读取
if merges_file and os.path.exists(merges_file):
with open(merges_file, 'r', encoding='utf-8') as f:
merges = f.readlines()[1:] # 跳过表头
print(f"\n前20个合并规则:")
for i, merge in enumerate(merges[:20]):
print(f"规则 {i}: {merge.strip()}")
# 方法2:从保存的tokenizer.json文件中提取合并规则
def inspect_tokenizer_json(tokenizer_json_path):
with open(tokenizer_json_path, 'r', encoding='utf-8') as f:
tokenizer_data = json.load(f)
# 检查是否包含merges
if 'model' in tokenizer_data and 'merges' in tokenizer_data['model']:
merges = tokenizer_data['model']['merges']
print(f"合并规则总数: {len(merges)}")
print(f"前20个合并规则:")
for i, merge in enumerate(merges[:20]):
print(f"规则 {i}: {merge}")
else:
print("该tokenizer.json不包含明确的合并规则")
# 打印tokenizer的模型类型
if 'model' in tokenizer_data and 'type' in tokenizer_data['model']:
print(f"Tokenizer模型类型: {tokenizer_data['model']['type']}")
# 示例用法
print("查看BERT tokenizer:")
inspect_tokenizer_merge_rules('bert-base-uncased')
print("\n查看GPT-2 tokenizer (使用BPE):")
inspect_tokenizer_merge_rules('gpt2')
# 如果有保存的tokenizer.json文件,也可以直接查看
# inspect_tokenizer_json('path/to/tokenizer.json')
BPE类型(如GPT-2, RoBERTa):
- 可以通过
.bpe_ranks
属性或.merges_file
文件查看合并规则 - 合并规则是明确的字符对合并列表
WordPiece类型(如BERT):
- 没有显式的合并规则列表
- 通过查看词汇表中以
##
开头的子词来理解分词策略 - 规则隐含在词汇表结构中
SentencePiece类型(如T5, XLM-R):
- 规则存储在二进制
.model
文件中 - 需要使用
sentencepiece
库来查看模型细节
为什么是组合规则,而不是直接查词表?
假如你训练好了一个bpe的tokenizer。对于新输入的一句话,tokenizer在处理他的时候组合规则具体是怎么发挥作用的?为什么不直接查词表中存在的词转换成token?我直接举个例子讲解:
BPE Tokenizer处理流程:
- 文本标准化
对中文和数字进行Unicode标准化处理:
"天津工业大学相当于中等985的水平" → "天津工业大学相当于中等985的水平"
- 预分词
将文本分割成基本单元,包括字符和数字:
["天", "津", "工", "业", "大", "学", "相", "当", "于", "中", "等", "9", "8", "5", "的", "水", "平"]
- 应用合并规则
迭代应用合并规则:
初始状态:["天", "津", "工", "业", "大", "学", "相", "当", "于", "中", "等", "9", "8", "5", "的", "水", "平"]
第一轮扫描:
- 检查所有相邻字符对
- 假设发现"天"+“津”→“天津"是最高优先级匹配
- 应用后:
["天津", "工", "业", "大", "学", "相", "当", "于", "中", "等", "9", "8", "5", "的", "水", "平"]
第二轮扫描:
- 假设发现"工”+“业”→“工业"是最高优先级匹配
- 应用后:
["天津", "工业", "大", "学", "相", "当", "于", "中", "等", "9", "8", "5", "的", "水", "平"]
第三轮扫描:
- 假设发现"大”+“学”→“大学"是最高优先级匹配
- 应用后:
["天津", "工业", "大学", "相", "当", "于", "中", "等", "9", "8", "5", "的", "水", "平"]
第四轮扫描:
- 假设发现"相”+“当”→“相当"是最高优先级匹配
- 应用后:
["天津", "工业", "大学", "相当", "于", "中", "等", "9", "8", "5", "的", "水", "平"]
第五轮扫描:
- 假设发现"天津”+“工业”→“天津工业"是最高优先级匹配
- 应用后:
["天津工业", "大学", "相当", "于", "中", "等", "9", "8", "5", "的", "水", "平"]
第六轮扫描:
- 假设发现"天津工业”+“大学”→“天津工业大学"是最高优先级匹配
- 应用后:
["天津工业大学", "相当", "于", "中", "等", "9", "8", "5", "的", "水", "平"]
第七轮扫描:
- 假设发现"9”+“8”→“98"是最高优先级匹配
- 应用后:
["天津工业大学", "相当", "于", "中", "等", "98", "5", "的", "水", "平"]
第八轮扫描:
- 假设发现"98”+“5”→“985"是最高优先级匹配
- 应用后:
["天津工业大学", "相当", "于", "中", "等", "985", "的", "水", "平"]
第九轮扫描:
- 假设发现"水”+“平”→“水平"是最高优先级匹配
- 应用后:
["天津工业大学", "相当", "于", "中", "等", "985", "的", "水平"]
第十轮扫描:
- 假设发现"中”+“等”→“中等"是最高优先级匹配
- 应用后:
["天津工业大学", "相当", "于", "中等", "985", "的", "水平"]
第十一轮扫描:
- 假设没有更多可匹配的规则
- 最终分词结果:
["天津工业大学", "相当", "于", "中等", "985", "的", "水平"]
- 词表查找(ID转换)
将token转换为ID:
"天津工业大学" → 8426
"相当" → 2145
"于" → 21
"中等" → 3762
"985" → 5329
"的" → 8
"水平" → 4218
生成最终的ID序列:[8426, 2145, 21, 3762, 5329, 8, 4218]
添加特殊token:
[101, 8426, 2145, 21, 3762, 5329, 8, 4218, 102]
为什么不直接查词表中存在的词转换成token就好了?
原因有几个:
- 未知词处理:直接查表无法处理训练中未见过的词,而BPE通过合并规则可以将未知词分解为已知子词
- 组合爆炸:语言中可能的词组合是无限的,无法全部放入词表
- 效率平衡:通过合并规则可以用有限的词表表达几乎无限的词汇
假设tokenizer从未见过"智慧"这个词,但在词表中有"智"和"慧”:
如果直接查表,“智慧"会被标记为[UNK](未知) 使用BPE,会先分成[“智”, “慧”],然后应用合并规则,由于没有"智”+“慧"的规则,最终结果是[“智”, “慧”],但这比[UNK]保留了更多信息.
结语
tokenizer本质上就是把一段话变成一堆ids,这个过程被称为"编码”(encode)。
当输入"今天天气真好"这样的文本时,tokenizer会将其转换为类似[101, 3352, 5401, 7321, 102]
这样的ID序列,然后模型才能处理这些数字并生成响应。
当模型生成回应时,会输出一系列ID,然后tokenizer执行反向操作(decode),将这些ID转换回可读的文本。
发明tokenizer的人真是一个天才,比我也不差多少了。