ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

LLM分词技术详解:从BPE到字节级,避开生产环境的坑

LLM分词技术详解:从BPE到字节级,避开生产环境的坑

在LLM模型开发这条路上摸爬滚打一段时间后,我越来越确定一件事:分词(tokenization)才是整个流程里最先决定成败的关卡。模型架构可以借鉴成熟方案,训练框架可以选现成工具,但分词器如果没选好、没训好,后续所有环节都会以一种特别隐蔽的方式漏气——训练时token数莫名其妙爆炸、生成时不定期冒出乱码、领域术语总是被切得稀碎。这个系列文章写到第七篇,我打算把模型分词技术从头到尾拆开讲清楚:原理是什么、主流算法各自在优化什么、字节级方案为什么能横扫一片、自己训练分词器时要注意哪些参数,以及生产环境里那些真正会咬人的坑。文章适合正在做模型二次训练、准备接入开源模型做产品,或者单纯想搞清楚“token到底是怎么切出来”的开发者,内容不偏向任何特定框架,重点放在可复现的思路和判断标准上。

1. 分词为什么是LLM的第一道生死线

1.1 从“字符串”到“ID序列”:这不是预处理杂活

很多刚接触大模型的同学会把分词当成普通的文本预处理:分词嘛,无非是把句子切块,然后查表转成数字。这个印象不算错,但严重低估了它的影响。

语言模型本质上是一个离散序列模型。网络第一层是embedding查表,输入是token ID,输出是向量;这一层之前,模型看到的所有东西都必须落在“词表”这个封闭集合里。换句话说,模型对语言的全部感知起点,就是分词器把文本切成了什么样子、每个片段分到了哪个ID。

这里的关键在于:分词粒度直接决定了模型的“最小语义单元”。切细了,比如中文一个字一个字地切,模型确实能覆盖所有文本,但每一步都要靠网络自己学会把字组合成语义,序列长度也会大幅拉长,注意力的计算量和内存占用水涨船高;切粗了,比如把常出现的短语直接做成词表项,短文本确实友好,但组合爆炸无法覆盖,遇上一个没见过的搭配就退化回字符级。

所以分词不是一个“处理完就不管”的流程。训练阶段用这套分词器切语料,预训练和微调阶段用同一套,推理阶段处理用户输入还是同一套。任何一步换掉分词器,等价于模型看到的语言整体变了,必须重新训练。很多时候模型“变笨”了,排查到最后发现不是架构问题,而是有人悄悄换了一个词表。

1.2 词表大小、序列长度和模型能力的三方博弈

分词器设计的核心矛盾,本质是三个指标之间互相拉扯:词表大小、序列长度、语义完整性。

我先给一组直观的换算。隐藏维度是4096的情况下,一个大小为3.2万的词表,embedding矩阵参数是3.2万乘4096,约1.31亿个参数;如果词表扩到10万,这个数字直接变成4.09亿。虽然很多模型会做输入输出层权重共享来摊薄成本,但这么大的参数矩阵在存储和访存上都不是小开销。

词表太大还有一个隐性成本:训练时每个token在输出层做softmax,分母的维度越大,学习效率越难保证。词表太小同样麻烦——中文场景如果词表只有七千,那“深度学习模型”这句话大概率会被切成“深”“度”“学”“习”“模”“型”这样的孤立字。模型必须消耗大量层数去学习“深度”“学习”这种组合关系,序列长度还多了不少。同样的上下文字数,字符级分词产生的token数量可能是子词级分词的一点五到两倍,而输入长度上限是按token数算的,不是按字数算的。

那么实际项目里怎么取舍?我的经验是:中文占主导、场景相对聚焦的小模型,3.2万词表已经够用;中文加英文加代码的通用模型,建议5万到6.4万;需要覆盖多语言,10万以上几乎是起步价。这个数字不是拍脑袋定的,后面我会讲怎么用语料统计来验证。

2. 四类主流分词算法,本质都是“统计压缩”

我现在把分词算法拆开讲。很多人听到BPE、WordPiece、Unigram这些词就发怵,其实它们的核心逻辑非常一致:在语料上统计某种“合并收益”,然后反复执行收益最高的合并,直到词表达到预设大小。区别只在于“收益”怎么定义。

2.1 BPE:把频次最高的邻居死死绑在一起

BPE(Byte Pair Encoding)是最经典的做法。它的思路很简单:

  • 初始词表是语料中出现过的所有单字符;
  • 遍历语料,统计每两个相邻符号对的出现频率;
  • 选出频率最高的那一对,合并成一个新符号,写进词表;
  • 重复这个过程,直到词表到达目标大小。

举个例子。假设语料里“低成本”连续出现500次,“本”和“模”相邻出现300次,而“学”和“习”相邻出现1000次,那么第一轮合并大概率会把“学”和“习”绑成“学习”。后面类似地,等“学习”+“模”+“型”合并成“学习模型”。

关键点是:BPE产出的不仅是词表,还有一串合并规则。解码时,拿到token序列后按合并规则的逆序逐步拆解,最终还原回原始字符。所以只要词表和规则没损坏,BPE是有损压缩的反面——它自己是无损的,原始字符串总能精确恢复。

2.2 WordPiece:每次合并只问“概率增益”

WordPiece和BPE长得很像,但挑选合并对象的标准不是“出现频次”,而是“概率增益”。它假设当前词表把语料切成了若干token,每个token有自己出现的概率;合并某两个token之后,整体语料的概率会发生变化。每次合并时,WordPiece优先选让概率增益最大的pair。

这个设计带来的结果是:WordPiece更看重“合并之后对模型的预测是否更有利”,而不是单纯看“这两个符号是不是经常一起出现”。一个典型的妥协场景是,“##模型”这种带前缀标记的token结构,经常在WordPiece里出现。它和前文所说的BPE各有拥趸,但逻辑内核同源。

2.3 Unigram:先给一个巨大词表,再一刀刀砍

BPE和WordPiece都是自底向上的构建,Unigram反过来,是自顶向下的剪枝。

它先从一个很大的候选词表出发——这个表可以包含所有字符以及各种长度的n-gram——然后用EM算法反复估计每个候选token在语料里的概率,并计算如果把某个token从词表里移除,整体语料的似然会损失多少。每次砍掉损失最小的那个token,一直剪到目标词表大小。

Unigram的思路在生成模型里不常单独出现,但它对“分词器本身还能怎么训”提供了很好的视角:先给模型一个完整的语言视图,再让它学会放弃冗余信息。很多现代分词工具其实是把Unigram和BPE的方法融合在一起供用户选择。

2.4 一张表看清三种算法

算法构建方向合并/剪枝依据保底单元典型特点
BPE自底向上相邻符号对频次单字符简单直接,规则数量可控
WordPiece自底向上整体概率增益单字符,常配##前缀更贴近语言模型目标
Unigram自顶向下移除后的似然损失单字符及候选n-gram需要先构建大词表,剪枝灵活

顺带说一句,现在研发圈经常用到一类“不依赖预分词、直接把整串文本当字符序列训练”的子词工具,业内习惯叫SP类工具。它的特点是把空格也显式转成下划线token,对中文、日文这类没有天然空格的语言格外友好。这类工具本身不是一种新算法,而是把上述算法做成统一的训练框架,用起来非常省心。

3. 字节级token化:把每个不可见字符都变成模型的一部分

这一章讲的是目前主流生成式模型普遍采用的关键改动:把BPE推到“字节级”。很多人不了解为什么这么做,我尽量讲透。

3.1 为什么需要字节,而不是字符

先想一个问题:直接拿Unicode字符跑BPE,会怎样?

Unicode字符集的规模是惊人的,汉字好几万,各国文字加在一起十多万,还有大量组合字符、变体选择符、emoji。如果初始词表要覆盖所有这些字符,词表直接膨胀到几十万,而且大量字符在语料里只出现一两次,纯属浪费词表位置。

字节级方案彻底绕开了这个麻烦。做法是:文本先按UTF-8编码成字节序列,然后把每个字节当作一个“原子字符”跑BPE。UTF-8编码一个Unicode字符最多用4个字节,而字节一共只有256种可能。

这意味着什么?任何字符串,无论多生僻、无论来自哪种语言,都能被表示成若干字节的组合,永远不会掉到词表之外。模型对“未登录词”的恐慌,在这个方案里被底层消解掉了——不存在OOV,只有“这个词没被合并成整词、只能以字节碎片形式出现”的遗憾。

对这个设计,我自己的理解是:它让词表从“枚举世界上所有符号”的奢望,降级为“枚举字节组合规律”的现实任务。代价是某些低频词会被切得很碎,但换来的是覆盖率近乎100%,这个交换在工程上非常划算。

3.2 中英文混合文本被切碎后如何还原

以“深度学习模型分词原理”为例,文本先变成UTF-8字节序列,字节级BPE会按语料统计做合并。实际效果可能是“深度学习”整体成为一个token,“模型”可能合并成一个token,“分”和“词”如果训练时出现频率高也会合并,但“原理”如果语料里见得太少就可能被拆成“原”“理”。

你可能会担心:切这么碎,还能还原吗?能。因为字节级BPE的词表里带着“字节到token的合并规则”,解码时按规则反向操作,先把token拆回字节,再按UTF-8把字节拼成字符串。实践里最常见的验证手段就是“编码-解码往返测试”:给一句话,encode得到ID序列,decode回来,必须和原字符串完全一致。凡是这一步都不通过的分词器,基本可以直接弃用。

这也是我在实际开发里的习惯:拿到任何预训练模型的tokenizer,第一件事不是看它的词表大小,而是先跑20条中英混合文本的往返测试,亲眼看一下解码结果。这一步能挡住很多“词表文件损坏”“特殊token冲突”这类低级但致命的坑。

3.3 空白、换行与数字拆分:藏在细节里的模型能力

字节级方案里还有一个容易被忽略的设计点:空白、换行和缩进,都是会被保留成token的。

你可能会想,空格这种东西有必要占词表位置吗?有。代码类语料中,缩进层级是语义的一部分;写作场景里,换行位置影响段落结构感知。如果把空白全部过滤掉,模型就少了一个极其重要的结构信号。

比空白更微妙的是数字。不同的分词器处理数字的方式天差地别:有的把连续数字“123456”合并成一个大整数token,有的坚持按字节拆成“1”“2”“3”“4”“5”“6”。这个选择直接影响了模型的数值比较能力。我实测下来的感受是:如果“1000”和“999”在词表里各自是整体token,模型判断“1000比999大”会容易得多;如果所有数字都被拆成单个字符,数值比较几乎等于让模型自己心算位权,表现肉眼可见地变差。

所以当你准备训练自己的分词器时,要特别关注数字的处理策略。对后续要做数学、代码、金融类任务的模型来说,“数字是否合并成整块”往往比词表大一点小一点更影响最终效果。

4. 动手实践:从词表加载到批量编码的全流程

原理讲了半天,还是要落到代码上。这一节我用统一的伪代码形式描述流程,具体API以你当前使用框架的为准,核心步骤是通用的。

4.1 编码/解码往返测试:第一关过不了直接回炉

我在任何项目里都强制要求先做这一件事:

# 加载tokenizer,不同框架的入口略有差异,这里用统一形态 tok = load_tokenizer("my_llm/tokenizer.json") # 测试文本,故意混合中文、英文、数字和标点 text = "深度学习模型分词技术原理与实践 2024年5月 test123" # 编码 ids = tok.encode(text) print("token数量:", len(ids)) print("前10个token id:", ids[:10]) # 解码并做往返校验 restored = tok.decode(ids) print("还原文本:", restored) assert restored == text, "往返不一致,分词器存在问题!"

这个测试的意义在于:分词器本质是一个“可逆压缩”工具,压缩之后必须能无损恢复。执行这段脚本时,我很在意几个细节:

  • token数量是否和预期相符。比如同样一段中文,不同分词器可能产生一倍以上的差距,如果某次改动后token数异常升高,通常意味着词表里少了关键合并项。
  • restored == text是否成立。如果不等,优先检查特殊token是否在普通文本里被错误触发。
  • 注意英文大小写在还原时不能丢。字节级方案理应保真,但不少实现里“uncased”选项会吞掉大写信息,训练时需要明确关掉这种行为。

4.2 特殊token的正确打开方式

特殊token是分词器里最容易出事故的部分。我习惯把它们分成三类:

  • 控制类:序列开头、结尾、分隔符;
  • 内容类:填充位、掩码位;
  • 未知类:碰到无法表示的内容时回退到的占位符。

加载和保存时,必须保证特殊token的ID在词表的末尾区域,并且它们的ID不参与普通文本映射。有一个工程上很容易踩的坑是“追加special token只改了词表文件,却没改embedding层维度”。模型加载时词表多了一个ID,embedding矩阵还是旧的,直接报维度不匹配。正确的流程是:先在词表注册新token,然后同步给embedding层增加一行参数,再做少量步数的warmup训练,让新token对应的向量不至于太生硬。

4.3 变长序列的padding与truncation细节

LLM训练时数据按batch喂入,一个batch里的句子长度天然不一致,所以必须做padding和truncation。

padding方面,主流选择是右侧补<pad>,同时用attention mask把填充位关掉。右侧padding的优势在于和自回归生成的位置顺序一致,模型注意力不会因为左侧补一堆占位符而扭曲。某些情况会用到左侧padding,但通常是为了推理时“提前计算”的工程优化,普通训练阶段我不建议。

truncation方面,很多人默认只保留前N个token,但这对很多任务有隐性风险。如果一篇长文档的关键结论在末尾,截断后模型根本看不到。更稳妥的策略是:保留文本开头和结尾,丢掉中间部分。开头通常交代主题,结尾常有总结,中间细节丢了损失可控。

还有一个细节:凡是在训练阶段用到的所有token长度限制,在后续评估和推理阶段必须保持一致。曾经有个项目,训练时max_token_length设成2048,评估时却忘调而用了默认512,结果大量输入被提前截断,指标全面下滑,排查了整整一天才找到原因。

5. 用自有语料训练自定义分词器

如果你准备继续预训练或者从零训一个小模型,自制分词器是绕不开的一步。这一节重点说训练之前、训练之中和训练之后的那些坑。

5.1 语料清洗与采样:分词器很记仇

分词器本质上是一个统计压缩器,它只会忠实地记录“语料里出现了什么”。如果语料里有大量乱码字符、重复段落和不统一的全半角符号,分词器会为这些东西分配词表位置,导致真正有价值的词反而挤不进来。

所以训练分词器之前,我会先做三件事:

  • 编码统一:全部转成UTF-8,清除非法字节序列。
  • 重复清洗:把文档级别的近似重复剔除,尤其是爬虫语料里常见的模板重复。
  • 语言均衡:统计语料里中文、英文、代码、数字各占多少比例,再按目标做加权采样。这个步骤比大多数人想的更重要——如果直接拿一堆原始语料去训,语料占比90%的英文会抢走大部分词表名额,中文术语只能以碎片形式存在,最终模型在中文任务上表现差得离谱。

5.2 词表大小与合并次数怎么定

训练自定义分词器时,我的起点经验值如下:

语料构成建议词表大小说明
仅中文,领域单一2.5万–3.2万词表紧凑,训练高效
中文+英文4万–6.4万照顾两种语言的高频子词
中文+英文+代码6.4万–10万代码token需要额外空间
多语言通用10万+跨语言语料必须给足容量

训练时还有一个参数叫min_frequency,它表示一个字符对至少出现多少次才允许被合并。默认设为2通常很安全,设高了会让很多低频但重要的领域术语失去合并机会,设太低则会让词表被噪声字符对污染。

合并次数往往不需要手动指定,更常见的做法是直接设定目标词表大小,让训练器自动决定总合并轮数。训练完成后,我会看两个统计指标:平均token长度和token序列熵。平均token长度太低说明词表“没吃饱”,碎片太多;太高则要小心词表里囤积了大量只在特定语料里出现的长词,泛化性差。

5.3 训练后的五连自查

训练完词表,先别急着进训练管线。我会花五分钟做一套自查,每一项都有明确目的。

第一,往返一致性。抽一百条来自不同领域文本的样本,全部编码解码,要求一一对应复原。

第二,领域术语抽查。把医疗、法律、代码、金融这些目标场景里的典型词汇各挑二十个,看看它们是被合并成整词,还是被拆成碎片。如果“梯度下降”四个字在词表里被拆成“梯度”+“下”+“降”,我会重新考虑词表大小和语料配比。

第三,中文覆盖率。中文的问题在字多、词更多,很容易出现常用词没进词表。常用的检查方式是拿一份中文新闻语料,统计“分词后的token总数/字符总数”,如果这个比值过高,说明碎片化严重。

第四,空白与换行。打印一段带缩进的代码和一段带空行的文章,确认空格、换行都被保留成了稳定token,而不是被丢掉。

第五,数字策略。检查“12345678”这类连续数字在编码后的形态。如果你想做数学推理相关任务,数字至少要被合并成2-3位的小块,而不是全部拆到单字节。

我把这五条写进团队的词表验收清单,后来确实挡掉过好几次“词表看着挺大但实际效果一塌糊涂”的翻车现场。

6. 真实生产环境里的高频坑与对策

最后聊几个生产环境里真正会咬人的坑,每一个都是我自己踩过或者亲眼看着别人踩过的。

6.1 不要混用不同模型的词表,ID宇宙完全不同

不同分词器对同一个词给出的ID可能是完全不同的数字,甚至A词表里没有B词表的某个token,强制映射会把整个语料切成乱码。有一个项目曾把“从模型A导出的已分词语料”和“从模型B导出的已分词语料”直接拼接在一起训练,结果词表里的ID语义完全对不上,训练损失尖锐震荡,模型基本废掉。

正确做法是:选定一个分词器之后,全流程锁定。所有语料都用同一个tokenizer重新切一遍;如果中途决定换词表,那就做好心理准备,所有缓存的分词结果全部作废。

6.2 token数不是字符数:计费、限流、延迟都按token算

这个坑几乎每个做应用的人都踩过。用户输入的“一千字”和“一千token”是两码事,中文字符转token的比例通常在1比0.6到1比1.5之间浮动,取决于分词器的碎片化程度。计费系统如果按字符数预估token数,到了月底账单会非常难看。

更实际的场景是推理延迟。生成阶段每个token都要过一次模型,token数直接决定首字延迟和总耗时。做线上服务时,我习惯在网关层单独缓存“每分钟token消耗量”,而不是用字符数或请求数做限流,否则高峰时段很容易被打爆。

6.3 不要在推理期临时新增token

有团队想给产品名词、专属术语直接加进词表,以为这样能让模型“认识”新词。乍一看很有道理,但这个改动如果只发生在推理阶段,embedding层根本没有对应向量,模型等于读到一个空白ID,表现只会更差。

正确做法是:如果领域词确实高频,应该把它们加入词表后,用少量领域语料做继续预训练,让embedding层真正学到这些词的向量。如果只是少数几个专名,完全不需要动词表,模型可以通过上下文学习自动掌握。

特殊token是例外。新增特殊token只需要保证ID不冲突,并且做一点点embedding初始化就好,因为它不承载语义,只起控制作用。

6.4 检索、拼接与“首token”的隐性偏差

做检索增强(RAG)时,文档切片通常有人按字符数硬切。如果一刀切在词的中间,这个chunk的起止token就是破碎的,虽然检索打分未必受大影响,但带着这种碎片去生成,答案完整性会打折扣。更好的方案是:按token数作为切分单位,并且尽量在自然段落边界停刀,让每个chunk的首尾都落在完整token上。

另外要注意句首句尾的位置敏感性。很多模型在长文本处理上对“开头信息”的记忆明显强于中间,所以truncation策略里保留开头几乎永远是优先项。拼接多个文档片段时,也要考虑不同片段衔接处的token可能被错误合并成一个跨片段组合,必要时可以在片段之间插入分隔token来隔离上下文。

现在我已经养成两个“笨”习惯:任何一条新数据源上线前,先跑一次token分布统计;任何一套新tokenizer换进模型前,先手工打印二十条解码结果,一条一条看。这两个习惯帮我挡掉了至少两次事故,一次是词表文件被意外覆盖后模型输出全乱码,另一次是换词表后训练loss怎么都降不下去。分词这块内容看起来不起眼,但它就像地基里的钢筋,看不见的地方出错,整栋楼都会跟着歪。希望这篇从原理到实战的拆解,能帮你在做LLM开发时少走一段弯路。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进