我最早给中文模型训 Tokenizer 的时候,干过一件蠢事:直接把英文 BPE 的脚本搬到中文语料上跑。等词表出来,我翻了几页,一脸疑惑——里面有"人工智能"这种正常词,也有"工智"这种怎么看怎么别扭的碎片。当时我的第一反应是:这破 Tokenizer 是不是坏了?

后来我才想通,它压根不在乎词面好不好看,只在乎一件事:频率。这件事几乎是中文 Tokenizer 所有矛盾的源头。
今天这篇文章,我想把自己踩过的坑全部摊开:用字、用词、用子词,三种路线到底各自适合什么场景。你会发现,看似最简单的选择,往往藏着最深的坑。
为什么"字"看起来最省事,却让模型很累
字级 Tokenizer 的思路简单到不像一个技术方案:每个汉字就是一个 token。"我爱北京天安门" 会被切成 [我, 爱, 北, 京, 天, 安, 门]。
好处非常明显:汉字常用字三千五,词表压到 5k 以内也够用,因此不需要分词,不会出现"未登录词"(OOV),整个预处理流程干净得可怕。BERT 的中文版本最早就是这么干的,而且效果久经考验。
但代价藏在序列长度里。同样是"我爱北京天安门",字级切出 7 个 token,词级可能只要 4 个。语料越长,这个差距越大。Transformer 的注意力复杂度又是 O(n²),序列长度直接决定了内存和计算开销。对于 GPT 这类解码器模型,输入一半被浪费在切碎的字上,模型还得自己想办法从"神""经""网""络"里领悟出"神经网络"的含义。
字级的本质是把分词这件事完全抛给模型。模型确实能学会,但代价是上下文窗口被大量无关细节占满。
词级:像人一样分词,但人也会犯错
既然字太碎,那就先用分词工具切好词,再拿词当 token。比如用 jieba 把"我爱北京天安门"分成 [我, 爱, 北京, 天安门]。这样每个 token 语义完整,序列也短,听起来完美。
问题出在三个地方。
- 词表爆炸:中文常用词汇量以十万计,加上专业术语和新词,词表做到 50 万都不够。Embedding 层会被撑得巨大无比,训练和推理都吃亏。
- OOV 无解:只要出现词表外的词,就会被扔进 UNK。互联网语境里新词层出不穷,"YYDS""绝绝子"这种词刚出现时,词表永远追不上。
- 分词错误会传染:分词器不是完美的。比如经典歧义句"南京市长江大桥",可能被切成"南京/市长/江大桥"。这种错误一旦发生,模型看到的就是另一个世界。
更隐蔽的是,分词标准本身就不统一。同一个"研究生命科学",有的词表切成"研究/生命/科学",有的切成"研究生/命/科学"。你用哪种标准训练,模型就继承哪种标准的偏见,而且此后再也无法纠正。
子词:让统计替你决定切在哪
子词 Tokenizer 走的是第三条路:不预设什么是"词",完全靠统计频率找出最合适的切分点。核心算法就是 BPE(Byte Pair Encoding)。
它怎么做?初始把每个汉字当成一个 token,然后反复扫描语料,找到相邻 token 里出现频率最高的那对,把它们合并成一个新 token。比如 "人" 和 "工" 老在一起,就合并成"人工";"人工" 和 "智" 如果有缘也会合并,最后可能生成一个"人工智能"的完整 token。但有时候它也会合出一些人类看不顺眼的组合,因为算法只看频率,不看语义。
BPE 是贪心的,它永远选择当下最高频的 pair,不保证全局最优。后来的 SentencePiece 实现了 Unigram 语言模型,用 EM 算法学习一套切分概率,在推理时用维特比解码找到整句最优的 token 序列。对于中文这种没有空格的语言,Unigram 通常比 BPE 更稳定。Google 开源的 SentencePiece 把空格也当普通字符,因此可以直接在原始中文文本上训练,不需要预先分词。
子词路线一举刷新了训练流程:你不再需要一个看起来像"分词器"的组件,而是得到一个完全由语料统计学出来的切分器。它的词表大小、序列长度、OOV 表现都居中,是目前大模型最常用的方案。
一张表,三种路线的真实差距
| 维度 | 字级 | 词级 | 子词级 |
|---|---|---|---|
| 词表大小 | ~5k | 5万-50万 | 3万-20万 |
| OOV 风险 | 基本没有 | 严重 | 很低 |
| 平均序列长度 | 长 | 短 | 中等 |
| 预分词依赖 | 无 | 强 | 无(或仅标点切分) |
| 语义完整性 | 弱 | 强 | 中等 |
| 典型代表 | 中文 BERT | 早期统计模型 | GPT、LLaMA 等 |
注意,这里的优势劣势是相对于通用预训练模型说的。如果你做的是垂直领域精调,也许词级反而更好,因为领域词汇边界非常明确。但对一个需要泛化到全网文本的大模型来说,子词的统计确定性是最大的安全感。
实操:用 SentencePiece 训一个中文子词 Tokenizer
我自己现在的工作流是:准备好清洗后的原始文本,直接用 SentencePiece 训练 Unigram。
spm_train --input=corpus.txt --model_prefix=chinese_spm --vocab_size=32000 --character_coverage=0.999 --model_type=unigram
--vocab_size:控制词表大小。32000 是很多开源模型的起步选择,越大词表切得越完整,Embedding 也越大。--character_coverage:保留多少比例的字符。0.999 意味着放弃约 0.1% 的极生僻字,它们会被归到 <unk>。--model_type=unigram:用 Unigram 而不是 BPE。对中文我更推荐前者。
训练完之后,用 Python 加载并验证效果:
import sentencepiece as spm; sp = spm.SentencePieceProcessor(model_file='chinese_spm.model'); print(sp.encode('我爱北京天安门', out_type=int))
这一步我一定会做,而且会拿一段 10 万字的语料跑一遍统计,看看平均一个 token 覆盖多少个汉字。如果平均一个 token 连一个汉字都覆盖不住,说明词表太小;如果几乎所有常用词都整个被收进词表,说明词表可能偏大。具体数字不需要强求,但这个统计能帮你快速找到瓶颈。
另一个容易踩的坑:不要在训练 Tokenizer 前用 jieba 预分词。你要是先分词再训练子词,分词错误就变成了不可逆的 bias。最好直接喂入原始文本,让字符和标点都有机会进入统计。
说说我的几条判断
- 通用大模型直接选子词,尤其是 Unigram。BPE 的贪心策略在中文这种非拼接语言上容易产生局部次优碎片;Unigram 的全局解码更接近我们直觉里的切分词。
- 词表大小别拍脑袋。最稳妥的做法是训练 30k、50k、80k 三个版本,分别跑一遍下游任务的 token 数或困惑度,再综合模型参数量决定。大词表不一定好,因为 Embedding 参数量会同步膨胀。
- 中文 Tokenizer 的问题本质上是中文"词"的边界问题。字和词都只是人类强加的边界。子词用统计重建了一种边界,它和人类语言学的"词"并不一致,但这不一定坏——模型要的是可预测的切分,不是语言学正确。
- 如果哪天 Byte-level Tokenizer 的序列长度问题被解决,它可能会取代子词。字节级完全不需要任何语言先验,可以处理所有语言和一切字符。但在当前 Transformer 的 O(n²) 注意力复杂度下,字节级序列长度对中文实在太不友好。
回到开头那个让我困惑不已的词表碎片:现在我会告诉你,那不是 bug,是统计的必然。Token 是否"合理"不重要,重要的是它在计算图上是否高效、在数据上是否无损。中文 Tokenizer 的每一次选择,本质上都是在算力、泛化与语言直觉之间做权衡。
如果你也在训自己的中文模型,别急着抄别人的词表。自己跑一遍,看看切分结果,听听统计告诉你的中文长什么样。
原创文章,作者:guanweilu,如若转载,请注明出处:https://guanweilu.cn/article/718.html