深入理解BPE:大模型Tokenizer分词原理与实战

发布时间:2026/9/3 23:40:37
深入理解BPE:大模型Tokenizer分词原理与实战 很多人在入门大模型时第一行真正执行的代码往往不是模型本身而是这样一行input_ids tokenizer.encode(text)这行代码看起来就像一个普通工具函数把字符串变成一串整数。直到你开始做微调、处理生僻词、设计特殊 token或者在中文、代码、医学术语面前看到模型输出一团乱码才会意识到自己完全不知道 tokenizer 内部发生了什么。这篇文章想解决的就是这个问题。我会用一个几十行的教学版 BPE 实现把大模型中最常见的分词算法 Byte Pair Encoding 的合并逻辑讲透再带你用 Hugging Face 的 tokenizers 库训练一个生产可用的 BPE tokenizer最后讨论在真正训练大模型时应该怎么设计词表和特殊 token。我的判断很明确Tokenizer 并不是一个可以跳过的前置细节它是大模型的输入基石。不懂 BPE后续学 embedding、位置编码、指令微调时都会有一层窗户纸捅不破。1. 这篇文章真正要解决的问题很多初学者学大模型习惯直接从 Transformer 架构、Attention 机制入手结果在跑通代码后遇到一个非常尴尬的问题模型需要的是input_ids但文本应该怎么变成input_ids为什么同一个词在不同模型里会被切成不一样的片段为什么英文一句话可能只有 10 个 token中文却变成几十个这些问题全部指向同一个底层组件Tokenizer。在实际工程里Tokenizer 相关的问题甚至比模型结构更容易踩坑。比如你在做垂直领域微调语料里大量出现“心肌梗死”“民法典”“IFRS17”这样的领域词直接用通用大模型的 tokenizer很可能把一个 4 字词拆成好几个 token不仅浪费序列长度还影响模型对完整概念的感知。这个时候你需要重新训练一个适配领域语料的 tokenizer而不是简单去改模型参数。还有一类常见问题是特殊 token 设计。很多人训练自定义 BPE 时没有在一开始预留[BOS]、[EOS]、[PAD]等特殊 token 的 id训练完再想加就得重新训或者手动调整词表很容易把词表顺序搞乱。这类问题一旦发生轻则训练报错重则模型推理结果完全不可用。这篇文章适合三类读者正在自学“从零构建大模型”的开发者需要把输入侧的原理彻底搞懂。需要在垂直领域语料上训练自定义 tokenizer 的 NLP 工程师。算法工程师在面试或工作中被问到 BPE、WordPiece、SentencePiece 区别时希望有一个体系化的回答框架。读完这篇文章你会掌握BPE 的核心动态、一个教学版的 Python 实现、生产环境训练自定义 BPE 的完整流程以及在大模型训练中设计词表的关键原则。2. Token、词表与编码先补齐基础概念在讲 BPE 之前先把几个最基础的概念对齐。因为后面所有代码和表格都建立在这几个词上。Token是文本切分后的最小单元。它不一定是完整的单词可以是子词、字符甚至是一个 UTF-8 字节。比如单词understanding在 BPE 词表里可能被切成understand和ing两个 token。词表vocab是一整套 token 的集合每个 token 在训练后被分配一个固定的整数 id。编码encode是把原始字符串变成 token id 列表解码decode是反过来把 token id 列表还原成字符串。为什么不能直接按字符切分理论上可以但效率太低。一段 1000 字的英文文本按字符切分可能得到 4000 到 5000 个 token序列长度迅速膨胀Transformer 的注意力计算复杂度是序列长度的平方序列越长开销越大。为什么不能直接按单词切分主要有三个问题第一是词表爆炸英文单词形态变化很多run、runs、running、ran如果都作为独立词条存进词表词表很容易超过百万第二是未登录词OOV问题遇到词典里没有的生僻词、新词、人名词模型直接不认识第三是跨语言问题不同语言分词规则差异很大很难用同一套单词级切分统一处理所有语言。于是业界采用了一个折中方案子词分词Subword Tokenization。把一个词拆成更小、但仍有意义的片段。高频词保留完整形式低频词由高频子词组合而成任何未登录词都能通过子词拼出来这就同时解决了词表体积和覆盖度的问题。GPT 系列使用的 BPEBERT 使用的 WordPiece以及 T5、LLaMA 等模型使用的 SentencePiece都属于子词分词的范畴。它们的共同点是“词表由训练语料自动构建”不依赖人工维护的词典区别主要在于合并规则和文本预处理方式。3. BPE 原理把最高频的字符对反复合并BPEByte Pair Encoding最早是 1994 年提出的数据压缩算法思路非常朴素扫描数据找出出现频率最高的相邻符号对把它们合并成新符号重复这个过程直到满足停止条件。后来被引入 NLP 领域成为一种子词分词方法。直观理解可以把 BPE 看作“搭积木”。一开始所有文本都被拆成一个个字符每个字符是一个独立积木。然后统计所有相邻积木对出现的频率把最高频的一对黏合成一个大积木。下一步这个大积木又可以作为整体和旁边的积木继续黏合。如此反复直到词表大小达到预设目标。举个例子假设训练语料里low出现了 5 次lower出现了 3 次。字符级别的统计可能是这样的lo在low中出现 5 次在lower中出现 3 次共 8 次ow同样出现 8 次we出现 3 次er出现 3 次如果第一轮最高频是lo就合并成lo。第二轮low频率达到 8合并成low。这时候lower变成lower后续可能继续把er合并成er。最终lower被切分成lower两个子词。这个例子揭示了 BPE 的核心特点训练过程是按频率驱动的语料中出现越频繁的组合越有机会成为独立 token。所以 BPE 训练出来的词表天然适配训练语料的分布。如果语料是代码词表里会大量出现def、class、lambda这样的代码 token如果语料是医学论文词表里会积累很多医学术语的子词组合。字节级 BPEByte-level BPE是 GPT-2 引入的一个重要变体。它不直接对字符操作而是先把文本变成 UTF-8 字节序列再在字节级别上做合并。为什么这样做因为 Unicode 字符数量庞大直接建立字符词表难以覆盖所有语言。而 UTF-8 编码下任何字符都由 1 到 4 个字节组成全世界的文本最终都能落到 256 个字节的范围内。基础词表固定为 256再用合并规则扩展词表就可以处理任意语言的文本。这也解释了一个大模型使用者经常遇到的疑惑为什么中文在 GPT-2 里一个汉字经常被拆成 2 到 3 个 token 因为一个汉字在 UTF-8 下通常占 3 个字节字节级 BPE 先把它拆成 3 个字节 token再根据语料频率决定是否合并。通用英文语料里很少出现高频的中文字节组合所以中文汉字的子词合并机会少token 数自然就多。WordPiece 和 BPE 常被拿来对比。两者都是反复合并子词但 BPE 按“相邻对出现频次”来选合并对象WordPiece 按“合并后语料似然概率提升最大”来选也就是它不仅看频次还看合并后对整体建模的增益。SentencePiece 则是另一个维度它不依赖预分词直接把整个句子当作字符流处理可以避免不同语言分词规则不一致的问题。BERT 的 WordPiece 通常还需要先按空格分词SentencePiece 连这一步都不需要。算法代表模型合并依据是否依赖预分词BPEGPT-2、GPT-3、LLaMA相邻对出现频次通常需要WordPieceBERT合并后似然概率提升需要SentencePieceT5、LLaMA 的部分实现BPE / Unigram / LPM不依赖4. 环境准备一台普通笔记本就够训练和运行一个 BPE tokenizer并不需要 GPU。BPE 本质上是字符串频次统计和合并算法CPU 完全能处理。我建议的本地环境是 Python 3.9 或 3.10操作系统不限Windows、macOS、Linux 都可以。如果你只是想理解原理连第三方库都不用装直接用 Python 标准库就能写出教学版 BPE我们下一节就会这样做。如果你想在生产环境训练和使用 tokenizer需要安装 Hugging Face 的两个库pip install tokenizers transformerstokenizers是 Rust 实现的快速分词库训练速度非常快适合处理大规模语料。transformers提供统一的模型和分词器接口方便把训练好的 tokenizer 接入大模型训练流程。版本以实际安装环境为准本文示例基于当下的常见稳定版本核心 API 在 0.19.x 和 4.4x 系列中均可用。另外建议准备一个小型文本语料文件。不需要很大几千行文本就可以完成一次完整的 BPE 训练演示。如果你想看到中文切分效果直接准备一个纯中文语料效果最好。5. 从零实现一个教学版 BPE完整代码这一节我们从零写一个SimpleBPE类完整实现训练、编码、解码三个核心方法。代码只有几十行但 BPE 的核心逻辑一个不少。文件路径simple_bpe.py# simple_bpe.py 教学版 BPEByte Pair Encoding 只保留最核心的合并逻辑方便理解算法本质。 import re from collections import Counter, defaultdict def word_tokenize(text: str) - list[str]: 简单预分词按空白切分保留非空白片段。 return re.findall(r\S, text) def get_pair_stats(words_ids: list[list[str]]) - dict[tuple[str, str], int]: 统计所有相邻 token 对的频次。 stats defaultdict(int) for word_ids in words_ids: for pair in zip(word_ids, word_ids[1:]): stats[pair] 1 return stats def merge_word(word_ids: list[str], pair: tuple[str, str], new_token: str) - list[str]: 将 word_ids 中所有等于 pair 的相邻片段替换为 new_token。 result [] i 0 while i len(word_ids): if ( i len(word_ids) - 1 and word_ids[i] pair[0] and word_ids[i 1] pair[1] ): result.append(new_token) i 2 else: result.append(word_ids[i]) i 1 return result class SimpleBPE: def __init__(self, vocab_size: int 50): self.vocab_size vocab_size self.merges: list[tuple[tuple[str, str], str]] [] self.vocab: set[str] set() def train(self, texts: list[str]) - None: # 1. 预分词并把每个词拆成字符列表 words_ids: list[list[str]] [] self.vocab set() for text in texts: for word in word_tokenize(text.lower()): word_ids list(word) words_ids.append(word_ids) self.vocab.update(word_ids) # 2. 反复合并最高频相邻对直到词表达到目标大小 while len(self.vocab) self.vocab_size: pair_stats get_pair_stats(words_ids) if not pair_stats: break best_pair max(pair_stats, keypair_stats.get) new_token .join(best_pair) # 在全部词序列上应用合并 words_ids [merge_word(w, best_pair, new_token) for w in words_ids] self.merges.append((best_pair, new_token)) self.vocab.add(new_token) print(fstep {len(self.merges)}: 合并 {best_pair!r} - {new_token!r}) print(f训练完成词表大小 {len(self.vocab)}合并规则数 {len(self.merges)}) def encode(self, text: str) - list[str]: words_ids [list(word) for word in word_tokenize(text.lower())] encoded_tokens: list[str] [] for word_ids in words_ids: changed True while changed: changed False for pair, new_token in self.merges: merged merge_word(word_ids, pair, new_token) if merged ! word_ids: word_ids merged changed True break encoded_tokens.extend(word_ids) return encoded_tokens def decode(self, tokens: list[str]) - str: return .join(tokens) if __name__ __main__: corpus [ low low low low low, lower lower lower, newest newest, widest widest widest, ] bpe SimpleBPE(vocab_size20) bpe.train(corpus) text lowest tokens bpe.encode(text) print(文本:, text) print(编码:, tokens) print(解码:, bpe.decode(tokens))这段代码的逻辑可以拆成三部分理解。训练部分先对每段文本做预分词这里用的是最简单的\S按空白切分然后对每个词做字符级初始化得到初始词表接着进入 while 循环每次统计全部词序列中的相邻对频次找到最高频的 pair生成新 token并把词序列中所有该 pair 替换成新 token。这个过程一直持续到词表大小达到目标或者没有可合并的 pair。编码部分对输入文本做同样的预分词和字符级拆分然后从小到大遍历训练得到的合并规则能应用就应用直到规则无法再使用。因为self.merges保存的是训练顺序所以先训练出来的规则会优先应用这能比较好地模拟 BPE 的贪心合并过程。解码部分直接把 token 列表拼接成字符串。教学版为了简洁没有保留词间空格所以 decode 后空格会丢失生产环境中我们通常会处理掉这个问题。运行上面的if __name__ __main__代码控制台会打印出每一步合并规则类似下面的形式step 1: 合并 (l, o) - lo step 2: 合并 (lo, w) - low step 3: 合并 (e, r) - er ... 训练完成词表大小 20合并规则数 10 文本: lowest 编码: [low, e, s, t] 解码: lowest注意具体合并顺序取决于 Python 字典的遍历顺序和语料统计结果不同环境可能略有差异但核心现象是一致的训练语料中没有完整出现过的lowest在编码时也能通过已有子词拼出来。这正是 BPE 处理未登录词的关键能力。6. 生产级实践用 Hugging Face tokenizers 训练 BPE教学版适合理解原理但要处理真实语料我们通常直接使用 Hugging Face 的tokenizers库。它是 Rust 实现训练速度快自带字节级编码、解码、保存加载等全套能力。先准备一个语料文件每行放一条样本命名为corpus.txtThe quick brown fox jumps over the lazy dog. The quick brown fox is learning tokenization. Tokenization is the first step of large language models.然后编写训练脚本。文件路径train_tokenizer.py# train_tokenizer.py from tokenizers import Tokenizer, models, pre_tokenizers, decoders, trainers # 1. 初始化 BPE 模型设置未登录词 tokenizer Tokenizer(models.BPE(unk_token[UNK])) # 2. 使用字节级预分词保留空格信息对应 GPT-2 的方案 tokenizer.pre_tokenizer pre_tokenizers.ByteLevel(add_prefix_spaceTrue) # 3. 配置解码器让 token 可以还原为原始文本 tokenizer.decoder decoders.ByteLevel() # 4. 配置训练器 trainer trainers.BpeTrainer( vocab_size5000, special_tokens[[UNK], [PAD], [CLS], [SEP], [MASK]], min_frequency2, ) # 5. 训练并保存 files [corpus.txt] tokenizer.train(files, trainer) tokenizer.save(my_bpe_tokenizer.json) print(tokenizer 训练完成并已保存)这段代码里有几个关键设计需要解释。ByteLevel预分词会把文本转换成 UTF-8 字节序列并在英文单词前面加一个空格符号。在 token 列表里这个空格显示为Ġ这是字节级 BPE 的典型特征。比如Hello world可能被切成Hello和Ġworld解码时Ġ会被还原成普通空格。这样设计的好处是解码时能精确还原原文的空格位置。special_tokens参数很重要。这些特殊 token 会占据词表最前面的固定位置后续训练模型时[PAD]、[BOS]、[EOS]这类 token 有确定且稳定的 id不会因为词表内容变化而漂移。如果你用的是 GPT 类模型通常会把[BOS]和[EOS]换成|endoftext|等样式。训练完成后写一个验证脚本。文件路径verify_tokenizer.py# verify_tokenizer.py from tokenizers import Tokenizer tokenizer Tokenizer.from_file(my_bpe_tokenizer.json) text Hello, world! 你好世界。 encoded tokenizer.encode(text) print(原始文本:, text) print(ids:, encoded.ids) print(tokens:, encoded.tokens) print(解码:, tokenizer.decode(encoded.ids))运行后你会看到类似下面的输出原始文本: Hello, world! 你好世界。 ids: [157, 224, 235, ...] tokens: [Hello, ,, Ġworld, !, ä½, å¥, ½ï, ¼, å, ä¸, ç, å, ...] 解码: Hello, world! 你好世界。注意中文部分的 tokens 看起来像乱码这是因为它们实际上是 UTF-8 字节片段。这并不代表中文被错误处理只要解码后能还原出原文就说明 byte-level 逻辑正常。这也再次说明了字节级 BPE 的跨语言能力无论什么语言在字节层面都统一处理。如果你想把这个自训练的 tokenizer 接入 Hugging Face Transformers 训练流程可以用PreTrainedTokenizerFast包装一层# wrap_tokenizer.py from tokenizers import Tokenizer from transformers import PreTrainedTokenizerFast tokenizer Tokenizer.from_file(