大模型技术里,Tokenizer 往往是被忽视的第一道工序。很多学习路线从 Transformer、注意力机制开始,等到自己想训练一个模型或者复现一个开源模型时,才发现数据进入 embedding 层之前,文本必须先被转换成整数 id,而这串 id 的质量会直接影响模型能学到什么。这篇文章聚焦“从零构建大模型”的第一个环节:Tokenizer 的 encode 过程,并选择字节级 BPE(Byte Pair Encoding)作为核心算法。读完你可以理解 BPE 为什么能覆盖任意 Unicode 文本,能够用 Python 标准库实现一个最小可运行的 BPE 编码器,也知道训练词表和编码新文本时最容易踩哪些坑。整套实现不依赖深度学习框架,只需要 Python 3.8 以上的环境,适合作为学习大模型数据流水线的第一个动手项目。
1. 先搞清楚 Tokenizer 在整个大模型流程里的位置
1.1 从原始文本到 token id 的第一公里
预训练大模型时,训练数据通常是几 TB 的原始文本,但模型内部并不认识“字”或“词”。Transformer 的输入层接收的是离散 token id,每个 id 对应 embedding 矩阵中的一行向量。所以训练和推理都要经过同一条链路:
原始文本 -> 预分词 -> 字节编码 -> BPE 合并 -> token id 序列 -> embedding 查表Tokenizer 负责的是这条链路的前半段。有人把 Tokenizer 看成一个纯“工具”,认为它不重要,但实际并非如此。token id 序列的长度决定了每个样本占多少计算量;词表大小决定了 embedding 层的参数量;合并规则的质量决定了模型能否稳定处理生僻字、Emoji、多语言以及代码中的特殊符号。一个质量差的 Tokenizer 会让同一段语义被切得支离破碎,增加模型学习负担。
本文标题里特别强调 encode,是因为训练阶段和推理阶段都会调用 encode。训练时需要对整个语料批量编码,把文本变成 id 存成二进制数据集;推理时需要对用户输入实时编码。encode 的稳定性和速度,是 Tokenizer 工程化最需要关注的部分。
1.2 为什么选择 BPE 而不是按字切分
最朴素的想法是按字符切分,中文按单个汉字,英文按字母。但字符级切分会让序列变得非常长,一个 1000 词的英文段落会被切成一万多个字符,模型很难捕获“ing”“tion”这类高频子词单元。
另一个极端是按词切分,英文按空格分词,中文按词表分词。这会产生巨大的词表,而且遇到人名、专有名词、网络新词时会出现大量 OOV(Out of Vocabulary),也就是词表里找不到的单词。传统 NLP 里 OOV 需要特殊处理,在大模型时代这是不可接受的。
BPE 走的是第三条路:从字节或者字符开始,不断把语料中出现频率最高的相邻单元合并成一个新单元。它既能覆盖任意文本,因为底层是完整的 256 个字节,又能通过多次合并压缩序列长度,让高频片段成为独立的 token。大模型领域常见的 GPT 系列、ChatGPT 后端模型,以及大量开源模型都使用 BPE 或其变体,这正是它作为“从零构建大模型第一课”的原因。
1.3 Tokenizer 涉及的三个核心对象
实现 BPE Tokenizer,脑子里要始终装着三个对象。
第一个是词表 vocab,它保存“token id 到字节片段”的映射。初始状态下,词表就是 256 个字节,id 0 到 255 分别对应一个字节。每完成一次合并,就新增一个 id,词表扩大一点。
第二个是合并规则 merges,它保存“哪些相邻 token 对合并成哪个新 id”。比如 id 97 和 id 98 相邻出现频率最高,训练时合并出 id 256,这条规则就是(97, 98) -> 256。merges 的顺序非常重要,因为它记录了合并的先后优先级。
第三个是编码器本身,也就是 encode 函数。它接收字符串,输出整数列表。很多学习材料只讲词表,不讲 merges,这是不完整的。没有 merges,你无法把新文本编码成与训练一致的 id 序列。
2. BPE 的原理:字节、合并、优先级
2.1 字节级 BPE 的起点是 256 个字节
BPE 全称是 Byte Pair Encoding,直译是“字节对编码”。它的核心思路不是直接对字符做统计,而是对“相邻字节对”做统计。
在 Python 里,任何字符串都可以先编码成 UTF-8 字节序列。一个 ASCII 字符占一个字节,一个中文字符通常占三个字节,一个 Emoji 可能占四个字节。这样做的最大好处是:世界上任何一种 Unicode 文本,都可以用 0 到 255 的整数序列表示,不存在“词表里没有”的问题。
text = "大模型 Tokenizer" byte_seq = list(text.encode("utf-8")) print(byte_seq)这段代码会输出一串 0 到 255 之间的整数。它们是 BPE 训练的起点,也是 encode 的起点。初始词表不需要任何外部数据,直接构造:
vocab = {i: bytes([i]) for i in range(256)}这就是字节级 BPE 不容易出现 OOV 的原因。即使遇到训练语料里从没出现过的文字,它也能退化成单个字节表示,只是合并程度不高而已。
2.2 训练阶段:不断合并出现频率最高的相邻片段
训练 BPE 的过程可以描述成一个循环:
- 把整个训练语料编码成字节序列。
- 统计所有相邻 token 对的出现次数。
- 找出出现次数最多的那一对。
- 把这一对合并成一个新 token,新 token 的 id 是当前词表大小。
- 把合并后的序列作为新的序列,重复第 2 步。
- 直到词表达到预设的 vocab_size。
需要注意的是,合并是全局的。每一次合并完成后,整个语料中所有出现该相邻对的位置都会被替换。比如合并了(256, 100),那么语料里每一处“token 256 后面跟着 token 100”的地方,都会被替换成新的 id。
训练结束后的产出是两份数据:一份是 merges,记录了每次合并的原始对和新 id;另一份是 vocab,记录了每个 id 对应的字节内容。前者用于 encode,后者用于 decode。
2.3 编码阶段:按训练顺序应用合并
编码一个全新的文本时,不能用“看当前哪个相邻对频率最高”来决定合并。因为新文本可能和训练语料分布不同,按当前频率贪心合并会产生不稳定、不可解释的结果。
正确做法是:按照训练时的合并顺序来应用。具体来说,每次扫描当前 token 序列里的所有相邻对,找到“在 merges 中编号最小”的那一对,执行合并,再重复。之所以编号最小的优先,是因为训练时先合并的是全局频率最高的对,它们代表最稳定的语义单元,优先级应当最高。
pair = min(stats, key=lambda p: merges.get(p, float("inf")))float("inf")表示“这个相邻对从未被合并过”,优先级最低。当所有可合并的对都已经处理完,循环结束,剩下的整数序列就是编码结果。
这里有一个新手最容易犯的错:把训练阶段的max(stats, key=stats.get)直接抄到编码阶段。训练时选最高频,编码时选最小编号,两个阶段的目标完全不同。
2.4 为什么解码不能分段进行
decode 是 encode 的逆过程,逻辑上很简单:把每个 token id 对应的字节片段拼接起来,再按 UTF-8 解码。
def decode(ids, vocab): raw = b"".join(vocab[idx] for idx in ids) return raw.decode("utf-8", errors="replace")关键是不能对每个 token 单独调用decode("utf-8")。因为一个中文字符可能被切在三个字节中间,单个 token 的字节可能是残缺的 UTF-8 序列。必须先把所有字节拼接成完整字节流,再整体解码。使用errors="replace"是为了防止极少数情况下出现无法解码的残留字节时直接抛异常,在工程实现里更稳妥。
3. 环境准备与项目骨架
3.1 只需要标准库,不需要 GPU
本篇文章的 BPE 实现完全不依赖 PyTorch、TensorFlow 或任何第三方库。环境要求只有两点:
| 项目 | 要求 |
|---|---|
| Python | 3.8 及以上 |
| 第三方库 | 无,仅使用标准库 |
| GPU | 不需要 |
| 操作系统 | Windows / macOS / Linux 均可 |
先确认 Python 版本:
python --version如果输出类似Python 3.10.12,环境即可用。接下来创建一个项目目录,建议结构如下:
bpe-tokenizer/ ├── bpe.py # BPE 核心实现 ├── train.py # 训练脚本 ├── test.py # 编码解码验证脚本 └── data/ └── corpus.txt # 训练语料3.2 安装 tiktoken 做对照实验
虽然核心实现不依赖第三方库,但为了验证自己的 BPE 实现是否正确,推荐安装 tiktoken。tiktoken 是 OpenAI 开源的分词库,GPT 系列模型使用的就是其中的 BPE 实现。
pip install tiktokentiktoken 的作用有三个:一是对照验证自己的 encode 结果是否合理;二是用于理解真实生产环境对 tokenizer 的要求;三是作为后续实现更完善 tokenizer 的参考对象。注意它的 token 规则与我们的最小实现并不完全相同,所以不要期待同一句话编码出的 id 完全一致,重点是比较 token 数量和语义切分是否合理。
3.3 准备一份中英文混合语料
训练语料决定了合并规则的质量。这里准备一份很小的示例语料,只用于验证算法流程。实际训练大模型时,语料通常是 GB 级别,并且会经过清洗、去重、过滤敏感内容等步骤。
大模型是从零构建过程中最需要理解的一整套技术栈。 Tokenizer 是把文本变成整数序列的组件,也是数据进入模型的第一关。 Byte Pair Encoding 通过合并高频字节对,让模型可以用更短的序列表达文本。 在实际工程中,还需要处理特殊 token、词表存储、分布式训练和推理加速。 学习大模型时,应该先跑通 tokenizer,再进入 attention 和 transformer。保存为data/corpus.txt。这份语料虽然小,但足以观察 BPE 的合并行为和边界问题。真实项目中,建议语料至少覆盖目标语言的高频词汇、数字、标点、代码片段和特殊符号,否则编码效果会明显偏向训练语料中的高频语言。
4. 用 Python 实现一个最小 BPE 编码器
4.1 统计相邻 token 对:get_stats
训练 BPE 的第一步是统计相邻对频率。输入是一串整数 id,输出是一个字典,键为相邻对,值为出现次数。
def get_stats(ids: list[int]) -> dict[tuple[int, int], int]: counts = {} for pair in zip(ids, ids[1:]): counts[pair] = counts.get(pair, 0) + 1 return countszip(ids, ids[1:])会生成所有相邻的两个元素。比如ids = [1, 2, 2, 3],相邻对就是(1, 2)、(2, 2)、(2, 3)。这个函数在训练和编码中都会被调用。
对于教学实现,每次遍历整个序列来统计是完全可以接受的。生产环境面对的是数十 GB 语料,需要维护一个堆结构做增量统计,避免每次都全量扫描,这一点后面会展开。
4.2 合并相邻 token 对:merge
merge 函数把序列中所有等于指定 pair 的相邻位置替换成新 id。
def merge(ids: list[int], pair: tuple[int, int], idx: int) -> list[int]: new_ids = [] i = 0 while i < len(ids): if i + 1 < len(ids) and ids[i] == pair[0] and ids[i + 1] == pair[1]: new_ids.append(idx) i += 2 else: new_ids.append(ids[i]) i += 1 return new_ids注意两个细节。第一,合并后立即跳过两个位置,防止(pair[0], pair[1])继续和后面的元素组成新对;第二,合并是全局替换,整个序列中的所有匹配位置都会被替换,而不是只替换第一次出现的位置。
4.3 训练合并规则:train_bpe
有了统计和合并,训练函数就水到渠成了。
def train_bpe(text: str, vocab_size: int) -> tuple[dict, dict]: ids = list(text.encode("utf-8")) num_merges = vocab_size - 256 assert num_merges > 0, "vocab_size 必须大于 256" merges = {} vocab = {i: bytes([i]) for i in range(256)} for i in range(num_merges): stats = get_stats(ids) if not stats: print(f"语料中已无相邻对可合并,提前停止于第 {i} 次合并") break pair = max(stats, key=stats.get) idx = 256 + i ids = merge(ids, pair, idx) merges[pair] = idx vocab[idx] = vocab[pair[0]] + vocab[pair[1]] return merges, vocab每轮循环做四件事:统计、找最高频对、合并、记录规则。vocab[idx]保存的是新 token 对应的完整字节内容,它是左右两个 token 字节内容的拼接。这一步是为 decode 准备的,因为 decode 需要知道每个 id 到底代表什么字节。
这里要理解一个关键点:total merges 的数量等于vocab_size - 256。如果目标词表是 512,就需要合并 256 次。但如果语料太小,可能提前就没有可合并的相邻对了,函数会提前停止。
4.4 编码与解码:encode 与 decode
encode 是新文本进入模型前的核心入口。
def encode(text: str, merges: dict) -> list[int]: ids = list(text.encode("utf-8")) while len(ids) >= 2: stats = get_stats(ids) pair = min(stats, key=lambda p: merges.get(p, float("inf"))) if pair not in merges: break ids = merge(ids, pair, merges[pair]) return ids这个循环的精髓在于min(stats, key=lambda p: merges.get(p, float("inf")))。它不找最高频对,而是找“在训练时最早被合并”的相邻对。因为训练时先合并的对优先级更高,编码时也应该先应用它们。如果一个相邻对从未在训练中出现过,merges.get(p, float("inf"))返回无穷大,它永远不会被优先选中。当所有候选对都不在 merges 中时,循环终止。
decode 在前文已经给出完整实现:
def decode(ids: list[int], vocab: dict) -> str: raw = b"".join(vocab[idx] for idx in ids) return raw.decode("utf-8", errors="replace")4.5 特殊 token 与预留 id
真实模型里还有一类特殊的 token,比如<|endoftext|>、<|sep|>、<|pad|>。它们不是从语料中合并出来的,而是人为预留的 id。处理方式通常是在词表尾部预留一段 id 区间,训练和编码时把特殊 token 单独识别出来,不参与 BPE 合并。
教学实现可以这样处理:先在词表里预留特殊 token 的 id,再计算可用的合并次数。
def train_bpe_with_specials(text, vocab_size, special_tokens): num_specials = len(special_tokens) num_merges = vocab_size - 256 - num_specials assert num_merges > 0, "vocab_size 太小,无法容纳特殊 token 和合并结果" ids = list(text.encode("utf-8")) merges = {} vocab = {i: bytes([i]) for i in range(256)} for i in range(num_merges): stats = get_stats(ids) if not stats: break pair = max(stats, key=stats.get) idx = 256 + i ids = merge(ids, pair, idx) merges[pair] = idx vocab[idx] = vocab[pair[0]] + vocab[pair[1]] for j, token in enumerate(special_tokens): idx = 256 + num_merges + j vocab[idx] = token.encode("utf-8") return merges, vocab更严谨的实现在训练前先把文本中的特殊 token 替换成占位 id,训练完成后再把占位 id 映射到正式 id。生产级的实现可以参考 minbpe 或 tiktoken 对特殊 token 的处理方式,它们用正则先切分文本,把特殊 token 单独隔离出来。
5. 运行验证与结果分析
5.1 训练一个小词表,观察合并顺序
把 3.3 节准备的语料读取进来,训练一个词表大小为 512 的 BPE:
with open("data/corpus.txt", "r", encoding="utf-8") as f: corpus = f.read() merges, vocab = train_bpe(corpus, vocab_size=512) print("合并规则数量:", len(merges)) for pair, idx in list(merges.items())[:10]: print(f"{idx}: {vocab[pair[0]]} + {vocab[pair[1]]} -> {vocab[idx]}")由于语料体积小,最先被合并的往往是最常见的字节组合,比如英文单词里的th、er,中文 UTF-8 编码中某个汉字的高频字节片段。看到这些输出,你就能直观理解 BPE 是在做什么:它不断把“一起出现”的字节打包成一个 token。
如果打印结果里出现类似256: b'\xe5\xa4' + b'\xa7' -> b'\xe5\xa4\xa7'的输出,说明它把中文“大”字的三个 UTF-8 字节完整合并成了一个 token。这正是字节级 BPE 处理中文的方式:模型看到的不是汉字,而是被合并后的 UTF-8 字节 token。
5.2 验证 encode 后能否 decode 还原
BPE 最重要的一条性质是信息无损。任何文本 encode 后再 decode,必须还原成原文。
text = "大模型通过 tokenizer 把文本映射为整数序列" ids = encode(text, merges) print("token ids:", ids) print("token 数量:", len(ids)) restored = decode(ids, vocab) print("还原文本:", restored) print("是否完全一致:", restored == text)预期输出中,restored == text为True。这一步非常重要,如果在这里不一致,说明 encode 或 decode 的合并逻辑有 bug。常见错误包括 merge 函数没有跳过已合并位置、decode 对单 token 单独解码、或者 merges 字典与 vocab 字典没使用同一个训练结果。
5.3 与 tiktoken 做结果对照
用 tiktoken 对同一段文本编码,观察 token 数量和切分风格:
import tiktoken enc = tiktoken.get_encoding("cl100k_base") text = "大模型通过 tokenizer 把文本映射为整数序列" ids = enc.encode(text) print(ids) print(enc.decode(ids) == text)tiktoken 的 cl100k_base 词表规模约 10 万,合并规则远比我们的 512 词表丰富,所以同样一句话,它的 token 数量通常更少,切分也更自然。不要直接比较 id 数值,因为词表 id 分配不同;要比较的是整体 token 数和可还原性。
| 对比项 | 本文最小实现 | tiktoken cl100k_base |
|---|---|---|
| 词表大小 | 512(示例) | 约 100256 |
| 基础层 | UTF-8 字节 | UTF-8 字节 |
| 是否可还原 | 是 | 是 |
| 特殊 token 支持 | 需自行实现 | 内置 |
| 编码速度 | 慢,适合教学 | 快,适合生产 |
5.4 核心参数速查与调参方向
| 参数 | 含义 | 常见范围 | 调大影响 | 调小影响 |
|---|---|---|---|---|
| vocab_size | 最终词表大小 | 8192 到 100352 | token 序列更短,embedding 层参数更多 | token 更碎片化,序列更长 |
| 训练语料量 | 合并规则来源 | 越大越好,至少数 MB | 规则更通用,跨语言更稳定 | 规则过拟合,泛化差 |
| 是否预留特殊 token | 特殊 id 区间 | 通常个位数 | 挤占合并次数 | 容易与文本 token 冲突 |
| 是否预分词 | 合并前的正则切分 | GPT-2 风格或不做 | 语义更稳定 | 实现简单但有统计噪声 |
这里要特别提醒:vocab_size 不是越大越好。词表大会让 embedding 矩阵变大,增加显存占用和训练参数;词表太小又会让一句话被切成很多 token,序列变长,训练效率下降。实际项目中,中文模型的常见词表在 3 万到 10 万之间,具体要用验证集上平均 token 数和模型效果来权衡。
6. 常见问题与排查路径
6.1 新文本编码后出现大量未合并字节
现象:对训练语料之外的新文本编码时,输出的整数序列大部分是 0 到 255 之间的原始字节,几乎没有合并 token。
可能原因:训练语料太小,或者新文本的语言、领域和训练语料差异太大。比如训练语料全是中文,却拿一段英文代码来编码,英文中的常见子词没有进入合并规则,自然退化成字节。
检查方式:统计编码结果中 id 小于 256 的占比。
ratio = sum(1 for x in ids if x < 256) / len(ids) print(f"未合并字节占比: {ratio:.2%}")处理建议:扩大领域相关的训练语料,让目标文本的高频片段进入 merges;或者在确定目标语言后,用该语言的代表性语料重新训练词表。
6.2 编码结果不稳定或与直觉不符
现象:同一段文本在不同次编码中产生不同的 token 序列,或者一段高频短语没有被合并成一个 token。
可能原因:最典型的是编码阶段用了“当前文本最高频对”作为合并依据,而不是“训练时最早合并的编号”。训练时的max(stats, key=stats.get)和编码时的min(stats, key=lambda p: merges.get(p, float("inf")))被混用。
检查方式:打印每次合并选中的 pair,对比它是否是 merges 中编号最小的可合并对。
处理建议:编码循环严格按照 merge index 从小到大应用合并。这是 BPE 编码器最容易出错的地方,建议在函数注释里写清楚“训练选最高频、编码选最小编号”。
6.3 中文、Emoji 解码后乱码
现象:decode 输出类似�的替换字符,或者文本内容错乱。
可能原因:对单个 token 分别执行了 UTF-8 解码,导致多字节字符被切断;或者 vocab 中某些 id 保存的字节内容不完整,无法拼成合法 UTF-8 序列。
检查方式:先打印 vocab 中相关 id 的原始字节,确认字节内容是否合法。
for idx in ids: print(idx, vocab[idx].hex())处理建议:decode 必须把字节拼接后整体解码,使用errors="replace"兜底。如果只是个别 token 拼接后不合法,通常不会影响整体,但如果是系统性乱码,要检查训练阶段vocab[idx] = vocab[pair[0]] + vocab[pair[1]]是否正确。
6.4 词表保存和加载后无法还原
现象:训练时 encode/decode 正常,把 merges 和 vocab 存成文件后,重新加载编码新文本,结果不一致。
可能原因:保存格式不完整,只保存了 vocab,没有保存 merges;或者 merges 的键值对在加载时被转换成了字符串,丢失了元组语义。
检查方式:加载后打印 merges 的前几项,确认键的类型是(int, int)而不是str。
保存时推荐使用 JSON,并把 merges 的键序列化成数组形式处理建议:生产环境保存词表时至少要包含三部分:merges 列表、vocab 字节表、特殊 token 映射。不要只保存 vocab,因为 encode 依赖 merges 才能把新文本编码成与训练一致的 id。
6.5 语料太小导致合并规则泛化差
现象:训练阶段合并不满 vocab_size 就提前停止,或者在训练语料上编码效果好,换到真实数据上效果明显变差。
可能原因:语料量不足以支撑目标词表大小。比如 1KB 的语料想训练 50000 词表,每轮合并都会把剩余序列变得很短,很快就没有可合并的相邻对了。
检查方式:观察train_bpe是否提前退出,打印实际合并次数。
处理建议:先降低 vocab_size 跑通流程,再逐步扩大语料。教学项目用几百 KB 语料演示即可,生产项目至少准备数 GB 并经过去重清洗的语料。词表训练和模型训练一样,数据质量直接决定上限。
7. 生产环境与工程化最佳实践
7.1 教学实现和生产实现的分界线
本文的最小实现适合理解算法,但不能直接用于生产。教学实现每轮全量统计频率,时间复杂度接近 O(n * merges),语料变大后会非常慢。生产级 BPE 编码器至少要做三件事。
第一,使用高效数据结构维护相邻对。参考 minbpe 的做法,在训练时用堆或者有序集合维护所有相邻对的频率,每次合并且只更新局部受影响的相邻对,而不是全量重算。
第二,加入预分词。GPT-2 的 tokenizer 先用正则把文本切成字母、数字、标点、空白等类别,再对每个片段单独做 BPE。这样能避免“单词片段横跨空格”这类语义边界问题。tiktoken 的cl100k_base就使用了类似的预分词逻辑。
第三,支持批量编码和缓存。推理场景中,高频提示词可以缓存 encode 结果;训练场景中,把语料一次性编码成二进制 id 序列再训练,避免每次 epoch 都重新编码。
7.2 词表发布至少要包含三部分内容
真实项目的 tokenizer 需要序列化保存,供训练和推理共用。一个完整的词表发布包应该包含:
| 内容 | 作用 | 示例格式 |
|---|---|---|
| merges 列表 | encode 时按顺序应用合并 | [[97, 98], [256, 99]] |
| vocab 字节表 | decode 时还原字节内容 | {256: "b'\\xe5\\xa4\\xa7'"} |
| special tokens | 特殊 id 映射 | `{"< |
tiktoken 的.tiktoken文件格式是每一行token_id token_bytes,例如306 b' hello'。minbpe 提供了保存和加载函数,格式类似。落地时建议同时保存一个版本号,方便词表更新后排查不一致问题。
7.3 什么时候该换成 SentencePiece 或 tiktoken
自己实现 BPE 的目的是学习,不是重复造轮子。实际项目选型可以参考下表。
| 场景 | 推荐方案 | 原因 |
|---|---|---|
| GPT 系列模型兼容 | tiktoken | 与 OpenAI 生态一致,可复现 |
| 多语言、中文场景 | SentencePiece | 把空格也作为字符处理,对中文更友好 |
| 日语、韩语等黏着语 | SentencePiece / Unigram | 子词切分更灵活 |
| 教学、研究算法细节 | 自研 BPE | 便于理解内部机制和调试 |
SentencePiece 区别于本文实现的关键点在于,它把空格编码成_,整句文本不需要预分词,空格也不会被忽略。LLaMA 等模型倾向于使用 SentencePiece 训练词表。如果要进一步深入学习,建议按这个顺序:先把本文的 BPE 调通,再用 minbpe 阅读工业级实现,最后对比 tiktoken 与 SentencePiece 在不同语言上的 token 效率。
7.4 从 Tokenizer 继续往模型方向走的路线
Tokenizer 只是大模型数据流水线的第一关。跑通 BPE 后,下一步建议按这条路线推进:
- 实现一个简单的数据集类,把文本批量 encode 成固定长度的 id 序列,支持 padding 和 truncation。
- 实现 embedding 层和反推层,理解 token id 如何变成向量、模型输出又如何映射回 token。
- 实现 masked self-attention,理解为什么模型只能看到当前位置之前的 token。
- 训练一个极小的因果语言模型,用自己训练的 tokenizer 生成文本,观察分词质量对生成质量的影响。
- 阅读 minbpe 源码和 tiktoken 源码,把教学实现升级成支持预分词、多线程训练、词表保存加载的完整版。
自检清单也值得保留一份:训练前确认语料编码格式是 UTF-8;训练后打印合并规则前几条观察是否合理;验证 encode 后 decode 能还原原文;测试未登录词、Emoji、中文、代码混合文本的编码稳定性;保存的词表文件要包含 merges、vocab 和版本号。这五条都通过,你的 BPE Tokenizer 才算真正可用,而不是只在示例语料上跑通。
大模型构建的第一步,不在于模型结构多复杂,而在于这些看似琐碎的数据工程细节是否可靠。Tokenizer 做扎实了,后面的 embedding、attention、训练和推理才有稳定的输入。下一阶段无论继续深入 Transformer 还是学习分布式训练,都可以把这套 tokenizer 当作固定的地基来使用。