我第一次用SentencePiece训练出一个分词器,拿它编码一句中文,结果输出是:['▁我爱', '▁北京']。盯着那个奇怪的▁看了半天——这到底是空格还是什么特殊字符?后来我才明白,正是这个小小的▁,让SentencePiece能和任意语言打交道,也让它成了Google系列模型(T5、Gemma、PaLM)的标配。

大模型的第一步,卡在“拆词”上
在把文本喂给Transformer之前,你必须先决定一个“词”的边界在哪里。英文天然有空格,中文却没有。老一辈分词器(比如BPE的原始实现)要求你先用空格预分词,然后再做词表合并。这就形成了一个死循环:对中文分词需要分词器,但分词器的训练又需要你已经分好词的语料。
当然你可以用字级模型,但单个汉字的信息密度低,模型需要更长的序列才能学到同样的语义。理想情况是让模型自己发现哪些字符序列应该作为一个词——这正是SentencePiece的初衷。
SentencePiece是Google和日本东京大学合作开源的一个工具库,它实现了无监督的subword分词,核心思路是:不依赖任何语言上的先验知识,直接从原始文本中学习tokenization规则。
一个反直觉的发明:把空格当普通字符
SentencePiece把空格替换成“▁”(Unicode字符U+2581),然后把替换后的整句话喂给子词算法。这样空格就不再是分隔符,而是一个可以出现在任何位置的普通字符。
英文:Hello world -> ▁Hello ▁world
中文:我爱北京 -> ▁我爱 ▁北京
注意,中文的“我爱北京”没有空格,但SentencePiece在句首加了一个▁。训练完模型后,词表里既可能有“我爱”,也可能有“▁北京”。那为什么要把空格特殊标记?为了可逆性——当你把token序列转换回文本时,得知道哪里该有空格。
传统BPE在分词前已经丢了空格,所以无法重建原始文本。SentencePiece的▁机制保证了分词后的token能无损还原,这对翻译、生成这类需要输出原始文本的任务非常关键。
与BERT时代的WordPiece相比,这种设计更是降维打击:
| 对比项 | WordPiece(BERT) | SentencePiece |
|---|---|---|
| 是否需要预分词 | 是,先按空格拆 | 否,直接吃原始文本 |
| 空格处理 | 预分词时丢失 | 变成▁字符保留 |
| 中文支持 | 需要额外分词器 | 开箱即用 |
| 可逆性 | 差 | 强 |
BPE和Unigram,两条路径走到同一个目标
SentencePiece最常用的两种算法是BPE和Unigram。它们的思路截然不同。
先看BPE。它的训练过程像是个贪心合并游戏:
- 把每一个Unicode字符当作初始token(此时空格已变成▁)。
- 统计语料中所有相邻token对的出现频率。
- 把频率最高的那一对合并成一个新token,加入词表。
- 重复第2~3步,直到词表大小达到你设定的目标值。
合并的次数越多,单个token越长,词表越大。这个过程简单、可解释,但它很容易被高频字符对带偏,比如英语中的“th”、“ing”会早早合并,导致一些低频语言的特征不够突出。
Unigram走的是完全相反的路线:它不是从原子字符开始合并,而是从一个很大的候选词表开始,然后根据一个语言模型的目标不断删掉那些“不重要”的token。具体过程依赖EM算法,最终得到一个词表,使得整个语料在这个词表下的分词结果概率最大。
从效果看,Unigram可以生成多个候选切分,并在解码时用维特比算法找到最优路径。这让它在处理未登录词和跨语言数据时更加灵活。
| 对比维度 | BPE | Unigram |
|---|---|---|
| 训练方向 | 自底向上(合并) | 自顶向下(删除) |
| 贪心程度 | 完全贪心 | 基于全局概率优化 |
| 能否采样 | 否 | 可以 |
| 典型使用者 | GPT-2、LLaMA | T5、Gemma |
你可能会问,GPT-2和LLaMA也是BPE,为什么不用SentencePiece?它们用的是byte-level BPE,把输入从Unicode字符换成字节。这样做的好处是任何字符都不会变成UNK,但代价是词表里可能混入不完整的UTF-8片段。而T5和Gemma选择的是SentencePiece的Unigram,它们更看中基于字符+▁的“语言无关”的干净。
三行命令,训练一个多语言分词器
SentencePiece的使用简单得不像一个学术工具。你只要准备一个文本文件,然后运行:
spm_train
--input=all_languages.txt
--model_prefix=mymodel
--vocab_size=32000
--model_type=unigram
训练完成后,你会得到mymodel.model和mymodel.vocab。加载并编码文本:
import sentencepiece as spm
sp = spm.SentencePieceProcessor(model_file='mymodel.model')
sp.encode('Hello world', out_type='str')
# ['▁Hello', '▁world']
有意思的是,即使输入的是带空格的英文,空格也被转成了▁。很多初学的人会以为这是bug,其实是特性。
我最早以为SentencePiece需要像旧工具那样传入已经预处理好的文本,结果发现它直接把我的乱糟糟的数据扔进去也能训练。后来才意识到,它对输入格式的容忍度正是它作为通用工具的底气。
从T5到Gemma,Google到底看上了它什么?
Google几乎所有主流大模型都押在SentencePiece上。T5论文明确提到,他们在C4语料上训练了一个32k词表的Unigram分词器。Gemma模型卡也写着“使用SentencePiece”,而且词表大小达到了256k——这么大规模的词表,还是SentencePiece在处理。
统一选择SentencePiece有两个核心原因:
- 语言无关:不需要为中文、日文、阿拉伯文各自准备一套分词脚本,一个工具就能覆盖全世界上百种语言。
- 可逆性:在需要将模型输出映射回原始文本时(比如翻译或生成),无歧义的重建是基本要求。
反观Mistral和Llama 2,它们用的是byte-level BPE,在多语言上靠字节级曲线救国。SentencePiece则是直接从文本层面学习,对中文这种“无空格语言”更自然。
它最大的坑,是“Character”不等于“Byte”
SentencePiece并不是没有弱点。它的一个根本限制是:处理的是Unicode字符序列,而不是字节序列。
假如你的语料里有Unicode没有分配的私有区字符,SentencePiece会直接把它标记为UNK。而byte-level BPE永远不会UNK——因为字节有且只有256种。这也是为什么很多追求零损失的模型会放弃SentencePiece。
另一个坑是归一化。SentencePiece支持NFKC、NFC等选项。如果你训练时用了NFKC,但推理时忘了,同一个词会被拆成完全不同的token。我自己就踩过这个坑:线上模型突然大量出现UNK,排查了半天,最后发现是训练时开了NFC,推理时用默认值。
还有一个实际选择问题:vocab_size设多少?太小会丢失长词信息,太大会让模型学到很多无意义碎片。T5选了32k,Gemma选了256k,跨度很大。实际使用中,8k~32k是一个常用区间,具体要看语料的多样性和模型容量。
你可能会问的3个问题
Q1:SentencePiece能不能做纯字符分词?
能。训练时指定--model_type=char,它就会每个字符一个token,相当于字符级模型。
Q2:为什么我不能直接用encode()处理大批量数据?
encode()默认会做更多处理,速度较慢。如果要快速批量处理,用encode_as_pieces(),或调用内置的C++接口。
Q3:SentencePiece和HuggingFace的tokenizer能结合使用吗?
能。HuggingFace的PreTrainedTokenizerFast可以直接载入SentencePiece模型文件,让你继续使用transformers生态。
最后的判断
回到标题的问句:SentencePiece为什么能成为Google系模型的标配?因为它把“分词”这件脏活累活变得标准化、可复现。你不需要为每个语言写正则规则,也不需要担心训练和推理时在tokenization上的不一致。
但它也有清晰的边界:如果你极度关注字节级的多语言覆盖,或者语料包含大量不可预测的Unicode,SentencePiece可能不是最稳的方案。选择分词器,本质上是在“语言可移植性”和“字节完整性”之间做取舍。理解这个取舍,比背诵任何模型的tokenizer名字都重要。
原创文章,作者:guanweilu,如若转载,请注明出处:https://guanweilu.cn/article/740.html