资讯动态

从零实现分词器:BPE算法原理与工程实践全解析

发布时间:2026/10/22 2:58:30 来源:尧图企业网站定制
我电脑里躺着一个文件夹名字就叫20260414_分词器。那天建好这个项目后文件名被我随手丢在角落直到翻出来才觉得这一段值得好好写一写。分词器听起来是自然语言处理流水线里最不起眼的一环但它偏偏是所有文本进入模型前最先经过的关卡。我们日常说的 token、subword、词表、OOV、序列长度这些概念全部跟它绑在一起。我在这天做的事情很简单不借助任何现成的封装从零实现一遍分词器并且把它用在自己的实验数据上。如果你好奇为什么同一个句子在不同模型里 token 数差很多如果你在做搜索、文本分类或者大模型微调时被 unknown token、长度不匹配这类问题折磨过如果你只是想把分词、BPE 这些名词彻底搞明白这篇文章应该能帮你看清地基。我尽量讲清楚它是什么、能做什么、有哪些坑也附上能直接跑的教学代码。1. 为什么我决定从零造一个分词器1.1 分词器在整条处理链路里的位置我用一个很土的类比如果模型是一个炒菜师傅分词器就是提前把菜切好的人。切得太粗师傅没办法处理切得太碎炒出来的菜又没有口感。文本进入模型之前要先被切成一串离散的符号也就是 token然后再通过查表变成数字索引。分词器就是做“切菜”这一步的组件。它决定三件事句子会被切成什么样词表里有哪些符号以及模型到底能看到多大的文本范围。举个例子一句“我喜欢奶茶”如果分词器切成“我”、“喜欢”、“奶茶”三个 token模型就能比较好地理解“奶茶”是一个整体如果切成“我”、“喜”、“欢”、“奶”、“茶”模型要花更大力气去还原语义。而在大模型场景里token 数量还直接和算力、时间、推理成本挂钩。所以分词器不是边角料它是影响整个项目体验的前置开关。1.2 现成工具很多为什么还要自己写一份市面上的现成分词工具有很多开源生态里也有不少可以直接 import 的库。它们最大的优点是稳定、省心、经过大量验证普通业务场景直接拿来用效率最高。但我在那次实验里坚持自己写是因为遇到了几个现实问题。第一现成词表不一定覆盖目标领域。比如我要处理日志、代码片段和混合中英文的短文本预训练模型自带的词表往往是面向通用语料的切分日志里的时间戳、IP、括号嵌套时经常不合预期。第二现成方案是个黑盒。很多库可以直接出 token 结果但不会告诉你为什么“20260414”这个串被切成了两半也不会告诉你词表里哪些 token 是噪声。第三业务和模型解耦不友好。一旦底层模型升级自带分词器也得跟着换线上数据的 token ID 就可能对不上排查起来很痛苦。自己写一遍至少能把内部机制暴露出来。训练数据洗坏了你能看到错误合并出现在哪里词表里出现了奇怪 token你能顺着 merge 规则找到根源。这种“看得见”的能力在调优阶段非常值钱。1.3 该不该自己训练的决定性判断并不是所有场景都适合从零造分词器我自己也会先做一轮判断再决定要不要动手。判断标准可以简化成三条第一领域文本是否足够特殊比如代码、医疗、金融术语专有词很多现成词汇表覆盖度明显偏低第二有没有真实的数据规模和算力预算训练一个像样的分词器至少需要准备干净的语料和足够的机器时间如果只是几十 KB 文本做实验直接用现成方案更合理第三是否需要完全可控的产物比如产品要对 token id 有严格的稳定性要求不能因为库版本升级就导致线上结果漂移。只有当至少两条都成立时我才会考虑自研或在此基础上去做二次训练。如果只是为了写一个小工具直接调现成库完全没问题。但如果想真正理解这套机制亲手实现一次是绕不开的路。2. 主流分词算法都在忙些什么2.1 BPE把最高频相邻片段焊在一起BPE 的全称是 Byte Pair Encoding最初是数据压缩里的一种算法后来被引入到自然语言处理里做子词切分。它的思路非常朴素先从一个字符级别的词表出发统计语料里所有相邻符号对的出现频率然后把出现频率最高的那对合并成一个新符号再重复这个过程直到词表大小达到目标值。我用一个小例子解释。假设语料里反复出现 “low”、“lower”、“lowest”一开始词表是字母级别l、o、w、e、r、s、t。统计相邻字符对会发现 “lo” 出现的次数不少因为 low 这个词是高频的于是把 “lo” 合并成一个 token。继续统计可能 “low” 又因为和 lower、lowest 里的 low 重复出现而继续合并。这样不断“焊”下去最终会把一些高频片段固化在词表里。BPE 最吸引人的地方是它能自动找到语料里真正常见的片段而不需要人工维护一部词典。它对未知词的容忍度也比较高即使碰到没见过的词也能退回到更小的子词甚至字母级别去表示。2.2 WordPiece用似然增益代替原始频率WordPiece 和 BPE 乍看很像都是从小词表开始不断合并但选择合并对象的依据不一样。BPE 看的是相邻对的原始频次WordPiece 计算的则是“合并之后整体语料的概率提升有多大”。也就是说它不仅看这个片段出现得频不频繁还看它出现之后能不能降低整个语料的不确定性。这种差异带来的实际影响是 WordPiece 生成的词表会更偏向语义稳定、通用性强的片段而不是单纯只出现在某一个词里的奇怪高频组合。它还会用一个特殊前缀标记词中片段比如把“tokenizer”切成“token”、“##izer”这样的结构方便模型知道后面这块不是一个独立的词。这类分词方式在经典的双向预训练模型体系里用得很普遍。2.3 Unigram从大词表往下剪BPE 和 WordPiece 都是自底向上的“生长式”方法Unigram 则相反。它一开始先准备一个非常大的候选词表甚至可以覆盖所有可能的子串然后基于语言模型计算每个 token 对整个句子概率的贡献把贡献低的 token 一步一步删掉直到词表收缩到目标大小。这种方式的好处是候选词表天然具备多粒度特征同一个词在句子里既可能以长片段出现也可能切成更小的片段模型可以根据上下文选择最好的切法。它也更灵活支持正则化之类的技巧。很多现代开源工具里Unigram 和 BPE 是并列的两个选项谁更适合要看语料和任务。2.4 SentencePiece把空格也变成普通符号还有一个很关键的思路叫 SentencePiece它的最大特点是不把空格当作天然的切分边界。传统方法通常先按空格把句子切成单词再做子词合并。但中文、日文这些语言本身没有空格按空格切分完全没有意义。SentencePiece 的做法是直接把空格当成一个普通字符塞进文本里在空格符号的参与下做 BPE 或 Unigram 训练。这样做有一个附带的好处可逆性更强。只要保存了词表和合并规则就能从 token 序列相对可靠地拼回原始文本因为空格也是一级 token丢失信息的概率会小很多。很多多语言模型都是用这类方法训练的也是我那次实验里重点参考的思路。2.5 一张表横向对比四种思路算法基本思路词表增长方向对预分词依赖常见使用场景BPE合并最高频相邻片段从字符向上生长通常依赖大模型默认方案之一WordPiece按似然增益合并从字符向上生长依赖双向预训练模型常用Unigram按似然贡献剪枝从大词表向下收缩依赖多语言子词训练SentencePiece / 空格符号法把空格并入符号后统一训练视具体算法而定不依赖空格预切分中文、日文等多语言场景这张表是我做选型时最常看的决策表。理解每种算法背后的哲学比自己硬记参数更重要。3. 实操训练一个迷你版分词器3.1 数据准备先把语料洗到能用的程度任何分词训练的第一步都不是写算法而是洗数据。我在这天准备了几段混合中英文语料内容是模拟日志和中文短文本混合的场景。洗数据我主要做了三件事统一换行符、把标点尽可能归一化、去掉控制字符和不可见字符。有些看起来“很干净”的语料实际处理时会发现全角标点、零宽空格、不规范的破折号混在一起。这些噪声如果进入训练会被 BPE 当成普通高频符号合并结果就是词表里出现一堆莫名其妙的前缀 token。中文场景里我还会额外注意繁体、简体是否要统一因为简繁混用的语料会让同一个词被切出两套字符组合白白浪费词表空间。洗完之后我会把语料拆成训练集和验证集。验证集不参加分词器训练只在最后做编码测试时用用来观察“没见过的文本”能否被正常切分。3.2 核心训练代码一个能跑的 BPE 简化实现为了演示核心机制我写了一个非常简化的 BPE 实现。它不适合大规模生产但非常适合理解合并流程。完整代码如下import re from collections import Counter def pre_tokenize(text): # 英文单词和数字按字符拆中文/标点按单字处理 pattern re.compile(r[a-zA-Z]|\d|[^\sa-zA-Z0-9]) out [] for m in pattern.finditer(text): word m.group() if re.fullmatch(r[a-zA-Z]|\d, word): out.append(tuple(word)) else: out.append((word,)) return out def replace_pair(word, pair): a, b pair res [] i 0 while i len(word): if i 1 len(word) and word[i] a and word[i 1] b: res.append(a b) i 2 else: res.append(word[i]) i 1 return tuple(res) def bpe_train(texts, num_merges): word_freq Counter() for text in texts: for w in pre_tokenize(text): word_freq[w] 1 vocab set() for w in word_freq: for ch in w: vocab.add(ch) merges [] for _ in range(num_merges): pair_freq Counter() for w, freq in word_freq.items(): for i in range(len(w) - 1): pair_freq[(w[i], w[i 1])] freq if not pair_freq: break best_pair max(pair_freq, keypair_freq.get) merges.append(best_pair) vocab.add(.join(best_pair)) new_word_freq Counter() for w, freq in word_freq.items(): new_word_freq[replace_pair(w, best_pair)] freq word_freq new_word_freq return merges, vocab def encode(text, merges): out [] for w in pre_tokenize(text): seq list(w) changed True while changed: changed False for pair in merges: i 0 while i len(seq) - 1: if seq[i] pair[0] and seq[i 1] pair[1]: seq[i:i 2] [.join(pair)] changed True else: i 1 out.extend(seq) return out texts [ 分词器是文本处理的第一道关卡。, tokenizer is the first gate for text., 今天记录一次分词器的实验日期是20260414。, ] merges, vocab bpe_train(texts, num_merges40) print(前20条合并规则, merges[:20]) print(词表大小, len(vocab)) tokens encode(分词器是文本处理的第一道关卡。, merges) print(编码结果, tokens)这段代码的运行效果是先在字符级别建立词表然后不断统计相邻符号对把最高频的 pair 合并成新符号并同步更新语料的表示。跑完后可以用 encode 函数对任意新文本做切分。3.3 逐步讲解代码背后的关键决策几个关键点我想单独展开说一下。第一为什么英文和数字先按整词切分再拆成字符因为 BPE 的合并规则不能跨过自然词边界。如果不做这层处理“text is”里的 “t” 和 “i” 可能被统计成相邻 pair但跨词合并对语义没有任何帮助反而会污染词表。我在 pre_tokenize 里把每个英文单词当成一个独立的元组就是为了让 pair 统计只在单词内部进行。第二为什么中文要按单字去处理因为中文没有天然的词边界。把每个汉字当成一个基础符号让 BPE 自己决定“分词器”这个词要不要合并成一个 token这是更贴近数据驱动的方式。如果一开始就按外部词典切词训练结果很容易被词典质量绑架而且词典没收录的新词会很难处理。第三replace_pair 做了什么事它扫描一个词内部的字符序列如果发现相邻两个符号正好等于当前要合并的 pair就把它们替换成合并后的新符号。这里有个细节它每次从左往右扫描遇到符合条件的就替换替换后跳两个位置防止把重叠内容重复合并。这种实现虽然简单但也可能在极少数情况下和真正的 BPE 编码有差异生产环境还是建议直接使用成熟的开源实现。3.4 词表大小与模型参数的影响词表大小直接决定了模型的 embedding 层参数规模。假设隐藏维度是 4096词表从 32K 增到 64Kembedding 层就会多出 32K×4096 个参数。在显卡内存有限的场景里这是一笔不能忽视的开销。我通常按这样的参考值来选择初始词表大小微型实验用 1K10K单语言通用模型用 30K50K多语言大规模训练再用 100K 以上。实际选择还要结合语料的丰富程度。如果语料本身只有几万条硬上 100K 词表很多 token 因为出现次数太低学到的 embedding 几乎等于随机初始化反而拖累训练。另外一个容易被忽略的因素是特殊 token。像 [PAD]、[UNK]、[BOS]、[EOS] 这些标记要提前留出位置最好在训练之前就确定它们的 ID。否则训练完再往词表里硬塞可能会打乱原有的排序和 index线上加载时很容易踩坑。3.5 编码、解码与结果抽查训练结束后我用一条没进入训练集的句子做了编码测试。预期输出可能是类似这样的结果前20条合并规则 [(t, o), (e, r), (m, o), (日, 期), ...] 词表大小 40 编码结果 [分, 词, 器, 是, 文本, 处理, 的, 第一, 道, 关卡, 。]如果语料太小你能看到很多中文词其实没有被合并起来这很正常。BPE 本质上是靠频率说话的单个样本里“文本”出现一次“分”和“词”各出现多次机器不会强行把它们焊到一起。要得到更合理的词表需要把语料规模放大到几十万甚至上百万句。抽查时我会做两件事第一看 merge 列表前面几条确认它们是不是符合直觉的高频片段第二把 vocab 按出现次数排序后打印出来检查 top 部分有没有明显噪声比如一串不应该合并的符号。如果噪声太多通常不是算法问题而是数据清洗没做好。4. 训练和调试中的常见问题4.1 语料没洗干净导致的连锁问题我见过最典型的错误是没有清洗就把原始日志直接拿去训练。日志里经常有毫秒级时间戳、随机生成的 UUID、颜色控制码这些东西频率高又毫无语义。BPE 会把它们当成高频组合焊进词表结果词表里出现大量“看起来很像规则、实际上毫无用处”的 token。更麻烦的是这类 token 会占用宝贵词表空间把真正有用的词挤掉。排查时很容易陷入误区模型表现不好第一反应是调网络结构实际上问题是分词器一开始就没吃饱“干净饭”。我的经验是先洗数据再训练分词器最后才轮到模型。4.2 词表大小怎么选才不别扭词表太小生僻字和专有词会切得很碎甚至产生大量 unknown token词表太大embedding 层又太笨重。对于中文语料还要考虑一个特性字本身的信息密度很高很多情况下 30K 词表能覆盖绝大多数常用词而英文因为存在大量屈折变化和派生词可能需要更大的词表才能达到相同的压缩率。我个人常用的方法是做一个快速扫描实验分别用 10K、20K、50K 的词表去编码同一批验证集看平均 token 数和 unknown 率的变化曲线。曲线在某个点之后趋于平缓就说明词表已经够用了再往上加只是浪费模型容量。4.3 特殊 token 的位置与冲突特殊 token 的处理顺序是个很细节但很致命的问题。比如 [PAD] 和 [UNK] 如果放在最后才补它们的 ID 可能会和文本里真实出现的某段字符 ID 冲突。一旦发生冲突训练时模型分不清这个位置到底表示的是什么结果会非常诡异。更稳妥的做法是在训练一开始就保留特殊 token 的位置或者在训练完成后重新生成一份带特殊 token 的完整词表并且保证文本 token 的 ID 映射不改变。任何一步迁移到生产环境时都要先跑一个“加载后编码再解码”的往返测试确认 ID 没有偏移。4.4 OOV 与生僻字符的处理训练集覆盖不了的字符在推理阶段就会变成 OOV。为了缓解这个问题业界经常使用字节级 BPE也就是把文本先转成 UTF-8 字节流再在字节级别做合并。这样任何 Unicode 字符都能被拆成若干字节理论上不存在完全未知的输入代价是普通文本的 token 数会略有上升。如果不想上字节级最朴素的办法是尽量收集足够多样化的语料至少覆盖目标场景里的常见符号。对中文来说生僻字和 emoji 是两个常见的重灾区。我会在清洗阶段把 emoji 拆成单独符号处理而不是让它们混在中英文片段里被合并掉。4.5 排查实录速查表现象可能原因排查与解法词表里出现大量乱码 token语料未清洗干净重新做归一化和过滤验证集 unknown token 很多词表太小或语料覆盖不足增大词表或改用字节级 BPE训练时词表大小总是对不上特殊 token 未预留重建 vocab 时统一加上 special ids同一句话的 token 结果不稳定预处理没有固定顺序或依赖随机采样固定正则规则和训练种子解码后空格丢失预分词阶段丢掉了空白信息参考 SentencePiece 思路把空格纳入符号编码后序列特别长预分词把标点切得过碎检查符号处理策略是否要合并常见标点这张表算是我的“踩坑索引”每次项目遇到分词相关问题我都会先照表自查一遍能省很多时间。5. 分词器的影响范围到底有多大5.1 从搜索引擎到大模型的共同地基分词器不只服务大模型在搜索引擎、语音识别、文本校对、代码补全等场景里都是地基级组件。搜索引擎里文档和查询都会先分词再建立倒排索引。如果分词器把“分词器”切成“分”、“词”、“器”用户搜索“分词器”时召回效果会明显变差因为索引里根本没有一个完整的三字词来承载相关性。语音识别系统里文本分词的边界会影响声学模型的对齐粒度代码补全工具里代码的分词方式和自然语言不同缩进、括号、字符串字面量都要作为特殊结构处理。可以说几乎任何和文本打交道的系统都绕不开这一步。5.2 换一套 tokenizer 的代价有多高很多人以为换分词器就是换一个预处理函数实际上代价可能高到整个模型要重新训练。模型的 embedding 层是把 token ID 映射到向量不同分词器给出的 ID 体系完全不同。如果你已经用旧的词表训练了好几个月的模型然后再换一个新的 tokenizer除了 embedding 层尺寸可能变化之外语义映射也全部失效几乎只能从零开始训练。所以做模型迭代时我强烈建议分词器一旦定下来就不要轻易更换。如果确实需要扩充词汇表更现实的做法是在原词表基础上做“增量扩展”新增的 token 用原有子词表示去初始化再和模型一起微调而不是全部推倒重来。5.3 衡量分词器质量的四个指标评估分词器我最看重四个指标压缩率、覆盖度、稳定性和可逆性。压缩率指的是同一个句子平均能切成多少个 token这个数字直接影响训练和推理成本。覆盖度衡量的是目标文本里有多少字符或子词能直接命中词表覆盖度低代表 OOV 风险高。稳定性说的是同一段文本在多次运行或不同并发请求下是否总能得到完全一致的切分结果这个特性在生产系统里非常关键。可逆性是指从 token 序列能否尽量还原原始文本空格和标点是否被妥善保留。这四个维度不一定需要同时最优化。搜索场景可能覆盖度和稳定性更重要大规模生成场景则更关注压缩率。关键是根据任务目标去取舍。5.4 给不同阶段开发者的选择建议如果你刚开始接触这个东西建议直接使用成熟库把精力放在理解 token 和模型训练的关系上如果你已经在做精细的领域模型并且被词表覆盖度或 OOV 问题卡住可以考虑在开源实现基础上做增量训练如果目标是追求最大的可控性和完整性那自研一套完全可解释的分词器是值得投资的但要做好长期维护的准备。从项目命名就能看出来20260414_分词器对我来说是一次“把地基挖开看看”的实验。这种实验不需要多大规模但它能帮你把黑盒变成灰盒再把灰盒变成白盒。如果你打算自己动手跑一遍我建议从很小的语料开始先设置 2K 词表把 merge 过程逐条打印出来亲眼看看“合并规则是怎么一步步长出来的”。我个人在跑完这轮实验后做的最有价值的一件事是把词表前 100 个 token 打印出来人工扫了一遍发现里面有大量数字和标点组成的噪声这比任何评估指标都更能说明数据清洗的重要性。另一个实际体会是再简单的分词器也要保留完整的状态记录练完以后立即把词表、合并规则、特殊 token ID 一起存档否则几天后再加载你可能连自己当时为什么这么切都回忆不起来。分词器不是越复杂越好而是越可控越好。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑