写在前面:为什么这件事值得关心
很多人觉得"分词而已,差几个 token 有什么关系"。但实际上:同一段文本在不同模型下的 token 计数可能相差显著(实测中 10%~30% 的量级并不罕见,中文场景偏差尤其明显),而 API 是按 token 计费的,上下文窗口是按 token 算的。
行业通用的粗略锚点是:
- 英文:1 token ≈ 4 个字符 或 ≈ 0.75 个单词
- 中文:1 token ≈ 0.7~1.5 个汉字
再次强调:这只是行业经验值,唯一准确的数字只能来自对应模型自己的 tokenizer。
本文系统讲清三件事:OOV 是什么、词汇表在模型间如何共用、以及工程上怎么避免踩坑。
一、OOV 是什么?
一句话定义
OOV = Out Of Vocabulary(词表外的词),就是词表里没有收录、tokenizer 不认识的词。
大白话类比
你有一本词典(词表),遇到一个词典里没有的新词(比如"ChatGPT"在 2018 年),这个词就是 OOV。
各代分词算法的 OOV 情况
| 算法 | OOV 严重程度 | 说明 |
|---|---|---|
| 词级分词 | 严重 | 英语常用词约 10~20 万,总词形更多,不可能全收录,遇到新词直接懵 |
| 字符级分词 | 极少 | 若词表只包含常用 Unicode 字符,仍有罕见字符无法覆盖;但现代大模型极少使用 |
| 子词分词(BPE 等) | 轻微 | 罕见词拆成已知子词,大部分词都能处理 |
| 字节级 BPE | 无 | 任何文字最终都能拆成 256 个字节,字节一定在词表里,彻底消除"无法编码"的问题 |
现代大模型怎么处理 OOV?
字节级 BPE 从根本上消除了"无法编码"的问题。
- 任何词,哪怕是生僻字、emoji、乱码,最终都能拆成字节
- 256 个字节是基础 token,一定在词表里
- 所以 GPT-2 之后的模型,不存在"无法编码"的情况
但"能编码"不等于"切分得好"------一个罕见词可能被拆成 10 个字节碎片,模型理解起来还是困难。
字节级 BPE 的普及,使得每个模型都可以构建一个高度定制化的、覆盖其训练数据特征的词汇表。这种"定制化"正是本文后续讨论的核心:它使得不同模型之间的词汇表几乎不可能通用,从而引出了词汇表管理和共用的工程挑战。
二、每个模型系列都有独立的词汇表吗?
答案:是的,每个模型系列都有自己独立的词汇表。
词汇表(tokenizer)是训练模型时一起确定、一起发布的,不是通用的。
为什么不能通用?
- 训练数据不同:A 模型用英文数据多,B 模型用中文数据多,高频词不一样
- 词表大小不同:LLaMA 1/2 是 32K,Qwen 是 151,936(约 152K)
- 合并规则不同:BPE 合并了哪些字符对,取决于训练数据的统计
- 特殊 token 不同 :每个模型加的
<s>、<pad>、对话标记都不一样
类比
- 就像每个出版社都有自己的词典------商务印书馆的《现代汉语词典》和上海辞书出版社的《辞海》,收词、释义、编号都不一样
- 你不能用商务印书馆的词典页码,去查辞海里的词
三、同一系列的模型可以共用词汇表吗?
✅ 同一系列的同代模型通常共用
同一个模型家族的同代版本,共用同一个 tokenizer:
| 模型系列 | 共用 tokenizer 的版本 | 备注 |
|---|---|---|
| LLaMA | LLaMA-7B / 13B / 33B / 65B 共用 | 可通过 HuggingFace 仓库验证 tokenizer 文件完全一致 |
| LLaMA2 | LLaMA2-7B / 13B / 70B 共用 | 同上 |
| Qwen(千问) | Qwen-1.8B / 7B / 14B / 72B 共用 | 词表约 152K,中文优化 |
| GPT-4 / GPT-3.5-turbo | 共用 cl100k_base | OpenAI 官方文档确认 |
| GPT-4o | 使用更新的 o200k_base | 与 GPT-4 不共用 |
为什么要共用? 共用 tokenizer 意味着不同参数规模的模型处在同一个"语言空间"中,可以做 logit 蒸馏、模型权重合并(model merging)等操作。
需要澄清一个常见的误解:无论使用何种微调方法(包括 LoRA),都必须使用与基座模型完全相同的 tokenizer。 因为模型的 embedding 层权重是与词汇表一一对应的,tokenizer 变了,输入的 token id 就失去了原有语义,模型会彻底失效。
至于"共用 tokenizer 便于做 logit 蒸馏、模型权重合并",那是在同一个 tokenizer 空间下,不同参数规模的模型才能进行权重层面的融合。LoRA 本身只作用于 attention 的线性层,不直接修改 embedding,但这并不代表它可以跨越不同的 tokenizer。
⚠️ 跨代可能更换
同一系列的不同代际之间,tokenizer 可能更换,典型案例:
- LLaMA 2 → LLaMA 3:Llama-3 将 tokenizer 由 SentencePiece 换成了 tiktoken,与 GPT-4 保持一致,同时词表大小由 32k 扩展到了 128k。这是教科书级的"跨代换 tokenizer"案例。
- Qwen 2 → Qwen 2.5:基础词表没变(都是 152k 个常规 token),但控制 token 从 3 个扩展到了 22 个,新增了工具调用等专用 token。词表主体兼容,但特殊 token 集合已经不同。
补充一个容易搞混的点:LLaMA 1 和 LLaMA 2 的 tokenizer 是同一个。 Llama 2 论文明确写道:"Tokenizer:我们使用与 Llama 1 相同的 tokenizer,它采用了字对编码(BPE)算法,使用了来自 SentencePiece 的实现......总词汇量为 32k 个标记。" 所以"同系列跨代一定换 tokenizer"是不成立的,具体必须查阅官方文档确认。
❌ 跨系列不能共用
- 千问(Qwen)的 tokenizer ≠ 豆包(Doubao)的 tokenizer
- 千问 ≠ LLaMA ≠ GPT ≠ Claude
- 不同公司、不同系列,词汇表几乎都不一样
关于豆包(Doubao):它是字节跳动出品,有自己独立训练的 tokenizer,系列内部各版本共用。但其具体词表大小和合并规则未完全公开,以上基于公开信息整理,具体以官方文档为准。
四、词汇表是每次都重新计算吗?
答案:不是,是训练时一次性确定,推理时加载固定文件。
训练阶段
- 收集海量训练文本
- 运行 BPE/Unigram 算法,统计频率,合并出词表
- 词表确定后,保存为文件(tokenizer.json、vocab.json、merges.txt 等)
- 用这个固定的 tokenizer 处理所有训练数据,训练模型
推理阶段
- 加载模型权重 + 加载 tokenizer 文件
- 用户输入 → 按固定规则切分 → 查表得到 id
- 不会重新计算词表,切分规则是固定的
类比:训练时 = 编词典,花了很大功夫统计、合并,最终出版一本固定词典;推理时 = 用这本出版好的词典查词,不会每次重新编词典。
五、不同模型分词不一样,是因为代码实现不同吗?
答案:算法可能相同,但词表数据不同,所以结果不同。
代码实现层面,大多数模型用的分词算法框架是一样的:
- HuggingFace tokenizers 库(Rust 实现,速度快)------大部分开源模型
- tiktoken------OpenAI 模型使用的分词库,已开源,可独立调用
- SentencePiece------多语言模型、T5 等
算法框架可能都是 BPE,但切分结果不同,因为:
-
词表内容不同:合并了哪些字符对、每个 token 的 id 是多少,完全不同
-
预处理规则不同:
- 是否在词前加空格?
- 是否统一小写?
- 数字怎么处理?
- 中文是按单字切、按字节回退,还是把高频二字词/三字词直接收录进词表?
在 HuggingFace 的 tokenizers 库中,这些规则被明确地区分为
normalizer(文本规范化,如 Unicode 归一化、大小写转换)和pre_tokenizer(预切分,如按空格或字节切分)。当你需要调试分词差异时,可以直接打印 tokenizer 对象的这些属性来查看具体配置。 -
特殊 token 不同 :
<s>、</s>、<pad>的 id 和数量不同 -
合并顺序不同:BPE 是按频率依次合并,合并顺序不同,最终切分结果不同
类比:两个人都用"按频率合并"的方法编词典,但一个用人民日报数据编,一个用小说数据编,最终两本词典的收词、编号都不一样,查同一个词结果当然不同。
六、调用公共 SDK 分词,结果就一样了吗?
答案:取决于用的是谁的 SDK、哪个模型。
情况 1:同一个 SDK + 同一个模型 → 结果一样
- 用 OpenAI SDK 调 gpt-3.5-turbo,不管谁调用、在哪调用,分词结果都一样,因为 SDK 内部固定用 cl100k_base
- 用 HuggingFace 加载 Qwen-7B,每次加载的都是同一个 tokenizer 文件,结果一样
情况 2:不同 SDK / 不同模型 → 结果可能不一样
- 用 OpenAI SDK 分词 vs 用 HuggingFace 加载 LLaMA 分词 → 结果不同
- 用千问的 tokenizer vs 用豆包的 tokenizer → 结果不同
- 即使都是"中文分词",切出来的 token 数量和内容都可能不同
情况 3:同一个 SDK 但不同模型 → 看是否共用 tokenizer
OpenAI 的完整映射关系如下:
| 编码 | 对应模型 |
|---|---|
| o200k_base | GPT-4o、GPT-4.1、GPT-5、o1、o3、o4-mini |
| cl100k_base | GPT-4、GPT-4 Turbo、GPT-3.5 Turbo |
| p50k_base | text-davinci-003、Codex |
| r50k_base | GPT-3 |
所以:gpt-3.5-turbo 和 gpt-4 都用 cl100k_base → 结果一样;但 gpt-4o / gpt-5 / o1 / o3 用 o200k_base → 结果不同;text-davinci-003 用 p50k_base → 结果不同。
HuggingFace 侧:Qwen-7B 和 Qwen-72B 共用 → 一样;Qwen-7B 和 LLaMA-7B → 不一样。
实践经验
- 不要假设"都是中文分词结果就一样"
- 不要假设"都是 BPE 结果就一样"
- 必须用对应模型的官方 tokenizer 来分词
七、分词结果不一样,怎么控制差异?
核心原则:用哪个模型,就必须用哪个模型的 tokenizer。
1. 模型和 tokenizer 从同一路径加载,并使用官方 tokenizer 对象
python
from transformers import AutoTokenizer, AutoModelForCausalLM
# 模型和 tokenizer 从同一个路径加载,保证配对
model_path = "Qwen/Qwen-7B"
tokenizer = AutoTokenizer.from_pretrained(model_path)
model = AutoModelForCausalLM.from_pretrained(model_path)
不要自己单独搞一个 tokenizer,不要用别的模型的 tokenizer。新手常犯的错误是自行解析 vocab.json 和 merges.txt 实现分词,极易遗漏预处理规则(如空格折叠、Unicode 归一化)------始终使用 AutoTokenizer.from_pretrained() 加载的对象。
工程提示: 对于部分模型(如早期的 Qwen 版本),HuggingFace 要求设置
trust_remote_code=True才能加载其自定义的分词器代码。在使用前,请确保你了解并信任该模型仓库的代码。
2. 微调时必须用基础模型的 tokenizer
- 用 Qwen-7B 做基础模型微调 → 必须用 Qwen 的 tokenizer 处理训练数据
- 不能用 LLaMA 的 tokenizer 处理数据,然后送入 Qwen 模型
- 否则 id 对不上,模型看到的是乱码
3. 跨模型迁移时,必须重新分词
- 从 LLaMA 迁移到 Qwen → 所有训练数据、向量库、缓存都要用 Qwen 的 tokenizer 重新处理
- RAG 的向量库:如果换了 embedding 模型(tokenizer 也换了),所有文档必须重新向量化
4. 不要自己实现分词逻辑
- 不要写"按空格切""按标点切"这种自定义逻辑
- 用官方提供的 tokenizer,它包含了所有预处理规则、特殊 token 处理
- 自己实现几乎一定会出错
5. 评估 token 数量时,用对应模型的 tokenizer
"1000 字等于多少 token"没有统一答案,中文的 token 比例因模型而异:
| 情况 | 切分方式 | 1 字 ≈ X token |
|---|---|---|
| 中文优化词表(Qwen 等) | 常用字直接注册为 token,非常用字拆字节 | 平均 1~1.5 |
| 非中文词表(LLaMA 32k) | 多数汉字不在词表,拆成 UTF-8 字节 token,部分字节对可能合并 | 2~3 |
| GPT-4 (cl100k_base) | 介于两者之间,对中文有一定覆盖但不如 Qwen | 1.5~2 |
算 API 费用、上下文窗口时,必须用对应模型的 tokenizer 计算。
6. 确保 tokenizer 来源和版本一致
BPE 的合并顺序由训练语料和算法确定性决定,不会因为"下载渠道"而变化。真正需要注意的是:同一模型系列的不同代际(如 Qwen2 与 Qwen2.5)发布的 tokenizer 文件本身可能不同,此外社区转换的第三方版本与官方发布版本也可能存在细微差异。始终从模型的官方仓库获取 tokenizer。
八、常见踩坑场景
坑 1:用 A 模型的 tokenizer 处理数据,训练 B 模型
- 表现:模型不收敛、输出乱码、效果极差
- 原因:id 完全对不上,模型看到的是错误的输入
- 解决:确保 tokenizer 和模型配对
坑 2:RAG 换了 embedding 模型,但没重建向量库
- 表现:检索准确率暴跌,返回不相关文档
- 原因:主因是 embedding 模型换了,向量空间完全不同(即使 tokenizer 一样也不兼容);此外 tokenizer 变化还会导致同一段文本被切成不同片段,进一步加剧不一致
- 解决:换 embedding 模型必须重建整个向量库
坑 3:微调时加了自定义 token,但没正确处理
- 表现:自定义 token 被拆成碎片,模型识别不了
- 原因:词表里没有这个 token,tokenizer 把它拆成了子词
- 解决:
python
# 添加特殊 token(如对话标记、角色标识)
tokenizer.add_special_tokens({"additional_special_tokens": ["<|my_token|>"]})
# 或添加普通词汇(领域术语、产品名等)
tokenizer.add_tokens(["新词"])
# 两者都需要扩展 embedding 层,否则新 token 没有对应的向量
model.resize_token_embeddings(len(tokenizer))
注意: 添加 token 后,必须调用
model.resize_token_embeddings(len(tokenizer))来扩展模型的 embedding 层矩阵。否则,当模型遇到新 token 的 ID 时,会因为索引超出 embedding 矩阵的维度而直接报错。
坑 4:不同框架的 tokenizer 结果不一致
- 表现:HuggingFace 切出来是 100 个 token,另一个框架切出来是 105 个
- 原因:预处理规则有细微差异(比如空格处理、Unicode 归一化)
- 解决:训练和推理用同一个框架的同一个 tokenizer
坑 5:多模态模型的 tokenizer 包含图像 token
- 表现:直接传文本给多模态 tokenizer 没问题,但如果要处理图像,需要按特定格式传入
- 补充:一张图片通常会被编码为几十到几百个 Vision Token,具体数量取决于图像分辨率和切块策略(例如,常见的 CLIP ViT-L/14 架构处理一张 224x224 图片会固定产生 256 个图像块 token 加 1 个分类 token,共 257 个)。这部分 token 同样计入上下文窗口和 API 费用------很多团队在接入多模态模型后上下文突然爆掉,就是没把图像 token 算进去
- 解决:查阅模型文档,了解图像 token 的插入方式,并在容量规划时预留图像 token 预算
九、附录:Tokenizer 相关文件简介
| 文件 | 格式 | 说明 |
|---|---|---|
| tokenizer.json | JSON | HuggingFace tokenizers 库的统一格式,包含词表、合并规则、预处理配置 |
| vocab.json + merges.txt | JSON + 文本 | GPT-2 风格的 BPE 格式,vocab 存 token→id,merges 存合并规则 |
| tokenizer.model | 二进制 | SentencePiece 的模型文件,包含词表和训练参数 |
| tokenizer_config.json | JSON | 加载时的预处理配置(最大长度、特殊 token 行为等) |
| special_tokens_map.json | JSON | 特殊 token 到具体字符串的映射 |
后两个文件正是"为什么手动解析 vocab.json 会出错"的直接原因------AutoTokenizer.from_pretrained() 加载时会读取这些配置来完成预处理和特殊 token 注入,光有词表和合并规则是不够的。
十、全文要点回顾
- OOV 已被字节级 BPE 消除,但"能编码"不等于"切分得好",罕见词仍可能被切成大量碎片。
- 每个模型系列都有独立的词汇表,训练时一次性确定,推理时加载固定文件,不会重新计算。
- 同系列的同代模型通常共用 tokenizer,跨代可能更换,跨系列不能共用(LLaMA 1/2 共用,LLaMA 3 换成了 tiktoken + 128k 词表)。
- 不同模型分词结果不同,主要是词表数据和预处理规则不同,不是算法框架不同。
- 调用公共 SDK 时,同一个模型的分词结果一致,但跨模型不一致。
- 控制差异的唯一正确做法:用哪个模型就必须用哪个模型的官方 tokenizer,绝不混用;跨模型迁移时必须重新分词处理所有数据。
一句话总结:OOV 已被字节级 BPE 消除,但词汇表因模型而异、同系列共用、跨系列不共用;控制差异的唯一方法是始终使用对应模型的官方 tokenizer。