☰
【大模型】手撕 BPE:一个词是怎么被拆成子词的——从 0 训练出自己的分词器
2026/10/27 2:00:59 网站建设 项目流程

《从零手撕大模型》第 02 篇 |建议先读
大白话分词器-理论篇
手撕数据工程-基础篇手撕数据工程-生产篇
——洗完的干净文本,下一步就是切成 token 喂给模型。
【大模型】大白话讲透:大模型的 Tokenizer(分词器)

【大模型】手撕生产级数据工程:从跑通的 demo 到扛得住 TB 语料的企业流水线

【手撕大模型|基础篇】手撕数据工程:一条流水线怎么从 1260 篇原始语料里洗出 795 篇能喂给模型的干净数据

【大模型】大模型的"口粮"从哪来?万字讲透数据工程:从语料来源、清洗流水线到配比与质量评估

【本文内容全景概览】

数据工程把脏语料洗成了干净文本,可模型并不能直接读文本——它读的是一串整数 ID。把"文本"变成"ID"的第一道关口叫分词器(tokenizer)。本文不借助任何现成分词库,从第一行代码开始,手写一个 BPE 分词器,把它训练出来、用它编码解码、并拿"注入时就知道的正确答案"验收,全文闭环解答7 大核心问题:

  1. 模型为什么不直接吃字符、也不直接吃整词,而要先"分词"?词表、子词、token ID 这三件套各是什么?
  2. BPE 是怎么"从字符出发、按共现频率反复合并"把一个词切成子词的?词尾标记</w>在里面起什么作用?
  3. 两个候选对频率打平了怎么办?为什么必须确定性地打破平票,否则同样的语料会训出不同的词表?
  4. 学出来的 merge 表怎么用来编码一个新词?编码时的优先级规则和训练时为什么要严格一致?
  5. 合并次数(也就是词表大小)该选多少?为什么不能靠抄参数,而要拿"已知答案"扫一遍?压缩率和覆盖率是怎么互相拉扯的?
  6. 子词为什么能治 OOV(未登录词)?一个从没见过的稀有词,在合适的词表预算下会被切成几段?
  7. 这条 demo 分词器,离真正的生产分词器还差什么?

读完本文,你将从零跑通一个"合成语料 → 统计词频 → BPE 合并 → 得到有序 merge 表和词表 → 编码 → 解码 → 对账"的完整分词器,并且理解分词器唯一可靠的验收方式,和数据工程一脉相承:先埋好答案,再训练词表,最后拿答案对账。


模型不认识"字",它只认识数字。一句"我喜欢训练语言模型",进到模型之前要先被切成一个个片段、每个片段查到一个整数 ID,这串 ID 才是模型真正吃的东西。切得不好的分词器,会把常用词剁得稀碎、把罕见词整块塞进词表撑爆显存,甚至一个没见过的词直接变成"未知符"。所以分词器是模型理解文本的第一道关口。本文就从零手写这套关口里最主流的算法——BPE,把它讲透、跑通、并用"已知答案"验到服服帖帖。


一、概念精讲:模型眼里的文本长什么样

1.1 为什么不能直接吃字符,也不能直接吃整词

摆在面前有三个选择,各有各的死法:

切分粒度好处致命短板
字符级(每个字/字母一个 token)词表极小(几十个)、永不 OOV序列太长,一个词被拆成七八步,模型很难学到词级语义,训练慢
整词级(每个词一个 token)序列最短、语义最直接词表爆炸(几十万)、拼写错误和新词全部 OOV,一个"modeling"没进词表就废了
子词级(subword,本文的 BPE)常用词整块留、罕见词拆成有义的子词、词表可控、几乎不 OOV需要训练,实现复杂一点

子词级是目前所有主流大模型(GPT、LLaMA、Claude……)的共识选择。它的核心洞察是:词表不必在"字符"和"整词"之间二选一——高频的词就整块存成一个 token,低频的词就拆成若干个"经常一起出现"的片段,片段本身也是 token。这样词表大小可控,又几乎不会有"完全没法表示"的词。

1.2 三件套:词表、子词、token ID

一个分词器本质上就是三样东西:

词表 vocab :一堆子词(token)的集合,比如 ["语言", "模型", "token", "ing", ...] ID 映射 :每个子词 → 一个整数下标,比如 "模型" → 42 合并规则 merges:怎么把字符一步步拼成子词的"配方"(BPE 的核心,编码时就靠它)

编码就是:文本 → 按规则切成子词 → 查词表换成 ID。解码就是反过来:ID → 子词 → 拼回文本。本文手写的 BPE,产出的正是这份"合并规则 + 词表"。

1.3 BPE 的核心思想:从字符出发,反复合并最常一起出现的相邻对

BPE(Byte Pair Encoding,字节对编码)原本是文本压缩算法,被 Sennrich 等人 2016 年搬进机器翻译当分词器,从此成为主流。它的训练思想一句话就能说清:

先把所有词都看成"一串字符",然后反复找到"出现频率最高的相邻字符对",把它合并成一个新符号;如此循环几百到几万步,就得到了一份"最常共现"的子词表。

举个最小的例子,假设语料里这几个词:low / lower / newest / wider。BPE 会这样长大:

第 0 步 全是单字符 l o w </w> l o w e r </w> n e w e s t </w> ... 第 1 步 合并 (e,s)? 先看哪个相邻对出现最多,比如 (e,r) 出现 2 次 → 合并成 "er" 第 2 步 再合并次高频 比如 (er,</w>) → "er</w>" ... 每步都贪心地吃掉当前最高频的相邻对

合并得越多,词表越大、常用词越能被整块表示;但合并过头,连极罕见的长词也会被"强行捏成一个 token",既浪费词表名额又没意义。"合并多少步"就是分词器最关键的旋钮,本文第四节用真值扫出来。

那个</w>是词尾标记:它粘在每个词的末尾,一是让"词结尾的字符"有独立身份(比如 “low” 和 “lower” 开头的 low 不同,因为一个后面跟</w>一个跟 “e”),二是天然阻止合并跨越空格——两个不同词之间永远隔着空格(切词后各自带</w>),BPE 只在词内部合并。


二、基础实现(一):从零训练 BPE

这一节把上面的思想落成代码,纯标准库、固定随机种子、确定性复现。

2.1 先埋好答案:一份带"真值"的合成语料

延续基础篇 01 的方法论——语料自己造,所以答案自己知道。本文造了一份 648 篇的中英混合语料(脚本真实输出):

文档 648 篇(英文 484 / 中文 164) 去重词形 226 个;基础符号 64 个(非空格字符 63 + 词尾标记 </w> 1)
  • 英文:用一组模板 × 一批高频词(token、model、data、text…)反复铺量,再撒入几个只出现 1-2 次的稀有长词(quixotism、xylophone、flibbertigibbet)。
  • 中文:中文没有空格,这正好用来演示"BPE 能不能自己学会中文分词"。脚本从一个双字词库(语言、模型、训练、词表、分词…)里随机抽词拼成句子,让每个高频词的邻居千变万化、自身内部那一对字符却稳定高频。再夹入几个生僻单字(齉、彧、鑫)当负例。

于是我们预先就知道两组"标准答案":

TRUTH_POSITIVE = 高频词,合并到位后应该各自"整块成为一个 token" TRUTH_NEGATIVE = 稀有词,在合理预算下应该"被拆成多个碎片"

这两组真值,就是后面扫参数和对账的尺子。

2.2 统计词频:把文本变成「词 → 次数」

BPE 训练的第一件事,是数每个词(带词尾标记)出现多少次:

defword_freqs(texts):freq=Counter()fortintexts:forwinnormalize(t).split(' '):ifw:freq[w]+=1returnfreq

每个词再用list(w) + ['</w>']表示成"一串带词尾的字符",作为合并的起点。

2.3 合并循环:贪心吃最高频对,平票按字典序

核心就是这个循环:

for_inrange(num_merges):bag=Counter()forsymbols,cinwords.items():count_pairs(symbols,c,bag)# 统计所有词的相邻字符对频率ifnotbag:break# 选频率最高的一对;平票用字典序(pair 元组天然可比)确定性打破best=min(bag.items(),key=lambdakv:(-kv[1],kv[0]))[0]a,b=best new=a+b merges.append((a,b))# 有序记录:下标即优先级words={tuple(apply_merge(sym,best,new)):cforsym,cinwords.items()}

三个点要讲透:

  1. 贪心选最高频对:key=lambda kv: (-kv[1], kv[0])——先按频率降序(-kv[1]),频率相同再按"这对字符串本身"升序。这个二级排序是刻意的:如果两个不相干的相邻对频率打平,不指定明确的先后,随机数、字典遍历顺序都可能让同样的语料两次训出不同的词表。字典序把平票变成确定的、可复现的。脚本第六节会重训一次验证逐条一致。

  2. merges是有序的:第几条被合并,就代表它的优先级(rank)。先合并的排前面——这决定了编码时"先应用哪条规则",见第三节。

  3. 词表怎么长大:基础符号是所有去重后的非空格单字符再加词尾标记</w>(本语料是 63 个非空格字符 + 1 个</w>= 64 个基础符号;空格不参与合并、不进词表);每步合并若产生一个全新符号就并入词表。本文训练 200 步后,词表 264 = 64 基础符号 + 200 合并产物。

训练完成后,最靠前的几条 merge(脚本真实输出)是这样的:

前 10 条 merge(按优先级): 0.e+</w> 1.s+</w> 2.n+</w> 3.t+h 4.o+r 5.d+</w> 6.e+n</w> 7.t+o 8.t+</w> 9.th+e</w>

第一眼就该看出 BPE 的"贪心"本性:最先合并的全是e</w>、s</w>、n</w>这种"英语词尾最常见的字母 + 词边界",以及th(the/th… 的词首)——全是最高频、最无争议的片段,完全符合直觉。


三、基础实现(二):用 merge 表编码与解码

学会了合并规则,编码就是"对新词重放这套规则",而且重放的顺序必须和训练时一致。

3.1 编码:每轮挑「已学且优先级最高」的相邻对合并

defencode_word(word,merges,rank):symbols=list(word)+['</w>']whilelen(symbols)>=2:cand=[(rank[(symbols[i],symbols[i+1])],i)foriinrange(len(symbols)-1)if(symbols[i],symbols[i+1])inrank]ifnotcand:break_,i=min(cand)# 优先级最小 = 最早学到的 mergepair=(symbols[i],symbols[i+1])symbols=apply_merge(symbols,pair,pair[0]+pair[1])returnsymbols

关键在min(cand):一个词里可能同时有好几对"已学过的"相邻组合,编码时永远先应用rank 最小(最早学到、最高频)的那条。这个 rank 顺序是编码正确性的生命线:假如编码时"任意挑一对能合的就合",同一个词就可能走上不同的合并路径——比如token先合to再合ken,或先合tok再合en,两条路径会切出完全不同的子词序列。那样训练集里一个词是一种切法、推理时又是另一种切法,同一个词对应到不同的 ID,模型就懵了。所以编码必须严格按 rank 重放,和训练时的贪心顺序完全一致——先高频后低频,一步都不能乱。

看几个真实编码结果(脚本输出):

token → token</w> # 高频英文词:整块成一个 token model → model</w> # 同上 语言 → 语言 </w> # 中文双字高频词:本语料里"语+言"这对字符稳定高频,被合并成整块 模型 → 模型</w> # 同上(注意:见 4.2 的限定——这是合成语料的词内高频所致,非真实中文分词) quixotism → qu i x o t i s m </w> # 稀有词:合并预算轮不到它,被剁成 8 个碎片

这五行就是子词级的全部精髓:常用词(含中文双字词)整块保留、序列最短;稀有词被拆成有义的碎片(注意连qu这种次高频对都保住了),永远不会有"完全没法表示"的 OOV。

3.2 解码:把子词拼回原文,必须无损

编码把词打成片段串,解码就是反向:

defdecode(pieces):s=''.join(pieces)# 子词按字符拼回s=s.replace(WORD_END,' ')# 词尾标记 → 空格returnnormalize(s)

</w>当初是"防跨词合并"的,解码时把它还原成空格,词就重新分开了。脚本对全部 648 篇做encode→decode,要求逐篇 bit 级还原规范化原文——真实结果 round-trip 失败 0 篇(报表【D】)。这类"可逆性"是分词器最容易翻车的地方:一旦某个字符在编码时被规范化吃掉却没法还原,模型生成的文本就对不回原文,所以必须全量断言。


四、跑通验证:合并次数不靠抄,拿真值扫出来

到这里一个 BPE 已经能训、能编、能解了。但**"合并多少步"这个最关键的旋钮选多少?** 数据工程里我们不抄阈值,这里同样不抄——拿两组真值扫一遍。

4.1 合并次数 sweep:压缩率、覆盖率、和"合并过头"的坑

脚本扫六档合并次数(60/120/200/300/450/600),每档重新训练一遍、量四个指标(真实输出):

merges vocab tokens/char 正例覆盖 负例覆盖 60 124 0.5720 25.0 % 0.0 % 120 184 0.3747 50.0 % 0.0 % 200 264 0.2525 100.0 % 0.0 % ← 甜点 300 364 0.2384 100.0 % 33.3 % 450 514 0.2294 100.0 % 100.0 % 600 664 0.2211 100.0 % 100.0 %

四个指标的含义:

  • tokens/char(压缩率):每 1 个字符平均产出多少 token,越小越省。它随合并数单调下降但边际递减——从 60 到 200 步,压缩率从 0.572 猛降到 0.253(几乎腰斩再腰斩);可 200 之后到 600,只从 0.253 挪到 0.221。把这笔账算细一点:从 200 到 600,合并数翻 3 倍(多花 3 倍词表名额),压缩率只再改善约 12.5%(0.2525→0.2211),词表却从 264 涨到 664(+151%)——这就是"词表膨胀的性价比拐点",200 步之后基本是花大价钱买零头。
  • 正例覆盖(应整块成词的高频词,覆盖了多少):从 25% 一路升到 200 步的100%——高频词全学会了。
  • 负例覆盖(稀有词被整块合并的比例,越低越好):200 步时还是0%,可一到 300、450 步就飙到 33%、100%。为什么是 300 步起跳?稀有词整体低频,但它内部的某些字符对(如qu、ot)会因为别的常用词(quick、note…)而攒到中等频率;当高频对快被合并光、剩余预算继续往后走时,这些"中频内部对"就成了下一批被合并的对象,稀有词于是从"8 个碎片"一段段缩成"1 个整块"。这个临界点完全取决于语料分布,不是固定值——换个语料,起跳的合并数就会变,这正是"合并数不靠抄、要拿真值扫"的理由。

第三行和"负例"这一列合起来,是本节最重要的发现:

合并过头,连只出现过一两次的稀有长词都会被强行捏成一个 token。词表名额是有限的(真实大模型也就 3.2 万、12.8 万这些档),把这些一年碰不到一次的词整块塞进词表,是纯浪费——它们本该保持碎片、把名额让给真正高频的模式。

所以最优的合并数不是"越大越好",而是压到边际递减的拐点、同时正例覆盖拉满、负例覆盖为 0——本语料上的甜点正好落在200:正例 100%、负例 0%、压缩率已过最大降幅。和数据工程里那个"k=8 既抓重复又不误删"的最优点,是同一种工程直觉。

4.2 真值对账:正例全成词、负例全被拆

脚本最后用两组真值对账(报表【F】,合并次数=200):

正例(应整块成词): 模型 训练 语言 词表 分词 token model data → 全部"整块 片段数=1" 负例(应被拆碎): quixotism 被拆碎 片段数=8 xylophone 被拆碎 片段数=7 flibbertigibbet 被拆碎 片段数=12

正例 8/8 全部整块成一个 token(含 5 个中文高频双字词);负例 3 个稀有词全部保持碎片。验收通过,且没有一个数字是靠肉眼估的——全是拿注入时的已知答案对账对出来的。

一个必须加上的限定:这里"BPE 把中文双字词整块合并出来",是本文合成语料的构造方式决定的,不能推广成"BPE 在真实中文里也能无师自通地分词"。本语料的中文是从一套双字词库里随机拼的,词内部那一对字符(如"语+言")稳定高频,跨词搭配又被随机化稀释,于是 BPE 的合并机制"恰好"把词内高频对并成整块——它捕捉的信号是"词内字符对稳定高频",而不是"读懂了中文词边界"。真实中文没有空格、词边界模糊、还存在大量跨词高频搭配(“的时”“了一”),BPE 在真实中文上会切出许多非词片段,这也是为什么中文场景往往要靠更大的词表预算和真实语料去训(这是我们将在生产篇说的内容)。把这句限定记住:demo 证明的是 BPE 对"高频共现"敏感,不是证明它懂分词。

4.3 六段自校验:全部通过

脚本末尾六段断言全绿:

1. 编码/解码 round-trip:648 篇全部无损还原(encode→decode == normalize) 2. 合并确定性:重训一次,200 条 merge 序列逐条完全相同(平票按字典序打破) 3. 合并优先级与词频一致:200 条 merge 的 rank 下标连续且与合并顺序一致 4. 真值正例:高频词整块成词比例 100.0%(≥ 60% 底线断言) 5. 真值负例:稀有词整块合并 0 个(负例失控即报错) 6. sweep 不变量:tokens/char 随合并数单调不增 0.572 > 0.375 > 0.252 > 0.238 > 0.229 > 0.221;词表随合并数不减 124 < 184 < 264 < 364 < 514 < 664 全部断言通过。

第 2 段专门验证"平票字典序"的价值:同参重训两次,200 条 merge 一字不差——没有那层字典序,这一步就复现不出来。第 6 段把压缩率单调性和词表单调性都卡死,是 BPE 正确性的结构性保证。

产物清单:

文件内容
corpus.jsonl648 篇合成语料(带 lang 标注)
vocab.json有序 merges + 词尾标记,生产篇据此复现同一套合并、与 HF 对齐
merges.txt200 条合并规则(每行a b,按优先级排序)
report.txt六节文字报表(A~F)
sweep.tsv合并次数扫描全表
fig-02-bpe.png四联配图

五、配图说明

四张子图依次是:① 每一步被合并对的词频曲线(单调下降,一眼看出"先合并最频繁的");② 压缩率 tokens/char 随词表大小下降且边际递减;③ 真值正例覆盖率 vs 负例覆盖率随词表的拉扯(正例应高、负例应低,200 步是分水岭);④ 高频词token被整块合成时片段数随合并数的下降。


六、生产勾子:这个 demo 分词器离生产还差什么

到这里,一个能训、能编、能解、能自证的 BPE 就完整了。但它是教学版——字符级 BPE、</w>词尾标记、只认预先切好的词。真正的生产分词器,在这几个方向上完全是另一副样子:

  • 字节级预处理(ByteLevel):本文用 Unicode 字符当基元,遇到 emoji、生僻字、多字节编码就麻烦。生产的 GPT-2/BPE 用ByteLevel——把文本先映射到 256 个"可见字节符号",空格变成Ġ前缀,从根上杜绝 OOV,任何字节序列都能表示。这是本文词表到生产词表之间最大的一道概念坎。(顺带说:本文的</w>词尾标记是 Sennrich 2016 的经典做法、也是教学简化;生产里 GPT-2 用 ByteLevel 的Ġ空格前缀、LLaMA 用 SentencePiece 的▁前缀,标记方式不同,但"给词边界一个独立身份、阻止跨词合并"的原理一样。)
  • 和工业库逐条对齐:生产上你会直接用 HuggingFacetokenizers。手写 BPE 怎么和它对得上?合并序列能不能复现、编码 ID 一致率、解码是否无损——这正是生产篇 02的主戏。
  • 词表规模是几万到十几万:本文才 264 词。真实的 GPT 词表 5 万、LLaMA 3.2 万、新一代 12.8 万起步。词表大小直接决定 embedding 层显存、编码速度、中文/多语言的碎片化程度——"词表到底开多大"是生产的头号权衡,生产篇用一组 sweep 给出硬证据。量级先有个体感:词表 32K、hidden 4096、FP16,单 embedding 层就约32768×4096×2 字节 ≈ 256MB,128K 词表直接翻到约 1GB;而且这还没算输出层 LM head 那份同样vocab×dim的权重——词表开大,是两头一起吃显存。
  • BPE 不止一种:本文是"字符起步 + 词频贪心合并"的经典 BPE(Sennrich 2016)。生产里还有几个近亲——SentencePiece(把空格也当符号,支持 BPE 与 Unigram 两种模式,LLaMA/T 系常用)、WordPiece(BERT 用,按"合并后最大似然"而非"最高频"选对)、ByteLevel BPE(GPT-2 起的主流,字节级预处理)。机理都是"把高频共现压成一个 token",差别在基元和选择合并的准则。
  • 多语言、代码、特殊 token:中英混排、缩进敏感的代码、[CLS]/<|endoftext|>这类特殊控制符……各有各的坑。
  • 版本与稳定 ID:换了词表,同一个词的 ID 就变了,已训好的 embedding 全部错位——这是线上代价最大的事故之一。

这些——ByteLevel 字节级预处理、手写 BPE 与 HFtokenizers逐条对齐、词表大小的取舍权衡、以及多语言与 OOV 处理——正是《手撕大模型 · 生产篇》第 02 篇《分词器》要讲的内容。基础篇负责让你理解机理、跑通 demo,生产篇负责让你对得上工业库、扛得住词表权衡。


全文总结

分词器是模型理解文本的第一道关口,它把文本切成子词、再映射成整数 ID。本文从零手写了一个 BPE 分词器:先把每个词看成一串带词尾标记</w>的字符,再反复贪心地合并"当前最高频的相邻字符对",每步合并的下标就是它的优先级,最终得到一份有序 merge 表和词表;编码时对新词按"已学且 rank 最小"的顺序重放这套规则,解码时把</w>还原成空格、全量断言 bit 级无损。
最关键的是那个"合并次数 sweep":因为语料里预埋了高频正例词和稀有负例词这两组已知答案,我们不抄参数,而是扫六档合并数、拿真值对账——压缩率 tokens/char 随词表下降但边际递减(200 步后几乎不省),正例覆盖率一路升到 200 步的 100%,而负例覆盖率在 200 步还是 0%、到 300 步起就飙到 33%、450 步 100%,暴露出"合并过头会连稀有词都整块塞进词表浪费名额"的真问题。甜点正是合并数=200(正例 100%、负例 0%、压缩率过最大降幅),和数据工程里"召回与误删互相拉扯、拿真值扫最优阈值"是同一种工程直觉。
ByteLevel 字节级预处理、与 HF tokenizers 逐条对齐、词表大小到几万到十几万的权衡,见生产篇 02。

完整代码见:GitHub: https://github.com/leozhangsr/llm-coding.git ,或留言。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询