
大家好,我是写代码的中年人。在这个人人谈论“Token量”、“百万上下文”、“按Token计费”的AI时代,“Tokenizer(分词器)”这个词频频出现在开发者和研究者的视野中。它是连接自然语言与神经网络之间的一座桥梁,是大模型运行逻辑中至关重要的一环。很多时候,你以为自己在和大模型对话,其实你和它聊的是一堆Token。
今天我们就来揭秘大模型背后的魔法之一:Tokenizer。我们不仅要搞懂什么是Tokenizer,还要了解BPE(Byte Pair Encoding)的分词原理,最后还会带你看看大模型是怎么进行分词的。我还会用代码演示:如何训练你自己的Tokenizer!
注:揭秘大模型的魔法属于连载文章,一步步带你打造一个大模型。
Tokenizer 是什么
Tokenizer是大模型语言处理中用于将文本转化为模型可处理的数值表示(通常是token ID序列)的关键组件。它负责将输入文本分割成最小语义单元(tokens),如单词、子词或字符,并将其映射到对应的ID。
在大模型的世界里,模型不会直接处理我们熟悉的文本。例如,输入:
复制Hello, world!
模型并不会直接理解“H”、“e”、“l”、“l”、“o”,它理解的是这些字符被转换成的数字——准确地说,是Token ID。Tokenizer的作用就是:
把原始文本分割成“Token”:通常是词、词干、子词,甚至字符或字节。
将这些Token映射为唯一的整数ID:也就是模型训练和推理中使用的“输入向量”。
最终的流程是:
复制文本 => Token列表 => Token ID => 输入大模型
每个模型的 Tokenizer 通常都是不一样的,下表列举了一些影响Tokenizer的因素:

Tokenizer 是语言模型的“地基”之一,并不是可以通用的。一个合适的 tokenizer 会大幅影响:模型的 token 分布、收敛速度、上下文窗口利用率、稀疏词的处理能力。

如上图,不同模型,分词方法不同,对应的Token ID也不同。
常见的分词方法介绍
常见的分词方法是大模型语言处理中将文本分解为最小语义单元(tokens)的核心技术。不同的分词方法适用于不同场景,影响模型的词汇表大小、处理未登录词(OOV)的能力以及计算效率。以下是常见分词方法的介绍:
01 基于单词的分词
原理:将文本按空格或标点分割为完整的单词,每个单词作为一个token。
实现:通常结合词汇表,将单词映射到ID。未在词汇表中的词被标记为[UNK](未知)。
优点:简单直观,token具有明确的语义。适合英语等以空格分隔的语言。
缺点:词汇表可能很大(几十万到百万),增加了模型的参数和内存。未登录词(OOV)问题严重,如新词、拼写错误无法处理。对中文等无明显分隔的语言不适用。
应用场景:早期NLP模型,如Word2Vec。适合词汇量有限的特定领域任务。
示例:文本: "I love coding" → Tokens: ["I", "love", "coding"]
02 基于字符的分词
原理:将文本拆分为单个字符(或字节),每个字符作为一个token。
实现:词汇表只包含字符集(如ASCII、Unicode),无需复杂的分词规则。
优点:词汇表极小(几十到几百),内存占用低。无未登录词问题,任何文本都能被分解。适合多语言和拼写变体。
缺点:token序列长,增加模型计算负担(如Transformer的注意力机制)。丢失单词级语义,模型需学习更复杂的上下文关系。
应用场景:多语言模型(如mBERT的部分实现)。处理拼写错误或非标准文本的任务。
示例:文本: "I love" → Tokens: ["I", " ", "l", "o", "v", "e"]
03 基于子词的分词
原理:将文本分解为介于单词和字符之间的子词单元,常见算法包括BPE、WordPiece和Unigram LM。子词通常是高频词或词片段。
实现:通过统计或优化算法构建词汇表,动态分割文本,保留常见词并拆分稀有词。
优点:平衡了词汇表大小和未登录词处理能力。能处理新词、拼写变体和多语言文本。token具有一定语义,序列长度适中。
缺点:分词结果可能不直观(如"playing"拆为"play" + "##ing")。需要预训练分词器,增加前期成本。
常见子词算法
01 Byte-Pair Encoding (BPE)
原理:从字符开始,迭代合并高频字符对,形成子词。
应用:GPT系列、RoBERTa。
示例:"lowest" → ["low", "##est"]。
02 WordPiece
原理:类似BPE,但基于最大化语言模型似然选择合并。
应用:BERT、Electra。
示例:"unhappiness" → ["un", "##hap", "##pi", "##ness"]。
03 Unigram Language Model
原理:通过语言模型优化选择最优子词集合,允许多种分割路径。
应用:T5、ALBERT
应用场景:几乎所有现代大模型(如BERT、GPT、T5)。多语言、通用NLP任务。
示例:文本: "unhappiness" → Tokens: ["un", "##hap", "##pi", "##ness"]
04 基于SentencePiece的分词
原理:一种无监督的分词方法,将文本视为字符序列,直接学习子词分割,不依赖语言特定的预处理(如空格分割)。支持BPE或Unigram LM算法。
实现:训练一个模型(.model文件),包含词汇表和分词规则,直接对原始文本编码/解码。
优点:语言无关,适合多语言和无空格语言(如中文、日文)。统一处理原始文本,无需预分词。能处理未登录词,灵活性高。
缺点:需要额外训练分词模型。分词结果可能不够直观。
应用场景:T5、LLaMA、mBART等跨语言模型。中文、日文等无明确分隔的语言。
示例:文本: "こんにちは"(日语:你好) → Tokens: ["▁こ", "ん", "に", "ち", "は"]
05 基于规则的分词
原理:根据语言特定的规则(如正则表达式)将文本分割为单词或短语,常结合词典或语法规则。
实现:使用工具(如Jieba for Chinese、Mecab for Japanese)或自定义规则进行分词。
优点:分词结果符合语言习惯,语义清晰。适合特定语言或领域(如中文分词)。
缺点:依赖语言特定的规则和词典,跨语言通用性差。维护成本高,难以处理新词或非标准文本。
应用场景:中文(Jieba、THULAC)、日文(Mecab)、韩文等分词。特定领域的专业术语分词。
示例:文本: "我爱编程"(中文) → Tokens: ["我", "爱", "编程"]
06 基于Byte-level Tokenization
原理:直接将文本编码为字节序列(UTF-8编码),每个字节作为一个token。常结合BPE(如Byte-level BPE)。
实现:无需预定义词汇表,直接处理字节序列,动态生成子词。
优点:完全语言无关,词汇表极小(256个字节)。无未登录词问题,适合多语言和非标准文本。
缺点:序列长度较长,计算开销大。语义粒度低,模型需学习复杂模式。
应用场景:GPT-3、Bloom等大规模多语言模型。处理原始字节输入的任务。
示例:文本: "hello" → Tokens: ["h", "e", "l", "l", "o"](或字节表示)。
从零实现BPE分词器
子词分词(BPE、WordPiece、SentencePiece)是现代大模型的主流,因其在词汇表大小、未登录词处理和序列长度之间取得平衡,本次我们使用纯Python,不依赖任何开源框架来实现一个BPE分词器。
我们先实现一个BPETokenizer类:
复制import json
from collections import defaultdict
import re
import os
class BPETokenizer:
def __init__(self):
self.vocab = {} # token -> id
self.inverse_vocab = {} # id -> token
self.merges = [] # List of (token1, token2) pairs
self.merge_ranks = {} # pair -> rank
self.next_id = 0
self.special_tokens = []
def get_stats(self, word_freq):
pairs = defaultdict(int)
for word, freq in word_freq.items():
symbols = word.split()
for i in range(len(symbols) - 1):
pairs[(symbols[i], symbols[i + 1])] += freq
return pairs
def merge_vocab(self, pair, word_freq):
bigram = ' '.join(pair)
replacement = ''.join(pair)
new_word_freq = {}
pattern = re.compile(r'(?<!\S)' + re.escape(bigram) + r'(?!\S)')
for word, freq in word_freq.items():
new_word = pattern.sub(replacement, word)
new_word_freq[new_word] = freq
return new_word_freq
def train(self, corpus, vocab_size, special_tokens=None):
if special_tokens is None:
special_tokens = ['[PAD]', '[UNK]', '[CLS]', '[SEP]', '[MASK]']
self.special_tokens = special_tokens
for token in special_tokens:
self.vocab[token] = self.next_id
self.inverse_vocab[self.next_id] = token
self.next_id += 1
word_freq = defaultdict(int)
for text in corpus:
words = re.findall(r'\w+|[^\w\s]', text, re.UNICODE)
for word in words:
word_freq[' '.join(list(word))] += 1
while len(self.vocab) < vocab_size:
pairs = self.get_stats(word_freq)
if not pairs:
break
best_pair = max(pairs, key=pairs.get)
self.merges.append(best_pair)
self.merge_ranks[best_pair] = len(self.merges) - 1
word_freq = self.merge_vocab(best_pair, word_freq)
new_token = ''.join(best_pair)
if new_token not in self.vocab:
self.vocab[new_token] = self.next_id
self.inverse_vocab[self.next_id] = new_token
self.next_id += 1
def encode(self, text):
words = re.findall(r'\w+|[^\w\s]', text, re.UNICODE)
token_ids = []
for word in words:
tokens = list(word)
while len(tokens) > 1:
pairs = [(tokens[i], tokens[i + 1]) for i in range(len(tokens) - 1)]
merge_pair = None
merge_rank = float('inf')
for pair in pairs:
rank = self.merge_ranks.get(pair, float('inf'))
if rank < merge_rank:
merge_pair = pair
merge_rank = rank
if merge_pair is None:
break
new_tokens = []
i = 0
while i < len(tokens):
if i < len(tokens) - 1 and (tokens[i], tokens[i + 1]) == merge_pair:
new_tokens.append(''.join(merge_pair))
i += 2
else:
new_tokens.append(tokens[i])
i += 1
tokens = new_tokens
for token in tokens:
token_ids.append(self.vocab.get(token, self.vocab['[UNK]']))
return token_ids
def decode(self, token_ids):
tokens = [self.inverse_vocab.get(id, '[UNK]') for id in token_ids]
return ''.join(tokens)
def save(self, output_dir):
os.makedirs(output_dir, exist_ok=True)
with open(os.path.join(output_dir, 'vocab.json'), 'w', encoding='utf-8') as f:
json.dump(self.vocab, f, ensure_ascii=False, indent=2)
with open(os.path.join(output_dir, 'merges.txt'), 'w', encoding='utf-8') as f:
for pair in self.merges:
f.write(f"{pair[0]} {pair[1]}\n")
with open(os.path.join(output_dir, 'tokenizer_config.json'), 'w', encoding='utf-8') as f:
config = {
"model_type": "bpe",
"vocab_size": len(self.vocab),
"special_tokens": self.special_tokens,
"merges_file": "merges.txt",
"vocab_file": "vocab.json"
}
json.dump(config, f, ensure_ascii=False, indent=2)
def export_token_map(self, path):
with open(path, 'w', encoding='utf-8') as f:
for token_id, token in self.inverse_vocab.items():
f.write(f"{token_id}\t{token}\t{' '.join(token)}\n")
def print_visualization(self, text):
words = re.findall(r'\w+|[^\w\s]', text, re.UNICODE)
visualized = []
for word in words:
tokens = list(word)
while len(tokens) > 1:
pairs = [(tokens[i], tokens[i + 1]) for i in range(len(tokens) - 1)]
merge_pair = None
merge_rank = float('inf')
for pair in pairs:
rank = self.merge_ranks.get(pair, float('inf'))
if rank < merge_rank:
merge_pair = pair
merge_rank = rank
if merge_pair is None:
break
new_tokens = []
i = 0
while i < len(tokens):
if i < len(tokens) - 1 and (tokens[i], tokens[i + 1]) == merge_pair:
new_tokens.append(''.join(merge_pair))
i += 2
else:
new_tokens.append(tokens[i])
i += 1
tokens = new_tokens
visualized.append(' '.join(tokens))
return ' | '.join(visualized)
def load(self, path):
with open(os.path.join(path, 'vocab.json'), 'r', encoding='utf-8') as f:
self.vocab = json.load(f)
self.vocab = {k: int(v) for k, v in self.vocab.items()}
self.inverse_vocab = {v: k for k, v in self.vocab.items()}
self.next_id = max(self.vocab.values()) + 1
with open(os.path.join(path, 'merges.txt'), 'r', encoding='utf-8') as f:
self.merges = []
self.merge_ranks = {}
for i, line in enumerate(f):
token1, token2 = line.strip().split()
pair = (token1, token2)
self.merges.append(pair)
self.merge_ranks[pair] = i
config_path = os.path.join(path, 'tokenizer_config.json')
if os.path.exists(config_path):
with open(config_path, 'r', encoding='utf-8') as f:
config = json.load(f)
self.special_tokens = config.get("special_tokens", [])函数说明:
__init__:初始化分词器,创建词汇表、合并规则等数据结构。
get_stats:统计词频字典中相邻符号对的频率。
merge_vocab:根据符号对合并词频字典中的token。
train:基于语料库训练BPE分词器,构建词汇表。
encode:将文本编码为token id序列。
decode:将token id序列解码为文本。
save:保存分词器状态到指定目录。
export_token_map:导出token映射到文件。
print_visualization:可视化文本的BPE分词过程。
load:从指定路径加载分词器状态。
加载测试数据进行训练:
复制if __name__ == "__main__":
corpus = load_corpus_from_file("水浒传.txt")
tokenizer = BPETokenizer()
tokenizer.train(corpus, vocab_size=500)
tokenizer.save("./bpe_tokenizer")
tokenizer.export_token_map("./bpe_tokenizer/token_map.tsv")
print("\nSaved files:")
print(f"vocab.json: {os.path.exists('./bpe_tokenizer/vocab.json')}")
print(f"merges.txt: {os.path.exists('./bpe_tokenizer/merges.txt')}")
print(f"tokenizer_config.json: {os.path.exists('./bpe_tokenizer/tokenizer_config.json')}")
print(f"token_map.tsv: {os.path.exists('./bpe_tokenizer/token_map.tsv')}")此处我选择了开源的数据,水浒传全文档进行训练,请注意:训练数据应该以章节分割,请根据具体上下文决定。
文章如下:

在这里要注意vocab_size值的选择:
小语料测试 → vocab_size=100~500
训练 AI 语言模型前分词器 → vocab_size=1000~30000
实际场景调优 → 可实验不同大小,看 token 数、OOV 情况等
进行训练:
我们执行完训练代码后,程序会在bpe_tokenizer文件夹下生成4个文件:

vocab.json:存储词汇表,记录每个token到其id的映射(如{"[PAD]": 0, "he": 256})。
merges.txt:存储BPE合并规则,每行是一对合并的符号(如h e表示合并为he)。
tokenizer_config.json:存储分词器配置,包括模型类型、词汇表大小、特殊token等信息。
token_map.tsv:存储token id到token的映射,每行格式为id\ttoken\ttoken的字符序列(如256\the\th e),用于调试或分析。
我们本次测试vocab_size选择了500,我们打开vocab.json查看,里面有500个词:


进行测试:
我们执行如下代码进行测试:
复制if __name__ == '__main__':
# 加载分词器
tokenizer = BPETokenizer()
tokenizer.load('./bpe_tokenizer')
# 测试分词和还原
text = "且说鲁智深自离了五台山文殊院,取路投东京来,行了半月之上。"
ids = tokenizer.encode(text)
print("Encoded:", ids)
print("Decoded:", tokenizer.decode(ids))
print("\nVisualization:")
print(tokenizer.print_visualization(text))复制# 输出 Encoded: [60, 67, 1, 238, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 125, 1, 1, 1, 1, 1, 1, 1, 1, 1] Decoded: 且说鲁智深[UNK]离了[UNK][UNK][UNK][UNK][UNK][UNK][UNK][UNK][UNK][UNK]东京[UNK][UNK][UNK][UNK][UNK][UNK][UNK][UNK][UNK] Visualization: 且说 鲁智深 自 离了 五 台 山 文 殊 院 | , | 取 路 投 东京 来 | , | 行 了 半 月 之 上 | 。
我们看到解码后,输出很多[UNK],出现 [UNK] 并非编码器的问题,而是训练语料覆盖不够和vocab设置的值太小, 导致token 没有进入 vocab。这个到后边我们真正训练时,再说明。
BPE它是一种压缩+分词混合技术。初始时我们把句子分成单字符。然后统计出现频率最高的字符对,不断合并,直到词表大小满足预设。