【大模型】手撕 BPE:一个词是怎么被拆成子词的——从 0 训练出自己的分词器
《从零手撕大模型》第 02 篇 建议先读大白话分词器-理论篇手撕数据工程-基础篇手撕数据工程-生产篇——洗完的干净文本下一步就是切成 token 喂给模型。【大模型】大白话讲透大模型的 Tokenizer分词器【大模型】手撕生产级数据工程从跑通的 demo 到扛得住 TB 语料的企业流水线【手撕大模型|基础篇】手撕数据工程一条流水线怎么从 1260 篇原始语料里洗出 795 篇能喂给模型的干净数据【大模型】大模型的口粮从哪来万字讲透数据工程从语料来源、清洗流水线到配比与质量评估【本文内容全景概览】数据工程把脏语料洗成了干净文本可模型并不能直接读文本——它读的是一串整数 ID。把文本变成ID的第一道关口叫分词器tokenizer。本文不借助任何现成分词库从第一行代码开始手写一个 BPE 分词器把它训练出来、用它编码解码、并拿注入时就知道的正确答案验收全文闭环解答7 大核心问题模型为什么不直接吃字符、也不直接吃整词而要先分词词表、子词、token ID 这三件套各是什么BPE 是怎么从字符出发、按共现频率反复合并把一个词切成子词的词尾标记/w在里面起什么作用两个候选对频率打平了怎么办为什么必须确定性地打破平票否则同样的语料会训出不同的词表学出来的 merge 表怎么用来编码一个新词编码时的优先级规则和训练时为什么要严格一致合并次数也就是词表大小该选多少为什么不能靠抄参数而要拿已知答案扫一遍压缩率和覆盖率是怎么互相拉扯的子词为什么能治 OOV未登录词一个从没见过的稀有词在合适的词表预算下会被切成几段这条 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 的核心思想从字符出发反复合并最常一起出现的相邻对BPEByte 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”二是天然阻止合并跨越空格——两个不同词之间永远隔着空格切词后各自带/wBPE 只在词内部合并。二、基础实现一从零训练 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):freqCounter()fortintexts:forwinnormalize(t).split( ):ifw:freq[w]1returnfreq每个词再用list(w) [/w]表示成一串带词尾的字符作为合并的起点。2.3 合并循环贪心吃最高频对平票按字典序核心就是这个循环for_inrange(num_merges):bagCounter()forsymbols,cinwords.items():count_pairs(symbols,c,bag)# 统计所有词的相邻字符对频率ifnotbag:break# 选频率最高的一对平票用字典序pair 元组天然可比确定性打破bestmin(bag.items(),keylambdakv:(-kv[1],kv[0]))[0]a,bbest newab merges.append((a,b))# 有序记录下标即优先级words{tuple(apply_merge(sym,best,new)):cforsym,cinwords.items()}三个点要讲透贪心选最高频对keylambda kv: (-kv[1], kv[0])——先按频率降序-kv[1]频率相同再按这对字符串本身升序。这个二级排序是刻意的如果两个不相干的相邻对频率打平不指定明确的先后随机数、字典遍历顺序都可能让同样的语料两次训出不同的词表。字典序把平票变成确定的、可复现的。脚本第六节会重训一次验证逐条一致。merges是有序的第几条被合并就代表它的优先级rank。先合并的排前面——这决定了编码时先应用哪条规则见第三节。词表怎么长大基础符号是所有去重后的非空格单字符再加词尾标记/w本语料是 63 个非空格字符 1 个/w 64 个基础符号空格不参与合并、不进词表每步合并若产生一个全新符号就并入词表。本文训练 200 步后词表 264 64 基础符号 200 合并产物。训练完成后最靠前的几条 merge脚本真实输出是这样的前 10 条 merge按优先级 0.e/w 1.s/w 2.n/w 3.th 4.or 5.d/w 6.en/w 7.to 8.t/w 9.the/w第一眼就该看出 BPE 的贪心本性最先合并的全是e/w、s/w、n/w这种英语词尾最常见的字母 词边界以及ththe/th… 的词首——全是最高频、最无争议的片段完全符合直觉。三、基础实现二用 merge 表编码与解码学会了合并规则编码就是对新词重放这套规则而且重放的顺序必须和训练时一致。3.1 编码每轮挑「已学且优先级最高」的相邻对合并defencode_word(word,merges,rank):symbolslist(word)[/w]whilelen(symbols)2:cand[(rank[(symbols[i],symbols[i1])],i)foriinrange(len(symbols)-1)if(symbols[i],symbols[i1])inrank]ifnotcand:break_,imin(cand)# 优先级最小 最早学到的 mergepair(symbols[i],symbols[i1])symbolsapply_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)# 子词按字符拼回ss.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 涨到 664151%——这就是词表膨胀的性价比拐点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%、压缩率已过最大降幅。和数据工程里那个k8 既抓重复又不误删的最优点是同一种工程直觉。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-trip648 篇全部无损还原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~Fsweep.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 字节 ≈ 256MB128K 词表直接翻到约 1GB而且这还没算输出层 LM head 那份同样vocab×dim的权重——词表开大是两头一起吃显存。BPE 不止一种本文是字符起步 词频贪心合并的经典 BPESennrich 2016。生产里还有几个近亲——SentencePiece把空格也当符号支持 BPE 与 Unigram 两种模式LLaMA/T 系常用、WordPieceBERT 用按合并后最大似然而非最高频选对、ByteLevel BPEGPT-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 或留言。