为什么大模型会数不清「strawberry 里有几个 r」:把 BPE 分词从一次贪心合并讲到一张多语言账单
「strawberry 里有几个 r」这类问题曾让许多大模型翻车,原因很大程度在输入端:模型接收的不是逐字母的文本,而是分词器切好的 token(词块)编号——就像你隔着一板切好的豆腐去数整块豆腐里有几粒芝麻:芝麻并没有丢(token 可无损还原原文),但还原不在模型的计算路径里,你拿到的不是整块。本文用零基础的讲法把这件事讲透:BPE(字节对编码)如何从字符出发、每次贪心合并语料里出现次数最多的相邻对,一步步长出词表;字节级 BPE 如何用 256 个起始符号装下全世界所有文字;以及我们用 OpenAI 官方分词器 tiktoken 的 o200k_base 编码方案实测到的切分现场(strawberry 切成 st/raw/berry 三块、1234567 切成 123/456/7 三段,均为该方案下的实测;本文一组单句译文实测里,日语 token 数是英语的 2.18 倍,而 2023 年一项跨语言研究在其测试条件下报告的差异极值达 15 倍)。顺着机制你能自己推出三个后果:数不对字母、算不利索多位数、以及多语言文本的 token 账单差异。
想象你去一家豆腐坊买豆腐。师傅不切给你看整块豆腐,而是提前把豆腐切成一板一板的小方块,隔着柜台递给你。现在我问你:这块豆腐里一共嵌了几粒芝麻?
你当然答不上来——不是你眼力差,而是你拿到的根本不是「整块豆腐」,而是别人的刀工结果。芝麻正好嵌在切口里的,被劈成两半分属两块;嵌在方块正中央的,你连它的存在都不知道。你只能凭经验猜:「这种大小的方块里,一般有一粒芝麻吧。」
大语言模型读文字,拿到的就是这样一板切好的「豆腐块」。这些块有个正式名字,叫 token(词块):模型不直接接收字母或汉字,而是接收 token 的编号。你问它「strawberry 里有几个 r」,它收到的不是 s-t-r-a-w-b-e-r-r-y 这十个字母,而是三个编号——在本文实测里(OpenAI 官方分词器 tiktoken 的 o200k_base 编码方案,2026-10-19 本机运行),这三个编号对应的三块是 st、raw、berry。这里要精确一点:芝麻并没有丢——分词是可逆的,token 随时能还原成原文(第一节的编码/解码对照已经演示,第四节还会展开);问题在于还原这一步不在模型的计算路径里,模型工作时手里只有编号。要「数 r」,它就得在没有逐字母输入的情况下,凭训练里见过的痕迹回忆每一块内部的拼写。
这篇文章就讲一件事:把文本切成 token 的那把刀——BPE 分词器——是怎么造出来的,它为什么这么切,以及这套刀法与模型在数字母、算多位数、多语言账单上的表现有什么关系。 全文不需要任何机器学习背景,每个术语第一次出现时都会给一句话定义,每节末尾会给一句话总结或速记结论(讲解节叫「这一节想让你记住的一件事」);凡是可以亲手验证的数字,我们都真的跑了一遍,实测与推导在文中单列标注。
📐 符号与术语表
先把全文要用的记号与术语固定在一张表里,后面所有公式只用这套记号。
| 符号 / 术语 | 一句话定义 |
|---|---|
| token(词块) | 模型读写文本的最小单位,可以是半个词、整个词、标点或汉字 |
| 词表(vocabulary) | 一张固定清单,给每个 token 分配唯一编号 |
| 分词器(tokenizer) | 在文本与 token 编号序列之间做双向转换的程序 |
| 编码(encode) | 文本 → token 编号序列 |
| 解码(decode) | token 编号序列 → 文本 |
| 合并规则(merge) | BPE 训练学到的一条「把相邻两块合成一块」的规则,有先后顺序 |
| 相邻对 在整个语料里的加权总频次 | |
| $$ | V_0 |
| 合并预算,即训练要执行的合并次数 | |
| 压缩率,字节数 ÷ token 数 | |
| 模型对单个 token「回忆其内部拼写」的正确率(本文设定的量) | |
| 语言 相对英语的膨胀率(fertility) | |
| API 输入、输出的每 token 单价 |
一、先认识主角:token 到底是什么
Token(词块) 是大模型读写文本的最小单位。它可以是半个单词(如 st)、一个常见单词(如 hello)、一个标点、一个汉字,甚至两三个汉字(如 人工)。模型内部有一张词表(vocabulary):一份固定的清单,给每个 token 分配一个唯一编号。
文本进入模型前,先被一个叫分词器(tokenizer)的程序切成 token 序列,再把每个 token 换成编号——这一步叫编码(encode)。模型计算完,输出的也是编号,分词器再把编号翻译回文字——这一步叫解码(decode)。一个健康的分词器必须满足「编码再解码等于原文」,用 tiktoken README 的说法,这叫可逆且无损;tiktoken 仓库首页的示例代码第一行写的就是这个断言:把 hello world 编码后再解码,结果必须逐字符等于 hello world。
编号本身还不是模型能计算的东西。模型拿到编号后,第一步是查一张叫 **embedding(嵌入矩阵)**的大表,把每个编号换成一串浮点数(叫向量,你可以理解成这个 token 的「数值画像」)——此后的全部「思考」都在向量层面进行。这意味着:两个编号是否相邻、对应的文字是否相似,模型事先一概不知;token 之间的关系全部要从训练数据里学。编号 302(st)和 1618(raw)对模型来说只是两行向量;「它们拼起来是 straw」这件事,模型如果学过,是后天学的,而不是分词器告诉它的。
用一个生活化的对照来固定直觉(以下全部为本文实测,o200k_base,2026-10-19):
| 你看到的 | 模型看到的(token 编号 → 对应的块) |
|---|---|
| hello world | 24912(hello)、2375( world)——注意第二个块带着前导空格 |
| strawberry | 302(st)、1618(raw)、19772(berry) |
| Strawberry | 3504(Str)、1134(aw)、19772(berry) |
| 3.14159 | 18(3)、13(.)、16926(141)、4621(59) |
| 1234567 | 7633(123)、19354(456)、22(7) |
| GPT-4o | 162016(GPT)、12(-)、19(4)、78(o) |
| 人工智能 | 47243(人工)、60319(智能) |
这张表里有三个值得停下来看的细节,后面每一节都会用到。
第一,大小写会换一套切法。strawberry 与 Strawberry 的切分不同(st/raw/berry 对 Str/aw/berry),编号也完全不同。对模型来说,「同一个词的大小写变体」不是天然关联的两个 token,它们的关联要靠训练数据里见没见过来建立。
第二,空格被吃进了 token 里。hello world 切成 hello 和「 world」——引号内首字符是一个空格,它和后继字母同属一个 token。这就是为什么你在提示词里多打一个空格、或者把换行改成空格,模型拿到的输入编号会整个变掉——改动看起来只是排版,对模型却是另一串数字。
第三,数字被按固定块长切碎。1234567 被切成 123、456、7;3.14159 被切成 3、.、141、59——小数点后的数字每三位一块。这不是巧合,而是 GPT-3.5/4 这一代分词器的设计选择:给 1 位、2 位、3 位的数字组合各备了独立 token。这个设计对算术的影响,第七节专门讲。
还有一个隐含事实值得先点破:token 的边界既不尊重音节,也不尊重词根,更不尊重字母位置。它只尊重一件事——训练语料里的出现频次。这把刀是怎么按频次磨出来的,就是下一节的内容。
值得强调的是,这张表里的每个编号都不是举例虚构,而是 2026-10-19 在本机用 tiktoken 实际编码得到的输出;你在第五节能拿到完全一样的复现脚本。科普文章里的数字只有两种可信:能指到一手来源的,和能亲手复现的——本文尽量只用这两种。
这一节想让你记住的一件事:模型从不读「字」,它只读词表里编了号的 token;切分方式不同,模型看到的就是两个世界。
二、BPE 的训练:每次只合并「语料里最常见的那一对」
现在来造这把刀。BPE 全称 Byte Pair Encoding(字节对编码),最初是一种数据压缩算法:反复把数据里出现最多的相邻字节对替换成一个新符号,数据就变短了。2015 年,Sennrich、Haddow 和 Birch 把这个思路引入神经机器翻译,用来解决一个当时的核心矛盾:模型的词表是固定的,但语言是开放词表的——总有没见过的名字、复合词、外来词。他们的办法是不再以「词」为单位,而是以「子词(subword,比词小、比字母大的碎片)」为单位,让罕见词能用碎片拼出来。论文报告:在 WMT 15 英德、英俄翻译任务上,子词方案比词典回退基线分别提升 1.1 和 1.3 个 BLEU 点(BLEU 是机器翻译的常用自动评分,分数越高越好)。
后来 OpenAI 在训练 GPT 时把 BPE 用作分词方案,从此它成为大模型的主流刀法之一——GPT、GPT-2、RoBERTa、BART、DeBERTa 都用它(见 Hugging Face 课程第六章)。
BPE 的训练目标很朴素:找到一套合并规则,让常见字串变成单个 token,罕见字串还能用小块拼出来。 算法只有三步,循环执行:
- 把语料里每个词先切成最小单位(字符或字节),统计每个词的出现次数。
- 数一遍所有「相邻两块的组合」在整个语料里共出现多少次。
- 把出现次数最多的那一对合并成一个新块,加进词表,然后回到第 2 步。
为什么「贪心合并最频繁的一对」是好策略?直觉来自压缩:合并一对出现 f 次的相邻块,语料的总长度就减少 f 个单位——每次都挑减少量最大的那对,就是在贪心地追求最短编码。频次越高的字串越早整块化,模型学它时就越「见得整、记得牢」;频次低的字串被留在碎片层,靠组合表达。这正是上一节「hello 整块、strawberry 三块」的来源。
用符号把这三步写死(看不懂公式可以跳过,后面有完整的中文算例;公式只是把「数频次、取最大」写成一行)。设语料里词 出现了 次, 表示相邻对 在词 内部出现的次数,那么相邻对 的总频次是:
第 轮选中的合并对就是频次最大的那个:
每合并一次,词表里多一个新符号。设初始词表大小为 ,合并 次后:
真实分词器的 是几万到二十万次(合并预算通常由「目标词表大小」倒推出来),但每一轮做的事情和玩具例子一模一样。
值得一提的是,BPE 并不是唯一的子词算法。Hugging Face 课程同章还讲了 WordPiece(BERT 使用,用另一套评分方式选择合并对)和 Unigram(从另一个方向出发:先备一个大词表,再逐步删掉贡献最小的项)。三者的产物都是「词表 + 切分规则」,对使用者来说差别不大;本文用 BPE 作主线,是因为 GPT 系列与多数开源模型都用它。
就这么简单——BPE 的全部智慧就是「贪心地反复合并当前最常见的一对」。没有语言学知识,没有词典,没有词根词缀表;词表是语料频次排行榜的直接产物。下一节我们亲手算一遍,看看这几行公式落到纸上是什么样子。
还有两个工程细节值得知道。其一,分词器的训练语料(即训练用的文本集合)与模型的训练语料通常是同源的(都是大规模网页文本),所以词表天然偏向英语与代码——这是第八节多语言差距的根源之一:不是算法歧视某种语言,而是频次排行反映语料构成。其二,词表大小是设计者的显式选择:GPT-2 约 5 万,cl100k_base 约 10 万,o200k_base 约 20 万——趋势是越来越大,用更多的参数换更短的序列。注意这三个数都是词表规模的约数,不是合并次数;合并预算 T 约等于词表规模减去初始的 256 个字节和若干特殊 token,比如 o200k_base 的合并次数约 20 万。
这一节想让你记住的一件事:BPE 词表不是人编的,是从语料里「数相邻对、合并最频繁者」一步步长出来的。
三、动手算一遍:五个单词上的六次合并
Hugging Face 课程给了一个经典的玩具例子,语料只有五个词,词频分别是:hug 出现 10 次、pug 5 次、pun 12 次、bun 4 次、hugs 5 次。我们把上面的公式完整走一遍(本文用脚本独立复算;课程原文公开了前三轮合并并把后续留作练习,后三轮为本文续算):
初始状态,每个词拆成字符:
| 词 | 频次 | 初始切分 |
|---|---|---|
| hug | 10 | h, u, g |
| pug | 5 | p, u, g |
| pun | 12 | p, u, n |
| bun | 4 | b, u, n |
| hugs | 5 | h, u, g, s |
第一轮,把所有相邻对的频次全部数出来:
| 相邻对 | 出现在哪些词里 | 总频次 |
|---|---|---|
| (u, g) | hug×10、pug×5、hugs×5 | 20 |
| (p, u) | pug×5、pun×12 | 17 |
| (u, n) | pun×12、bun×4 | 16 |
| (h, u) | hug×10、hugs×5 | 15 |
| (g, s) | hugs×5 | 5 |
| (b, u) | bun×4 | 4 |
最大值是 (u,g) 的 20 次,于是合并出第一个新符号 ug,语料里所有的 u-g 相邻都改写成 ug。注意第二轮的一个细节:(u,g) 这个相邻对已经不存在了(它变成了单块 ug),但 (u,n) 还在——每合并一次,整张频次表都要重算,这正是公式里「回到第 2 步」的含义。
把第二轮也完整走一遍会更踏实。合并 ug 之后,语料变成 (h,ug,10)、(p,ug,5)、(p,u,n,12)、(b,u,n,4)、(h,ug,s,5),重新数对:(h,ug) 共 次,(p,ug) 5 次,(p,u) 12 次,(u,n) 仍是 次,(b,u) 4 次,(ug,s) 5 次。最大值换成 (u,n) 的 16 次,于是第二个新符号是 un。可以看到:(u,g) 虽然第一轮赢了,但它的「胜利」并没有改变 (u,n) 的计数——两对不相邻的候选各算各的;而像 (h,u) 这种与 ug 相邻的对,则被改写成了 (h,ug) 继续参赛。
如此循环六轮,完整合并轨迹如下(本文复算结果):
| 轮次 | 合并对 | 该对总频次 | 词表新增 |
|---|---|---|---|
| 1 | u + g | 20 | ug |
| 2 | u + n | 16 | un |
| 3 | h + ug | 15 | hug |
| 4 | p + un | 12 | pun |
| 5 | p + ug | 5 | pug |
| 6 | hug + s | 5 | hugs |
六轮之后,五个词的最终切分是:hug→[hug]、pug→[pug]、pun→[pun]、bun→[b, un]、hugs→[hugs]。一个诚实的补充:第 5 轮时 (p,ug) 与 (hug,s) 两对并列 5 次,本文按「先被数到者优先」的实现约定取 p+ug——并列破平的具体规则因实现而异,好在本例中无论先合并哪一对,六轮后的最终切分都一样。
注意 bun 的结局:它频次最低(4 次),在这六轮合并预算内没轮到 b 与 un 的合并,于是带着一个单字符碎块(再训练一轮就会合并 b+un,但预算到此为止)。这就是 BPE 的性格:常见词整块化,罕见词碎片化。 你在真实分词器里看到的每个「半截单词」(比如 st、raw),都是某个高频模式的碎片遗存。
训练好的分词器还能处理没见过的新词——靠的不是新规则,而是老规则的顺序。比如玩具词表从没见过 hugged 这个词,编码时先把它的字母排开(此处假设初始字符表已包含 e、d;真实分词器以全部 256 个字节为初始符号,不存在缺字符的问题,详见下一节),然后按合并规则表从早到晚逐条套用:先套第 1 条(u+g→ug),再套第 3 条(h+ug→hug),后面没有能用的规则了,最终切出 [hug, g, e, d]。认识的部分整块留下来,不认识的部分退回小碎片——字节兜底保证永不报错(见下节),只是碎。
这个例子还藏着一个普遍的形态:派生词的「词根部分」往往整块保留,变化的部分碎裂。hugged 保住 hug,是因为 hug 在语料里高频而 hugged 低频。真实词表里同样的现象随处可见:tokenization 切成 token + ization 之类,词根整块、词缀整块、只有组合处碎裂。这使得模型通常能「半认识」没见过的派生词——碎片里有它熟悉的零件。
整个训练流程画成图是这样:
flowchart TD
A[原始语料] --> B[预切分:按空格/标点拆成词]
B --> C[拆成字符并统计词频]
C --> D[统计所有相邻对的频次 f]
D --> E{还有合并预算?}
E -->|是| F[合并频次最高的一对<br/>词表 +1]
F --> D
E -->|否| G[输出:词表 + 有序合并规则表]这张图说明了 BPE 训练的主循环:数对、合并、再数对,直到合并次数用完;产物除了一张词表,还有一份有先后顺序的合并规则表——编码新文本时,规则表的套用顺序就是训练时的学到顺序。
这一节想让你记住的一件事:合并顺序按频次排定,高频词整块、低频词碎裂——词表是语料的「热门排行榜」。
四、字节级 BPE:256 个起始符号装下全世界
玩具例子有个隐患:如果新文本里出现一个训练时没见过的字符(比如一个生僻汉字或新 emoji),按字符起步的词表就束手无策,只能输出「未知词」标记,信息直接丢失。Hugging Face 课程里提到,GPT-2 和 RoBERTa 的分词器用了一个漂亮的办法绕开它:不站在字符层面,站在字节层面。
任何文本在计算机里都是字节序列:UTF-8(一种把全世界字符映射成字节的通用编码)下,一个英文字母是 1 个字节,一个常用汉字是 3 个字节(比如「猫」编码为 E7 8C AB 三个字节,本文按 UTF-8 规则核算;扩展区的生僻汉字是 4 个字节),一个 emoji 码点通常是 4 个字节(肤色、旗帜等组合 emoji 更多)。而字节的取值只有 0 到 255,共 256 种。字节级 BPE 把这 256 个字节作为初始词表:
这样,无论来的是什么文字、什么符号,都能先拆成字节再按规则拼回去,永远不会出现「不认识的字符」。代价是低频文字会被切得很碎——本文实测里,旧一代 cl100k_base 分词器切「猫」要 3 个 token(正好对应 UTF-8 的 3 个字节,说明它没落进合并词表,退化成了字节级碎片),而新一代 o200k_base 里「猫」已经是 1 个整块 token。这就是新词表把高频汉字整块收录的结果:词表从约 10 万扩到约 20 万,多出来的位置大量给了非英语文字(本文实测 tiktoken 报告的 n_vocab 值:cl100k_base 为 100277,o200k_base 为 200019——n_vocab 是接口报告的编号空间大小,含特殊 token,略大于普通词元条目数,用作规模参照即可)。
训练完成后,对新文本的编码是另一个流程:
flowchart LR
A[原始文本] --> B[正则预切分<br/>按空格/标点/数字分段]
B --> C[每段转成字节]
C --> D[按合并规则表的顺序<br/>从最早学到的规则开始套用]
D --> E[得到 token 序列]
E --> F[查词表换成编号]这张图说明了编码管线的顺序:先按一个固定的正则表达式(一种按字符模式切分文本的规则)把文本预切成段(GPT 系列的这把「预切刀」把字母串、数字串、空格、标点分开对待),段内再从字节出发、按训练时学到的合并顺序逐条套用。「预切分」解释了第一节的一个现象:3.14159 里的小数点和数字被分进不同的段,所以永远不会出现跨小数点的合并——刀还没出鞘,边界已经画好了。
预切分的段类大致有四种(这是帮助记忆的示意,不是完整正则规则):
- 字母串:连续的英文字母(含大小写混排)划为一段,段内做 BPE;
- 数字串:连续数字划为一段,按最多三位一块切分(第七节的来源);
- 空白:空格、换行、制表符是预切分的一部分——GPT 系的规则会把前导空格连同后面的字母串划进同一段(这就是「 world」成为一个 token 的来源:段内合并,不是跨段合并);
- 标点与其它符号:各自独立成段,与数字段互不相通。
这个四分类也解释了为什么「代码里的缩进」和「JSON 里的括号」会成为独立的 token:它们被预切刀单独分出来了。
tiktoken(OpenAI 的官方开源分词器)的 README 用几条要点概括了这套方案的性质,值得转述其中三条:它可逆且无损(token 能精确还原成原文);它能处理任意文本(包括训练语料里没出现过的);它有压缩效果(实际使用中平均每个 token 约对应 4 个字节)。把压缩率写成一个数:
这个「平均 4 字节」是英文文本的经验值,后面会成为估算 API 成本的快捷公式——但请记住它是「英文口径」,中文、日文的实际值要用第八节的方法实测。
这一节想让你记住的一件事:字节兜底让分词器永不「卡壳」,256 个起始字节 + 一份有序合并规则表就是全部装备。
五、亲手跑一遍:复现本文全部切分
本文的每个切分数字都可以在你的机器上复现。本文实测环境:Python 3.14.7 + tiktoken 0.14.0(2026-10-19 本机;tiktoken 0.14.0 官方要求 Python ≥3.9)。这也是验证「模型到底看到了什么」的标准动作——以后调试任何提示词问题,先把文本喂给分词器看一眼。
安装:
pip install tiktoken
然后把下面的代码存成 demo.py 运行:
import tiktoken
enc = tiktoken.get_encoding("o200k_base") # GPT-4o 这一代的分词器
for w in ["strawberry", "Strawberry", "1234567", "3.14159", "人工智能", "猫"]:
ids = enc.encode(w)
print(w, "->", ids, [enc.decode([i]) for i in ids])
你会得到与本文第一节完全一致的输出:strawberry → [302, 1618, 19772](st/raw/berry),1234567 → [7633, 19354, 22](123/456/7),人工智能 → [47243, 60319](人工/智能)。换成 cl100k_base(GPT-4/3.5 那一代)再跑一遍,就能看到两代词表的差异——比如「猫」从 3 个字节碎片变成 1 个整块。
第八节的膨胀率表格同样可以复现。本文实测用的四语句子与完整脚本如下(输出即表格里的 17/20/37/30 与 18/30/48/37):
import tiktoken
texts = {
"en": "Artificial intelligence is transforming the way engineers build software, from code completion to automated testing.",
"zh": "人工智能正在改变工程师构建软件的方式,从代码补全到自动化测试。",
"ja": "人工知能はエンジニアのソフトウェア構築方法を変えつつあり、コード補完から自動テストに至るまで。",
"fr": "L'intelligence artificielle transforme la façon dont les ingénieurs conçoivent les logiciels, de la complétion de code aux tests automatisés.",
}
for name in ["o200k_base", "cl100k_base"]:
e = tiktoken.get_encoding(name)
counts = {k: len(e.encode(v)) for k, v in texts.items()}
print(name, counts, {k: round(v / counts["en"], 2) for k, v in counts.items()})
想查某个具体模型用哪套分词器,用 enc = tiktoken.encoding_for_model(“gpt-4o”) 即可直接拿到对应的编码器。两套词表的实际大小也可以查:打印编码器的 n_vocab 属性,cl100k_base 是 100277,o200k_base 是 200019(本文实测;n_vocab 是编号空间大小,含特殊 token)。
如果你想知道 BPE 的合并过程长什么样,tiktoken 还内置了一个教学子模块(README 里提到的 tiktoken._educational),里面有一个几十行的教学版 BPE 实现,可以打印出每一步合并了哪一对——读它的源码是理解本文第三节最直观的方式。
跑通之后,还有三个值得顺手做的实验,每个都只要改一行:
- 试你自己的名字或网名。 高频词整块、低频词碎裂,你的名字多半被切成几块——这就是「模型念不对生僻名字」的原因。
- 试同一句话的中英版本。 数一下两边的 token 数,亲手算出你业务的膨胀率,比任何文章里的数字都可信。
- 试一段代码或 JSON。 注意缩进空格和大括号被切成什么样——这直接解释模型为什么有时会「看错」缩进层级。
这一节想让你记住的一件事:分词器是可运行的程序不是黑话——两段脚本,你就能看到模型看到的世界。
六、回到 strawberry:为什么数 r 这么难
现在可以正面回答开头的问题了。模型收到 strawberry 时,输入是三个编号:st、raw、berry。对它来说,字母 r 不在输入里——三个 r 分别位于 raw 的第 1 个字母和 berry 的第 4、5 个字母,而模型拿到的只是编号 302、1618、19772 这三行向量。
要数对 r 的个数,模型必须完成一条隐式的推理链:回忆 st 内部含 0 个 r、raw 内部含 1 个 r、berry 内部含 2 个 r,再把三个数加起来。设模型对单个 token「回忆其内部某个字母个数」的正确率为 ,一个词被切成 块。在「各块的回忆相互独立」的简化假设下,所有块都回忆正确的概率可用连乘示意:
请注意这条式子的地位:它是本文为了说明「块数越多越难」而构造的简化示意模型,不是模型最终答对概率的通用公式——各块回忆未必独立,个别块回忆错了总数也可能碰巧对。但方向性的结论是可靠的。
这个式子有两层含义。第一层是连乘衰减:即使单个 token 的拼写回忆率高达 ,三块连乘也只剩约 ;五块仍剩约 ,要到七块才跌破一半()。第二层更根本——这个 不是「模型聪不聪明」,而是「训练数据里有没有人教过它每个 token 的内部拼写」。语料里到处是 strawberry 这个词,但几乎没有人写「raw 里有 1 个 r」这样的句子:token 的内部拼写很少以显式形式出现在训练数据里,模型只能靠间接信号(押韵、拼写练习、词典条目)零星地补。所以 天然偏低,而且词被切得越碎( 越大),连乘衰减得越快。
「strawberry 有几个 r」在 2024 年成为社区著名的翻车题(切分编号为本文 2026-10-19 实测;翻车现象本身是广泛报道的社区观察,原始出处本文未逐篇溯源,见文末核验记录)。用上面的示意模型看,这类翻车的方向性原因是清楚的:不是模型不会数数,而是要数的信息没有以逐字母形式出现在输入里,只能靠逐块回忆拼出来——块越多、越碎,全对就越难。
把数字代进去感受一下衰减:若 ,三块连乘是 ;若 ,三块只剩 ,五块则约 ,不到两成。而且真实模型里各块的 并不相同:高频整块(如 berry)在训练数据里出现次数多,拼写回忆相对可靠;低频碎片(如 st 这种半截词头)出现语境杂,回忆率更低。碎块越多、越碎, 的整体水平越低,连乘的底数和指数同时恶化。
把切分现场画出来更直观:
flowchart TD
S[你看到的:strawberry<br/>10 个字母] --> M1[st<br/>编号 302]
S --> M2[raw<br/>编号 1618]
S --> M3[berry<br/>编号 19772]
M1 -.内部拼写不在输入里.-> R1[含 0 个 r]
M2 -.内部拼写不在输入里.-> R2[含 1 个 r]
M3 -.内部拼写不在输入里.-> R3[含 2 个 r]这张图说明了「数 r」难在哪:三个 r 分别位于三块内部的第 1、第 4、第 5 个字母处(raw 贡献 1 个、berry 贡献 2 个、st 一个没有),模型只能在 token 层面工作,字母层面的信息全靠记忆补全。
一个实用的推论:给模型「降维到字母」的脚手架,数 r 就变得很准。 比如在提示词里要求「先把单词逐字母用连字符拆开,再逐字母标记 r」,相当于人工把豆腐还原成整块再递给它——s-t-r-a-w-b-e-r-r-y 摆开来,字母就是模型可见的对象了。这也是提示词工程里「让模型分步显式展开」有效的一个微观原因:展开的过程把藏在 token 内部的信息提到了 token 层面。
同样的脚手架思路也适用于所有字母级任务:统计位置就让它先编号(1-s, 2-t, 3-r……),倒序拼写就让它先正序展开再倒读。原则只有一句:凡是需要 token 内部信息的任务,先让模型把内部信息显式写到 token 层面来。
顺带一提,同一条连乘示意还解释了另外几类常见的「低级翻车」:让模型把单词倒过来拼(要看到每个字母的顺序)、统计某个字母出现的全部位置(要看到字母与位置的对齐)、判断两个词是否押头韵(要看到首字母而不是首 token)。这些任务的共同点不是「难」,而是答案依赖 token 的内部拼写——凡是需要 token 内部信息的任务,块数一多就都变难。
这一节想让你记住的一件事:数错字母不是智力问题,是输入粒度问题——模型收到的是 token,字母信息要靠逐块回忆拼出来。
七、不止数不对字母:数字、算术与三位一切
字母层面的麻烦只是开胃菜,数字层面有同款问题,而且直接影响算术。
回忆第一节的实测:1234567 在 o200k_base 里被切成 123、456、7 三段;3.14159 被切成 3、.、141、59。2024 年 2 月的一篇论文《Tokenization counts》(arXiv:2402.14903)系统研究了这件事:不同模型对数字的切法设计不同——
| 模型家族 | 数字切分设计 | 1234567 的切法(示意) |
|---|---|---|
| LLaMA / PaLM | 每一位数字单独成 token | 1, 2, 3, 4, 5, 6, 7 |
| GPT-3.5 / GPT-4 | 1 位、2 位、3 位数字组合各有独立 token | 123, 456, 7(本文实测) |
「从左到右三位一切」带来一个隐蔽的对齐问题:位数不同的数,被切出的块结构不同,对应数位对不齐。比如 1234567 切成 123|456|7(个位 7 独占一个一位块),而 123456 切成 123|456(个位 6 挂在三位块的末尾)——两个数相加时,个位信息在一个序列里独占一块、在另一个序列里埋在块尾,模型要在内部额外做一次对齐(这两个切分均为本文 o200k_base 实测)。
该论文的实验发现:推理时给大数加上千分位逗号(1,234,567),强制从右往左对齐三位边界,算术正确率显著提升;而标准从左到右切法下,模型的错误呈现「模式化的错误数位」特征——错的总是对不齐的那几位,这正是切分而非「推理能力」出问题的指纹。
为什么加几个逗号就有效?机制完全在分词器里:逗号是数字段的预切分边界,1,234,567 会被强制切成 1、234、567 三个数字段(本文实测:连同两个逗号各占一个 token,共 5 个 token)——从右往左对齐,每个三位块的「个十百位」结构完全一致,个位永远在每个块的最后一位。对齐之后,竖式加法的「对应位相加」就变成了序列上位置整齐的局部模式,这正是模型最擅长学的东西。
这件事对工程有一个直接启示:模型做不对多位数算术,很多时候不是「推理能力」的锅,而是分词把数位结构切乱了。给模型配计算器工具、在提示词里引导它逐位竖式对齐、或者把关键数字改成逗号分隔格式,本质上都是在补偿分词器丢掉的对齐信息。
反过来也该公平地说一句:LLaMA 和 PaLM 的「每一位数字单独成 token」是另一种权衡——数位对齐天然整齐(个位永远是一个独立 token),但代价是数字占用的序列长度变成三倍,上下文窗口和计算量都被数字吃掉更多。三种设计(单位数字 / 三位一块 / 千分位引导)没有绝对最优,只有「你的任务里数字扮演什么角色」下的最合适。
这一节想让你记住的一件事:数字按「三位一块」从左往右切,数位对齐被切碎——多位数算术出错常是分词的锅,加千分位逗号是零成本的补偿。
八、多语言的账单:同一句话,token 数最多差 15 倍
分词器的刀法还有一个直接影响钱包的后果:同样的内容,不同语言的 token 数不一样。
2023 年 5 月的论文《Language Model Tokenizers Introduce Unfairness Between Languages》(arXiv:2305.15425)测了同一内容翻译成不同语言后的 token 化长度,在其测试条件下发现差异最大可达 15 倍——注意这是该研究样本与分词器下任意语言对之间的编码长度比,不是本文对 o200k_base 实测的上限;即便是有意为多语言设计的分词器也未能避免;该论文还报告,字符级与字节级模型在某些语言对之间也仍有超过 4 倍的编码长度差异。为了有一个可以就自己业务复用的口径,定义语言 相对英语的膨胀率(fertility):
本文做了一个最小实测(2026-10-19,同一句「人工智能正在改变工程师构建软件的方式,从代码补全到自动化测试」及其英、日、法译文,两种分词器各跑一遍;四份译文全文与脚本见第五节,可直接复现):
| 语言 | cl100k_base(约 10 万词表) | o200k_base(约 20 万词表) | o200k 膨胀率 φ |
|---|---|---|---|
| 英语 | 18 | 17 | 1.00 |
| 中文 | 30 | 20 | 1.18 |
| 日语 | 48 | 37 | 2.18 |
| 法语 | 37 | 30 | 1.76 |
两个信息一目了然:新一代词表全面改善了非英语的切分(中文从 1.67 倍降到 1.18 倍);但「同义不同长」的结构性事实没有变——日语仍要 2 倍以上的 token 来表达同一句话。
膨胀率该怎么测?方法朴素得出奇:取你业务里的真实文本和它的英译(或反过来),分别喂给目标模型的分词器,token 数相除即可。注意要用成段的连续文本而不是单词表——token 化对上下文敏感,零散词汇的膨胀率和真实段落的膨胀率可能差很多。
而 API 账单是按 token 计价的,一次调用的成本是:
其中 、 是输入、输出的单价,、 是 token 数。把两个公式连起来看:如果你的业务文本主要是某种膨胀率高的语言,你为同样的信息量付的钱就是英语用户的 倍——这就是那篇论文标题里「不公平」的含义:它不是修辞,是账单。同样的逻辑也适用于上下文窗口:窗口按 token 计,高膨胀率语言能装下的实际内容更少;处理时延同理,序列更长、计算更多。
用一个假想的价格把账算到元。假设某 API 的输入单价为每百万 token 2 美元(这是本文为了演示而假定的价格,不是任何厂商的真实报价,真实价格请以厂商官网为准),你要处理一批原文为 100 万个英文字符(主要为 ASCII)的文档,且仅为演示、假定其日译文的膨胀率沿用本次单句实测的 2.18:
- 英文原文按 字节/token 粗估,100 万字符约 25 万 token,输入成本约 0.5 美元。
- 同样内容的日译文,token 数约涨到 54 万,输入成本约 1.09 美元——同样的信息,两倍多的钱。
- 若换成论文里测到的极端语言对( 最高 15),成本差距就达到一个数量级以上。
这个例子的目的不是给价格表,而是给算法:先实测你的业务文本在目标模型分词器下的 token 数,再谈成本优化。 压缩 token 数的手段(去掉冗余空白、用更新更大的词表的模型等)可以作为实测优化候选——但任何改写手段都要同时检查输出质量,别为了省 token 把提示词改坏。
对做产品的工程师,这一节有三条能直接用的推论,外加两条容易被忽视的:
- 估成本别按字符数估,按 token 数估。 英文粗算用第四节的 字节/token,其它语言务必用第五节的脚本实测——fertility 随词表版本变化很大(本文的中文实测,两代词表差了 1.5 倍 token 数)。
- 上下文窗口是 token 口径的。 例如某模型标称的 128K token 窗口,装下的中文内容和英文内容不一样多,高膨胀率语言会更早顶到上限。
- 换模型可能换词表。 不同模型的分词器不同,同一份提示词迁移模型时 token 数会变,成本与窗口占用都要重测。
- 时延也与 token 数相关。 在模型、生成速度与内容相同的条件下,输出按 token 逐个生成,膨胀率高的语言输出序列更长、生成时间也更长;做实时交互产品(语音转写、流式客服)时,要把膨胀率算进延迟预算。
- 限速同样是 token 口径。 厂商的 rate limit(每分钟用量上限)通常限的是每分钟 token 数,高膨胀率语言的吞吐上限因此更低。
这一节想让你记住的一件事:token 是计价单位,而 token 的「汇率」因语言而异——同样的意思,账单最多可差 15 倍。
九、八个常见误解,一次拆完
分词器是「人人都用过、很少人打开看」的组件,围绕它的误解特别多。这一节把最常见的八个逐条拆掉。
误解一:「token 就是单词」。 不是。token 是频次驱动的碎片:hello 是一整块,strawberry 是三块,1234567 是三段,一个罕见汉字可能是三个字节。词与 token 的对应关系完全取决于词表在训练语料里学到的频次排行,不取决于词典。
误解二:「词表越大越好」。 大词表让更多字串整块化、序列变短(本文实测中文 token 数因此降了三分之一),但词表每一项都对应模型 embedding 层的一行参数,词表越大、低频 token 能分到的训练信号越稀疏。词表大小是压缩率与参数效率之间的权衡,不是越大越好。
误解三:「空格和大小写只是排版」。 对模型不是。「 world」(带前导空格)与 world 是两个 token,Str 与 str 是两组切分。改动排版等于改动模型的输入编号——这也是为什么「同一个提示词,复制到别处就不灵了」的排查清单里永远有一项:检查不可见字符与大小写。
误解四:「分词器是语言学家设计的」。 不是。BPE 词表是语料频次排行榜的机械产物,不含词根、词性、语义知识。它恰好常把 -ing、un- 这类高频片段切成整块,看起来「懂语言学」,但那只是频次的影子——tiktoken 的 README 特意说明了这种切法的好处:让模型在不同语境里反复见到同一个 ing,从而更好地学会语法规律。
误解五:「token 数多 = 内容多」。 不是。同一个意思,日语的 token 数可以是英语的 2 倍以上(本文实测 2.18 倍),信息量却完全一样。token 数是编码长度,不是信息量的度量——跨语言比较文本长度、评估「模型读了多少材料」时,这个坑尤其常见。
误解六:「换个分词器,模型还是那个模型」。 不是。词表与 embedding 层一一对应,分词器是模型与生俱来的器官:一个模型训练时用什么词表,推理时就必须用什么词表,中途换分词器等于换了一套编号系统,所有向量都对不上号。你能换的是「另一个模型」,不是「同一个模型的另一套分词器」。
误解七:「英文 1 token ≈ 0.75 个词,中文也照这个估」。 不是。0.75 词/token(约 4 字节/token)是英文口径的经验值;本文实测里中文连续文本约 0.6–0.7 个 token/字(o200k_base,第八节那句 31 字实测 20 token,约 0.65),日语更碎。把英文换算率套到中文业务上,成本与窗口估算会系统性偏乐观——直接实测是唯一可靠做法。
误解八:「分词器是模型训练时自己学出来的」。 不是。分词器在模型训练之前就单独训练完成并被冻结,模型的全部训练数据都以这套固定切分后的 token 序列形式喂入。也就是说,模型从没见过「原始文本」,它见过的世界从第一天起就是切好的——这就是为什么本文说分词器是模型与世界之间「唯一的光学器件」。
这一节想让你记住的一件事:token 是频次统计的产物,不是语言学单位;把它当「词」或「信息量」都会推错结论。
十、边界与未解:有没有不切 token 的模型?
一个很自然的问题是:既然 token 带来这么多麻烦,能不能让模型直接读字节或字符?
方向上确实有人在走。SentencePiece(2018 年,Google)至少把「训练」这一步做成了端到端:它不要求先把文本预切成词,直接从原始句子上训练子词模型,对不使用空格分词的语言(如中文、日文)更友好——但它的产物仍然是一张子词词表,本文讲的切分问题本身还在。更激进的路线是让模型直接在字节层面工作,彻底取消分词器;这类「无分词(token-free)」模型在原理上可以避免一部分由子词切分带来的粒度问题(但不保证数字母或算术因此必然正确),代价是字节序列比 token 序列长好几倍,注意力与计算的成本随之上升,截至 2026-10-19 还不是主流大模型的默认选择。(本段为方向性概述,未展开引用具体 token-free 论文,属于「未核实项」,见文末核验记录。)
所以现实里的格局是:BPE 及其变体仍将长期在场,因为它在「词表大小、序列长度、压缩率、无损性」四个目标之间取得了一个极难替代的平衡。序列长度这一条尤其关键:主流注意力机制(即模型让每个 token 关注其它 token 的计算结构)的计算量随序列长度平方增长,把 token 序列换成字节序列等于把长度放大好几倍、把注意力的账单放大十几倍——这是 token-free 路线在工程上最难迈过的坎。当然,「仍将长期在场」是截至本文写作日(2026-10-19)的判断,不是已被证明的事实。理解 BPE 的刀法,比等待一把完美的刀更实用。
这一节想让你记住的一件事:token-free 是研究方向,但今天和在可预见的将来,你都活在 BPE 的世界里。
十一、常见问题快答(FAQ)
问:我用 ChatGPT 网页版,也需要关心分词吗?
需要,但场景不同。网页版用户最关心两件事:数字母类问题要加「逐字母展开」的脚手架;长对话逼近上下文上限时,中文内容比英文更早顶满——聊得久又舍不得新开窗口时,这一点会实际影响记忆长度。
问:tiktoken 能用于非 OpenAI 的模型吗?
不能直接通用。词表是随模型走的(第九节误解六),tiktoken 只覆盖 OpenAI 系模型的编码。其它模型各有自己的分词器(多在 Hugging Face 仓库里随模型发布),但 BPE 的原理完全一致,本文的推导照样适用。
问:为什么我的程序里 len(text) 和实际计费差那么多?
因为 len 数的是字符,计费数的是 token。英文里两者大约差 4 倍(),中文、代码、混排文本各有自己的换算率,而且随词表版本变化——用目标模型的分词器实测是唯一可靠的估法。
问:分词器会被对抗利用吗?
会有一类「奇怪 token」现象:词表里存在一些在训练数据中极少真正出现的 token(比如某些 Reddit 用户名变成的整块),模型对它们的 embedding 训练不足,行为不可预测。这是「低频 token 训练信号稀疏」(第九节误解二)的极端形态。这类现象的细节超出本文范围,这里只点出它与词表机制的因果链。
问:新一代模型数 r 数对了,是不是分词问题解决了?
数对一道题不代表问题解决。更强的模型可能把更多 token 的内部拼写背了下来(相当于把 推高),但只要输入还是 token 序列,「逐块回忆、块多易错」的连乘结构就还在——换一个更长的生僻词、换一个小语种单词,同类问题仍可能出现。机制层的理解比「某道题过了」更耐用。
问:我想给自己训练一个分词器,从何入手?
两条路:用 Hugging Face tokenizers 库按课程第六章的流程在自己的语料上训练 BPE;或者直接用 SentencePiece 从原始句子端到端训练。本文重点讨论的一个决策是词表大小——它决定「常见字串多整、罕见字串多碎」的平衡点;此外还有语料选择、算法与预处理配置等因素,本文不展开。
问:上下文窗口为什么按 token 算而不是按字符算?
因为模型的计算对象是 token 序列:注意力机制的每一步都在 token 之间两两计算关联,计算量随 token 数(而非字符数)增长。窗口、计费、限速这三个口径全部挂在 token 上,是同一件事的三张面孔。
问:词表训练好后还会更新吗?
同一个模型内不会——词表与 embedding 绑定,训练后冻结。但厂商发布新一代模型时常会重新训练分词器:cl100k_base 到 o200k_base 就是一次换表(约 10 万 → 约 20 万),本文实测的中文、日文切分改善正来自这次换表。
问:token 和 embedding 是一回事吗?
不是。token 是离散的编号(词表清单里的一项),embedding 是这个编号对应的一串浮点数向量。关系像「门牌号与房子」:分词器只管发门牌号,模型计算用的是房子本身。本文第六节说的「字母藏在 token 内部」,准确说是「字母信息没有以逐字母形式出现在输入里,模型要回忆每个 token 的拼写」。
以上是八个高频疑问;若只记一句:凡是「按字符/按词/按句」的直觉,都先换成「按 token」再推一遍。
十二、把「分词思维」用起来:一页检查清单
把全文学到的东西压缩成一张工作中的检查清单。遇到下列场景时,先问一句「这件事在 token 层面长什么样」——大多数「模型玄学」在 token 层面都有朴素的解释:
- 模型数错字母、拼错单词、看漏字符 → 先用分词器看切分;答案藏在 token 内部的任务,加「逐字母展开」脚手架。
- 多位数算术出错 → 给数字加千分位逗号,或引导逐位竖式;关键计算交给工具。
- 提示词复制后行为变了 → 检查不可见字符、空格与大小写;它们改变 token 编号。
- 估算 API 成本 → 用目标模型的分词器实测 token 数,别按字符数估;英文粗算 字节/token。
- 评估上下文够不够 → 窗口按 token 计;已实测目标语言的 token 数就直接与窗口比较,只有从英文基准估算时才乘对应的膨胀率。
- 跨模型迁移提示词 → 两个模型的分词器不同,token 数与切分边界都会变,重新实测。
- 设计多语言产品 → 把 fertility 当成计价与窗口的「汇率」,用真实业务文本测,不要用别人的均值。
若只记一句:先实测 token 数,再谈成本、窗口与效果。
十三、小结:一把刀,四个要点
把全文收成四句话:
- 模型读 token 不读字。 分词器是模型与世界之间唯一的光学器件,它的切法决定了模型「能看到什么」。
- BPE 词表是语料的热门排行榜。 反复合并最频繁的相邻对,高频整块、低频碎裂;合并规则表的顺序就是编码时的套用顺序。
- 输入粒度决定能力边界。 数错字母(示意模型:各块回忆相互独立时「每块都对」的概率随块数连乘下降)、算错多位数(三位一切破坏数位对齐),都是「所需信息没有以逐字母/逐位形式出现在输入里」的直接推论,不是模型变笨了——而脚手架(逐字母展开、千分位逗号、计算器工具)能把信息重新提到 token 层面。
- token 是计价与窗口的单位,且「汇率」因语言而异。 成本公式 里的 N 必须由目标语言实测;跨语言编码长度差异在 2023 年那项研究的测试条件下极值达 15 倍,本文 o200k_base 单句实测的日英比为 2.18 倍。
下次再看到「大模型连 strawberry 都数不对」的新闻,你可以微笑着说出真正的原因:不是它不认识草莓,是有人提前把草莓切成了三块,而把籽藏在了切口里。
而当你自己写的提示词行为诡异时,希望你也会想起今天的两段脚本——把文本喂给分词器,看看模型到底看到了什么。很多时候,谜底就躺在那串编号里。
📋 来源与核验记录
以下每条来源均为本轮(2026-10-19)实际打开并读取过的页面;文中实测数据(token 切分、n_vocab、膨胀率表格)为本文用 tiktoken 在本机运行得到,玩具 BPE 合并轨迹为本文脚本独立复算并与 Hugging Face 课程例题核对一致。
一手来源:
- Sennrich, Haddow, Birch, Neural Machine Translation of Rare Words with Subword Units(BPE 引入神经机器翻译的原始论文,WMT 15 英德 +1.1 / 英俄 +1.3 BLEU),https://arxiv.org/abs/1508.07909
- Kudo, Richardson, SentencePiece: A simple and language independent subword tokenizer and detokenizer(从原始句子直接训练子词模型,无需预分词),https://arxiv.org/abs/1808.06226
- Petrov 等,Language Model Tokenizers Introduce Unfairness Between Languages(同一内容跨语言 token 化长度差异最高 15 倍;字节级模型亦有超 4 倍差异),https://arxiv.org/abs/2305.15425
- Singh, Strouse, Tokenization counts: the impact of tokenization on arithmetic in frontier LLMs(LLaMA/PaLM 单位数字切分 vs GPT-3.5/4 三位数字独立 token;千分位逗号的从右到左对齐提升算术表现),https://arxiv.org/abs/2402.14903
- OpenAI tiktoken 官方仓库 README(BPE 可逆无损、任意文本可切、平均每 token 约 4 字节、o200k_base 对应 GPT-4o、encoding_for_model 用法),https://github.com/openai/tiktoken
- Hugging Face LLM Course 第六章第 5 节 Byte-Pair Encoding tokenization(BPE 训练算法、hug/pug/pun/bun/hugs 例题、字节级 BPE 初始词表 256、GPT 系正则预切分),https://huggingface.co/learn/nlp-course/chapter6/5
本文自行推导与实测(与上方官方来源分列):
- 全部 token 切分示例(strawberry/Strawberry/hello world/3.14159/1234567/123456/1,234,567/GPT-4o/猫/人工智能,o200k_base 与 cl100k_base)、两处 n_vocab 数值、第八节四种语言的膨胀率表格:2026-10-19 本机实测(Python 3.14.7 + tiktoken 0.14.0),四份译文全文与两段复现脚本见第五节,可直接复现。
- 第三节六次合并的频次与轨迹、hugged 的编码示例:本文脚本按第二节公式独立复算;课程原文公开前三轮并把后续留作练习,第 4–6 轮为本文续算(第 5 轮两对并列 5 次,本文按「先被数到者优先」取 p+ug,最终切分不受破平次序影响)。
- 第六节「每块都回忆对的概率随块数连乘下降」与第八节成本连读:本文基于机制的简化示意与推导(独立性为假设),非任何论文原文,也非模型答对概率的通用公式。
- 未核实项:「数 r」翻车现象的原始社区出处未逐篇溯源(作为广泛报道的社区观察使用);第十节 token-free 方向未引用具体论文,仅作方向性概述;「猫 = E7 8C AB」的 UTF-8 字节值为本机按 UTF-8 编码规则核算。