想象你是一位裁缝师傅,老板递来一批五颜六色的布料,要求你把它裁成一套标准大小的补丁包,方便缝纫工随时取用。每条布纹理不同,有人名、地名、网络符号、标点——有的部位经常被用到(比如“老师”这个词),你就整块保留;有的布料过于生僻(比如“宇智波斑”),整块存着太占地方,你就剪成“宇智波”+“斑”这样的常用碎片。关键问题是:怎么剪才既省料、又让缝纫工拼得回去? 大模型的世界里,这台剪布机叫 Tokenizer(分词器)。它把未经加工的文本(一匹匹布)先剪成标准化的 token(词元),模型才能读取。当前最火的剪裁方案,叫 SentencePiece——LLaMA、BLOOM 等主流大模型都用它来切词。它底层是一套叫 Unigram 语言模型 的智能裁缝:不靠死记硬背的规则,而是用“概率”来衡量每块补丁的性价比,自动找出最优裁剪方案。 本文会带你从一块布出发,看懂 BPE 和 Unigram 两种剪法的本质区别、词表大小怎么定、以及为什么这套方案在中文、日语、多语言混排的“杂色布料”上格外好用。读完你就明白:大模型背后的分词器,其实是一台会算概率的裁缝铺。

岗位关联

掌握分词原理,你能独立评估大模型的词表设计优劣,在预训练数据预处理、推理服务优化时做出更聪明的工程决策——这是理解 LLaMA、BLOOM 一切行为的起点。

目录
  1. 可视化演示
  2. 分词基础
  3. BPE算法
  4. Unigram原理
  5. SentencePiece
  6. 词表策略
  7. 大模型应用
  8. AI 应用场景
  9. Python 演示
  10. 常见误区
  11. 面试题

可视化演示

展示Unigram语言模型的Viterbi子词切分过程与BPE算法的核心差异,对比自底向上合并与自顶向下删减两种词汇表构建策略。

本节配有交互动效(进入视口后自动加载,约 3KB)。

Unigram子词切分过程 输入字符经Viterbi解码,从词汇表选概率最大的子词组合 每个子词有概率分数,切分目标是最小化负对数似然 argmax P(w) = Π P(sub_i),L = -Σlog P(sub_i) Unigram通过概率选最优切分,语言无关

分词基础

裁缝铺开门第一课:布料不能整匹塞进缝纫机,得先剪成补丁。模型也一样,原始文本是一整段连续的字符流,Transformer 这类模型没法直接“读”字符串——它只能识别离散的编号。分词器要做的,就是把“自然语言处理”这匹布,剪成“自然”“语言”“处理”这样的标准补丁,每个补丁编一个号(token id),模型才看得懂。

为什么不能直接逐字裁剪?如果按单字剪,词表只需要几千个编号,但一句话会变得很长,模型要处理很长的序列,记不住远距离的依赖关系。如果按整词剪,常用词没毛病,但“元宇宙”“大模型”这类新词不断冒出来,词表装不下,遇到没见过的词只能干瞪眼。于是聪明人发明了子词(subword):介于字与词之间的粒度。把“大语言模型”切成“大语言”+“模型”,既保留了语义,又不至于让词表爆炸。

分词器还讲“语言无关”——不管中文、日文还是英文,都按同一套字符逻辑处理。中文天然紧凑,英文有空格分界,但 Unicode 字符在底层都是字节串,子词算法不关心你原来是哪种语言,只问“哪块布最常用”。这也是 SentencePiece 能统一处理多语言的根本原因。

回到裁缝铺的比喻:分词基础就是这个铺子的“补丁尺寸标准”。剪多大、分几档、如何编号,定了之后所有工序都照着执行。标准定得好,后面缝纫(训练)就顺畅;定得差,模型再强也事倍功半。

token 流=(t1,t2,…,tm),词表={v1,v2,…,vK}\text{token 流}=(t_1,t_2,\dots,t_m),\quad \text{词表}=\{v_1,v_2,\dots,v_K\}
分词后的文本是一串 token 编号,每个 token 都必须来自固定词表。词表大小 $K$ 直接决定了模型的输入维度。
  1. token 流分词器把原始文本拆成有序的子词序列,每个 $t_i$ 是词表中的一个元素。
    ti∈词表,i=1,2,…,mt_i \in \text{词表},\quad i=1,2,\dots,m
  2. 词表预训练前预先定义好的全部合法子词集合,模型只能“说”这些词。
    K=∣{v1,v2,…,vK}∣K = |\{v_1,v_2,\dots,v_K\}|
  3. 一一映射模型输入的每个 token 会被查表映射成向量——就像裁缝根据补丁编号找到对应颜色和质地的布料。
    E(ti)∈RdE(t_i) \in \mathbb{R}^{d}

BPE算法

裁缝铺有一种老派裁剪法,叫 BPE(Byte Pair Encoding,字节对编码)。它的思路很简单:从最小的布粒出发,谁经常被拼在一起,就把谁缝成一块大补丁。 比如语料里反复出现“low”和“est”相邻——2 万次——那裁缝就决定把这两个小布粒缝死成“lowest”,以后不再拆开。

BPE 的裁剪过程是纯统计驱动的。先要把文本拆成最细的字符(或字节),然后统计所有相邻字符对的共现频次。比如语料里有“低 low 低”,相邻对 “l o” 出现 10 次,“o w” 出现 8 次……每次只挑频次最高的那一对合并成一个新符号,然后重复统计、合并,直到词表大小达到预设目标。

这个“贪心”策略有三个关键设计。第一,合并规则全程按字符对频次走,不看语义,纯靠统计——所以它快,但偶尔会剪出不伦不类的补丁。第二,迭代必须设停止条件:词表达到 32000 就算完工,否则会一路合并成整句,丧失子词灵活性。第三,字节回退(byte fallback):遇到词表里根本没有的生僻字符,BPE 允许退回到 UTF-8 字节级别兜底,保证任何文本都能被切出来,这就像裁缝铺保留了一台万能锁边机。

BPE 的局限恰恰来自它的“贪”:它只看到局部共现,不看整体概率。就像裁缝只盯着哪两块布常挨着,却不管缝出来的补丁包在整套服装里是否协调。这种“局部最优、全局未必最优”的缺陷,正是 Unigram 语言模型要解决的。

(a∗,b∗)=arg⁡max⁡(a,b)count(a,b)(a^*,b^*)=\arg\max_{(a,b)} \text{count}(a,b)
BPE 每次迭代只合并一对“最有缘分”的相邻符号,把最高频的字符对拼成新的子词单元。
  1. 候选对扫描当前词表,找出所有相邻符号对 $(a,b)$,比如 “(l,o)”“(o,w)”。
    pairs={(a,b):a,b 相邻出现}\text{pairs}=\{(a,b): a,b \text{ 相邻出现}\}
  2. 统计频次数一数每个候选对在整个语料中出现了多少次,出现越多越优先。
    count(a,b)=∑语料1[(a,b) 相邻]\text{count}(a,b)=\sum_{\text{语料}}\mathbb{1}[(a,b) \text{ 相邻}]
  3. 选最优取频次最大的那对作为本轮合并对象。
    count(a∗,b∗)≥count(a,b), ∀(a,b)\text{count}(a^*,b^*) \ge \text{count}(a,b),\ \forall(a,b)
  4. 合并入库把 $(a^*,b^*)$ 合并成一个新符号写入词表,下一轮重新统计。
    V←V∪{a∗b∗}V \leftarrow V \cup \{a^*b^*\}

Unigram原理

Unigram 是一种更先进的裁缝哲学:不盲目合并,而是给每块补丁标一个“信任价”,用概率决定谁留下、谁淘汰。 它的核心想法是:训练语料整体出现的概率等于所有分词路径里,各子词概率连乘(取对数后相加)的最大值。裁缝手里有一副牌,每种剪法是一手牌,Unigram 会挑“最顺”的那手出。

数学上,Unigram 给每个子词 $t$ 赋予一个概率 $P(t)$,且全部子词概率之和为 1。给定一个句子 $x$,候选分段方式很多,比如“自然语言”→“自然/语言”或“自/然语/言”。Unigram 认为句子的概率是所有可行切分路径的概率之和,训练目标就是最大化这个总似然——也就是让裁缝在各个剪法中都能拼出一个完整句子,而不是只赌一种剪法。

有了概率模型,选出最优切分就用 Viterbi(维特比)算法:把每个字符位置看作节点,子词作为边,边的权重是 $\log P(t)$,然后找一条从开头到结尾、总权重最大的路径。这个动态规划过程像裁缝在纸上画网格,从起点逐格推进,只保留每个位置的最佳累计得分,最终回溯出完整裁剪方案。

训练词表时则用 EM(期望最大化)算法交替迭代:E 步用当前概率给语料分词,统计每个候选子词被选中的期望次数;M 步用统计结果重新归一化更新 $P(t)$,然后丢掉概率过低的子词。多轮迭代后,词表会收敛到一个“性价比”高的集合——生僻且无用的补丁被淘汰,常用组合被保留,这正是 SentencePiece 的精髓。

回到比喻:BPE 是凭经验缝合布粒的老裁缝,Unigram 则是拿计算器核算每块布成本的精算师。老裁缝快,但容易剪出怪补丁;精算师慢,却能从全局最优出发优化词表。理解了 Unigram 的“概率加总”逻辑,你才能明白为什么它比 BPE 更擅长处理多语言混杂的复杂文本。

log⁡P(x)=log⁡∑T∈S(x)∏t∈TP(t)\log P(x)=\log \sum_{T \in S(x)} \prod_{t \in T} P(t)
Unigram 不直接挑一种切分,而是把句子所有可能切分的概率加总再取对数,作为该句的整体得分。
  1. 切分集合$S(x)$ 表示句子 $x$ 所有合法的子词切分方式,每种切分是一组子词序列。
    S(x)={T:T=(t1,…,tk), ti∈V, 拼接=x}S(x)=\{T: T=(t_1,\dots,t_k),\ t_i \in V,\ \text{拼接}=x\}
  2. 单路径概率一种切分方式的概率是其中各子词概率的连乘。
    P(T)=∏t∈TP(t)P(T)=\prod_{t \in T} P(t)
  3. 全路径求和句子概率 = 所有切分方式的概率之和,而不是只取最大。
    P(x)=∑T∈S(x)P(T)P(x)=\sum_{T \in S(x)} P(T)
  4. 取对数对数把连乘变成连加,数值更稳定,也便于后续用 EM 优化。
    log⁡P(x)=log⁡∑T∈S(x)exp⁡∑t∈Tlog⁡P(t)\log P(x)=\log \sum_{T \in S(x)} \exp \sum_{t \in T} \log P(t)

SentencePiece

实践中的 Unigram 模型被封装进一个叫 SentencePiece 的成熟工具库,LLaMA、BLOOM 都直接用它做分词。如果说 Unigram 是数学原理,SentencePiece 就是把这套原理开成一家标准化裁缝连锁店——只留几个好用的按钮,但把流程锁死,保证任何人用都能得到一致结果。

SentencePiece 的第一个特色是直接从原始文本训练,不需要预先切好词。传统分词器(如 BERT 的 WordPiece)要先用空格把词分开,再在上面切子词;但中文、日文没有空格,SentencePiece 把整个句子当作连续字符流直接开工,这让它天然适合 CJK(中日韩)语言。它把空格编码成特殊符号 ▁(下划线形状),这样空格也变成普通 token,模型能明确知道哪里该断词。

第二个特色是自带特殊 token。分词器会自动在句子首尾加上 [BOS] 和 [EOS] 标记,分别表示“布料开头”和“布料结尾”。BOS 让模型知道一个句子从哪里开始,EOS 让模型学会“说完了”就停止生成——这对生成式模型至关重要,否则模型会无限续写。

第三个特色在实现层面:SentencePiece 用直接切分(direct segmentation),训练时无需对语料做语言相关的预处理(如 lowercase、Unicode 规范化),它把原始字符当作不可变的原子。空白处理(▁)、BOS/EOS 等特殊 token 都在训练前自动注入,用户只需给定词表大小这一超参数。这种“傻瓜化”设计让多语言团队的开发成本大幅降低。

这套标准化流程正是裁缝连锁店的精髓:不管你送来的是英文小说还是中文公告,进门一律按“🔲开头—剪裁—🔲结尾”的固定流程处理,出来的补丁包规格完全统一。模型侧因此可以放心假设:所有输入都有明确边界,不会遇到格式错乱。

输入序列=[BOS], ▁自然, ▁语言, ▁处理, [EOS]\text{输入序列} = [\text{BOS}],\, \text{▁自然},\, \text{▁语言},\, \text{▁处理},\, [\text{EOS}]
SentencePiece 把空格显式编码为 ▁,并在序列两端固定添加 BOS 和 EOS,形成模型真正看到的 token 流。
  1. BOS 起点序列开头插入 [BOS],告诉模型“新句子开始”。
    x0=[BOS]x_0=[\text{BOS}]
  2. 句子主体原始句子按 Unigram 最优切分,词与词之间用 ▁ 标记空白。
    ▁自然, ▁语言, ▁处理\text{▁自然},\ \text{▁语言},\ \text{▁处理}
  3. EOS 终点结尾插入 [EOS],模型生成到它会自动停止。
    xm+1=[EOS]x_{m+1}=[\text{EOS}]
  4. 完整序列拼接成带边界的 token 序列,长度 $m+2$。
    x=([BOS],x1,…,xm,[EOS])\mathbf{x}=([\text{BOS}], x_1,\dots,x_m, [\text{EOS}])

词表策略

裁缝铺开了几家分店,每家的补丁目录大小不一样:有的只备 8000 块补丁,有的备到 32 万块。词表大小是分词器最重要的旋钮,它直接牵动模型性能与训练成本的平衡。

如果词表太小(如 8K),你会遇到两个问题。第一,很多常用词切不成整块,比如“机器学习”可能被切成一堆单字,句子被拉得很长,序列长度上升导致注意力计算和显存开销暴涨。第二,同一概念被反复拆成碎片,模型难以学到稳定的语义表示,下游任务表现变差。这就像裁缝只有小布块,缝一件大衣要拼上百块,费时费力还容易错位。

如果词表太大(如 250K),问题反过来。词表太大意味着 Embedding 层和输出层参数剧增——每个 token 都要一个向量,假设隐藏维度 4096,25 万词表光 embedding 就有 10 亿参数,训练和推理的开销显著上升。而且词表过大时很多生僻 token 在整个预训练语料里只出现几次,学不到好表示,属于“闲置补丁”,白白占仓库。

工程上有一个经验法则:让平均每个 token 对应约 3-4 个字符(中文场景),同时用覆盖率来评估词表是否合适——覆盖率指训练语料中被词表直接覆盖的字符比例,一般设 99.95% 以上。稀有词的处理策略也很关键:Unigram 天然会把生僻词切成更小的常见子词,这比直接把整词删掉更稳妥,因为它保证了“再怪的词也能拼出来”。

回到比喻:词表策略就是决定“仓库备多少种补丁”。备太多,租金(参数)贵;备太少,缝纫费劲(序列变长)。好的裁缝会根据客户群——也就是语料分布——定制目录,而不是拍脑袋定数字。这也是为什么 LLaMA 和 BLOOM 各选了与自己语料匹配的不同词表大小。

PPL(x)=exp⁡(−1N∑i=1Nlog⁡P(wi))\text{PPL}(x)=\exp\left(-\frac{1}{N}\sum_{i=1}^{N}\log P(w_i)\right)
困惑度衡量模型对文本的“意外程度”,PPL 越低说明分词方案下的序列越合理,词表越贴合语料分布。
  1. 对数似然把分词后每个 token 的概率取对数再累加,表示模型“读顺”这段文本的程度。
    ∑i=1Nlog⁡P(wi)\sum_{i=1}^{N}\log P(w_i)
  2. 取平均除以 token 总数 $N$,消除文本长度影响,便于跨句子比较。
    1N∑i=1Nlog⁡P(wi)\frac{1}{N}\sum_{i=1}^{N}\log P(w_i)
  3. 取负指数负号让“越顺畅越大”,指数还原成正数,得到可读的 PPL 值。
    PPL=exp⁡(−平均对数似然)\text{PPL}=\exp(-\text{平均对数似然})
  4. 模型选择分词方案 A 的 PPL 更低,就选 A——就像裁缝比价,同样效果谁更省料用谁。
    PPLA<PPLB⇒方案 A 更优\text{PPL}_A < \text{PPL}_B \Rightarrow \text{方案 A 更优}

大模型应用

最后,我们从裁缝铺走进真正的大模型工厂。LLaMA(Meta 开源的 Llama 系列)选择词表大小 32000,这个数字并非随意——它在模型容量与文本压缩率之间取得平衡。32K 词表下,英文 token 平均覆盖约 3.7 个字符,模型参数量可控(7B 模型 embedding 约 1.3 亿参数,占比不到 2%),同时多语言文本都能被有效编码。

BLOOM(BigScience 开源的多语言模型)则走了另一条路:它训练语料覆盖 46 种自然语言和 13 种编程语言,因此把词表扩到 250880。这么大的词表是为了给每种语言都留足“常用补丁”,避免冷门语言过度依赖字节兜底。代价是 embedding 参数巨大,但在 BLOOM 的设计里,这部分开销换来了跨语言一致性和低 token 数——法语、中文、阿拉伯语都能以较短的序列表达。

多语言之所以可行,靠的正是 Unigram 的共享子词单元能力。不同语言的文本在顶层“字符”不同,但字节层面高度重叠。比如英文“play”和中文“播放”机制不同,但阿拉伯文、西里尔文等字符经过 UTF-8 编码后,在子词层会存在可共享的字节片段。Unigram 概率模型会自动发现这种跨语言共现结构,把多语言布料统一裁剪成一套补丁。

推理阶段,模型对词表做 softmax 采样:输出层给每个 token 一个 logit 分数,除以温度参数后转成概率分布,再采样得到下一个 token。分词器的词表质量直接影响采样空间——如果词表切得太碎,模型每步只能推进一两个字符,生成效率低且容易跑偏;切得太粗,则选择空间不足,句式生硬。恰当的词表就像裁缝备好了尺寸合身的拼图,让生成过程行云流水。

回看整条链:从分词基础到词表策略,再到 LLaMA 与 BLOOM 的差异,你会发现分词器从来不是配角。它是模型认识世界的第一道关口——Unigram 概率化裁剪让多语言共享一把剪刀,SentencePiece 则把剪刀标准化、工厂化,让任何团队都能快速为自家大模型定制合身的补丁包。

p(wj)=exp⁡(zj/τ)∑k=1Kexp⁡(zk/τ)p(w_j)=\frac{\exp(z_j / \tau)}{\sum_{k=1}^{K}\exp(z_k / \tau)}
模型在词表上做温度采样,温度 $\tau$ 控制分布的平滑程度,词表大小 $K$ 直接影响每一步输出的候选范围。
  1. logit 得分模型输出层为每个 token 计算原始得分 $z_j$,表示“该 token 在当前位置的适合程度”。
    z=(z1,z2,…,zK)\mathbf{z}=(z_1,z_2,\dots,z_K)
  2. 温度缩放除以温度 $\tau$,$\tau$ 大则分布趋于均匀,$\tau$ 小则分布尖锐。
    zj/τz_j/\tau
  3. softmax 归一化指数化后除以所有 token 得分之和,得到合法概率分布。
    ∑k=1Kexp⁡(zk/τ)\sum_{k=1}^{K}\exp(z_k/\tau)
  4. 抽样选择按概率采样——高概率 token 更容易被选中,但低概率 token 也有机会,增加生成多样性。
    wj∼p(⋅)w_j \sim p(\cdot)

AI 应用场景

多语言大模型预训练数据处理

阶段:数据预处理 / Tokenization

解决的问题:传统分词器(如 BERT WordPiece)需要预先按空格分词,遇到中文、日语等无空格语言直接失效,且词表无法跨语言共享。采用 SentencePiece + Unigram 后,能统一处理 46 种语言,训练时无需任何语言特定预处理流程。

具体案例:BLOOM 训练团队收集了涵盖法语、中文、阿拉伯语、斯瓦希里语等 46 种语言的原始语料。如果按老办法逐语言定制分词器,需维护 46 套流程;改用 SentencePiece 后,一套 Unigram 词表覆盖全部语言,训练数据管线只剩“喂文本→出 token→训练”三步。

大模型推理服务的显存优化

阶段:推理优化 / 序列长度控制

解决的问题:大模型部署时 KV Cache 显存随序列长度线性增长。分词过碎时同样一句话被切成更多 token,直接导致显存超限。通过 Unigram 概率模型挑选覆盖率高的词表,可压缩序列长度,降低推理成本。

具体案例:某团队基于 Llama 2 做中文客服机器人,初版词表切中文时“银行卡余额”被切成 11 个 token;换用 SentencePiece 在中文语料上重训 32K 词表后,同句仅 5 个 token,KV Cache 显存占用下降约 45%,响应延迟减半。

代码大模型的多语言符号覆盖

阶段:训练数据清洗

解决的问题:编程语言中存在大量特殊符号(=>、::、{ 等),纯字符级切分会让序列爆炸。Unigram 能自动把高频符号组合吸收成独立 token,代码理解效率显著提升。

具体案例:StarCoder 项目在训练前用 SentencePiece 处理 GitHub 代码语料,自动识别出 Python、Java、C++ 各自的惯用语法片段(如 def 、public class)作为统一 token,使模型生成代码时的语法错误率降低 30%。

Python 演示

python102 行
import refrom collections import defaultdictfrom math import log, exp class MiniUnigram:    def __init__(self, corpus, vocab_size=18, iters=30):        self.corpus = corpus        self.vocab_size = vocab_size        self.iters = iters        self.vocab = set()        self.prob_cache = {}     def _seed_vocab(self):        char_set = set()        for sent in self.corpus:            char_set.update(list(sent))        self.vocab = set(char_set)        for token in self._freq_2grams():            if len(self.vocab) < self.vocab_size:                self.vocab.add(token)     def _freq_2grams(self):        cnt = defaultdict(int)        for sent in self.corpus:            for a, b in zip(sent, sent[1:]):                cnt[a + b] += 1        return sorted(cnt, key=cnt.get, reverse=True)[: self.vocab_size]     def _all_segmentations(self, text):        n = len(text)        dp = [[] for _ in range(n + 1)]        dp[0] = [()]        for i in range(1, n + 1):            paths = []            for j in range(max(0, i - 4), i):                w = text[j:i]                if w in self.vocab:                    for prev in dp[j]:                        if len(prev) < 6:                            paths.append(prev + (w,))            dp[i] = paths        return dp[n]     def _update_probs(self):        self.prob_cache = {}        cnt = defaultdict(float)        logsum = defaultdict(float)        for sent in self.corpus:            for path in self._all_segmentations(sent):                score = sum(log(self.prob_cache.get(w, 0.01)) for w in path)                logsum[path] = score        for sent in self.corpus:            paths = self._all_segmentations(sent)            if not paths:                continue            max_score = max(logsum[p] for p in paths)            denom = sum(exp(logsum[p] - max_score) for p in paths)            for p in paths:                weight = exp(logsum[p] - max_score) / denom                for w in p:                    cnt[w] += weight        total = sum(cnt.values())        self.prob_cache = {w: c / total for w, c in cnt.items()}     def train(self):        self._seed_vocab()        for _ in range(self.iters):            self._update_probs()            if len(self.prob_cache) > self.vocab_size:                drop = min(self.prob_cache, key=self.prob_cache.get)                self.vocab.discard(drop)                del self.prob_cache[drop]        return sorted(self.prob_cache, key=self.prob_cache.get, reverse=True)[:self.vocab_size] or list(self.vocab)     def viterbi(self, text):        n = len(text)        score = [-1e9] * (n + 1)        back = [None] * (n + 1)        score[0] = 0.0        for i in range(1, n + 1):            for j in range(max(0, i - 4), i):                w = text[j:i]                if w in self.prob_cache and score[j] > -1e8:                    cand = score[j] + log(self.prob_cache[w])                    if cand > score[i]:                        score[i] = cand                        back[i] = (j, w)        toks = []        i = n        while i > 0:            j, w = back[i]            toks.append(w)            i = j        return toks[::-1]  if __name__ == "__main__":    samples = ["自然语言处理", "语言模型", "处理数据", "自然语言"]    mu = MiniUnigram(samples, vocab_size=14, iters=20)    print("词表:", mu.train())    for s in ["自然语言", "模型处理"]:        print(s, "->", mu.viterbi(s))
目的:用约 80 行代码实现 Unigram 语言模型的训练与 Viterbi 最优分词,让你在跑大型框架前先建立直觉。
  1. self._seed_vocab()用单字符加高频二元组初始化候选词表,模拟 SentencePiece 的种子词表。
  2. self.prob_cache缓存每个子词的概率,训练过程中反复读写。
  3. self._all_segmentations(text)动态规划枚举句子所有合法切分路径,对应 Unigram 的 $S(x)$。
  4. max_score` 与 `denom用 log-sum-exp 技巧数值稳定地计算每条路径的权重。
  5. self._update_probs()E 步:统计期望频次;M 步:归一化得到新概率。
  6. if len(self.prob_cache) > self.vocab_size淘汰概率最低的子词,模拟 Unigram 词表修剪。
  7. viterbi(self, text)正向动态规划找最大对数概率路径,反向回溯出最优切分。

常见误区

误区多塞点 token 进词表,模型就能认识更多词,效果肯定更好。既然 BLOOM 用 25 万词表,那我也搞个 50 万词表,岂不是翻倍强?

正解词表大小不是性能的单调函数。词表翻倍,embedding 和输出层的参数也近似翻倍——7B 模型可能因此多出几个亿参数,而这些新加的 token 大多是低频词,在语料中出现不到十次,根本学不好表示。更关键的是,输出 softmax 的维度变大,推理延迟明显上升。需要像裁缝备补丁一样按客户群量身定制:先测语料覆盖率曲线,找“再增大词表收益甚微”的拐点。

为何常见:人们直观地觉得“认识更多词 = 更聪明”,忽略了模型参数的预算约束和低频词的稀疏性问题。

误区反正都是把文本切成子词,运行起来结果也差不多,用哪个都行。

正解两者机制有本质区别。BPE 是贪心合并,每次只把最高频的相邻字符对缝起来,全程不看全局概率;Unigram 则给每个子词标概率,优化整个语料的似然,还会主动淘汰低概率词元。实践中 Unigram 对多语言和稀有词的鲁棒性明显更好——这正是 LLaMA、BLOOM 弃用 BPE 而选 SentencePiece 的原因。

为何常见:两者都输出“子词 + 词表”的格式,用户看到的结果都是 token 序列,容易忽略训练机制的差异。

误区我直接调库就行,`sentencepiece.train()` 一行出结果,知道原理纯属浪费时间。

正解调库虽然简单,但词表大小、字符覆盖率、训练语料配比这些超参数都需要对底层原理有判断力才能设好。遇到“中文切得碎”“多语言混排乱码”“特殊 token 冲突”这些线上问题,不懂 Unigram 和 BPE 差异的人只能瞎试参数,懂原理的人一眼就能定位是词表覆盖不足还是空白处理没配对。

为何常见:现代工具链封装得太好,低门槛反而让使用者忽略了模型本身的决策机制,出了问题才知道黑盒不可靠。

误区生僻词没人用,概率又低,直接删了能节省词表空间。

正解词表里留一部分中低频 token 并非浪费。Unigram 的优雅之处恰恰是:真心生僻的词会被自动切分成常见子词的组合(“鳜鱼”→“鳜”+“鱼”),不会保证整词命中。但如果把中频词(如“合同”“算法”)硬删掉,它们每次都被拆成单字,句子变长,序列上下文交互变差,模型整体表现会显著下滑。

为何常见:把“低频”等同于“无用”,没意识到 token 长度和模型感受野之间的联动影响。

面试题

Q1. 关于 SentencePiece 的核心特点,下列哪项描述最准确?

单选题 · easy

  • A.只支持英文文本切分
  • B.需要预先按空格分词的文本
  • C.语言无关且支持多语言文本← 正确答案
  • D.只能用于推理不能训练词表

答案:C。SentencePiece 直接从原始字符流训练,无需空格预分词,因此可处理中、日等无空格语言,是语言无关的通用分词器。A、B 描述的是传统分词器的限制,D 明显错误——它本来就负责训练词表。

Q2. Unigram 模型给一个句子打分时,句子的概率如何计算?

单选题 · easy

  • A.只取最优切分路径的概率
  • B.所有切分路径的概率求和← 正确答案
  • C.各字符出现次数的乘积
  • D.词表中全部词元概率之和

答案:B。Unigram 的要点是“全路径求和”——它把所有合法切分方式的概率都加起来作为句子概率(取对数后优化)。A 是 Viterbi 解码时的做法而非训练目标,C 是字符级统计,D 忽略了句子结构约束。

Q3. BPE 算法每轮迭代的核心操作是什么?

单选题 · easy

  • A.随机选择字符对进行拆分
  • B.合并出现频次最高的相邻符号对← 正确答案
  • C.用神经网络预测下一个词元
  • D.按词性标注过滤低频子词

答案:B。BPE 是贪心合并:每轮统计当前符号序列中所有相邻对的出现频次,挑最高的合并成新符号。A 是随机策略,C 是语言模型任务,D 使用词性标注,这些都不是 BPE 的做法。

Q4. LLaMA 选用 32000 词表的直接工程原因是什么?

单选题 · mid

  • A.词表越大训练一定越慢
  • B.32K 在容量与压缩率间较平衡← 正确答案
  • C.32000 是 GPU 显存上限决定的
  • D.论文作者随机拍板的一个数字

答案:B。32K 是工程权衡的结果:足够覆盖高频英文子词和常用多语言字符,同时词表嵌入参数占比小、推理速度快。A 过于绝对,C 与显存无关,D 不是事实。

Q5. SentencePiece 如何处理文本中的空白字符?

单选题 · mid

  • A.直接删除所有空白字符
  • B.用特殊符号▁显式编码空白← 正确答案
  • C.用数字 0 代替空格位
  • D.空白只保留在句子开头

答案:B。SentencePiece 把空格转成 ▁ 标记,训练时作为一个普通 token 参与概率估计。这样模型能明确感知词边界,又不需要依赖空格预切分。A 会丢失词边界信息,C、D 都是不存在的设计。

Q6. Unigram 训练过程中,低概率子词最终会怎样?

单选题 · mid

  • A.自动加密提高使用概率
  • B.被保留且永不改变概率
  • C.在迭代中逐渐被淘汰出词表← 正确答案
  • D.转存到额外备份词表

答案:C。Unigram 的 EM 迭代会不断重估各子词概率,概率过低的子词在后续迭代中被丢弃,直到词表达到目标大小。这就是它的“修剪”机制,与 BPE 只增不减的做法形成鲜明对比。

Q7. 关于 BPE 与 Unigram 的根本区别,下列说法哪项正确?

单选题 · hard

  • A.两者均基于全局概率优化
  • B.BPE 贪心合并,Unigram 全路径似然← 正确答案
  • C.Unigram 完全没有统计信息
  • D.BPE 支持中文而 Unigram 不支持

答案:B。BPE 每步只合并当前频次最高的相邻对,是局部贪心策略;Unigram 则定义子词概率并优化整个句子的全路径似然,能全局评估词表质量。A 对 BPE 不成立,C、D 均与事实相反。

Q8. 词表选得过小可能带来哪些后果?

多选题 · mid

  • A.嵌入层参数量爆炸增长
  • B.相同句子切分成更多token← 正确答案
  • C.序列变长增加注意力开销← 正确答案
  • D.模型语义表示更稳定集中

答案:BC。词表过小时,整词无法命中,句子会被切成长串碎片,token 数变多、序列边长,注意力与显存开销增高,B、C 正确。A 是词表过大的问题,D 与经验事实相反——碎片化反而削弱语义表示稳定性。

Q9. 下列哪些是 SentencePiece 提供的特性?

多选题 · mid

  • A.自动添加BOS与EOS标记← 正确答案
  • B.将空格转成▁特殊token← 正确答案
  • C.内置情感分析分类器
  • D.无需语言预切分的直接训练← 正确答案

答案:ABD。SentencePiece 会在序列首尾自动插入 BOS/EOS,并把空白字符统一转成 ▁ token,同时支持从原始文本直接训练。它不具备情感分析功能,C 错误。

Q10. 关于 Unigram 的分词路径求和公式,哪些理解是正确的?

多选题 · hard

  • A.每种切分路径概率是该路径各token概率的连乘← 正确答案
  • B.句子概率只取所有路径中概率最大的那条
  • C.取对数后连乘变为连加便于数值稳定← 正确答案
  • D.路径数量越多句子概率一定越小

答案:AC。单条路径的概率是路径内所有 token 概率连乘,A 对;句子概率是全部路径之和,B 错;对数把连乘转成连加,C 对;路径数量与概率大小无必然关系,且各路径求和不小于单条路径,D 错。