首页 / 资讯中心 / 文章详情

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

LLM分词技术详解:从BPE到字节级,避开生产环境的坑 ★ FEATURED ARTICLE
在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把频次最高的邻居死死绑在一起BPEByte 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层维度”。模型加载时词表多了一个IDembedding矩阵还是旧的直接报维度不匹配。正确的流程是先在词表注册新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开发时少走一段弯路。
阅读完成 · 觉得有帮助?
咨询建站