章节导航 第 9 章 / 共 30 章
Tokenization 与序列 Packing
分词器选择、词汇表构建、序列打包(packing)与样本边界处理。
Tokenizer 是文本与张量之间的桥梁。
Packing 是数据工程的最后一公里。两者决定 GPU 的真实利用率。
9.1 三大 Tokenization 算法
9.1.1 BPE (Byte Pair Encoding)
BPE 是 GPT 系列、Llama 系列的事实标准。
核心思想:从字符开始,迭代合并最频繁出现的相邻字符对。
初始词汇表: ['a', 'b', 'c', 'd', 'e', ...] # 单字符
训练语料: ['low', 'lower', 'newest', 'widest']
迭代1: 'l'+'o' → 'lo' (出现3次)
迭代2: 'lo'+'w' → 'low' (出现3次)
迭代3: 'e'+'r' → 'er' (出现2次)
迭代4: 'low'+'er' → 'lower' (出现2次)
...
最终词汇表: ['a', 'b', ..., 'low', 'er', 'lower', ...]
9.1.2 Unigram
Unigram 是 SentencePiece 的可选算法。
核心思想:从一个大词汇表开始,迭代删除”对整体似然贡献最小”的 token。
初始词汇表: 100K 个候选 token
训练语料: 大量文本
迭代1: 计算每个 token 对似然的贡献,删除贡献最小的 10%
迭代2: 重新计算,再删除 10%
...
最终词汇表: 32K 个最优 token
9.1.3 WordPiece
WordPiece 是 BERT 使用的算法,与 BPE 类似但选择标准不同:
- BPE:选择频率最高的对
- WordPiece:选择使整体似然最大的对
9.1.4 算法对比
| 算法 | 代表模型 | 优势 | 劣势 |
|---|---|---|---|
| BPE | GPT-2/3/4, Llama | 简单、可控 | 词表增长慢 |
| Unigram | T5, ALBERT | 似然最优、可逆 | 训练慢 |
| WordPiece | BERT | 似然导向 | 多语种支持差 |
| SentencePiece | 多语种模型 | 语言无关 | 配置复杂 |
效应量限定:算法之间的差异可能远小于直觉。 上表给的是方向性判断,落地时应先看效应量。LREC 2026 主会(Osváth, Molnár, Gunics, Ligeti-Nagy《The Impact of Tokenization Algorithms on Hungarian Language Model Performance》, pp. 2545–2556, DOI 10.63317/29hx92kq2dxe)在 OSCAR 匈牙利语子集上跨多个词表规模比较 BPE / WordPiece / Unigram(含一个形态感知初始化的改良 Unigram 变体),结论有三条:BPE 产出最紧凑且形态对齐最好的子词表示;改良 Unigram 的下游表现最好;但各方法之间、各词表规模之间的差异总体很小,除 HuCoPA 一个任务外都不具统计显著性。
⚠️ 匈牙利语不等于中文,跨语言外推需标注。 但对本章有一条直接可用的落地含义:§9.4.1 的”中文推荐 unigram”应理解为”中文场景下 unigram 是合理默认”,而不是”unigram 显著优于 BPE”;调词表规模与语料清洗的优先级,应当排在算法选型之前。
9.1.5 BPE 的改进方向
BPE 虽然是大模型事实标准,但其合并策略仍有显著改进空间。2025-2026 年两项工作值得关注:
- SuperBPE(COLM 2025):重新设计 BPE 的合并方式,在 30 个 benchmark 上平均提升 4.0%(30 个任务中 25 个胜出)、MMLU 提升 8.2%、减少 33% token 数量、推理计算节省 27%。官方已于 2025-11-03 发布 128k 词表的更新版英文 SuperBPE tokenizer,并推荐用于后续实验,可直接取用
⚠️ 跨语种限定(中文项目引入前必读): 上面四项数字是作者本人在以英语为中心的 benchmark、8B 规模上报告的,本章至今没有拿到中文上的同设计对照。SuperBPE / BoundlessBPE 的核心机制是放松”token 不得跨空白”这一约束——而这个”空白”假设在中文这类不靠空格分词的语言上是否成立,与 §9.3.3 坑1 讨论的中文切分问题是同一条线索。中文项目引入前,应在自己的中文语料上先做 bits-per-byte 或 tokens/千字 的对照,而不是直接套用 4.0%。
-
LiteToken(2026-02):去除 BPE 的”中间合并残留”(intermediate merge residues),不需要重训模型即可应用到已有 tokenizer——对工业团队有直接实用价值
-
BoundlessBPE:与 SuperBPE 同属”放松 token 不得跨空白”约束的谱系,在通用文本上最多减少 33% token(对比 PickyBPE 仅 ~1.1% 收益)
落地提示(这是该方案能否排进 pipeline 的分界线):BoundlessBPE 的早期实现训练极慢——1GB 数据需要 4.7 个 CPU 天,工业上基本不可用。arXiv:2604.05192《Faster Superword Tokenization》(Schmidt、Tanner、Pinter,2026-04-06,v2 2026-08-12)指出 supermerge 候选可以像普通 pretoken 一样按频率聚合,从而不必把整篇文档驻留在内存(原实现的最大瓶颈),把同一 1GB 数据的训练时间压到 603 秒(BoundlessBPE)/ 593 秒(SuperBPE),加速 600 倍以上,并为 BPE / SuperBPE / BoundlessBPE 三者各开源参考 Python 实现与快速 Rust 实现。
同一工作还厘清了两件事:① 给出 BoundlessBPE 的两阶段形式化(第一阶段学常规 merge、第二阶段学 supermerge),结果与原实现完全一致;② 证明两阶段 BoundlessBPE 与 SuperBPE 近似等价,唯一差别是 SuperBPE 需手工设定的那个超参,可在 BoundlessBPE 第二阶段自动确定。
→ 因此本章把二者并列呈现时读者必然产生的”该选哪个”疑问,答案是:选型主要看实现可用性,而非效果差异。
“跨空白超词”思想的推理侧外溢:SuperBPE/BoundlessBPE 的”跨空白超词”思想,其应用边界不止预训练效率,还外溢到了推理(生成)效率领域——Shorthand for Thought(arXiv:2604.26355,Writer Inc.,已录用 COLM 2026)对模型自己的 reasoning trace 做跨词 BPE 合并,得到模型专属的 supertoken 追加到基础词表,再仅微调 embedding 层 + LM head + 少数 transformer 层来”教会”模型使用它们。五个 benchmark 上推理轨迹平均缩短 8.1%。关键区分:它压缩的是“表达方式”而非”内容”,与内容级剪枝(step pruning、latent 替换等丢弃推理步骤的做法)有本质区别——后者会牺牲推理内容,前者只是换一种更紧凑的符号表达。
这些工作说明:即使 BPE 已是大模型事实标准,其合并策略仍有显著改进空间。对于训练成本敏感的项目,优先考虑 LiteToken(即插即用);对于新项目,SuperBPE 可提供更好的 token 效率。
补充一个时点判断:SuperBPE / BoundlessBPE 此前的主要障碍不是效果而是训练成本(1GB 数据 4.7 个 CPU 天)。自 2026-04 起该成本已降至约 10 分钟,且有了开源 Rust 实现,因此”新项目上 SuperBPE / BoundlessBPE”这条建议现在具备工程可行性,不再只是论文结论。上一节若只写”能省 33% token”而不写这一点,读者会得到一个正确但无法落地的结论。
再补一根轴:这个决策能不能事后改。 上面的”时点判断”讲的是训练 tokenizer 本身贵不贵;工程上更常被问的是另一件事——我已经有一个训好的基座,还能不能改。按这一问法,本章涉及的三条路线分属三档:
路线 是否需要重训基座 可回退性 LiteToken(清理中间合并残留) 不需要重训模型,直接应用到已有 tokenizer 高 byteification(Nature,2026-10-07) 不需要重训基座,追加一段对齐训练 高 SuperBPE / BoundlessBPE 词表变了 → embedding 与 LM head 需重训 低(决策要在项目早期做) 因此本章”成本敏感用 LiteToken,新项目用 SuperBPE”的建议仍然成立,但读者还应知道:“已有基座、想改善 token 效率”这个场景现在有 LiteToken 与 byteification 两条不重训的路,不是只能在”忍着”和”重训”之间二选一。
9.2 SentencePiece 详解
SentencePiece 是 Google 开发的多语种 Tokenization 库,支持 BPE 和 Unigram 两种算法。
9.2.1 基础用法
import sentencepiece as spm
# 训练 SentencePiece 模型
spm.SentencePieceTrainer.train(
input='corpus.txt',
model_prefix='tokenizer',
vocab_size=32000,
character_coverage=0.9995,
model_type='bpe', # 或 'unigram'
normalization_rule_name='nmt_nfkc',
input_sentence_size=1000000, # 训练时抽样100万句
shuffle_input_sentence=True,
train_extremely_large_corpus=True
)
# 使用
sp = spm.SentencePieceProcessor()
sp.load('tokenizer.model')
# 编码
tokens = sp.encode('Hello, world!')
print(tokens) # [1234, 5678, 9, 10]
print(sp.id_to_piece(tokens)) # ['▁Hello', ',', '▁world', '!']
# 解码
text = sp.decode(tokens)
print(text) # 'Hello, world!'
9.2.2 关键参数详解
# 推荐的 SentencePiece 训练参数
TRAINING_CONFIG = {
'vocab_size': 32000, # 词汇表大小
'character_coverage': 0.9995, # 字符覆盖率(多语种推荐)
'model_type': 'bpe', # BPE 或 unigram
'normalization_rule_name': 'nmt_nfkc', # NFKC 归一化
# 训练效率
'input_sentence_size': 1000000,
'shuffle_input_sentence': True,
'train_extremely_large_corpus': True,
# 特殊 token
'pad_id': 0,
'eos_id': 1,
'unk_id': 2,
'bos_id': 3,
# 数字处理
'split_by_unicode_script': True,
'split_by_whitespace': True,
'split_by_number': True, # 数字按位拆
# 多语种支持
'add_dummy_prefix': True, # 句首加 ▁
'remove_extra_whitespaces': True,
}
9.3 词表设计权衡
9.3.1 词表大小对比
| 词表大小 | 优势 | 劣势 | 适用场景 | 实际案例 |
|---|---|---|---|---|
| 32K | 训练快、内存省 | 多语种覆盖差 | 英文单语种 | Llama-1/2 |
| 64K | 平衡 | 中等 | 英文+少量多语 | Qwen2.5-7B |
| 128K | 多语种好 | 内存大、训练慢 | 多语种模型 | Llama-3, GPT-4o(o200k_base) |
| 152K | 更大覆盖 | 更大内存 | 超多语种 | Qwen2.5-72B |
| 200K | 多语种极好 | 大内存+慢 | 超多语种 | GPT-4o |
| 256K | 极多语种 | 内存巨大、稀有token多 | 超多语种 | Gemma-2/3 (262K) |
词表军备竞赛的背后逻辑:NeurIPS 2024 证明词表大小与模型参数量呈 log-linear 关系——7B 模型最优 32K,70B 需 216K。词表膨胀并非简单的”越大越好”,而是与模型规模协同设计的必然结果。
9.3.2 词表大小对模型的影响
设总训练 tokens 数 = T,词表大小 = V
参数量贡献:embed 层 = V × d_model
| 词表 | d_model=4096 | embed 参数占比(7B) |
|---|---|---|
| 32K | 134M | 1.9% |
| 64K | 268M | 3.8% |
| 128K | 536M | 7.7% |
| 256K | 1.07B | 15.3% |
词表过大时,embedding 层占参数比例过高,挤压 transformer 主体。 256K 时 embedding 占 15%,已经不划算。
9.3.3 中文词表的坑
坑1:字符级切分导致碎片化
直接用 BPE 训练中文,结果可能是”你好”被切成 ▁, 你, 好 三个 token,效率低。
解决:用 SentencePiece + Unigram,让算法学习常见中文词组。
坑2:字节级切分导致 OOV
字节级 BPE 把中文每个字切成 3 个 UTF-8 字节,“你好” 变成 6 个 token,效率极差。
解决(分两句,禁的对象不同):
- 如果是从零预训练、直接采用字节级 BPE 处理中文:确实极差,用字符级 BPE。上面这句”不要用字节级”只在这个语境下成立。
- 但”字节级”作为一条技术路线并没有被判死刑,因为它现在可以事后补上。
2026-10-07,Ai2 的 byteification 工作发表于 Nature(《Retrofitting language models to operate over bytes》,Minixhofer et al.;Ai2 官方博客同日发布)。它的做法是把已经训好的 subword 模型改造成 byte-level 模型:阶段 1 冻结原全局模型、只训练新增的 byte-level 组件做对齐;阶段 2 再做全模型训练。产物是 Bolmo 1B / Bolmo 7B(源模型 Olmo)、Bwen 8B(源 Qwen3 8B)、Blama 8B(源 Llama 3 8B)——即该流程不限于自家基座,可迁移到其他模型族;官方称 Bwen 8B 是其最强的 byteified 模型,在聚合评测上超过 Bolmo 7B。原始 Bolmo 论文(arXiv:2512.15586,2025-12-17)的摘要结论是:转换后的模型超过此前所有 byte-level 方案、在字符级推理任务上表现突出、同时在标准 benchmark 上保持竞争力。
对中文读者,这条路线有两点直接意义:① 它针对的正是 subword tokenization “obscures fine-grained information” 这一缺陷,收益场景明确包含需要细粒度字符理解的代码与生物序列;② “English-centric 词表偏置”是在 subword 范式下难以绕开的问题,而 byte-level 天然不受词表约束。
⚠️ 待核实(本轮未获一手来源,不写入正文数字):
可被本章安全引用的部分(已一手核实)是:Nature 正刊发表 + 两阶段转换配方 + 四个产物模型名 + 可迁移到其他模型族。成本与增益的量级请回 Nature 正文核实后再引用。
坑3:多语种词汇表分配失衡
某团队词表 64K,给中文分配 8K,结果中文常用词覆盖不全。中文 3500 常用字 + 几千常用词组就需要 15K+ token。
解决:按语种比例分配词汇表。
# 推荐的多语种词表分配(128K 词表)
VOCAB_ALLOCATION = {
'english': 50000, # 40%(主力语言)
'chinese': 30000, # 24%(中文字符+词组)
'code_tokens': 20000, # 16%(代码专用)
'numbers_punctuation': 10000, # 8%
'special_tokens': 8000, # 6%(多语种稀有用)
'reserved': 10000, # 8%(预留)
}
共享词表不是唯一解:模块化分词器。 上面的分配表在”必须用一张共享词表”时仍然是正确解法,但配额只能缓解、不能消除共享带来的两个结构问题。arXiv:2609.15528《To Each Language Its Tokenizer: Modular Tokenizers for Efficient Multilingual LLMs》(2026-09-14)给出的替代方案分两层:① 学习大型 modular BPE / Unigram 词表,可从中抽取面向任意语言子集的 subtokenizer,压缩率与单语 tokenizer 相当,并改善跨语言公平性;② 预训练时按 batch 采样 subtokenizer、把预测限制在相关词表子集上,从而在大词表下仍高效训练;推理时可用任意语言词表组合,降内存、提速且不牺牲效果。
与本章的关系有两点值得单独记:跨语言公平性是本章 §9.3.3 从未讨论过的一根轴(本章只讨论”覆盖够不够”,不讨论”各语言压缩率是否公平”);“模型实际只用得到语言子集”造成的浪费,是 §9.3.2 从”参数占比”角度讨论词表大小时没有覆盖的视角。
选型判据:“按语种分配配额”是共享词表约束下的工程兜底(零改造成本),“模块化词表”是结构解法(需要训练与推理管线配套)。前者今天就能用,后者要改管线。
坑4:数字处理
数字”12345” 可以是:
12345单个 token(数字直记)1,2,3,4,5按位拆(推荐)
推荐按位拆:让模型学会数字运算,而不是死记大数字。
# SentencePiece 配置:数字按位拆
spm.SentencePieceTrainer.train(
...
split_by_number=True, # 启用数字按位
max_sentence_length=4192, # 长句截断
num_sub_iterations=2, # 子迭代次数
)
9.4 中文 Tokenizer 实战
9.4.1 训练中文 Tokenizer
import sentencepiece as spm
# 训练中英双语 Tokenizer
spm.SentencePieceTrainer.train(
input='corpus_zh_en.txt',
model_prefix='tokenizer_zh_en',
# 词汇表
vocab_size=64000,
character_coverage=0.9995,
# 算法
model_type='unigram', # 中文推荐 unigram
# 数字与标点
split_by_unicode_script=True,
split_by_whitespace=True,
split_by_number=True,
# 中文专用配置
# 不限制字符种类,让算法学习
max_sentence_length=8192,
# 训练效率
input_sentence_size=2000000,
shuffle_input_sentence=True,
train_extremely_large_corpus=True,
)
# 测试
sp = spm.SentencePieceProcessor()
sp.load('tokenizer_zh_en.model')
# 中文
text_zh = "你好,世界!这是一个测试。"
print(sp.encode(text_zh)) # [5, 234, 17, 89, 92, ...]
print(sp.encode_as_pieces(text_zh)) # ['▁', '你好', ',', '世界', '!', '这是', '一个', '测试', '。']
# 英文
text_en = "Hello, world! This is a test."
print(sp.encode_as_pieces(text_en)) # ['▁Hello', ',', '▁world', '!', ...]
# 代码
text_code = "def hello():\n print('hi')"
print(sp.encode_as_pieces(text_code))
9.4.2 踩坑:稀有 token 过多
某团队训出的 Tokenizer 有 30% 的 token 在训练语料中只出现 1-2 次,这些”死 token”占用词表空间但没贡献。
解决:训练后做 token 频率分析,删除低于阈值的 token。
def analyze_token_frequency(sp, corpus_file):
"""分析 token 频率"""
from collections import Counter
freq = Counter()
with open(corpus_file) as f:
for line in f:
tokens = sp.encode(line.strip())
for t in tokens:
freq[t] += 1
# 找出稀有 token
rare_tokens = [t for t, count in freq.items() if count < 5]
print(f'Rare tokens (count<5): {len(rare_tokens)} / {len(freq)}')
return freq
除”稀有 token 占比”,还应报”词表利用率”。 上面这段代码给的是绝对频次视角:有多少 token 出现次数低于阈值。它回答不了另一个问题——这张词表有多大比例被真正用起来了。建议在代表性语料上同时统计”实际出现过的词表条目数 / 词表总条目数”,即词表利用率(vocabulary utilization)。
之所以要单独报这个数:arXiv:2610.01127(2026-10-01)在把词表扩到 1M 时测得 CNF-MCE 的利用率为 96.9%,BPE 为 52.9%,且 BPE 的利用率在 512K → 1M 区间已经走平——利用率曲线比单点压缩率更能暴露”词表建得对不对”。只数稀有 token,会在”词表建错了但每个建错的 token 都被用到”的情况下给出干净的报告。
⚠️ 读压缩率提升时的防呆:先问增益来自算法还是词表。 CNF-MCE 报告在 250K 词表下相对 o200k_base 与 qwen250k 提升压缩率 26% / 30%(正文 §4.2 为 26.7% / 32.4%)。但第三方评审(Pith)指出:在固定 qwen250k 词表下,MCE 相对 BPE 在英文 web 文本上只从 222.5 → 221.6 tokens/kchar(约 0.4%);换成 MCE 自建的 mix250k 词表才降到 168.0(24.5%)——增益几乎全部来自”跨空白的多词词表”这一性质,而它正是 SuperBPE / BoundlessBPE 已经提供的;该论文未做 matched-size 的 SuperBPE / BoundlessBPE 对比。评审同时指出摘要与正文数值不一致(26%/30% vs 26.7%/32.4%),全文无种子与误差棒。()
9.5 Packing 三种策略
Packing 是把变长 token 序列打包成定长序列(如 4096)以喂给 GPU。
9.5.1 三种策略对比
| 策略 | 描述 | 利用率 | 推荐度 |
|---|---|---|---|
| Naive Padding | 单文档填充到定长 | 30-50% | ❌ 不推荐 |
| Concat Packing | 多文档拼接 | 90-95% | ⚠️ 注意跨文档 |
| Best-Fit Packing | 最佳适配装箱 | 95-100% | ✅ 推荐 |
9.5.2 Naive Padding(不推荐)
# Naive Padding:单文档填充
def naive_padding(tokens, max_length=4096, pad_id=0):
"""单文档填充到 max_length"""
if len(tokens) > max_length:
return tokens[:max_length]
return tokens + [pad_id] * (max_length - len(tokens))
# 问题:如果平均文档长度 500 token,填充到 4096 后
# 利用率 = 500/4096 = 12%,GPU 90% 时间在算 padding
9.5.3 Concat Packing
def concat_packing(documents, max_length=4096):
"""
多文档拼接打包
简单但可能跨文档串味
"""
packed = []
current_pack = []
current_length = 0
for doc in documents:
if current_length + len(doc) > max_length:
# 当前包满,处理
if current_length < max_length:
# 填充剩余
current_pack.extend([0] * (max_length - current_length))
packed.append(current_pack)
# 开始新包
current_pack = doc[:max_length]
current_length = len(current_pack)
else:
current_pack.extend(doc)
current_length += len(doc)
return packed
9.5.4 Best-Fit Packing(推荐)
def best_fit_packing(documents, max_length=4096):
"""
最佳适配装箱
类似装箱问题,用 First-Fit-Decreasing 算法
"""
# Step 1: 按文档长度降序排序
sorted_docs = sorted(enumerate(documents), key=lambda x: -len(x[1]))
# Step 2: 用 bin packing 算法
bins = [] # 每个bin是 (current_length, doc_indices)
for doc_idx, doc in sorted_docs:
doc_len = len(doc)
# 找到第一个能装下的 bin
placed = False
for i, (bin_len, _) in enumerate(bins):
if bin_len + doc_len <= max_length:
bins[i] = (bin_len + doc_len, bins[i][1] + [doc_idx])
placed = True
break
# 没找到,开新 bin
if not placed:
bins.append((doc_len, [doc_idx]))
# Step 3: 生成 packed 序列
packed = []
for bin_len, doc_indices in bins:
sequence = []
for idx in doc_indices:
sequence.extend(documents[idx])
# 填充
if len(sequence) < max_length:
sequence.extend([0] * (max_length - len(sequence)))
packed.append({
'tokens': sequence[:max_length],
'doc_indices': doc_indices,
'utilization': bin_len / max_length
})
return packed
9.5.5 利用率对比
Naive Padding: 平均利用率 ~25%(如果平均文档 1000 tokens)
Concat Packing: 平均利用率 ~92%
Best-Fit Packing: 平均利用率 ~98%
Best-Fit 相比 Naive 提升 4 倍 GPU 利用率——同样的算力能训 4 倍数据。
9.6 Document Mask:防跨文档串味
Concat Packing 和 Best-Fit Packing 把多个文档塞进一个序列,但 transformer 的 attention 会让文档 A “看到” 文档 B 的内容。
9.6.1 跨文档串味问题
Packed 序列:
[doc_A (1000 tokens)] [doc_B (2000 tokens)] [doc_C (1096 tokens)]
问题:训练时 doc_A 的 token 会 attend 到 doc_B 的 token
→ 模型学到不正确的"上下文"
9.6.2 解决:Document Mask
import torch
def build_document_mask(doc_indices, max_length=4096):
"""
构建 document mask
阻止跨文档 attention
返回: [max_length, max_length] 的 mask
1 = 允许 attention, 0 = 阻止
"""
# 给每个 token 标记所属文档
token_doc_id = []
for doc_idx, doc_len in doc_indices:
token_doc_id.extend([doc_idx] * doc_len)
# 填充部分标记为 -1
while len(token_doc_id) < max_length:
token_doc_id.append(-1)
token_doc_id = torch.tensor(token_doc_id)
# 构建 mask:同 doc id 的 token 才能 attention
mask = (token_doc_id.unsqueeze(0) == token_doc_id.unsqueeze(1)).float()
# 把 padding 的部分(-1)置为 0,避免 padding 互相 attention
padding_mask = (token_doc_id != -1).float()
mask = mask * padding_mask.unsqueeze(0) * padding_mask.unsqueeze(1)
return mask
9.6.3 Megatron-LM 的实现
Megatron-LM 用 attention_mask 参数实现 document mask:
# Megatron 格式
import numpy as np
def create_megatron_format(documents, tokenizer, max_length=4096):
"""
生成 Megatron-LM 训练格式
输出: .bin (token序列) + .idx (索引)
"""
all_tokens = []
all_doc_lens = []
# Best-fit packing
packs = best_fit_packing(documents, max_length)
for pack in packs:
all_tokens.extend(pack['tokens'])
# 记录每个文档长度
for doc_idx in pack['doc_indices']:
all_doc_lens.append(len(documents[doc_idx]))
# 保存 .bin (二进制 token 序列)
tokens_array = np.array(all_tokens, dtype=np.uint16)
tokens_array.tofile('train.bin')
# 保存 .idx (索引)
# 格式: [num_docs] [doc_lens...] [context_lengths...] [sequence_lengths...]
write_idx_file('train.idx', all_doc_lens, max_length)
return 'train.bin', 'train.idx'
多模态特化:视觉 token 与多模态 packing
本章的 tokenization 有一个隐含前提:输入先被切成离散符号,再查表映射成 id。图像没有这一步——图像不分词,它先被切块编码成视觉 token:ViT 按 patch(常见 14x14)把一张图切成若干块,每块经视觉编码器产出 token,再经投影层对齐到 LLM 的隐空间。于是”词表大小""OOV""数字按位拆”基本失效,取而代之的是三个新问题。
1. 序列长度剧烈波动,token 预算成为配比的一等公民。固定分辨率时代一张图就是几百个 token,动态分辨率之后完全不同:LLaVA-NeXT 的 anyres 把图切成多个子图分别编码,InternVL 按 448x448 的 tile 拼接,Qwen2.5-VL 的原生动态分辨率让单图 token 数可在 4 到 16384 之间浮动。于是 packing 不再是单纯的”填充率优化”,而是配比问题:视觉 token 占掉多少预算,直接决定文本侧还剩多少容量——这与第 10 章的数据配比是同一个决策的两面。
2. 视觉 token 压缩是有代价的。三条路线:pixel shuffle(InternVL 系,重排 + 通道折叠,压缩比温和且保留细粒度)、Perceiver Resampler(Flamingo 系,压到固定长度的 latent)、Q-Former(BLIP-2 系,同样压到固定长度,另带预训练目标)。后两者长度可控、显存友好,代价是丢失细粒度 OCR 能力——把整图压成几十个 latent,小字、表格线、公式下标基本就没了。选型判据很直接:下游要不要读文档和图表?要,就别用重度压缩。
3. Packing 的约束比 9.6 更严。9.6 的 document mask 解决”跨文档串味”,多模态下还要再加两条硬约束:一张图不能被拆到两个训练样本里(视觉 token 序列必须完整落在同一条序列内),以及同一样本内的多图之间、图文之间需要 attention 隔离。前者意味着 packer 必须按”图块”而非按 token 装箱(本质是带不可分割物品的 bin packing),后者意味着 attention mask 要比 9.6.2 多一层样本/模态维度的分块。
结论:多模态训练的吞吐与显存由视觉侧主导——视觉 token 数通常远超同一样本的文本 token,优化视觉 token 数的收益比优化文本 packing 率高一个量级。
(详见第 26 章多模态数据工程)
9.7 Megatron 格式:.bin + .idx
Megatron-LM 用一种高效的二进制格式存储 token 数据,配合 mmap 实现零拷贝读取。
9.7.1 文件结构
train.bin # 二进制 token 序列(uint16 或 uint32)
train.idx # 索引文件(文档边界信息)
9.7.2 索引文件格式
# .idx 文件结构(简化版)
def write_idx_file(idx_path, doc_lens, max_length):
"""
写入 Megatron 索引文件
"""
import struct
with open(idx_path, 'wb') as f:
# Header
f.write(struct.pack('I', 1)) # 版本号
f.write(struct.pack('Q', len(doc_lens))) # 文档数
f.write(struct.pack('I', max_length)) # 序列长度
# 文档长度列表
for doc_len in doc_lens:
f.write(struct.pack('I', doc_len))
# 文档起始位置(在 .bin 中的偏移)
offset = 0
for doc_len in doc_lens:
f.write(struct.pack('Q', offset))
offset += doc_len
# 序列边界(每个 max_length 一个 sequence)
num_sequences = offset // max_length
f.write(struct.pack('Q', num_sequences))
for i in range(num_sequences):
seq_start = i * max_length
seq_end = min((i + 1) * max_length, offset)
f.write(struct.pack('Q', seq_start))
f.write(struct.pack('Q', seq_end))
9.7.3 mmap 读取
import numpy as np
import mmap
class MegatronDataset:
"""Megatron 数据集 mmap 读取"""
def __init__(self, bin_path, idx_path):
# 加载索引
self.idx = self._load_idx(idx_path)
# mmap 二进制数据
self.bin_file = open(bin_path, 'rb')
self.bin_mmap = mmap.mmap(
self.bin_file.fileno(), 0,
access=mmap.ACCESS_READ
)
# 转换为 numpy view(零拷贝)
self.tokens = np.frombuffer(
self.bin_mmap, dtype=np.uint16
)
def __getitem__(self, idx):
"""获取第 idx 个 sequence"""
start = self.idx['seq_starts'][idx]
end = self.idx['seq_ends'][idx]
return self.tokens[start:end]
def __len__(self):
return self.idx['num_sequences']
# 使用
dataset = MegatronDataset('train.bin', 'train.idx')
print(f'Total sequences: {len(dataset)}')
print(f'First sequence: {dataset[0]}')
mmap 优势:
- 零拷贝:数据不进入 Python 内存
- 多进程共享:多个 DataLoader 进程共享同一份 mmap
- 大文件友好:100GB+ 数据也能轻松加载
9.8 工业级 Tokenization 流水线
class TokenizationPipeline:
"""工业级 Tokenization + Packing 流水线"""
def __init__(self, tokenizer_path):
self.tokenizer = spm.SentencePieceProcessor()
self.tokenizer.load(tokenizer_path)
def process(self, documents, max_length=4096):
"""
完整处理流程
documents: list of {'text': str, 'metadata': dict}
"""
# Step 1: Tokenize
tokenized = []
for doc in documents:
tokens = self.tokenizer.encode(doc['text'])
# 过滤过长文档(或切分)
if len(tokens) > max_length:
# 切分成多个 chunk
for i in range(0, len(tokens), max_length):
tokenized.append({
'tokens': tokens[i:i+max_length],
'doc_id': doc.get('id'),
'chunk_id': i // max_length
})
else:
tokenized.append({
'tokens': tokens,
'doc_id': doc.get('id'),
'chunk_id': 0
})
# Step 2: Best-Fit Packing
packed = best_fit_packing(
[d['tokens'] for d in tokenized],
max_length=max_length
)
# Step 3: 构建 Document Mask
for pack in packed:
pack['attention_mask'] = build_document_mask(
pack['doc_indices'], max_length
)
# Step 4: 保存 Megatron 格式
self._save_megatron_format(packed, max_length)
return packed
9.9 踩坑经验
坑1:词表过小导致 OOV
某团队用 16K 词表,结果中文常用字都覆盖不全,“矗” 这种字频繁 <unk>。词表至少 32K,多语种至少 64K。
坑2:未做归一化导致同义词被切开
“café” 和 “cafe” 被切成不同的 token,模型把它们当成两个词。必须做 NFKC 归一化。
坑3:Packing 没做 Document Mask
某团队用 Concat Packing 但没做 document mask,结果模型经常在结尾生成下一段的开头(跨文档泄露)。必须做 document mask。
坑4:数字未按位拆
某团队训出的 Tokenizer 把”12345”作为一个 token,结果模型不会做 2345 + 10000 这类运算。数字必须按位拆。
坑5:mmap 读取时文件被修改
某团队在训练中重新生成了 .bin 文件,导致 mmap 数据损坏、训练 loss 突然飙升。生成新数据必须用新文件名,不能覆盖正在使用的文件。
9.10 本课总结
- 三大算法:BPE(GPT)、Unigram(T5)、WordPiece(BERT)
- SentencePiece 是多语种 Tokenization 的事实标准
- 词表大小权衡:32K(英文)→ 64K(双语)→ 128K(多语)→ 256K(超多语)
- 中文坑:字符级而非字节级、Unigram 优于 BPE、按比例分配词表
- Packing:Naive(不推荐)→ Concat → Best-Fit(推荐,利用率 98%)
- Document Mask:防止跨文档 attention 串味
- Megatron 格式:.bin + .idx + mmap,零拷贝高效读取
课后练习
- 训练一个 32K 的中英双语 Tokenizer,评估其压缩比
- 实现三种 Packing 策略,对比利用率
- 实现 Document Mask,验证其阻止跨文档 attention
- 用 mmap 加载 1GB token 数据,测量加载时间和内存占用
下一章我们将进入课程化与退火策略——预训练的最后阶段,决定模型最终能力的”压轴戏”。
🛠️ 本章衍生工具
数据工程·Packing 装箱可视化
《AI 数据全书》第 9 章「Tokenization 与序列 Packing」配套——① 分词器竞技场:BPE / Unigram / WordPiece 三种算法在浏览器内真训真跑(BPE 数 pair 合并、Unigram 跑 EM + Viterbi、WordPiece 按互信息式评分),8 条样本(中英混合/代码/数字/emoji/古文/多语种菜单/超长复合词/纯标点)并排切分 + §9.3.2 词表参数账(32K 占 1.9% 一路到 262K 占 15.3%,超过 15% 弹出「已经不划算」原文告警)+ log-linear 最优词表曲线 + §9.3.3 中文四坑一键复现(字符级 ▁/你/好 3 token vs 学会词组的 1 token · 字节级 BPE 6 token · 词表分配失衡 15K 门槛 · 数字按位拆)· ② Packing 装箱沙盘:Naive / Concat / FFD / BFD 四策略并排实算,5 种文档长度分布 + 文档数/均值/方差 σ/序列长度滑杆,利用率与 padding 浪费全部浏览器现算、不硬编码,带 rAF 逐步装箱动画 + §9.5.5 结论复现面板(书上的 25/92/98% 只作对照列,实算倍率随分布从 1.3 倍跳到 8 倍,讲清「4 倍是有条件的」)+ 书上三处源码 bug 的找 bug 卡 · ③ 跨文档串味实验室:doc_A 1000 + doc_B 2000 + doc_C 1096 的块级 attention 可见性矩阵 + 鼠标探针看「这个 token 能看见谁」+ §9.9 坑 3 续写示意 + Megatron .bin/.idx 七字段结构图 · ④ 多模态 Packing 升级包:视觉 token 预算滑杆(4~16384)+ 三条压缩路线的 OCR 保真度权衡 + 不可分割物品的 bin packing(图被拆到两条序列时整条变红,这是纯文字 packing 不存在的新失效模式)+ 三维 attention mask
Tokenizer 可视化 2.0
4 标签页——BPE 学习 + 13 种 Tokenizer (2024-2026) + 多模态视觉分词实验室(DiVT/UniAR) + PK 对决排行榜