词表(tokenizer),一个开训就改不了的决定

这是我从零做自己大模型的系列。这一篇聊一个看起来不起眼、但开训以后就没法反悔的东西:词表(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,字节对编码)。思路非常朴素:

  1. 一开始,词表里只有 256 个单字节;
  2. 在一大堆训练文本里统计:哪两个相邻片段一起出现的次数最多?
  3. 把它们合并成一个新 token,加进词表;
  4. 重复第 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 为什么选它

理由有三个:

  1. **覆盖好。**o200k 对中文、英文和代码的覆盖都很强,前面那些例子大家也看到了。对一个优先面向中文用户、又要会写代码的模型来说,这很关键。
  2. **走头部实验室的路线。**我的产品目标是对标 OpenAI、Anthropic 这类头部实验室的产品形态,用一个经过大规模验证的 200k 级词表,方向上不会错。
  3. **省时间。**自己训一个好的 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 步:语料分词与分片。我们下篇见。

相关推荐
Tiandaren1 小时前
么是agent、为什么要做agent、怎么做agent、怎么工程化
人工智能·python·深度学习·大模型
四六的六1 小时前
一次改回去越界的沙箱复盘:配置恢复了,token 还活着,检测器漏了最关键的那条
人工智能·openai·agent·个人开发·ai编程·ai大模型·ai产品
Daneas1 小时前
AI文生图提示词生成器(SD 1.5、SDXL、Flux、Krea-2)
人工智能
半摆烂日常1 小时前
手机表单的左滑操作:触摸事件的处理细节
大数据·开发语言·人工智能·智能手机·https·c#
xianghongtao01161 小时前
01_LangDiT_扩散语言模型_解读报告
人工智能·语言模型·自然语言处理
深度学习lover1 小时前
<数据集>棉花虫害识别<目标检测>
人工智能·深度学习·yolo·目标检测·计算机视觉·棉花虫害识别
AI模型调用笔记2 小时前
Replit 将 OpenAI 设为 Agent 首选,Claude 仍可选
人工智能·语言模型
霍格沃兹测试学院-小舟畅学2 小时前
京东JoyCode公开Testing Agent后,仓库级回归该怎样设计?
人工智能·数据挖掘·回归
张张123y2 小时前
后端第十一课:RAG 落地实战(从 0 到 1 接上私有知识库,并设计一个好用的 RAG)
开发语言·人工智能·python·机器学习·fastapi·rag