这是我从零做自己大模型的系列。这一篇聊一个看起来不起眼、但开训以后就没法反悔的东西:词表(tokenizer)。
先交代一下背景:我手里的算力是两张 RTX 4090,目标是训一个 4B 左右的模型,产品优先面向中文用户。在这种资源条件下,每一次"推倒重来"都很贵,所以我对那些"一旦定了就改不了"的决定格外小心。词表就是其中最典型的一个。
一、为什么词表这么重要
刚开始接触大模型的时候,我有个很朴素的误解:模型读的是文字。其实不是。模型从头到尾看到的只是一串整数 id。
一句话进模型之前,要先经过 tokenizer,把文本切成一个个片段(token),每个片段查表换成一个 id;模型输出的也是 id,再由 tokenizer 翻译回文字。中间这张"片段 ↔ id"的对照表,就是词表。
问题在于,模型的第一层(embedding)就是一张按 id 编号的大表:第 0 行对应 id 0,第 1 行对应 id 1......训练过程中,模型学到的所有"这个 id 是什么意思"的知识,都存在这些行里,也渗透进了后面所有层的参数里。
这就意味着:
- 训练一旦开始,词表就和模型锁死了;
- 你想换词表,哪怕只是把某几个 id 挪个位置,模型之前学的东西就对不上了;
- 真要换,基本等于重新训练。
对一个只有两张 4090 的个人项目来说,"重训"这两个字的分量大家可以想象。所以我把词表当成正式训练前的一道闸门:不定下来、不验证清楚,就不开训。
二、token 和 BPE,用大白话讲
2.1 token 是什么
token 可以理解成"模型眼里的最小单位"。它不一定是一个字,也不一定是一个词,而是由算法统计出来的、经常一起出现的一段字节。
下面这些例子都是我在机器上用 Python 的 tiktoken 库、o200k_base 编码实际跑出来的(这个编码后面会详细讲,我最终的词表就是基于它):
python
import tiktoken
enc = tiktoken.get_encoding("o200k_base")
s = "我正在从零开始训练自己的大模型。"
ids = enc.encode(s)
print(len(s), len(ids)) # 16 个字符 -> 10 个 token
print(ids)
# [7522, 70104, 17705, 42624, 40914, 190090, 97812, 1640, 184232, 788]
把每个 id 解码回来,切分结果是这样的:
我 | 正在 | 从 | 零 | 开始 | 训练 | 自己的 | 大 | 模型 | 。
可以看到,"正在""开始""训练""模型"这种常见词被合成了一个 token,"自己的"甚至三个字合成了一个。再看几个例子:
| 文本 | 字符数 | o200k token 数 | 切分结果 |
|---|---|---|---|
| 人工智能 | 4 | 2 | 人工 / 智能 |
| 机器学习 | 4 | 2 | 机器 / 学习 |
| 词表是模型的地基。 | 9 | 8 | 词 / 表 / 是 / 模型 / 的 / 地 / 基 / 。 |
| 饕餮 | 2 | 4 | (每个字被拆成两段字节) |
最后一行很有意思:"饕餮"这种不常见的字,词表里没有现成的整字 token,于是被拆成了字节片段。一个汉字在 UTF-8 里占 3 个字节,这里每个字被拆成了"2 字节 + 1 字节"两个 token。再极端一点,像"𰻞"(biáng 面的 biáng)这种生僻字,在 UTF-8 里占 4 个字节,直接被拆成了 4 个单字节 token。
这就是 byte-level(字节级)tokenizer 的好处:任何文字都不会"不认识",最坏情况就是退化成一个字节一个 token,只是效率低一点。
2.2 代码是怎么切的
我的模型以后要会写代码,所以代码的切分我也专门看了:
python
code = "def add(a, b):\n return a + b"
# 31 个字符 -> 11 个 token
切分结果:
csharp
def | ␣add | (a | , | ␣b | ):\n | ␣␣␣ | ␣return | ␣a | ␣+ | ␣b
(␣ 表示空格)几个细节值得注意:
add、return这些词是连同前面的空格一起成为一个 token 的;):\n这种 Python 里极常见的组合被合成了一个 token;- 四个空格的缩进被切成了"三个空格"加上"带一个空格的 return"。
再比如 import numpy as np,18 个字符只用了 4 个 token:import / ␣numpy / ␣as / ␣np。
中英混合的代码注释也一样能处理:
python
"# 计算两个数的和\ndef add(a, b):\n return a + b"
# 41 个字符 -> 19 个 token
# 中文注释部分:"计算" "两个" "数" "的" "和" 分别是一个 token
2.3 BPE 是怎么来的
那这张表是怎么来的?主流做法叫 BPE(Byte Pair Encoding,字节对编码)。思路非常朴素:
- 一开始,词表里只有 256 个单字节;
- 在一大堆训练文本里统计:哪两个相邻片段一起出现的次数最多?
- 把它们合并成一个新 token,加进词表;
- 重复第 2、3 步,直到词表达到预定大小。
所以"模型"能成为一个 token,是因为在训练 tokenizer 的语料里,"模"和"型"挨在一起的频率足够高。换句话说,tokenizer 用什么语料训练,决定了它擅长切什么语言。这一点在后面我踩的坑里会再次出现。
2.4 它影响什么
词表切得好不好,直接影响三件事:
训练效率。 同样一段文本,切出来的 token 越少,模型训练时每一步能"读"的内容就越多。算力是按 token 花的,中文切得碎,就等于用同样的算力读了更少的中文。
上下文能装多少。 模型的上下文长度是按 token 算的。假设上下文是固定的若干 token,中文平均每个 token 能代表的字越多,一次能塞进去的文章就越长。
代码理解。 缩进、括号、关键字如果切得乱七八糟,模型就要花更多参数去学"这几个碎片拼起来其实是 return"。切得规整,模型学起来也更轻松。
这里顺手做个对比(同样是实际跑出来的):OpenAI 上一代的 cl100k_base 编码,"机器学习"要切成 5 个 token,而 o200k_base 只要 2 个;"词表是模型的地基。"前者 10 个,后者 8 个。词表变大、训练语料里中文更多,中文就切得更"整"。当然,这只是几个例子,不能代表整体,整体要靠下面说的指标来衡量。
三、第一版:A2,32k 词表
我最早用的词表叫 A2,大小是 32k。
说实话,它在早期是够用的。我们用它训过 0.3B 规模的小模型,跑通了整个流程,该踩的工程坑也踩了一遍。对小模型来说,32k 词表的 embedding 不大,参数可以更多留给后面的层。
但在准备往 4B 走的时候,我们做了一次评审,结论是:**32k 对小模型可以,但上规模不行。**这个结论我们是认同的。词表太小,常见的中文词、代码片段很多没法成为整 token,只能被切碎;模型越大、训练的 token 越多,这种低效就越吃亏。
怎么衡量一个词表好不好:fertility
评估 tokenizer 最常用的指标叫 fertility(直译是"繁殖率",我更愿意叫它"切碎程度"),意思是:
- 对中文:平均每个字被切成多少个 token;
- 对英文:平均每个词被切成多少个 token;
- 对代码:可以看每个字符或每行的 token 数。
数字越小,说明切得越"整",效率越高。前面那句"我正在从零开始训练自己的大模型。"16 个字符切成 10 个 token,就是一个直观的感受(这是单句演示,不是正式评测结果)。
正式评估时,要在一份没参与 tokenizer 训练的留出数据上,按语言分别统计 fertility,再在几个候选词表之间横向比。这样比出来的才有意义,挑几个句子"看着顺眼"是不够的。
四、第二版:自己训练的 mgt-v1
既然 32k 不够,我的第一反应是:自己训一个。
4.1 设计
mgt-v1 的设计是这样的:
| 项目 | 设定 |
|---|---|
| 算法 | byte-level BPE |
| 词表大小 | 163840(含 1024 个特殊 token) |
| 训练语料 | 2GB |
| 语料配比 | 英文 45% / 中文 25% / 代码 20% / 数学 10% |
| 留出集 | 1%(用于评估) |
这里有个我比较得意的设计:提前预留 1024 个特殊 token 位置。
特殊 token 是那些不对应普通文本、而是表达"结构"的符号,比如"一段对话从这里开始""这里是模型的思考过程""这里是一次工具调用"。我给下面这些场景都预留了占位:
- 对话(chat):区分系统、用户、助手的轮次;
- 思考(thinking):标记推理过程的开始和结束;
- 工具调用(tools);
- FIM(Fill-in-the-Middle,代码补全时"给前文和后文、让模型补中间");
- 多模态:给以后可能接入的图像等内容留位置。
为什么要预留?还是那句话:词表开训就改不了。现在用不上的能力,以后想加的时候,如果没有现成的 id 位置,就只能动词表,也就是重训。提前留好位置,成本几乎为零。
4.2 草稿训练暴露的问题
我先跑了一版草稿训练,结果检查语料的时候发现了几个问题:
- **代码语料只有大约 22MB。**按配比,代码应该有约 400MB,实际只占到大约 1.5%,而不是计划中的 20%;
- 数学数据源是空的,10% 的数学配比实际上一点没有;
- 中文用的还是一份旧的切片,不是最新整理好的数据。
前面说过,BPE 学到什么,完全取决于训练语料。代码只占 1.5%、数学是 0,训出来的词表对代码和数学的切分肯定好不了。所以这一版只能算草稿,不能拿去正式用。
这件事给我的教训是:**配比写在配置文件里,不代表数据真的按这个比例进去了。**每一路数据进去之前,都要实际统计一下大小。
五、最终版:mgt-v1-o200k
修数据、重训 mgt-v1 当然可以,但我停下来想了想:我到底需要一个什么样的词表?
最后我选了另一条路:以 OpenAI 开放的 o200k tokenizer 为基础,加上我们自己的特殊 token,命名为 mgt-v1-o200k。
5.1 为什么选它
理由有三个:
- **覆盖好。**o200k 对中文、英文和代码的覆盖都很强,前面那些例子大家也看到了。对一个优先面向中文用户、又要会写代码的模型来说,这很关键。
- **走头部实验室的路线。**我的产品目标是对标 OpenAI、Anthropic 这类头部实验室的产品形态,用一个经过大规模验证的 200k 级词表,方向上不会错。
- **省时间。**自己训一个好的 tokenizer,光是把语料配比做对就要花不少功夫,而且还要做大量评估去证明它确实比现成的好。对个人项目来说,时间也是很贵的资源。
5.2 特殊 token 的安排
o200k 原生的 <|endoftext|> 在 id 199999,我直接把它作为 eos。其余的特殊 token 是我们自己加的:
| 用途 | token | id |
|---------|------------|-----------|------|--------|
| 结束(eos) | `< | endoftext | >` | 199999 |
| 填充(pad) | --- | 200019 |
| 开始(bos) | --- | 200020 |
| 对话轮次开始 | im_start | 200021 |
| 对话轮次结束 | im_end | 200022 |
| FIM 前缀 | FIM prefix | 200031 |
| FIM 中间 | FIM middle | 200032 |
| FIM 后缀 | FIM suffix | 200033 |
| 仓库名 | repo_name | 200035 |
| 文件分隔 | file_sep | 200036 |
其余位置一直预留到 201215 ,给思考、工具、多模态等以后的需求用。所以模型的 embedding 一共有 201216 行(id 从 0 到 201215)。
repo_name 和 file_sep 是为代码训练准备的:把一个仓库的多个文件拼在一起训练时,用它们标出"这是哪个仓库""这里换了一个文件",模型就能学到跨文件的上下文。
六、踩过的坑
坑 1:特殊 token 的 id 放错了
第一次生成 tokenizer 文件时,特殊 token 的 id 是错位的,没有落在我设计好的位置上。
这种错误非常隐蔽:tokenizer 照样能用,编码解码也不报错,但如果就这样开训,模型学到的"对话开始""文件分隔"全对应着错的 id,等发现的时候,模型已经训进去了。
发现之后,我修正了生成逻辑,重新生成了 tokenizer 文件,然后做了一次逐 token 的一致性校验 :把 o200k 原有的普通 token 逐个拿出来,对比我生成的 tokenizer 和 tiktoken 官方实现给出的结果。最终的差异数是 0。
到这一步我才放心:普通 token 和 o200k 完全一致,特殊 token 都在设计好的位置上。
坑 2:embedding 行数和词表大小不一致
第一次看到下面两个数字时,我也愣了一下:
- embedding 行数:201216
- tokenizer 的词表大小:201197
差了 19。是不是又哪里错了?
查下来,原因是 o200k 原始的 id 空间本身就有空位 。我用 tiktoken 实际查了一下 o200k_base:普通 token 占用的是 0 到 199997,199998 是空的;<|endoftext|> 在 199999;另一个原生特殊 token <|endofprompt|> 在 200018;而 200000 到 200017 这段也是空的。
python
import tiktoken
enc = tiktoken.get_encoding("o200k_base")
ranks = set(enc._mergeable_ranks.values())
print(len(ranks), max(ranks)) # 199998 199997 -> 普通 token 占 0..199997
print(enc.n_vocab) # 200019
print(enc._special_tokens) # {'<|endoftext|>': 199999, '<|endofprompt|>': 200018}
数一下:199998 一个,200000 到 200017 十八个,正好 19 个空位,和 201216 − 201197 = 19 对得上。也就是说,embedding 按"最大 id + 1"开行,而词表大小只数实际存在的 token。
所以多出来的行是有意为之的:这些行永远不会被查到,浪费的参数可以忽略。另外,embedding 的行数也做了对齐,201216 是 128 的整数倍(实际上也是 512 的整数倍),这对 GPU 上的矩阵运算更友好。
我把这件事专门写进了文档。下次自己或者别人看到这两个数字不一样,不用再查一遍。
坑 3:怎么保证用的是对的那个文件
前面已经有 A2、mgt-v1 草稿、mgt-v1-o200k 的修正前和修正后好几个版本了。万一哪天训练脚本加载了旧文件呢?
我的做法是:记录最终 tokenizer 文件的 sha256,训练和数据加载时都做校验。
python
import hashlib
EXPECTED_SHA256 = "..." # 记录在配置里的最终版本哈希
def load_tokenizer(path):
with open(path, "rb") as f:
digest = hashlib.sha256(f.read()).hexdigest()
if digest != EXPECTED_SHA256:
raise RuntimeError(f"tokenizer 版本不对: {digest}")
...
(上面是示意代码,不是项目里的原文。)
哈希对不上就直接报错退出,不给"差不多能跑"的机会。这样错的版本根本进不了训练流程。
七、取舍和遗留问题
选 o200k 不是没有代价的,有几件事我记了下来。
**许可。**o200k 的许可情况我已经记录在案,等资源允许的时候会再回头审视。
**embedding 参数变多。**词表大了,embedding 就大。我的模型在 4B 左右,d_model 是 3072,输入 embedding 和输出层共享权重(tied embeddings),算一下:
ini
3072 × 201216 = 618,135,552 ≈ 6.18 亿参数
也就是说,光 embedding 就占了约 618M 参数。对一个 4B 级别的模型来说这是不小的一块。好在因为是共享权重,这部分只算一次,不用输入、输出各来一份。用这些参数换中文、代码切分效率的提升,我认为是值得的。
**这是一道闸门。**词表的决定是在 4B 正式训练之前做的。我给自己定的规矩是:词表没定、没验证,正式训练就不开始。
八、结语
回头看这一步,我的体会可以总结成几句话:
- **词表是地基。**模型看到的只有 id,词表决定了 id 的含义;
- **早决定,定了就别改。**开训之后改词表等于重训;
- **提前预留特殊 token。**对话、思考、工具、FIM、多模态,现在用不上也先把位置留好;
- **每一步都要能验证。**语料配比要实际统计,特殊 token 要逐个校验,文件要用哈希锁住。
从 32k 的 A2,到自己训的 mgt-v1 草稿,再到最终的 mgt-v1-o200k,这一步走了不少弯路,但我觉得这些弯路值得,因为都发生在开训之前。
词表定下来,下一步就是把清洗好的语料全部用它切成 token,存成训练可以直接读的分片(shards)。下一篇,第 6 步:语料分词与分片。我们下篇见。