cs336-tokenizer

June 2, 2026

cs336-tokenizer

模型并不直接「读懂文字」,它只能处理数字。分词器(Tokenizer)做的事,就是把原始文本切成一个个词元(token),再映射成整数 ID 供模型计算。它常被当作模型的一部分,实际上却是一个独立训练、训练完即冻结的模块:先在大规模语料上统计出一套 词元 → ID 的对应表(词表,vocab),之后任意文本都按这张表被转换成离散的数字序列。

一句话概括二者分工:LLM 负责「理解和生成」,tokenizer 负责「把语言变成模型能理解、可复用的结构」。

这种「对世界的切分」直接决定了序列长度、信息密度与语义的组合方式,进而影响训练效率与表现。以 incredible 为例:['i','n','cr','e','dible'] 过于碎片化,['incredible'] 又过于笼统,理想切分 ['in','credible'] 既能在不同单词间复用、控制 token 总数,又能提升 embedding 的映射能力。

分词器全解:训练流程、粒度权衡与子词算法

本文是对 Datawhale diy-llm 第二章「分词器」的整理。关于 BPE / BBPE 的逐步推导可参阅 BPE 与 BBPE 详解,本文侧重训练流程与四种算法的横向对比。

分词器是怎样训练出来的

训练一个现代 LLM 的分词器,可拆为四步:准备语料 → 预分词 → 统计并迭代合并 → 输出产物。不同算法只在「候选生成方式」与「迭代更新策略」上有差异。

分词器训练流程:准备语料、预分词、统计迭代、输出 vocab 与 merges

准备语料

语料应覆盖目标场景(小说、散文、代码……)与多种语言。多语言场景必须先统计各语言占比并设计采样策略,否则统计过程会被高频语言主导,低资源语言的常见字串挤不进高频合并,最终在词表里被切得过碎(碎片化),下游任务表现显著劣化。

语言原始语料量问题
中文200 GB主导统计
英文150 GB主导统计
法语10 GB易碎片化
韩文5 GB易碎片化

对上述不平衡语料,可通过下采样高资源语言、过采样低资源语言,把比例调成如 中文:英文:法语:韩文 = 4:4:1:1 或完全均衡。

此外还要做清洗与标准化:去除无关元数据、修正乱码、统一编码,并对敏感信息(姓名、电话、住址等)做脱敏。脱敏不仅是合规需要——姓名、证件号这类高基数信息近乎唯一出现,属于低频噪声,会干扰 BPE / Unigram 学习高频结构的统计效率。从信息论看,脱敏是一种结构化去噪:压缩高熵但低语义价值的信号,让分词器更专注于可复用的语言模式。最后保留一小部分(如 99:1)验证语料,用于评估编码效率与平均 token 长度。

预分词

预分词把原始文本切成可统计、可合并的基础单元。三种常见策略:

① 空格 / 标点切分,适用于大多数以空格为词界的语言:

import re
 
def part(text):
    # 把标点单独拆开,再按空格分割
    text = re.sub(r'([.,!?;:()"\'\[\]{}])', r' \1 ', text)
    return text.split()
 
part("I like Datawhale.")
# ['I', 'like', 'Datawhale', '.']

② Unicode 类别划分:按字母、数字、标点、汉字、emoji 等类别自动切分,同一个 token 内字符类型一致,天然适合多语言混合文本。

"Hello👋👋,Datawhale成立于2018年!!!"
→ ['Hello', '👋👋', ',', 'Datawhale', '成立于', '2018', '年', '!!!']

③ UTF-8 字节级划分:先把字符编码为 UTF-8 字节序列,每个字节作为一个 token,不依赖语种。

def tokenize_byte_level(text):
    tokens = []
    for ch in text:
        # 取字符的 UTF-8 字节,转成十六进制
        tokens.extend(f"{b:02X}" for b in ch.encode("utf-8"))
    return tokens
 
tokenize_byte_level("All for learners!")
# ['41','6C','6C','20','66','6F','72','20','6C','65','61','72','6E','65','72','73','EF','BC','81']
# 英文与空格是 ASCII,UTF-8 下 1 字节;全角「!」是 3 字节 (EF BC 81)

Unicode vs UTF-8:Unicode 是「编码标准」,给每个字符分配唯一码点(如 → U+4E2D);UTF-8 是「编码格式」,规定码点如何落到字节上。UTF-8 向后兼容 ASCII(0–127 仍占 1 字节),因此比 UTF-16/32 更通用。

三种策略中,①② 在缺乏显式分隔符或出现长段同类字符时会被迫回退到接近字符级的兜底切分;UTF-8 字节级通用性最强,把任意文本统一拆为字节,从根本上减少未登录词(OOV)。代价是起点最细,需要更多轮合并才能把零散字节压成紧凑且有语义的 token。

这一点直接关系到效率:token 越碎,序列越长,而注意力的计算复杂度按 O(N2)O(N^2) 增长,且受上下文窗口容量限制。预分词产物(基础单元序列 + 位置信息)会被保存下来,作为后续统计合并的输入。

统计并迭代合并

这一步是各算法分野所在——遍历语料收集统计量,再据此迭代更新词表:

  • BPE:统计相邻对的频次,贪心合并频次最高的相邻对,迭代构建词表,决策仅基于频率。
  • WordPiece:评估合并对语料似然的贡献,选择能显著提升语料拟合度的合并。
  • Unigram:从一个过大的种子词表出发,用 EM 迭代优化子词概率并剪枝。
  • SentencePiece:语言无关的训练框架,在内部承载 BPE 或 Unigram,而非新算法。

需要厘清:分词算法决定 token 的划分策略,分词器则把算法、词表、编码机制组合成可将文本转为模型输入的完整流程。下一节逐一展开这四者的差异。

输出产物与评估

无论用哪种算法,训练完都要导出两个核心文件,二者共同决定编解码逻辑并保证编码可逆

  • vocab:记录所有 token 及其 ID,是编解码的核心索引。
  • merges:按顺序记录子词合并规则或概率模型。

上线前应在验证集上统计三项指标:平均 token 数与最大长度分布(影响显存、训练与推理速度)、碎片化情况(关键术语是否被切碎)、跨语言 token 平衡度。后续若需扩表(新增领域术语、品牌名),优先采用增量训练、追加 merges 项、清理极低频 token,而非完全重训,并做一次回归测试确保兼容与可逆。

三种分词粒度的权衡

在子词算法之前,先看三种「朴素」粒度,它们恰好暴露了词表大小、序列长度、OOV 三者的矛盾。

字符级:以单个字符为最小单位(字母 a,b,c / 汉字 你,好)。词表极小、无 OOV,但序列过长(消耗上下文窗口、抬高显存),且单字符语义稀疏。

字节级:维护一个固定 256 的字节词表(0x000xFF),直接对字节操作。它彻底解决跨语言与特殊符号(emoji)问题,但压缩比恒为 1——每个 UTF-8 字节对应一个 token:

compression_ratio=NbytesNtokens=NN=1\text{compression\_ratio} = \frac{N_{\text{bytes}}}{N_{\text{tokens}}} = \frac{N}{N} = 1

因此现代 LLM 很少单独用纯字节分词,而是把字节作为 BPE 的基础单位(即 BBPE)。

词级:按空格或分词算法切出完整的「词」,语义完整,但词表爆炸(look/looks/looked/looking 算四个 ID)、OOV 严重(生僻词只能记为 <UNK>,信息丢失)。

一个直观对比(输入 Hello, 🌍! 你好!,原始 20 字节):

粒度token 数压缩率 (byte/token)说明
字节级201.00无压缩,最稳定
字符级131.54UTF-8 字符压缩(中文 / emoji 更明显)
BPE111.82学习高频子串,数据驱动压缩,最接近真实 LLM

四种子词算法

子词分词在「字符级太细」与「词级太粗」之间取平衡。下图是 BPE、WordPiece、Unigram 三者的核心差异——准则与构建方向各不相同。

BPE、WordPiece、Unigram 的核心差异对比

BPE:合并最频繁的相邻对

核心思想:统计语料中相邻字符(或字节)对的频率,迭代地把最频繁的一对合并成新 token。训练步骤:

  1. 把每个词拆成基础单元序列,末尾加词尾标记 </w>
  2. 统计全局相邻对频次。
  3. 合并频次最高的一对,加入词表并记录这条有序合并规则
  4. 用规则重写语料,回到第 2 步,直到达到目标词表大小。

</w> 的作用是标记单词边界、保证可逆the 被表示为 ['t','h','e','</w>'],BPE 知道这是词尾、不会跨词错误合并;解码时去掉 </w> 即可拼回 the。BPE 实现简单、训练高效、对高频子词压缩效果好,是 GPT、LLaMA、RoBERTa 等的主流选择。

BBPE:把粒度下沉到字节

BBPE(Byte-level BPE)算法与 BPE 完全一致,只是把最小单元从字符换成 UTF-8 字节。任何文本经 UTF-8 都是字节流,初始词表恒为 256、与语种无关,从结构上消除 OOV。GPT-2/4、LLaMA 等都用它来一并解决跨语言与 emoji 等特殊符号的编码问题。代价是非 ASCII 文本初始序列更长(一个汉字 3 字节),需要靠合并把高频字节串压回单 token。

WordPiece:按似然增益合并

核心思想:同样是自底向上合并,但准则不是「频率最高」,而是「最大化语料似然的提升」。常用近似评分为:

score(A,B)=P(A,B)P(A)P(B)\text{score}(A, B) = \frac{P(A, B)}{P(A)\,P(B)}

该比值衡量 A 与 B 的关联性是否强于二者独立出现的期望:score > 1 说明结合比随机更有意义、更值得合并。WordPiece 用于 BERT、DistilBERT,擅长控制词表大小、减少 OOV,适合 MLM 类模型。(Google 未公开其完整细节,此处依据 HuggingFace 的原理介绍。)

Unigram:从大词表中剪枝

核心思想:基于一个子词概率语言模型,把句子概率定义为其所有可能分词方式的概率之和,通过迭代优化子词概率使整体语料似然最大化。它自顶向下工作,用 EM 算法:

  1. E 步:在当前词表与概率下,为每个句子计算最可能的(或前 n 个高概率)分词方案,估计每个子词的期望使用次数。
  2. M 步:据 E 步统计更新各子词概率,使语料似然最大化。
  3. 剪枝:每轮淘汰概率最低的 token(如底部 10%~20%),逐步收敛到目标词表大小。

相比 BPE / WordPiece,Unigram 更依赖概率建模,能灵活处理不同长度子词、对低频词更友好、多语言适配强,用于 T5、mT5、Gemma 等。

SentencePiece:语言无关的训练框架

SentencePiece 不是新算法,而是一个框架:它能直接从原始文本训练子词模型,把标准化与预分词内置,因此无需用户在外部显式预分词。它将空格、词边界编码为特殊字符(输出中常见的 表示词首空格),从而把空格也作为建表对象,再在初始 token 上应用 BPE 或 Unigram。它支持 byte-fallback,被 LLaMA(SP-BPE)、DeepSeek 系列采用。

横向对比

算法构建方向合并 / 切分准则优势典型模型
BPE / BBPE自底向上合并频次最高的相邻对简单高效、压缩好GPT-4、LLaMA、RoBERTa
WordPiece自底向上合并似然增益 P(A,B)/P(A)P(B)控词表、减 OOVBERT、DistilBERT
Unigram自顶向下剪枝淘汰似然贡献最低者概率建模、低频词友好T5、mT5、Gemma
SentencePiece框架(承载上述算法)语言无关、端到端LLaMA、DeepSeek

再从粒度维度汇总:

类型粒度词表大小OOV序列长度代表
字符级小 (100–5k)非常长Char-RNN
字节级更细很小 (~256–1k)很长GPT-2
词级极大 (>100k)严重Word2Vec, GloVe
BPE中(自适应)适中 (30k–100k)极少适中GPT-4, LLaMA 3

三者最易混淆,记住一句话即可区分:BPE 看频率、WordPiece 看似然增益、Unigram 看剪掉谁损失最小;前两者从字符往上合并,Unigram 从大词表往下剪。没有绝对最优——选型取决于语料分布、任务类型(理解 / 生成)、词表规模与多语言需求。

特殊 token 的作用

迭代过程中必须保护一批特殊控制 token,它们不参与统计合并 / 概率优化,也不会被拆分覆盖,从而保证 词 ↔ ID 映射固定、编码可逆。常见的有:

  • [CLS]:序列开头,其输出向量聚合全局信息,用作分类特征(BERT)。
  • [SEP]:分隔两个句子。
  • [PAD]:把同一 batch 内不等长序列补齐到相同长度。
  • [MASK]:MLM 训练时遮盖待预测的 token。
  • <UNK>:承接词表外(OOV)词。词级分词中 OOV 严重时大量信息会坍缩成 <UNK>;子词 / 字节级则从根本上缓解这一问题。

这些 token 在任何算法(BPE / WordPiece / Unigram / SentencePiece)下都需固定保护,以保证训练与推理的一致性,并减少碎片化 token。

实战:DeepSeek 分词器

DeepSeek(尤其 Coder 系列)用优化过的字节级 BPE,对中英文与代码缩进做了精细建模。直接用 transformers 加载即可:

from transformers import AutoTokenizer
 
MODEL_NAME = "deepseek-ai/deepseek-coder-6.7b-instruct"
tokenizer = AutoTokenizer.from_pretrained(MODEL_NAME)
print(f"词表大小 V: {len(tokenizer.get_vocab())}")
 
text = "注意力机制是AI的核心技术。 🚀 🚀"
ids = tokenizer.encode(text, add_special_tokens=False)
tokens = tokenizer.convert_ids_to_tokens(ids)  # 还原成子词字符串,便于观察切分
print(tokens, ids)

token ↔ id 映射只描述「token 的内容」,不含任何位置信息——相同字符(如空格、特定 emoji)无论出现在哪里,ID 都一致,这正是表征稳定性的来源。BPE 本身不理解语义,它只是一个按频率 / 概率切分与合并字符序列的统计模块,为模型提供稳定紧凑的离散输入。

为什么 DeepSeek 在分词阶段用 latin-1 编解码? BPE 训练阶段需要按「字符」操作,若直接用 UTF-8,汉字 / emoji 这类多字节字符拆成单字节时会出现不完整序列而报错或被替换。latin-1 是单字节编码,把每个字节(0–255)机械映射为一个 Unicode 字符,保证任意字节序列都能完整、可逆地保存,从而让 BPE 安全地「把字节当字符」合并而不丢数据。

总结

  • 分词器是 LLM 与文本之间的接口:把连续文本转为离散 token 序列并映射为整数 ID,本身不「理解」语言,但更合理的切分会让输入分布更清晰,从而间接提升训练效率与性能。
  • 训练四步——准备语料 → 预分词 → 统计迭代 → 输出 vocab/merges——的核心是「迭代更新候选 → 控制词表大小 → 监控质量指标」。
  • 三种朴素粒度暴露了词表大小 / 序列长度 / OOV 的三角矛盾:字符级压小词表、词级压短序列、字节级结构性无 OOV 但零压缩。
  • 子词算法是这套矛盾的折中:BPE 频率贪心、WordPiece 似然增益、Unigram 概率剪枝,SentencePiece 则是承载它们的语言无关框架。BPE 因实现简单、训练高效而最为流行,但并非唯一选择。

参考资料