从零实现BPE:大模型Tokenizer核心原理与代码实战
发布时间:2026/9/3 13:26:08 作者:尧图编辑部 阅读量:1,286

这次我们来看大模型里最容易被忽略、但所有模型都依赖的核心模块Tokenizer重点拆解其中的 BPEByte Pair Encoding字节对编码。很多人用 Llama、Qwen 做本地部署时已经见过模型加载时打印的 tokenizer 配置也听说过 prompt 会被切成 token但真正动手写过 encode 逻辑的人不多。这篇文章的目标很直接从零实现一个能运行的 BPE 训练器把文本变成 token id 序列再实现 decode 还原让你彻底搞清楚“大模型到底是怎么读句子的”。整个实现不依赖 GPU不依赖任何深度框架纯 Python 标准库就能跑。本文会带你完成环境准备、BPE 训练、encode/decode 验证、与真实 TokenizerGPT 系列、Llama、Qwen的对比以及性能观察和问题排查。适合正在入门大模型原理、需要为后续模型训练/微调/部署打基础的读者。先把结论放前面学懂 BPE不是让你以后非要手写一个而是为了在模型跑不通、token 数异常、显存超限这些问题出现时你能一眼看出根因在哪里。1. 核心能力速览能力项说明项目类型大模型基础组件Tokenizer 的 BPE 训练与编码实现运行环境Python 3.8无需 GPUCPU 即可运行核心功能BPE 词表训练、文本 encode、token 序列 decode技术依赖Python 标准库re、collections可选依赖tiktoken、sentencepiece输入纯文本语料输出词表、合并规则、token id 序列、还原文本是否支持批量支持批量文本只需循环调用 encode知识基础了解基础 Python 语法即可不需要前置深度学习经验应用方向大模型原理学习、tokenizer 定制、模型微调前的数据预处理、推理问题排查2. BPE 是什么为什么大模型离不开它大模型不能直接读字符串只能处理数字。所以任何文本进入模型前都要先经过一套“文本 → token id”的转换。Token 是模型处理文本的最小单位可以是一个完整单词也可以是一个单词的一部分甚至是一个汉字或一个字节。早期最简单的方案是按单词切分比如把 Playing 切成 Play 和 ing 之前的形态但英文词形变化太多词表很容易做到几十万以上而且遇到没见过的单词就只能标记为 unknown。按字符切分更彻底但序列长度会变得非常长训练和推理效率都很差。BPE 走的是第三条路子词切分。BPE 的核心思路很朴素先把文本拆成最小单位字符或字节然后统计所有相邻组合的出现频率每次把最高频的组合合并成一个新的子词。重复这个过程直到词表达到目标大小。这样既不需要把每个完整单词都记下来也不会把句子拆得过碎还能保证任何文本都能被切分成词表内的 token天然没有 unknown。下面用一个对比表看三种切分方式的差异切分方式词表大小序列长度未知词表示颗粒度单词切分大短有完整单词字符切分小超长无单个字符BPE 子词切分可控中无高频片段组合GPT、Llama、Qwen、ChatGLM 这些主流大模型的 tokenizer底层用的都是 BPE 或 BPE 的变体。区别只在于初始切分单位是字符还是字节以及空格、中文、特殊符号的处理方式不同。所以把 BPE 吃透等于同时理解了一大半开源模型的文本处理逻辑。3. 环境准备与前置条件这一节不需要安装 CUDA也不需要下载任何模型文件。环境要求非常低操作系统Windows / Linux / macOS 均可。Python 版本3.8 以上即可推荐 3.10 或 3.11。依赖库只用re和collections都是标准库无需安装。可选依赖tiktokenOpenAI 官方编码库和sentencepieceGoogle 开源分词库用于后续和真实 tokenizer 对比。如果只是为了跑通本文的 BPE 实现一个干净的 Python 解释器就够了。建议用虚拟环境隔离避免测试tiktoken、sentencepiece时污染全局 Pythonpython -m venv bpe_env source bpe_env/bin/activate # Windows 下执行 bpe_env\Scripts\activate pip install tiktoken sentencepiece这里把tiktoken和sentencepiece作为可选安装。不装也不影响第 4 章的完整代码运行只是第 6 章与真实 tokenizer 对比时需要用到。4. 从零实现 BPE训练器与编码器这一章是全文核心。我们按照 BPE 的标准流程分四步实现构建词频表、统计相邻字节对频率、合并最高频字节对、训练循环。最后给出 encode 和 decode 函数。4.1 构建词频表BPE 训练的第一步是把语料拆成单词序列并对每个单词的字符序列做初始化。一个常见的处理方式是在每个单词末尾加一个/w结束符这样合并时能区分“单词内部”和“单词边界”。import re from collections import Counter, defaultdict def build_vocab(text): 把语料转换成字符级词频表。 vocab Counter() for word in re.findall(r\b\w\b, text.lower()): tokens list(word) [/w] vocab[ .join(tokens)] 1 return vocab这里用正则\b\w\b提取单词转小写后展开成字符序列每个单词末尾加上/w。词频表保存的 key 是“空格分隔的字符序列”value 是这个词在语料中出现的次数。之所以用空格分隔是为了方便后面统计相邻 pair 和合并操作。4.2 统计相邻字节对频率BPE 中“字节对”实际指的是相邻的两个符号这个符号可以是字符也可以是已经合并出来的子词。统计每个相邻 pair 在各单词中出现的总次数def count_pairs(vocab): 统计词频表中所有相邻符号对的频率。 pairs defaultdict(int) for word, freq in vocab.items(): symbols word.split() for i in range(len(symbols) - 1): pairs[(symbols[i], symbols[i 1])] freq return pairs这个函数遍历词频表里每个单词对每个相邻 pair 累加频率。defaultdict(int)让不存在的 key 自动初始化为 0统计起来很简洁。注意这里的频率要乘以词频因为同一个单词出现多次它的内部 pair 也会出现多次。4.3 合并最高频字节对拿到所有相邻 pair 的频率后选出频率最高的一个 pair把它合并成一个新符号。例如(l, o)合并成lo然后更新词频表中所有出现这个 pair 的位置。def merge_pair(pair, vocab): 把词频表中所有 pair 组合替换为合并后的子词。 merged_vocab {} bigram .join(pair) replacement .join(pair) for word, freq in vocab.items(): merged_vocab[word.replace(bigram, replacement)] freq return merged_vocab替换逻辑依赖字符串的replace方法。因为词频表的 key 是空格分隔的字符序列所以(l, o)对应字符串l o合并后变成lo。这样一次合并就完成了。4.4 训练主循环BPE 训练其实就是一个不断“统计 → 合并”的迭代过程。每次迭代选择当前频率最高的相邻 pair合并然后把它记录到merges列表中。merges列表记录了合并顺序这是后面 encode 时贪心匹配的关键依据。def train_bpe(text, num_merges100): 训练 BPE返回最终词表和合并规则。 vocab build_vocab(text) merges [] for _ in range(num_merges): pairs count_pairs(vocab) if not pairs: break best max(pairs, keypairs.get) if pairs[best] 2: break vocab merge_pair(best, vocab) merges.append(best) return vocab, merges这里加了一个小优化如果最高频 pair 只出现一次就不继续合并了因为合并一个只出现一次的片段对压缩几乎没有帮助还会让词表失控。num_merges控制最终词表大小实际项目中通常设置为几千到几万次。4.5 encode 与 decode训练完成后词表和合并规则就固定了。encode 的任务是给定一段新文本按照合并规则的优先级从字符级序列一步步合并成可能的子词序列。用贪心策略每次选择当前序列中“合并顺序最早”的 pair优先合并。def encode_with_merges(text, merges): 用训练好的合并规则对文本编码。 tokens list(text.lower()) [/w] rank {pair: i for i, pair in enumerate(merges)} while len(tokens) 1: best_pair None best_rank len(rank) 1 for i in range(len(tokens) - 1): pair (tokens[i], tokens[i 1]) if pair in rank and rank[pair] best_rank: best_pair pair best_rank rank[pair] if best_pair is None: break new_tokens [] i 0 while i len(tokens): if i len(tokens) - 1 and (tokens[i], tokens[i 1]) best_pair: new_tokens.append(best_pair[0] best_pair[1]) i 2 else: new_tokens.append(tokens[i]) i 1 tokens new_tokens return tokens这段代码有两点需要注意。第一它没有直接给每个子词一个独立的数字 id而是输出子词字符串列表方便阅读实际工程场景中还需要再加一步“子词 → id”的映射。第二rank保存了每个 pair 的合并优先级合并越早的 pairrank越小越要优先合并。这是保证 encode 和训练过程一致的关键。decode 就简单很多把 token 序列直接拼接然后把/w还原成空格即可def decode_tokens(tokens): 把 token 序列还原为文本。 return .join(tokens).replace(/w, )4.6 完整单文件代码把上面的函数汇总到一个 Python 文件里可以直接运行import re from collections import Counter, defaultdict def build_vocab(text): vocab Counter() for word in re.findall(r\b\w\b, text.lower()): tokens list(word) [/w] vocab[ .join(tokens)] 1 return vocab def count_pairs(vocab): pairs defaultdict(int) for word, freq in vocab.items(): symbols word.split() for i in range(len(symbols) - 1): pairs[(symbols[i], symbols[i 1])] freq return pairs def merge_pair(pair, vocab): merged_vocab {} bigram .join(pair) replacement .join(pair) for word, freq in vocab.items(): merged_vocab[word.replace(bigram, replacement)] freq return merged_vocab def train_bpe(text, num_merges100): vocab build_vocab(text) merges [] for _ in range(num_merges): pairs count_pairs(vocab) if not pairs: break best max(pairs, keypairs.get) if pairs[best] 2: break vocab merge_pair(best, vocab) merges.append(best) return vocab, merges def encode_with_merges(text, merges): tokens list(text.lower()) [/w] rank {pair: i for i, pair in enumerate(merges)} while len(tokens) 1: best_pair None best_rank len(rank) 1 for i in range(len(tokens) - 1): pair (tokens[i], tokens[i 1]) if pair in rank and rank[pair] best_rank: best_pair pair best_rank rank[pair] if best_pair is None: break new_tokens [] i 0 while i len(tokens): if i len(tokens) - 1 and (tokens[i], tokens[i 1]) best_pair: new_tokens.append(best_pair[0] best_pair[1]) i 2 else: new_tokens.append(tokens[i]) i 1 tokens new_tokens return tokens def decode_tokens(tokens): return .join(tokens).replace(/w, ) if __name__ __main__: corpus the cat sat on the mat the dog sat on the mat the bird sat on the mat final_vocab, merges train_bpe(corpus, num_merges30) print(合并规则数量:, len(merges)) print(前 5 条合并规则:, merges[:5]) encoded encode_with_merges(the cat sat, merges) print(encode 结果:, encoded) print(decode 结果:, decode_tokens(encoded))这段代码可以直接保存为bpe_demo.py运行。输出的合并规则数量、前几条合并规则、encode 和 decode 的结果会因语料内容而不同但整体流程是一致的。5. 功能测试与效果验证写完成代码后最好按一套固定流程验证它真的能工作。这一步对应工程里的“最小可用验证”避免后面把 BPE 用到真实语料或模型微调时才发现问题。5.1 用一段小型英文语料测试先准备一个小语料包含一些常见单词的重复模式corpus the cat sat on the mat the dog sat on the mat the bird sat on the mat the cat chased the mouse the dog chased the mouse the bird chased the mouse 运行训练函数final_vocab, merges train_bpe(corpus, num_merges50) print(合并规则数:, len(merges)) print(最终词表大小:, len(final_vocab))可以看到合并规则会优先覆盖高频组合例如th、he、s a等。这是因为它们在语料中出现次数最多。任务越集中、语料越干净合并就越有规律。5.2 验证 encode 和 decode 一致性BPE 的一个基本要求是decode(encode(text))能尽量还原原文本。对上面语料里的句子做验证tests [the cat sat, the dog chased the mouse, a new bird] for t in tests: tokens encode_with_merges(t, merges) restored decode_tokens(tokens) print(f原文: {t}) print(ftoken: {tokens}) print(f还原: {restored})注意如果测试文本里出现了训练语料完全没有的单词或字符decode 的结果和原文可能不完全一致因为 BPE 对未登录字符会退化成字符级表示。这个现象不是 bug而是子词切分对所有未知内容都能兜底的正常表现。5.3 压缩率评估BPE 的价值可以从 token 数量上看。对比字符级切分和 BPE 切分text the cat sat on the mat char_tokens list(text) [/w] bpe_tokens encode_with_merges(text, merges) print(字符级 token 数:, len(char_tokens)) print(BPE token 数:, len(bpe_tokens)) print(压缩比: {:.2f}.format(len(char_tokens) / len(bpe_tokens)))通常 BPE 编码后 token 数会明显少于字符数。这个压缩意义很大大模型训练时 context 长度是有限的同样一段文本tokenizer 切得越高效能塞进上下文的内容就越多注意力计算量也越小。5.4 合并次数对词表和效果的影响num_merges的取值直接决定词表大小。合并次数太少词表基本还是字符集压缩效果有限合并次数太多会把很多低频片段也收进词表参数量和过拟合风险上升。可以用一组实验观察for num_merges in [0, 10, 30, 60, 100]: vocab, merges train_bpe(corpus, num_mergesnum_merges) encoded encode_with_merges(the cat sat on the mat, merges) print(fmerges{num_merges}, vocab_size{len(vocab)}, tokens{encoded})实际输出会显示合并次数从 0 到 30 时token 数和词表大小变化最剧烈到 60 之后继续增加合并次数收益逐渐变小。这个特性在设计真实 tokenizer 时很关键后面会再提到。6. 与真实大模型 Tokenizer 的关系理解了上面的代码再看真实大模型的 tokenizer 就会容易很多。可以说现在所有主流开源大模型用的都是 BPE 这条路线区别主要在细节。6.1 GPT-2 的 Byte-level BPEGPT 系列使用的是 Byte-level BPE。它和上面的字符级 BPE 有一个关键差异初始单位不是字符而是字符的 UTF-8 字节。比如一个中文字符会被拆成 3 个字节然后对这 3 个字节做 BPE 合并。这样一来任意语言的任意字符都能被表示为字节序列词表里不会出现 unknown也天然支持多语言混合。GPT-2 的词表大小是 50257其中 256 个初始字节、一个特殊结尾 token其余都是合并产生的子词。6.2 Llama 的 SentencePieceLlama 系列用的是 Google 开源的 SentencePiece底层训练算法也是 BPE但它把空格当成一种可见符号▁并且在整个训练流程中先对原始文本做归一化处理。这样编码结果是可逆的decode 时不会丢失空格信息。这也是为什么直接用 Llama 的 tokenizer 处理中英文混排时中文通常一个 token 一个字或几个字英文是一个片段一个 token。6.3 Qwen 与通用 tokenizer 库Qwen 系列使用 OpenAI 开源的 tiktoken 库词表大小约 15 万。大词表的好处是每个 token 的语义更完整模型输入序列可以更短代价是 embedding 层的参数量变大、显存占用增加、解码时最后的线性层计算量也更大。这就是为什么词表大小会直接影响模型体积和部署显存。用一个 tiktoken 例子看真实 encode 过程import tiktoken enc tiktoken.get_encoding(cl100k_base) text Hello world, BPE is important! tokens enc.encode(text) print(tokens:, tokens) decoded enc.decode(tokens) print(decoded:, decoded)运行后会输出一个数字 id 列表decode 后能回到原始文本。这里的cl100k_base就是 GPT-4、ChatGPT 等模型背后使用的编码器之一。6.4 词表大小与模型参数的关系从模型参数角度理解词表大小非常关键。Transformer 模型的输入需要经过 embedding 层这个层的参数量约等于vocab_size × hidden_size。假设 hidden_size 是 4096词表 32000embedding 参数量约 1.31 亿。词表 150000embedding 参数量约 6.14 亿。差值接近 5 亿参数。在显存有限的场景下这直接决定了模型能不能塞进一张显卡。所以在做本地部署时如果看到同系列模型有不同的 tokenizer 配置不用奇怪这是经过权衡的设计选择。7. 性能与资源占用观察BPE 训练本身就是个纯 CPU 任务算法瓶颈在“统计 pair 频率”和“更新词表”这两个步骤。对小型 demo 语料运行时间可以忽略不计但如果拿几十 GB 语料从头训练 tokenizer就需要考虑优化了。第一词频表可以限制 word 数量只保留出现次数最多的前 N 个单词减少统计开销。第二每轮迭代都全量扫描词频表效率偏低真实实现会维护 pair 频率的增量更新只重算受影响的分词。第三合并次数和语料规模决定训练总耗时一般生产级 tokenizer 训练要跑几十分钟到几小时不是秒级任务。推理阶段的 encode 比训练快得多。因为 merge 规则固定后对一段文本做贪心匹配的时间复杂度接近线性CPU 上处理一条 prompt 通常只需要毫秒级。这也是大模型解码流程里 tokenizer 基本不构成性能瓶颈的原因。关于显存需要提醒一点vocab_size越大显存占用越高。这个影响出现在两个地方一是 embedding 表二是输出层。很多模型在部署时会把 embedding 和 lm_head 做参数共享或低精度量化就是为了压这部分开销。实际占用多少要以你的模型配置和推理框架为准不要轻信一个固定的显存数字。8. 常见问题与排查方法问题现象可能原因排查方式解决方案训练时合并规则很快停止语料太小最高频 pair 出现次数不足打印每轮 best pair 频率增加语料或调低频率阈值encode 后 decode 和原文不一致文本中出现训练语料没有的字符检查测试文本是否有特殊符号接受退化到字符级输出或扩充训练语料中文文本 token 数特别多使用的 tokenizer 不是针对中文优化的大词表对比不同词表编码结果中英混合场景优先选择中文友好的 tokenizer词表太大模型参数暴涨num_merges 设置过大统计 vocab_size 与参数量的关系按目标参数量反推合适的词表大小训练速度慢每轮全量扫描词频表分析日志耗时分布限制词频表规模或改增量更新直接调用 tiktoken 报错tiktoken 未安装或编码名不存在检查依赖和编码名安装 tiktoken使用cl100k_base等合法名称批量处理时内存暴涨一次性加载所有文本分批读取并统计词频用流式处理限制单批大小9. 最佳实践与学习建议现在你已经亲手实现了 BPE接下来要注意几个工程习惯。第一tokenizer 是独立于模型训练的组件。正式项目中先训练或选好 tokenizer再开始模型预训练。训练完成后tokenizer 词表和 merge 规则要固化下来不能随意改动否则模型权重和 token 序列的对应关系会错乱。第二第一次跑通后先保留一套最小可运行配置。比如一个几百 KB 的演示语料、一个固定的合并次数这样可以随时用来验证后续改动。第三生产环境不建议自己手写 BPE。主流做法是直接用tiktoken、sentencepiece、tokenizers库。自己实现的意义在于调试和深入理解不是替代成熟库。第四做大模型部署、微调、数据预处理时要多关注 token 数量。比如在构造微调数据集时可以用 tokenizer 统计每段样本的长度看看是否超过模型最大序列长度。这一步在训练前做好能避免很多显存溢出问题。第五涉及多语言或特殊符号时要用 Byte-level BPE 思路来兜底。任何字符都可以先转成字节再做合并这样词表永远不需要一个真正的 unknown token。从学习路线看BPE 只是大模型数据流的第一步。下一步建议做三件事一是把 token id 接上 embedding 层理解词向量怎么被模型使用二是继续研究 Transformer 的注意力机制这是大模型最核心的计算单元三是把训练好的 tokenizer 和真实模型对接跑一次完整的文本生成流程。10. 总结BPE 是理解大模型文本处理流程的最佳入口之一。它原理简单、代码量小却直接关系到模型参数量、序列长度、训练效率、显存占用和部署效果。这篇文章用纯 Python 实现了一个完整的 BPE 训练器、编码器和解码器验证了 encode/decode 的还原性并与 GPT、Llama、Qwen 等真实模型的 tokenizer 方案做了对照。如果只看一个点建议先跑通第 4 章的完整代码然后用不同语料和不同num_merges参数做实验观察词表大小和 token 数的变化。这个实验做完了你对“词表为什么影响显存”“为什么同一句话在不同模型里 token 数不一样”“中文为什么经常一个字一个字切”这些问题的理解会比背十篇理论文章都有效。下一步可以直接把这个 tokenizer 接到一个 tiny GPT 模型里用 token id 作为输入真正跑通“文本 → token → embedding → 模型输出”的完整链路。这就是下一篇可以展开的内容了。