BPE分词:相邻符号如何合并

黎 浩然/ 13 10 月, 2026/ 大语言模型/LARGELANGUAGEMODEL/LLM, 机器学习/MACHINELEARNING, 研究生/POSTGRADUATE, 计算机/COMPUTER/ 0 comments

BPE 分词器不是把每个词都存进词表,而是逐步合并训练文本中常见的相邻符号。训练阶段学习合并顺序,编码阶段复用这套顺序;分词长度因此取决于具体词表与预处理,不能只按汉字数或英文单词数估算。

文章目录
  1. 先看一次合并
  2. 词频必须进入计数
  3. 训练规则与应用规则分开
  4. 代码:统计并合并相邻对
  5. 上下文预算要用实际 tokenizer
  6. 参考资料

先看一次合并

用虚构的小语料:cat 出现3次,car 出现2次。初始按字符拆开。相邻对 c+a 的加权计数为5,a+t 为3,a+r 为2。第一步选择 c+a,得到符号 ca;两个词分别变成 [ca,t] 与 [ca,r]。

原来每次 cat、car 都要三个符号,现在只需两个。这里的压缩是这个小例子的一轮结果,不能推成真实模型一定少多少 token。训练语料改变,合并优先级也可能改变。

起点
c · a · t ×3 / c · a · r ×2
加权相邻对
c+a=5,a+t=3,a+r=2
第一轮合并
ca · t ×3 / ca · r ×2
原创示意图,数值为文中人工构造例子的计算结果,不是模型实测。

词频必须进入计数

如果只把不同单词各看一次,c+a 的计数会变成2,忽略了它在语料中实际出现的次数。教学实现把“符号序列→出现次数”放进 Counter;统计相邻对时按词频累加。

当计数并列,需要固定处理方式才能复现。本例按符号对的字典序打破平局。它只是明确的教学约定,不声称等同于任何模型的训练工具。

训练规则与应用规则分开

Sennrich、Haddow、Birch 在 ACL 2016 论文中把子词单元用于机器翻译的稀有词处理。论文中的 BPE 思路提供这篇教程的算法背景;本文不介绍为新论文,也不移用作者实验成绩。

训练时需要语料来决定合并顺序。使用已经训练好的 tokenizer 时,不应重新根据每次请求的词频学习新规则;否则同一串文本在不同请求中可能变成不同 token。模型的词嵌入和输出词表已经绑定特定 token 编号。

实现真正的编码器,还需保存合并排名,并明确初始拆分、边界标记、特殊 token 与未知符号策略。本例只展示字符级的一轮训练合并,没有实现完整编码器或 byte-level BPE。

代码:统计并合并相邻对

from collections import Counter

def count_pairs(words):
    counts = Counter()
    for symbols, frequency in words.items():
        for pair in zip(symbols, symbols[1:]):
            counts[pair] += frequency
    return counts

def merge_pair(words, pair):
    result = Counter()
    for symbols, frequency in words.items():
        merged, i = [], 0
        while i < len(symbols):
            if tuple(symbols[i:i+2]) == pair:
                merged.append(symbols[i] + symbols[i+1])
                i += 2
            else:
                merged.append(symbols[i])
                i += 1
        result[tuple(merged)] += frequency
    return result

words = Counter({tuple("cat"): 3, tuple("car"): 2})
counts = count_pairs(words)
assert counts[("c", "a")] == 5
assert counts[("a", "t")] == 3
best = min(counts, key=lambda pair: (-counts[pair], pair))
assert best == ("c", "a")
merged = merge_pair(words, best)
assert merged == Counter({("ca", "t"): 3, ("ca", "r"): 2})
assert merge_pair(Counter({tuple("aaa"): 1}), ("a", "a")) == Counter({("aa", "a"): 1})
assert count_pairs(Counter({("x",): 1})) == Counter()
assert count_pairs(Counter()) == Counter()
print(best, dict(merged))

代码已执行,验证了加权计数、第一轮合并、aaa 中相互重叠的 aa 按从左到右非重叠方式合并,以及单符号和空语料。输出为 (‘c’,’a’),随后得到 {(‘ca’,’t’):3,(‘ca’,’r’):2}。

一次统计与一次合并都扫描当前序列。设 L 为不同序列的总符号数、P 为不同相邻对数,统计约 O(L),选最佳对 O(P),合并 O(L),输出需额外空间。这是简化实现的成本;完整大语料训练需要不同的数据结构与工程优化。

上下文预算要用实际 tokenizer

API 中的 token 预算涉及最终编码结果,还可能包含消息包装与特殊 token。一个中文字符或英文单词,都没有与 token 一一对应的普遍保证。估算适合初筛,精确边界应使用目标模型匹配的 tokenizer 与接口计数规则。

对RAG 分块而言,先按字符切再想当然当成 token 切,会让上下文预算失准。对PPL 对比而言,换分词器也改变“每 token”的计量单位。

更大的词表可能减少部分文本的序列长度,但这不是免费收益:它也改变嵌入与输出层、覆盖方式和训练需求。本文没有对这些取舍做模型实验,只说明为何分词结果必须作为工程条件记录。

Read the English version

参考资料

本文于2026年10月13日补发,文章日期保留原计划北京时间。参考资料早于计划时段,示例为原创教学计算。

支持

如果这篇文章对你有帮助,欢迎支持本站。

微信支持二维码,点击查看大图
微信
支付宝支持二维码,点击查看大图
支付宝
Buy Me a Coffee,点击支持本站
Buy Me a Coffee

二维码可点击放大。更多支持方式见支持页面。

Share this Post

Leave a Comment

您的邮箱地址不会被公开。 必填项已用 * 标注

*
*