[控场AI]
· 17 分钟阅读· 8,777 字

大模型为何数不清草莓里的r?深入Tokenizer分词机制

大模型为何数不清草莓里的r?深入Tokenizer分词机制

LLM 只读整数 Token ID,分词机制解释了字母计数、算术与多语言成本等失败模式。

文章从「大模型数不清 strawberry 里的 r」这一现象切入,指出 LLM 在进入 Transformer 之前,文本已被分词器切成 token 并转成整数 ID,token 内部的拼写信息对模型不可见。文章基于 tiktoken 在 GPT-2、GPT-4、GPT-4o 词表上的实测,梳理了从文本到 token ID 的五个步骤、BPE 的学习过程与字节级 BPE 如何消除未知词,并解释了若干经典失败模式:字母计数不可行、多位数加法因切分方式而不稳定(GPT-3.5 上仅因逗号不同就是 75.6% 对 97.8%)、非英语文本 token 成本最高差 15 倍、以及训练不足导致的故障 token。作者给出的应对策略是改变表示形式(拆字母、加逗号)或把精确任务交给代码工具,并强调必须用真实分词器测量而非按字符估算。

大模型为何数不清草莓里的r?深入Tokenizer分词机制

问一个语言模型 "strawberry" 里有几个字母 r,很长一段时间里,它会回答"两个"。同一个模型能写一首关于草莓的十四行诗,能解释草莓的基因构成,能把这个词翻译成四十种语言。为什么偏偏数字母就出错?

答案藏在分词器(Tokenizer)里。在提示词进入第一层 Transformer 之前,一个独立的程序已经把文本切成了块,每一块换成一个整数。模型从头到尾只读这些整数。如果 "strawberry" 变成了一整个整数,三个 r 就被藏进了一个模型无法拆开的数字里。

分词是 LLM 技术栈中最不起眼的环节,却制造了相当多的问题:多位数算术不稳定、非英语用户的成本更高、让模型行为异常的"故障词",以及与你按字符数估算完全对不上的 token 计数。本文基于 OpenAI 开源 tiktoken 库在 GPT-2、GPT-4(cl100k_base)和 GPT-4o(o200k_base)词表上的实际测量,逐一拆解这些问题。

rss source: Why Can't LLMs Count the R's in "Strawberry"? How Tokenizers Work

Token:模型唯一认识的字母表

神经网络做不了字符串运算,它需要数字。最朴素的方案是给每个字符一个编号,但一份两千词的文档就会变成超过一万个位置,每一层 Transformer 都要为每个位置付出计算代价。另一个极端是给每个单词一个编号,但没有任何固定列表能覆盖人们会输入的所有单词、人名、拼写错误、URL 和代码标识符。

Rico Sennrich 等人在 2016 年的论文《Neural Machine Translation of Rare Words with Subword Units》中量化了这个权衡。在一个约一亿词的德语训练语料上,保留完整单词需要 175 万条词表,测试集里仍留下 1,079 个未知词。拆成字符只需要约 3,000 个符号,但语料膨胀到 5.5 亿个位置。他们提出的子词方法 BPE 落在中间:约 63,000 个符号、1.12 亿个位置、零个未知词。

这个中间产物就是 token:对罕见词来说通常短于一个单词,对常见词来说恰好等于一个单词。现代 LLM 的词表规模在 5 万到 20 万之间。tiktoken 的 README 给出平均值约为 每个 token 4 字节文本。在英文文本上实测,GPT-4o 分词器约为每 token 4.4 个字符,也就是约 0.63 个单词。

BPE(Byte Pair Encoding) 是本文反复出现的核心概念。它原本是 Philip Gage 在 1994 年提出的一种数据压缩算法:不断找出数据中最常见的相邻字节对,用一个新增的未使用符号替代它,从而缩短表示。Rico Sennrich 等人在 2016 年把这一机制改造为构建子词词表的方法——把语料中每个词拆成字符,反复合并最高频的相邻符号对,每次合并产生一个新 token 并记下规则。之所以用它,是因为它在「整词」和「单字符」两个极端之间取得了平衡:常见词坍缩成单个 token,罕见词拆成若干子词片段,既控制了词表规模,又几乎不产生无法表示的输入。

从文本到 Token ID 的五个步骤

以 "I love strawberry" 这句话进入 GPT-4o 的过程为例:

第一步,原始文本。 字符串以 UTF-8 字节形式到达。部分分词器会先做归一化(Unicode 规范化、旧模型会转小写),但 GPT 系列保留字节原样。

第二步,预切分。 一个正则表达式把文本切成合并规则永远不能跨越的片段:字母串、数字串、标点、空白。GPT-2 的版本会把前导空格附着到后面的单词上,所以片段是 I、 love、 strawberry。这意味着带空格的 " strawberry" 和不带空格的 "strawberry" 是两个不同的 token。

第三步,BPE 合并。 每个片段从字节序列开始,分词器按学习顺序应用合并规则,把相邻对粘合起来,直到没有规则可用。常见词坍缩为一个 token,罕见词变成几个子词块。

第四步,Token ID。 每个最终块在词表中查找。用 o200k_base 编码,"I love strawberry" 变成 [40, 3047, 101830]:三个整数。

第五步,嵌入。 模型内部,每个 ID 选择嵌入矩阵的一行——一个几千维的向量。从此 Transformer 只跟这些向量打交道。

这个设计带来两个性质:分词是无损的,解码 ID 能还原原始字节;分词适用于任何文本,包括训练时从未出现过的字符串。后者依赖于字节级 BPE 这个技巧。

BPE 如何学习自己的词表

Byte Pair Encoding 最初是一种数据压缩算法(Philip Gage,1994):反复找出最常见的相邻字节对,用一个未使用的新符号替换它。Sennrich 等人把这个思路改造成了构建词表的方法。训练过程如下:

  1. 把训练语料中每个词拆成字符,初始词表就是这些字符。
  2. 统计每一对相邻符号的出现次数,按词频加权。
  3. 把最高频的配对合并为新符号,加入词表,记录规则。
  4. 重复直到词表达到目标大小。

合并次数是唯一真正需要设定的参数。词表最终大小等于起始符号数加上合并操作次数。

用 Hugging Face LLM 课程中的玩具语料举例:"hug"(10 次)、"pug"(5 次)、"pun"(12 次)、"bun"(4 次)、"hugs"(5 次)。初始词表是 b g h n p s u。第一次合并:u + g 出现 20 次,高于任何其他配对,ug 成为 token。第二次合并:u + n 以 16 次领先,un 加入。第三次合并:h + ug 出现 15 次,第一个三字母 token hug 诞生。

真实分词器要在数 GB 文本上把这个循环跑上万次。GPT-2 的词表有 50,257 条。GPT-4 的 cl100k_base 约 10 万条,GPT-4o 的 o200k_base 约 20 万条。Meta 表示 Llama 3 转向了 128K token 词表,在同样文本上比 Llama 2 少用最多 15% 的 token。

编码新文本就是重放合并规则:拆成字符(或字节),按优先级顺序应用学到的规则。训练中常见的词坍缩为一个 token,没见过的词仍能编码,只是碎片更小。用 GPT-4o 词表,"unbelievably" 变成 un | bel | ievably。

合并顺序还解释了一些怪现象。合并规则反映的是分词器的训练数据,而不一定是模型的训练数据。这一点在故障 token 部分会再次出现。

字节级 BPE:告别"未知词"

经典 BPE 从字符出发,而 Unicode 定义了约 15 万个字符。训练语料中缺失的任何字符都会变成 [UNK],即"未知"token。这也是早期 NLP 模型处理 emoji 效果差的原因之一。

GPT-2 引入了一个简单的修复:在字节而非字符上运行 BPE。字节值只有 256 种,基础词表固定为 256 条,任何输入——无论是 emoji、泰米尔文还是二进制垃圾——都能用它们写出来。合并规则再逐步构建常见的多字节序列。GPT-2 实际上没有未知 token,因为字节级 BPE 下未知字符不可能出现。

字节回退机制可以直观看到:在 GPT-2 词表中,emoji 🙂(4 个 UTF-8 字节)变成 2 个 token;GPT-4o 更大的词表学会了整个序列,所以是 1 个 token。代价是罕见文字系统可能被切成连完整字符都不是的碎片,这就引出了下面的"多语言 token 税"。

UTF-8 是一种变长字符编码:ASCII 字符占 1 个字节,许多欧洲字符占 2 个,中日韩文字通常占 3 个,emoji 一般占 4 个。字节级 BPE 之所以只从固定的 256 种字节值出发,正是因为 UTF-8 保证任何 Unicode 文本都能被表示为字节序列。这也解释了本文提到的现象:一个 4 字节的 emoji 在小词表下会被切成多个 token,而大词表通过合并规则学会了完整序列后只需一个 token。代价是当一段文字系统的字节组合在训练语料中过于罕见时,分词器可能切出跨越字符边界的碎片,连一个完整的可见字符都不是。

模型为什么看不见那些 r

回到草莓问题。GPT-4o 分词器对经典问句的处理是:"How many r's are in strawberry?" 变成 8 个 token:How | ·many | ·r | 's | ·are | ·in | ·strawberry | ?。你关心的那个词是 token #101830。对模型来说,这是一个不透明的符号,映射到一个嵌入向量。里面没有 "r" 可以数。

模型关于 strawberry 拼写的任何知识,都必须从训练文本中偶然出现拼写的地方记住——词典、拼写比赛页面、儿童练习册。这种知识稀薄而间接,所以模型只能猜,而对于以双写字母结尾的词,"两个"是一个合理的猜测。

切分方式还以人们意想不到的方式依赖上下文:带前导空格的 strawberry(句中常见情况)是 1 个 token;不带空格的 strawberry(比如消息开头)是 3 个 token:st | raw | berry;首字母大写的 Strawberry 是另外 3 个:Str | aw | berry。模型对同一个词可能学到了略微不同的东西。

修复方法都是让字母暴露出来。 把词写成 s-t-r-a-w-b-e-r-r-y 会变成 10 个 token,每个字母一个,此时计数就变成了模型真正能做的事。更好的做法是:当精确的字符级答案很重要时(计数、反转字符串、检查正则),让模型调用代码工具。一行 Python:"strawberry".count("r"),永远返回 3。

多语言 Token 税

分词器的合并规则来自训练数据,而这些数据以英语和代码为主。英语单词有专属 token,其他语言——尤其是拥有独立文字系统的语言——得到的合并更少,更接近原始字节。

同一句短句 "The weather is nice today" 及其简单翻译,在 OpenAI 三个分词器下的表现:英语在三个分词器中都是 6 个 token。泰米尔语在 GPT-2 分词器下是 85 个 token,GPT-4 是 42 个,GPT-4o 是 11 个。印地语从 27 降到 21 再到 5,俄语从 27 降到 13 再到 6。

Aleksandar Petrov 等人在 2023 年的论文《Language Model Tokenizers Introduce Unfairness Between Languages》中做了系统研究。他们发现同样文本在不同语言间的 token 长度差异可高达 15 倍,即使是字节级和字符级模型也显示出超过 4 倍的差距。后果很具体:API 按 token 计费;128K token 的上下文窗口容纳的泰米尔语页面远少于英语;输出逐 token 生成,影响延迟。如果你服务非英语市场,用真实流量测量 token 数,不要假设"每 token 约 4 个字符"。

数字:长加法为何出错

同一个预切分正则决定数字如何分块,各家实验室的选择差异很大。Aaditya Singh 和 DJ Strouse 在 2024 年的论文《Tokenization counts》中做了梳理:

  • 纯 BPE(GPT-3、Claude 2.1、OLMo):哪个数字串碰巧高频就获得 token。710 可能是一个 token 而 711 不是,同长度的数字切分不一致。
  • 单个数字(PaLM、Llama 1 和 2、Mistral):每个数字独立成 token。
  • 最多 3 位数字,从左到右(GPT-3.5、GPT-4):tiktoken 模式中的 \p{N}{1,3} 规则。GPT-4o 的 o200k_base 使用同样规则。

从左到右分块意味着 1234567 变成 123 | 456 | 7。块与数字的开头对齐,而位值和加法中的进位是从末尾开始的。加上逗号后分词器会在逗号处切分,得到 1 | , | 234 | , | 567,每个块都落在千位边界上。

Singh 和 Strouse 在 GPT-3.5 上用 7 到 9 位数加法测试了这一点。用纯数字,8-shot 准确率是 75.6%;加逗号后是 97.8%。当答案位数多于输入位数时(因为最后的进位),纯数字准确率跌到 8.25%,而且错误不是随机的:模型几乎总是前三位正确、第四位错误,正好在第一个 token 边界处。要求模型先用逗号重写问题,能恢复大部分损失的准确率。GPT-4 受影响较小,但效应没有消失。

结论不是"到处加逗号",而是数字格式改变了模型需要解决的问题。对于发票或金融计算这类必须精确的场景,把算术交给计算器或代码工具。

预切分使用的正则表达式(如 tiktoken 模式中的 \p{N}{1,3})在分词前先扫描文本,把不可能被合并规则跨越的片段划出来。\p{N} 匹配任意数字字符,{1,3} 表示匹配 1 到 3 个,且正则引擎默认从左向右贪心匹配,因此长数字串被切成每三个一组、从左侧对齐的块。这正是本文所说「块与数字开头对齐、进位却从末尾开始」的根源:模型看到的 token 序列天然按高位到低位分组,而笔算加法需要从最低位起逐位处理,二者的对齐方式不一致。换成逗号分隔恰好让每个 token 边界落在千位分组处,与人类书写和心算的分组方式吻合,所以准确率大幅回升。

故障 Token:当词表活得比数据久

2023 年初,Jessica Rumbelow 和 Matthew Watkins 在 GPT-2 和 GPT-3 的 token 嵌入中发现了一簇奇怪的条目:SolidGoldMagikarp、TheNitromeFan、cloneembedreportprint、petertodd。在他们的 LessWrong 文章中,要求模型简单复述这些字符串会产生回避、辱骂或替换。ChatGPT 把 " SolidGoldMagikarp" 复述为 "distribute"。有些 token 甚至让 GPT-3 在温度 0 下给出不同答案——本该是确定性的。

他们的部分解释回到合并规则:分词器在包含 Reddit 用户名、电商站点内部标识和游戏日志的爬取文本上训练,而模型在更严格筛选的文本上训练。像 SolidGoldMagikarp 这样的字符串(在 GPT-2 词表中确认为单一 token #43453)获得了词表槽位,但在模型训练中几乎从未出现过。它们的嵌入向量停留在初始位置附近,模型遇到时不知道该做什么。

这不只是 GPT-2 的猎奇故事。2024 年的论文《Fishing for Magikarp》构建了自动检测器,结合分词器分析、权重信号和提示探测,在多种模型中都发现了训练不足的 token。在新的 OpenAI 词表中," SolidGoldMagikarp" 是 5 个普通 token(·Solid | Gold | Mag | ik | arp),但每个新分词器都可能带来自己的怪东西。如果你训练或微调模型,扫描训练不足的 token 是廉价的保险。

其他分词器家族

BPE 主导着当今的 LLM,但你还会遇到另外两种算法:

WordPiece(Google 为 BERT 开发)同样自底向上学习合并,但它对配对的评分是频率除以各部分的频率乘积,偏好那些组成部分本身罕见的配对。它只保留最终词表,编码时对每个词取已知的最长片段,再取下一个。词内片段带 ## 前缀,如 token ##ization。

Unigram LM 来自 Taku Kudo 2018 年的《Subword Regularization》,采用自顶向下策略。它从大量候选词表出发,评估每个片段对语料概率模型的贡献,剪掉最没用的片段。同一个词可以有多种切分方式,Kudo 用采样的切分训练翻译模型作为噪声,使模型更鲁棒,尤其在低资源和域外场景。

SentencePiece(Kudo 和 Richardson,2018)是一个库而非算法,同时实现 BPE 和 Unigram,直接在原始句子上训练,无需语言特定的预切分。空白变成可见符号 ▁,I saw a girl 变成 ▁I ▁saw ▁a ▁girl。这使它天然适合日语和中文这类词间不加空格的语言。

词表大小的权衡

为什么不设一百万个 token?每个条目在输入嵌入矩阵和输出层各占一行。以 4096 维向量计算,20 万条词表约为 8.2 亿参数每矩阵。条目越多,稀有 token 的训练样本越少,又回到故障 token 问题。

更小的词表意味着更长的序列:注意力开销更大、上下文窗口填得更快、生成步数更多。近期模型趋向更大的词表(50K、100K、128K、200K),因为嵌入表在总参数中的占比越来越小。Meta 认为 Llama 3 的 128K 分词器带来了"显著的模型性能提升"。

嵌入矩阵是模型把 token ID 转成向量的查表结构,形状为「词表大小 × 隐藏维度」;输出层则通常是一个形状相反的线性投影,把最终隐藏状态映射回每个 token 的分数。这两处参数量都与词表大小成正比,所以词表翻倍意味着两处矩阵同时翻倍。但在总参数中,嵌入层占比随模型变大而下降:一个 200K 词表、4096 维的嵌入矩阵约 8.2 亿参数,对几十亿参数的模型是沉重负担,对上千亿参数的模型却只是零头。这正是近期模型敢于把词表从 5 万扩到 20 万的算术原因——单 token 的平均信息量提升带来的收益,超过了新增参数的边际成本。

工程师实用指南

用真实分词器计数。 对 OpenAI 模型使用 tiktoken.encoding_for_model("gpt-4o"),或使用服务商的 token 计数端点,在强制上下文限制或估算成本之前先测量。基于字符的估算对代码、非英语文本和 JSON 都会出错。

注意提示词末尾的边界。 GPT 系列分词器把空格附着到后一个词上,所以 " Paris" 是单个 token。以尾随空格结尾的提示词("The capital of France is ")留下一个裸空格 token,模型必须以不带惯常前导空格的方式续写 "Paris",这是训练中较少见的不寻常序列。补全式提示词应修剪尾随空白。

特殊 token 是控制字符。 像 <|endoftext|> 这样的字符串映射到保留 ID(cl100k_base 中是 100257,o200k_base 中是 199999),标记文档和消息边界。tiktoken 默认拒绝从普通文本编码它们,保持这个保护开启,粘贴的文本就无法伪造文档或消息边界。

核心要点

  • LLM 读的是整数,不是字母。 分词器在模型运行前把文本转成 ID,token 内部的任何信息(比如拼写)都是不可见的,除非模型背下来了。
  • BPE 通过合并最高频的相邻配对来构建词表,字节级 BPE 保证任何输入都能编码,没有"未知"token。
  • 分词解释了经典失败模式: 字母计数、多位数算术(GPT-3.5 上仅因逗号不同就是 75.6% 对 97.8%)、部分语言成本更高(编码最长可达 15 倍)以及故障 token。
  • 修复方法通常是改变表示形式或移交工作: 把单词拆成字母、刻意格式化数字、对必须精确的任务调用代码。
  • 用实际分词器测量。 "每 token 4 个字符"只是粗略的英语平均值,不是预算。

动手试试:pip install tiktoken,然后 tiktoken.get_encoding("o200k_base").encode(" strawberry"),看看结果是什么。

#Tokenizer#BPE#大模型分词#LLM token#strawberry 计数#字节级 BPE#tiktoken#故障 token
分享:

相关推荐