章节导航 第 8 章 / 共 20 章
Tokenization与Packing
BPE/Unigram/SentencePiece算法、词表设计(32K/64K/128K/256K)、中文坑(字节切分/多语种平衡/数字按位拆)、Packing三种策略、Document Mask、Megatron格式(.bin/.idx+mmap)
Tokenizer 是文本与张量之间的桥梁。
Packing 是数据工程的最后一公里。两者决定 GPU 的真实利用率。
8.1 三大 Tokenization 算法
8.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', ...]
8.1.2 Unigram
Unigram 是 SentencePiece 的可选算法。
核心思想:从一个大词汇表开始,迭代删除”对整体似然贡献最小”的 token。
初始词汇表: 100K 个候选 token
训练语料: 大量文本
迭代1: 计算每个 token 对似然的贡献,删除贡献最小的 10%
迭代2: 重新计算,再删除 10%
...
最终词汇表: 32K 个最优 token
8.1.3 WordPiece
WordPiece 是 BERT 使用的算法,与 BPE 类似但选择标准不同:
- BPE:选择频率最高的对
- WordPiece:选择使整体似然最大的对
8.1.4 算法对比
| 算法 | 代表模型 | 优势 | 劣势 |
|---|---|---|---|
| BPE | GPT-2/3/4, Llama | 简单、可控 | 词表增长慢 |
| Unigram | T5, ALBERT | 似然最优、可逆 | 训练慢 |
| WordPiece | BERT | 似然导向 | 多语种支持差 |
| SentencePiece | 多语种模型 | 语言无关 | 配置复杂 |
8.1.5 BPE 的改进方向
BPE 虽然是大模型事实标准,但其合并策略仍有显著改进空间。2025-2026 年两项工作值得关注:
- SuperBPE(COLM 2025):重新设计 BPE 的合并方式,在 30 个 benchmark 上平均提升 4.0%、MMLU 提升 8.2%、减少 33% token 数量、推理计算节省 27%
- LiteToken(2026-02):去除 BPE 的”中间合并残留”(intermediate merge residues),不需要重训模型即可应用到已有 tokenizer——对工业团队有直接实用价值
- BoundlessBPE:与 SuperBPE 同属”放松 token 不得跨空白”约束的谱系,在通用文本上最多减少 33% token(对比 PickyBPE 仅 ~1.1% 收益)
“跨空白超词”思想的推理侧外溢:SuperBPE/BoundlessBPE 的”跨空白超词”思想,其应用边界不止预训练效率,还外溢到了推理(生成)效率领域——Shorthand for Thought(arXiv:2604.26355,Writer Inc.)对模型自己的 reasoning trace 做跨词 BPE 合并,得到模型专属的 supertoken 追加到基础词表,再仅微调 embedding 层 + LM head + 少数 transformer 层来”教会”模型使用它们。五个 benchmark 上推理轨迹平均缩短 8.1%。关键区分:它压缩的是“表达方式”而非”内容”,与内容级剪枝(step pruning、latent 替换等丢弃推理步骤的做法)有本质区别——后者会牺牲推理内容,前者只是换一种更紧凑的符号表达。
这些工作说明:即使 BPE 已是大模型事实标准,其合并策略仍有显著改进空间。对于训练成本敏感的项目,优先考虑 LiteToken(即插即用);对于新项目,SuperBPE 可提供更好的 token 效率。
8.2 SentencePiece 详解
SentencePiece 是 Google 开发的多语种 Tokenization 库,支持 BPE 和 Unigram 两种算法。
8.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!'
8.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,
}
8.3 词表设计权衡
8.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。词表膨胀并非简单的”越大越好”,而是与模型规模协同设计的必然结果。
8.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%,已经不划算。
8.3.3 中文词表的坑
坑1:字符级切分导致碎片化
直接用 BPE 训练中文,结果可能是”你好”被切成 ▁, 你, 好 三个 token,效率低。
解决:用 SentencePiece + Unigram,让算法学习常见中文词组。
坑2:字节级切分导致 OOV
字节级 BPE 把中文每个字切成 3 个 UTF-8 字节,“你好” 变成 6 个 token,效率极差。
解决:用字符级 BPE,不要用字节级。
坑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%(预留)
}
坑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, # 子迭代次数
)
8.4 中文 Tokenizer 实战
8.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))
8.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
8.5 Packing 三种策略
Packing 是把变长 token 序列打包成定长序列(如 4096)以喂给 GPU。
8.5.1 三种策略对比
| 策略 | 描述 | 利用率 | 推荐度 |
|---|---|---|---|
| Naive Padding | 单文档填充到定长 | 30-50% | ❌ 不推荐 |
| Concat Packing | 多文档拼接 | 90-95% | ⚠️ 注意跨文档 |
| Best-Fit Packing | 最佳适配装箱 | 95-100% | ✅ 推荐 |
8.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
8.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
8.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
8.5.5 利用率对比
Naive Padding: 平均利用率 ~25%(如果平均文档 1000 tokens)
Concat Packing: 平均利用率 ~92%
Best-Fit Packing: 平均利用率 ~98%
Best-Fit 相比 Naive 提升 4 倍 GPU 利用率——同样的算力能训 4 倍数据。
8.6 Document Mask:防跨文档串味
Concat Packing 和 Best-Fit Packing 把多个文档塞进一个序列,但 transformer 的 attention 会让文档 A “看到” 文档 B 的内容。
8.6.1 跨文档串味问题
Packed 序列:
[doc_A (1000 tokens)] [doc_B (2000 tokens)] [doc_C (1096 tokens)]
问题:训练时 doc_A 的 token 会 attend 到 doc_B 的 token
→ 模型学到不正确的"上下文"
8.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
8.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'
8.7 Megatron 格式:.bin + .idx
Megatron-LM 用一种高效的二进制格式存储 token 数据,配合 mmap 实现零拷贝读取。
8.7.1 文件结构
train.bin # 二进制 token 序列(uint16 或 uint32)
train.idx # 索引文件(文档边界信息)
8.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))
8.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+ 数据也能轻松加载
8.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
8.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 突然飙升。生成新数据必须用新文件名,不能覆盖正在使用的文件。
8.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 数据,测量加载时间和内存占用
下一章我们将进入课程化与退火策略——预训练的最后阶段,决定模型最终能力的”压轴戏”。