标记化的本质:BPE、Unigram与现代LLM的隐痛

上个月,我们团队被一个古怪的bug折磨了三天。输入明明一样,输出的token序列却不同——模型直接废掉。排查一圈,发现是标记化层惹的祸。训练时用的tokenizer和推理时差了那么几个特殊token的处理,结果整个embedding矩阵对不上位。那一刻,我真想砸键盘。

说实话,太多人轻视标记化了。以为就是简单的切词、查表。实际上,它是大模型最底层的感官系统,一步错,步步错。今天不讲形而上的概念,直接剖开肚子看内脏——BPE(Byte Pair Encoding)和Unigram是怎么运转的,压测数据告诉我们什么,以及三个让我掉层皮的大坑。

BPE合并过程 step-by-step 动画图解
BPE合并过程 step-by-step 动画图解

追本溯源:那些token从哪来

所有现代LLM的tokenizer,几乎都指向两种算法:BPE和Unigram。BERT用的是WordPiece,算是BPE的变种;GPT系列、Llama用的都是BPE;而T5、XLNet则青睐Unigram。它们的共同目标——以最低的熵构建词表,也就是用尽可能短的token序列表达最多的信息。

先撕开BPE。它起源于1994年的数据压缩算法,后来被Sennrich等人用到机器翻译里。核心就一句话:统计语料中所有相邻符号对的频次,把出现最多的那对合并成一个新符号,然后重复,直到达到预设的词汇量。这个过程,特别像玩俄罗斯方块——你不断寻找最常在一起出现的两个方块,啪一下合并消除。但你得小心,合并不当会把罕见词也弄进来,造成词表虚胖。

看个例子。假设语料里只有三个词:low, lower, newest。先把每个词拆成字符序列,末尾加上一个结束符(比如)。然后开始数对:l o, o w, w , l o w e r , n e w e s t 。首轮合并,”l”和”o”出现两次,频率最高,于是合并成”lo”。第二轮,”lo”和”w”又凑一起了?对,因为”lo”现在是一个符号,”w”是下一个,它们一起出现了两次,再次合并得”low”。就这样不断进行,直到词表达到4096或32000这种大小。结果生成的词表里会有”low”, “low“, “er”, “er“这些子词,既能表示完整词,又能进行词的派生和变形。工程上,这个合并顺序会被记录下来,形成一个merge规则列表,推理时按照同样的顺序应用规则,就能保证编码一致。这就是训练和推理必须共用同一套规则的根源。

Unigram则反其道而行。它假设所有token都是独立出现的,从一个大词表开始,反复计算每个token对语料似然函数的贡献,然后剪掉贡献最小的token,直到剩下预算。这种基于概率的修剪,让Unigram生成的词表往往更均衡,稀有词不会被过度合并,但训练计算量巨大。它的魅力在于可解释的概率模型,每一个切分候选都有一条概率路径,最终选择总体概率最大的切分方式。在实际引擎,比如SentencePiece里,Unigram通常比BPE压缩率略高,词表利用率更优。下表是我用1B tokens的英文语料,在相同词表大小(32k)下的压测结果,算力有限,但趋势很说明问题。

BPE vs Unigram 词表利用率与压缩率对比柱状图
BPE vs Unigram 词表利用率与压缩率对比柱状图
算法词表实际使用率平均序列长度OOV率(评测集)编码速度(句/秒)
BPE (GPT-2 style)92.3%58.70.21%10400
Unigram (T5 style)96.8%55.20.09%7600

BPE的坑在于,它会把一些高频的垃圾组合固定下来,比如空格加逗号这种,平白无故占据词表位置。Unigram慢,但智商更高。这就是为什么后来的模型,比如Bloom、Qwen,开始转向BBPE(Byte-level BPE)或更精巧的tokenizer-free方案,直接在字节上操作,彻底扔掉OOV的问题——当然,序列长度也拉长了,这是个取舍。

落地三宗罪 —— 血泪换来的教训

落地三宗罪 —— 血泪换来的教训
落地三宗罪 —— 血泪换来的教训

理论归理论,真到了生产环境,标记化带来的麻烦远不止选哪个算法。下面这三大坑,每个都让我熬过夜。

陷阱1:训练与推理的tokenizer不一致
你可能会说,这怎么可能不一致?但现实中太常见了。模型训练时用的是某个HuggingFace仓库里的tokenizer,保存时可能只存了词表,没存完整的配置,或者特殊token(bos_token_id, eos_token_id, pad_token_id)设置不同。推理时重新加载,哪怕词表一样,如果这些ID对不上,模型就会在解码时把起始符解释成另外一个词,导致整个生成歧义百出。我那次bug,就是因为训练时设置的pad_token是”“且ID=0,而推理时框架默认把pad设成了None,发生自动分配ID,把原来的0位置挤占了。解决方案:固化Tokenizer配置,用同一份json文件保存全部参数,并通过checksum校验,禁止动态创建。部署时用docker镜像打包,不给任何灵活配置的机会。

陷阱2:特殊token置乱与注意力掩码
在构建输入时,我们需要把句子用特殊token包装,比如[BOS] sentence [EOS],然后做padding。但如果你不加区分,直接用tokenizer返回的input_ids去做掩码,可能会让padding位置也参与注意力计算。很多模型实现默认的attention_mask会把0视为padding不参与计算,可如果你的pad_token_id不是0,或者你忘了传attention_mask,灾难就来了——模型会试图从一堆0中学出无意义的依赖。有一次,我发现模型在长文本上效果极差,偶然打印注意力矩阵,才发现padding区域全是满值。解决方案:显式构造attention_mask,保证mask与pad_token_id严格对应,并在预处理后可视化检验。建议写个单元测试,故意输入同批次不同长度的句子,检查掩码形状和数值。

陷阱3:大词表的性能噩梦
现在动辄250k的tokenizer词表,比如Llama 3,带来了更好的多语言覆盖,但也让embedding层和最后的线性层膨胀到数十亿参数。这部分参数占据的显存,在推理时可能和主模型一样大!更糟的是,embedding查表的IO密集特性,在高并发下会让GPU利用率掉到谷底。我们做过压测,batch_size=1, max_length=2048,使用60k词表的模型,序列化embedding查表操作耗去了总延迟的23%,而主模型计算才占40%。增大batch虽能摊销,但实时服务不可能无限等。解决方案:使用embedding quantization、weighted embedding分层缓存,或者转向更激进的tokenizer-free架构。对于无法改架构的情况,把embedding矩阵切分到多个GPU做张量并行,并利用FlashAttention-2优化Kernel,能挽回不少。另外,适当缩减词表(通过剪枝低频token),在不明显增加OOV的前提下,词表从250k砍到128k,我们实测推理吞吐提升了17%,长尾词用字符级回退影响甚微。

工程美学的涟漪

工程美学的涟漪
工程美学的涟漪

标记化的设计,像一面镜子,照出整个系统的权衡哲学。BPE的贪心合并,是局部最优逼近全局最优;Unigram的修剪,是概率模型的奥卡姆剃刀。而所有落地中的坑,根源都在于对“一致性”的傲慢和忽视。你必须在代码的每一条分支、每一个配置项前刻上“不要假设默认值”。真的,别信框架的默认,自己从头钩一遍。

有时候觉得,我们这些搞LLM的,就像在湍急河流上搭积木。标记化就是最底下的那块,默默承受所有荷载。一旦它歪了,上面垒多少层精调、RLHF都白搭。这种底层的不安全感,却能催生最健壮的架构——强迫你去想最坏的情况,做最冗余的校验。而这种思维方式,可能就是工程美学里,那种在毫厘之间求千钧稳妥的偏执

免责声明:市场有风险,选择需谨慎!此文仅供参考,不作买卖依据。如有侵权请联系删除。
文章名称:标记化的本质:BPE、Unigram与现代LLM的隐痛
文章链接:https://www.lfdjt.com/info_23_7994.html