资讯详情

LLM分词器原理与实战:BPE、WordPiece、SentencePiece及词表设计

📅 2026/10/24 3:02:09 | 华诺云谱 👁 阅读
LLM分词器原理与实战:BPE、WordPiece、SentencePiece及词表设计
做LLM开发这一年多我反复被问到一个问题模型明明吃的是文本为什么代码里全是数字答案就在分词器。分词器负责把原始文本切成一个个token再映射成整数id是整个LLM训练和推理链条的第一环。它看起来只是个预处理工具实际却决定了词表规模、训练效率、推理成本甚至模型的多语言能力。这次教程就把分词技术的原理和实践一起讲透适合正在跑模型训练脚本、但还没仔细拆过分词器的开发者。我将从三件事入手分词为什么会存在、主流算法的核心逻辑是什么、以及你在工程里到底该怎么选和怎么调。聊到后面会给出可复现的训练代码、参数选择和若干坑位提醒。1. 为什么LLM离不开分词器1.1 从字符到向量的第一道关卡神经网络处理不了原始字符串它只能处理浮点数。所以常规做法是把文本映射成固定维度的稠密向量。如果直接按字符做映射英文还好遇到中文这种表意文字一个字一个向量信息密度过高且组合空间巨大如果按整句映射又完全无法泛化到没见过的句子。分词器走的是中间路线把文本切分成语义上相对完整的子串比如英文单词、中文词组、常见的构词片段。每个子串对应一个唯一的整数id模型只负责在id序列上做计算。分词器输出的每个token最后都会去Embedding表里查向量所以词表大小和token切分质量直接决定模型参数里有多少空间在存“词义”。1.2 语言建模目标与词表的关系LLM的训练目标是下一个token预测。这个“token”不是天然存在的单位而是分词器定义出来的人为单位。你会发现同样的句子换一个词表token数量可能差20%。更短的token序列意味着计算量更小、上下文窗口能装下更多原文但分词粒度太粗又会牺牲对新词的表达能力。这里有个核心矛盾词表越大每个token的表达越“完整”但Embedding矩阵和输出头的参数量也会暴涨。一个50k词表的模型光词嵌入和输出层就能占好几亿参数。所以分词器设计是一种平衡在尽量短的序列长度里用尽量小的词表覆盖尽量多的语言现象。理解了这层平衡后面所有算法看起来都顺理成章。1.3 分词不是“切词”那么幼稚很多人以为分词就是按空格和标点把句子切开那是Tokenizer的简化版。现代BPE和Unigram算法会把一个单词拆成多个子词比如“unhappiness”可能被拆成“un”、“happi”、“ness”。这么做的目的是让模型学会复用构词规律遇到“unhappinessness”这种生造词也能拼凑出大致语义。所以分词器实际上在构建一个“子词库”。它既要覆盖高频词也要照顾低频但常见的词根词缀。这种子词建模能力是搜索引擎和传统NLP工具里的分词器做不到的也是LLM通用性的基础。2. 主流分词算法的核心原理对比2.1 BPE字节对编码最经典的合并法BPE的思路特别直白从字符级别开始反复统计相邻token对的出现频率把频率最高的对合并成一个新的token直到词表达到目标大小。假设语料里“re”和“ad”经常一起出现第一步就可能把“read”合并成“read”整体。经过多轮合并高频词汇先成为一个token中频词变成词根加词缀的组合低频生词则退化成单字符甚至单字节。这保证词表里永远没有真正的“死token”。实操上现在主流方案是Byte-level BPE也就是在字节层面做合并。这么做的好处是能处理任意Unicode字符而不出现未登录字问题。工程中常配合正则预分词规则比如把数字和字母分组、把空格转义避免把“hello world”错误合并成跨越空格的token。2.2 WordPiece从频率到似然增益WordPiece和BPE长得像但合并标准完全不同。它选的不是出现次数最高的对而是能让整个语料的似然函数提升最大的对。白话讲合并这对token之后模型对语料的预测概率提高得最多那就合并这对。这样得到的分词结果通常更符合语言学直觉。BERT家族基本都走WordPiece路线。不过它有一个明显特点对未知词处理稍弱需要一个专门的[UNK] token兜底遇到没见过的字就只能退化成未知。这也是为什么我在做多语言模型时很少直接用原生WordPiece而倾向于用带byte fallback的方案。2.3 SentencePiece预分词不重要了SentencePiece和上面两种不是同一维度的东西。它不按空格做预分词而是把所有文本当成一个连续字符流空格本身也保留为特殊符号“▁”。这个设计对日语、中文这种没有明显空格边界的语言特别友好。实际使用中SentencePiece经常搭配BPE或Unigram模型一起用。翻译成大白话就是你可以用SentencePiece先做字符流切分再在切分结果里套BPE或Unigram的合并/剪枝逻辑。它自己内置了Unigram语言模型算法所以很多多语言模型直接用它端到端搞定分词训练。2.4 三种算法上手建议从工程角度我建议按需求选英文和代码为主的任务直接上Byte-level BPE省心未知词处理能力强。需要和BERT类模型对齐用WordPiece并保留[UNK]等特殊token。多语言、中日韩混合、新词不断出现的场景优先SentencePiece配Unigram配合byte_fallback。极端追求推理速度可以考虑统一词表加少量字节回退避免长序列退化。下面这张表可以帮你在选型时快速落点算法合并/裁剪依据预分词依赖未登录词能力典型使用场景Byte-level BPE相邻对频率最高有但可放宽极强字节级兜底GPT系列、代码模型WordPiece似然增益最高依赖空格/标点弱依赖[UNK]早期BERT类模型SentencePieceBPE/Unigram频率或语言模型概率不依赖空格强可用byte_fallback多语言模型、T5、LLaMA类3. 分词器训练实操从语料到tokenizer.json3.1 直接可用的BPE训练代码我这里直接给一套能跑的代码基于HuggingFace的tokenizers库。先准备一个文本文件列表然后执行训练脚本pip install tokenizers写一个train_tokenizer.pyfrom tokenizers import Tokenizer, models, pre_tokenizers, trainers # 1. 初始化BPE模型 tokenizer Tokenizer(models.BPE()) # 2. 设置预分词规则按空格和标点分隔并保留空格前缀 tokenizer.pre_tokenizer pre_tokenizers.ByteLevel(add_prefix_spaceFalse) # 3. 创建训练器指定词表大小和特殊token trainer trainers.BpeTrainer( vocab_size32000, min_frequency2, special_tokens[|endoftext|], show_progressTrue, ) # 4. 训练 files [data/corpus_a.txt, data/corpus_b.txt] tokenizer.train(files, trainer) # 5. 保存 tokenizer.save(my_tokenizer.json)这里有几个关键参数值得展开。min_frequency2的意思是语料里只出现一次的token对不参与合并。这个设置能过滤大量噪声但也会让超低频词退化成单字符对专业领域术语不太友好。我在医疗和法律语料里会把它降到1确保罕见词也能形成独立token。vocab_size也不是越大越好。32k对大部分中英文混合场景够用但如果要覆盖代码、数学公式、多国语言建议直接上50k到100k。大词表会拖慢Embedding层和LM Head的显存占用所以没刚需别贪大。3.2 WordPiece和SentencePiece怎么切换换WordPiece只需要改模型初始化和预分词部分from tokenizers import Tokenizer, models, pre_tokenizers, trainers tokenizer Tokenizer(models.WordPiece(unk_token[UNK])) tokenizer.pre_tokenizer pre_tokenizers.Whitespace() trainer trainers.WordPieceTrainer( vocab_size30000, min_frequency2, special_tokens[[UNK], [CLS], [SEP], [PAD], [MASK]], )SentencePiece的风格差异比较大它连文件格式都不一样通常生成.model和.vocab两个文件。如果你只是想快速用SentencePiece建议走sentencepiece库而不是直接调tokenizers库因为这个库封装得更全spm_train --inputcorpus.txt --model_prefixmy_sp --vocab_size32000 --character_coverage0.9995 --model_typebpe跑完之后会生成my_sp.model和my_sp.vocab。转成本项目能直接用的格式也很简单import sentencepiece as spm sp spm.SentencePieceProcessor(model_filemy_sp.model) text 深度学习模型训练非常依赖分词 ids sp.encode(text, out_typeint) print(ids)character_coverage参数也很重要。它控制字符覆盖率0.9995代表允许语料里0.05%的极端生僻字符掉到byte fallback。如果设成1.0词表里就会混进大量只出现一次的字符压缩率会很差。多语言场景建议保持0.9995到0.9999之间。3.3 训练好的分词器怎么校验训练完不要直接拿去用至少做三件事第一看词表质量。随机抽500个token看看里面有没有大量非语义字符如果发现一大片孤立的单字节说明词表太小或min_frequency卡太高。用Python抽样打印一下from tokenizers import Tokenizer tokenizer Tokenizer.from_file(my_tokenizer.json) vocab tokenizer.get_vocab() tokens list(vocab.keys()) for t in tokens[1000:1030]: print(repr(t))第二跑一轮真实编码对比。把训练集外的验证集文本拿过来统计平均token长度和压缩率。中文一句20个字的句子如果被切成60个token说明这个分词的合并能力很弱看看是不是字节级模式没有正确开启。第三测特殊token行为。把[UNK]、[CLS]、[PAD]等token的id打印出来确认没有和正常词冲突。尤其是padding token如果和普通token共用了同一个id训练时会出现注意力掩码错误虽然不报错但效果会莫名变差。3.4 继续训练增量更新词表有时语料是持续增长的比如我维护的对话系统每个月都有新流行语和新产品名。如果每次重新训练整个词表模型Embedding还得跟着改代价很大。更稳的做法是定期拿增量语料微调现有词表只新增出现频率足够高的新token。tokenizers库支持从json加载已有模型并继续训练但要注意保留原token的id顺序避免已经分配好的id错位。实操中我会写一个脚本把旧词表中出现过的token单独过滤出来混合增量语料重新跑一次BPE训练再把旧id映射表平移过去。这个方案实测下来能让词表保持新鲜同时不会破坏下游模型已训练好的embedding分布。4. 词表设计的前置决策4.1 词表大小32k、50k还是100k这不是随手填的数字。词表大小直接决定模型的Embedding参数规模。以一个3072维的hidden size为例词表从32k涨到100k嵌入层参数会增加约2亿。这还没算输出层LM Head的反向投影训练时的显存压力会成倍上升。我的经验值是这样通用中英混合场景32k到50k足够代码和自然语言混合建议50k如果要把多语言、代码、数学符号全包进同一个模型100k起步。同时要注意词表太大但语料不够丰富时大部分token在训练中只会被少量样本更新很容易学出劣质embedding。所以语料规模乘以平均token数至少要达到词表大小的几十万倍以上不然就缩减词表。4.2 规范化NFC、小写和去重音很多教程不提规范化但我在项目里吃过亏。原始语料里可能有全角半角混用、大小写不一致、带重音的法语字符。如果不做规范化同一个词会被切成不同token白白浪费词表容量。推荐在预分词前做三步Unicode标准化到NFC、大小写折叠如果任务不强制区分、把常见变体字符映射到基础形式。注意大小写折叠不总是安全的比如代码里的变量名区分大小写。处理代码语料时我会把unicode normalization和lowercase开关分开控制只做NFC和去重音保留大小写。4.3 特殊token与控制符号词表里除了正常文本token还要预留特殊token。常见的有[UNK]、[PAD]、[BOS]、[EOS]或|endoftext|以及对话任务里的角色标记和函数调用标记。这里有个工程建议特殊token尽量和正常文本token保持格式上的可区分性。比如统一使用带尖括号的|user|、|assistant|这样在构造训练样本时不容易和正文文本混淆也方便后处理阶段过滤。我见过有项目把特殊token写成普通英文词结果语料里出现同名字符串时被错误替换排查了半天。4.4 token id排序策略词表里token怎么排序也有讲究。最常用的方案是按出现频率排序让高频token获得较小id。这个排序本身不会直接影响模型能力但会影响训练时的随机采样分布以及和某些并行策略的兼容性。如果是从头训练一个模型建议让Embedding矩阵初始化时使用正态分布并把词表里的低频token适当放大初始化方差这样能减少低频token训不动的问题。我在做大词表模型时还会额外给词表尾部token加一个小的偏置防止输出层在低频token上过度置信。5. 分词在训练和推理中的隐性影响5.1 token数量决定计算成本同一个句子的token数量在不同分词器下会有明显差异。英文“I love this product”在Byte-level BPE下通常是5个token左右中文“我非常喜欢这个产品”在常见中文词表下约7到9个token。如果我们把中英文混合输入喂给同一模型中文部分的token密度更高seq长度更长attention的计算量也随之上升。要精确估算训练成本可以把采样数据通过分词器统计平均token长度。比如你有1000万字预料平均每字0.6个token那么总数就是600万token左右再乘上epoch数就能算出训练数据总量。很多任务报的“训练了xxB token”其实就是这么统计出来的。5.2 上下文长度的幻觉上下文窗口是按token算的不是按字数。一个4k上下文的模型如果分词偏细可能只能装下3000个汉字。我在评测模型长文本能力时经常遇到用户抱怨“只能读半页文档”其实不是模型短而是分词粒度太粗。这带来一个优化方向通过调整词表合并策略让常用词组能变成单个token从而降低长文本的token占用。比如把“人工智能”“深度学习”这类高频词组直接合成词表中一个token长文摘要和问答场景里就能省出不少上下文空间。Transformer对token顺序敏感但语义块的整体性确实可以缓解长依赖问题。5.3 数字和代码的特殊处理数字是分词器最头疼的对象。如果按字符合并数字12345可能变成一个token这样模型就难以泛化到“12346”。如果按单字符切又会浪费大量token。常见的做法是把数字切成单个数字字符或两个数字组成的小块让模型学会通过位置和组合来理解数值。代码场景更麻烦。缩进、驼峰命名、下划线、各种符号混在一起预分词器的正则规则必须仔细设计。我常用的是ByteLevel加自定义RegexSplit把标识符、数字、运算符分别切开既保留句法结构又控制token长度。这类自定义规则最好先在小语料上跑一遍观察各类代码文件的分词稳定性再正式训练。5.4 多语言与跨脚本问题不同语言的字符分布差异巨大。英语、法语这种拉丁语系字符种类少BPE合并效率高。中文、日文这种表意文字单字信息量高子词合并的空间有限。阿拉伯语和印地语又有复杂的连字和变音符号。统一处理这些问题最实用的方案就是bytes fallback。先把所有Unicode字符映射到UTF-8字节流遇到词表里没有的字符时自动拆成多个字节token。这样无论输入什么语言都不会出现[UNK]。代价是长文本里可能出现大量单字符token推理速度会被拖慢所以实际使用中还是要配合字符覆盖率和常用字符的显式收编。6. 实战中的典型问题与排查思路6.1 词表里全是单字token这是最常见的翻车现场。跑完分词训练看一眼词表发现大量孤立字符而不是有意义的子词。原因通常是语料太杂、数量太少或者BPE的合并阈值设置过高导致没有成功的候选对可以合并。解决办法是先清理语料。把重复度高的文本去掉用perplexity过滤一遍内容质量再增大语料规模到至少几百万行。同时把min_frequency调到1或2让低频但有规律的合并也有机会出现。如果还是不行大概率是词表目标大小离语料词汇总量太远把vocab_size提到一个更高的值或者改用Unigram模型。6.2 中文被切得稀碎中文分词效果差很多时候不是算法问题而是语料切分习惯没处理好。SentencePiece默认会把“▁”放在词首连续中文文本如果没有空格预分词器一系列操作后单字比例会很高。我的做法是在中文语料里预先做一次轻量分词或词组切分把语料里高频共现的汉字黏在一起再送入BPE训练。这样模型学到的不是单字流而是带有语义边界的子词流。另一个技巧是把英文预分词正则里的空格规则对中文关闭让中文字符按连续串处理再从频繁共现序列里合并。6.3 生僻字和新词导致[UNK]泛滥大量[UNK]出现通常是词表覆盖不足或者特殊token定义不完整。检查一下分词器的unk_token设定。在BPE和SentencePiece场景下优先开启byte fallback这样未登录字会退化成字节token而不是[UNK]。如果希望保留[UNK]来标记真正的噪声比如乱码、不可见字符那就要在数据清洗阶段把正常生僻字和乱码区分开。我的策略是对可解码的合法Unicode字符一律走byte fallback对无法映射的非法字节统一替换成[UNK]。这样既保住了多语言能力又能识别异常输入。6.4 分词不稳定导致同一词在不同位置不同切法同一句话在不同上下文里被切出不同token这是子词模型的通病。比如“apples”在“apple”前面可能被切成“apples”整体在“apple pie”后面又可能被切成“apple”加“s”。这种现象会让模型更难学到稳定的词义映射。缓解办法有两个方向。第一个是加大词表让更多完整词落成单token第二个是引入额外的词边界约束让合并操作尽量不跨越预分词边界。后者在tokenizers库里可以通过给预分词结果加一个word边界标识来实现具体做法是使用PreTokenizer返回带偏移量的split结果让BPE训练只在同一个word内合并。6.5 padding和mask的坑训练时的collator会把一个batch的序列都padding到相同长度但padding使用的token id如果和某个正常词共用就会出现模型对padding位置产生错误预测。这个问题通常不直接报错但训练loss会居高不下。我每次训练前都会强制检查padding token id是否等于训练语料中某个高频token的id。对于从零训练的分词器建议在special_tokens列表的靠前位置定义[PAD]让它的id落在低位段。在使用HuggingFace trainer时还要额外设置一下padding_side和label的ignore_index确保padding位置的label不会参与loss计算。6.6 分词器版本回溯难分词器是模型的一部分训练时用什么分词器推理时也必须用同一个。哪怕只是微调了合并规则整个token序列都会变所有下游任务都得重新验证。我之前踩过一次坑优化了词表后只更新了训练脚本忘了同步推送词表文件结果模型和推理服务端的token id对应不上生成结果完全乱码。这里分享一个强制规范词表文件必须和模型权重文件放在同一个版本目录里模型发布时带上分词器配置和可复现的版本哈希。一旦要更新分词器只能先导出新的词表再重新做一次模型评测不能直接替换生产环境。7. 最后一个实践心得与扩展思路我在实际项目里最深刻的体会是tokenizer不是一个可以随便换掉的预处理模块它是模型能力边界的一部分。很多长文本任务效果差、推理慢、多语言覆盖弱根子其实都在分词策略上。与其后期调模型架构不如在最开始花一整周去打磨语料、调词表、跑评测。如果想再往前一步可以试试动态词表。也就是在训练过程中根据当前已训练好的embedding周期性地淘汰低频token、加入新token让词表持续演化。这个方法在超大规模语料训练时能明显提升压缩率但实现复杂度高对训练框架的扩展性要求很高适合团队精力充足时再碰。还有一个非常实用的小技巧在训练前把整个词表按语义相关性做一次聚类让语义相近的token在词表id空间上尽量靠近。这个做法和Embedding初始化配合起来能显著加快模型收敛速度同时在维基百科类任务的评测集上能看到明显的困惑度下降。代码里只需要在初始化阶段对embedding矩阵做一次PCA或聚类排序成本很低收益却稳。搞LLM开发分词器值得认真对待。它不是论文里的背景板而是你训练效率和模型质量的第一杠杆。希望这篇教程能把原理和实践连成一条线帮你少踩几个坑。
📝

华诺云谱内容团队

资深建站顾问 · 行业研究员

10年+企业数字化服务经验,专注智能建站、SEO优化与品牌营销,持续输出建站技巧、行业洞察与营销干货,已帮助5000+企业实现数字化增长。

你可能需要的服务

订阅华诺云谱资讯周报

每周一封,精选建站技巧、SEO与营销干货,直达邮箱。已有 8,000+ 企业主订阅,助你少走弯路。

↑