BPE(Byte Pair Encoding,字节对编码)分词是一种自底向上的子词切分方法:先把文本拆成最小单位(英文是字符/字节,中文常是单字或 UTF-8 字节),再反复把语料中最高频的相邻单位对合并成一个新单位,直到达到预设的合并次数或词表大小。对中文来说,这意味着分词器会从单个汉字出发,逐步“学出”“我们”“模型”“人工智能”这类高频片段,最终用较少的 token 表示原文字符串。
BPE分词原理:从字符到子词的贪心合并
BPE 的核心只有三步循环:统计相邻对 → 选最高频的一对 → 合并并记录日志。这里的“相邻对”指当前切分序列里紧挨着的两个单位;"合并"是把这两个单位在所有出现位置替换成一个新的整体单位,并给它分配新 ID。所谓“贪心”,是指每一步都只做当前频率最高的那一次合并,不做全局最优规划,因此结果依赖语料和合并顺序,可复现但不唯一。
用一个小例子走一遍。假设语料只有三个词,词频用括号标注(这是构造的示例,不是真实统计):
低 (5)
低于 (3)
低语 (2)
初始词表是单字集合 {低, 于, 语},每个词被切成单字序列。统计相邻对:低-于 出现 3 次,低-语 出现 2 次。最高频是 低-于,于是合并为新单位 低于,词表加一项。合并后语料变成:
低 (5)
低于 (3)
低语 (2)
此时 低 是独立单位(5 次),低于 是独立单位(3 次),低-语 仍出现 2 次。若再做一轮,低-语 会被合并成 低语。注意合并后原本“低”的独立出现不受影响——BPE 只替换被选中那一对,其他位置原样保留,这正是它能同时保留单字和高频片段的原因。
对中文,起点有两种常见做法:按单字切分(词表从几千个汉字起步),或按 UTF-8 字节切分(一个汉字通常 3 字节,词表从 256 个字节值起步,这也是 GPT 系列一类模型的做法)。字节起点的好处是任何文本都不会出现“未登录词”,代价是一个汉字可能被拆成多个 token;单字起点更贴合中文语义,但遇到生僻字仍需回退。
构建中文小词表:逐步合并与合并日志
为了能手工核算,我们用一份极小的示例语料(构造数据),并只做 4 次合并。语料如下,括号内为词频:
人工 (4)
智能 (3)
人工智能 (2)
工人 (1)
初始切分(全部单字):人 工×4、智 能×3、人 工 智 能×2、工 人×1。逐对统计相邻出现次数:
人-工: 4 + 2 = 6 (来自"人工"4次 和 "人工智能"2次)
工-智: 2
智-能: 3 + 2 = 5
工-人: 1
第 1 轮合并 人-工 → 人工(频次 6)。合并后语料变为:人工×4、智 能×3、人工 智 能×2、工 人×1。合并日志记一行:
round=1 pair=(人,工) freq=6 new_token=人工 vocab_size=7
第 2 轮重新统计:智-能 = 3+2 = 5,人工-智 = 2,工-人 = 1。最高频 智-能,合并为 智能,日志 round=2 pair=(智,能) freq=5 new_token=智能 vocab_size=8。
第 3 轮:语料为 人工×4、智能×3、人工 智能×2、工 人×1。相邻对只剩 人工-智能=2 和 工-人=1(人工、智能 内部已不可再拆)。合并 人工-智能 → 人工智能,频次 2,日志 round=3 ... vocab_size=9。
第 4 轮:人工×4、智能×3、人工智能×2、工 人×1,唯一剩余相邻对是 工-人=1,合并为 工人,vocab_size=10。
小词表最终为 4 个初始单字 + 4 个合并产物 + 若含特殊符号则另计,共 8 个内容单位(示例中记作 10 是含起始/结束等占位符的写法,实际以你的实现为准)。合并日志的价值在于完全可复现:给定日志顺序,任何一句新文本都能用同样的优先级依次套用合并规则完成切分。
手工计算压缩率:原始字符数对比Token数
压缩率在这里指「原始字符数 ÷ 分词后 Token 数」,数值越大说明同样一段文字被压得越短,模型需要处理的序列越短。
假设我们在第 2 节构造的小词表上,把这句话送进分词器:
我爱北京天安门
先做假设(便于手算,不代表任何真实词表):
- 词表里有
北京、天安门两个合并后的子词; 我、爱各自是单字 Token(未发生合并);- 全句无空格、无标点。
那么分词结果为:
我 | 爱 | 北京 | 天安门
Token 数 = 4,原始字符数 = 7(我、爱、北、京、天、安、门)。
压缩率 = 原始字符数 / Token 数
= 7 / 4
= 1.75
也就是说,平均每个 Token 承载 1.75 个汉字。对照未合并的情形(7 个字全部单字成词),压缩率为 7 / 7 = 1.0。这一步合并把序列长度从 7 降到 4,减少了 3 个位置,约 43%(3/7 ≈ 0.4286)。
再验证一下乘法:4 个 Token × 1.75 = 7 个字符,与原始字符数吻合,算术无误。
要注意两点实操细节:
- 分母口径:如果文本含空格、英文、数字,「字符数」的统计方式会影响结果。建议统一用 Unicode 码点计数,并在报告里写清口径。
- 压缩率不等于质量:它只衡量序列长度,不衡量语义保真度。词表过小则压缩率接近 1,词表过大则大量子词只出现一两次,泛化反而变差。
用 Token Counter 工具验证分词结果
手算容易在合并顺序上出错,所以要用工具交叉验证。下面是通用流程和一份核对清单。
输入:同一段中文文本(建议逐句,避免长文本掩盖局部错误)
操作:粘贴文本 → 读取 Token 总数与分词切分结果
输出:Token 数、每个 Token 的字符串、可选 Token ID
核对清单:
- 总数一致:工具输出的 Token 数与你手算的 4 是否相同;不同则先查词表版本。
- 切分边界一致:逐个 Token 对比字符串,重点看
北京是否被切成北+京——这是最常见的分歧点。 - 空格与前缀:很多分词器会给词首加特殊标记(如
▁),或把空格并入 Token。确认工具显示的 Token 是否包含这类符号,统计字符数时要把它们排除。 - 未登录字:检查生僻字是否退化为字节级 Token(一个汉字可能变成 3 个 Token),这会让压缩率骤降。
- 换行与标点:单独测一句带标点的文本,确认标点是否独立成 Token。
- 口径统一:工具报的是 Token 数,你要自己算字符数;两边都用码点计数,再代回
压缩率 = 字符数 / Token 数。
若工具结果与手算不符,优先怀疑三件事:词表不同、是否做了小写化/规范化、是否把特殊标记计入 Token 数。把这三项对齐后,手算与工具输出通常能完全一致——这也正是手工演示的价值:让你知道每个数字是从哪一步合并里来的。
常见问题解答
BPE分词为什么对中文和英文的处理效果不同?
英文用空格天然切分出单词,BPE 的起点是字母,合并几十次就能得到常见词根和词缀,一个 token 往往对应半个或一个单词。中文没有词边界,BPE 的起点是单个汉字,而常用汉字只有几千个,每个汉字本身已携带完整语素信息,合并只能朝“词”和“词组”方向走,粒度天然更粗。结果是:同样一段话,中文的字符数少但每字符信息密度高,英文的字符数多但每字符信息密度低,两者的压缩率和 token 分布因此不可直接横向比较。
合并日志中的词频统计是如何决定合并顺序的?
每一轮先统计当前语料中所有相邻符号对的出现次数,取频次最高的那一对合并成新符号,写入词表并记入日志。频次相同的情况按实现约定处理(如按字典序或首次出现位置),所以不同工具在同一语料上可能得到略有差异的词表。关键是:频次统计的是“当前切分下的相邻对”,合并一次后切分改变,下一轮的统计对象也随之改变,因此顺序是贪心的、逐步演化的,而不是一次性全局排序。
压缩率越高是否意味着模型效果越好?
不是。压缩率高只说明同样文本占用的 token 更少,推理更省算力、上下文能装更多内容,但它和模型能力没有必然的正相关。词表过小会把常见词拆得很碎,压缩率低但泛化可能更稳;词表过大则把低频组合也塞进去,压缩率高却让每个 token 的训练样本变少,稀有 token 学不充分。合理的判断标准是压缩率、词表大小、下游任务表现和推理成本之间的平衡,而不是单看压缩率一个数。
中文BPE词表大小对分词粒度有什么影响?
词表小(比如几千),合并轮数少,输出偏向单字和少量高频双字词,序列变长、压缩率低。词表大(比如几万到十几万),能容纳大量多字词和短语,常见表达被压成单个 token,序列变短。但词表越大,长尾 token 越多,其中不少在训练语料里只出现极少次,等于占着词表位置却学不好;同时 embedding 矩阵和输出层也同比变大。实践中常见做法是把中文词表控制在能覆盖高频词、又不至于让长尾过度膨胀的区间,具体数值需按语料规模和任务实测确定。
如何用Token Counter工具复现本文的手算分词结果?
按文中给出的合并日志顺序,在工具里加载同一份小词表(或手动指定初始字符集与合并规则),输入与手算完全一致的例句,逐字比对切分结果。注意三点:确认工具是否对文本做了规范化(如全半角、空白处理),确认合并是否严格按日志顺序执行,确认是否附加了特殊符号。若切分与手算不符,先检查这三处,再核对词表中是否存在同频竞争导致的顺序差异。