回到从零手搓大模型这条主线。S07文本编码做到E03,前面已经把人话、Unicode、UTF-8这几个地基盘得差不多了,这次集中解决一个看起来很小、实际坑非常多的环节:滑动窗口的数字采样。
说直白一点,就是让一个固定大小的窗口在“文本转换出的整数序列”上滑动,把滑过的相邻数字对收集起来,按出现次数统计,用这些数字样本作为后续BPE合并的候选依据。这个环节在主流BPE教程里通常只有一行伪代码,但自己动手实现时,几乎所有tokenizer词表异常、训练变慢、merge结果错乱,根子都藏在这里。
如果你正在跟着系列实现自己的大模型,或者只想搞清楚文本编码里“字节变成token”这中间到底发生了什么,这节都建议仔细看。我们不会直接调transformers库,而是用Python把一整套最小可跑的BPE数字采样器写出来,跑通“原始语料 -> 滑动窗口采样 -> 频次表 -> top1合并 -> 词表扩展”全流程。看完你应该能理解为什么现代大模型普遍采用字节级BPE,也能顺手排查掉一大类tokenizer实现里的潜在问题。
1. 先拆开看:滑动窗口和数字采样在文本编码里分别做了什么
在学习具体代码前,得先把标题里的三个关键词对齐:文本编码、滑动窗口、数字采样。它们不是三个独立概念,而是一条流水线里的三个动作。文本编码是目标,滑动窗口是手段,数字采样是结果。
1.1 文本编码不是“查码表”一步到位
很多初学者的第一反应是:文本编码不就是查码表吗?每个字符对应一个整数,查完就完事。这种思路做一个小玩具没问题,但要支撑大模型训练就完全不够。
原因有三个。第一,真实语言里字符组合接近无限,固定码表必然遇到未登录词OOV。第二,如果按词给ID,词表会膨胀到几十万上百万,嵌入层参数爆炸,模型学不动。第三,如果退回去按字给ID,序列太长,Transformer每个token都要参与注意力计算,成本随序列长度平方上涨。
BPE这类子词算法就是来打破这个死局的。它先让字母和符号变成UTF-8字节,再把字节序列里高频相邻组合逐步合并成子词单位。经过若干轮合并之后,模型看到的token既不是某个汉字,也不是整个词,而是“文本中反复出现的最小可复用单元”。所以文本编码链其实是多步的:原始文本 -> Unicode码点 -> UTF-8字节 -> 子词token。滑动窗口就发生在最后这一步里。
1.2 滑动窗口到底在扫什么
滑动窗口是一个很老的概念,但在这个语境下指的事情非常具体:对当前token ID序列,用一个固定大小为2的窗口,从左往右一步一步滑,每次取出窗口内的两个数字组成一个二元组。
举个例子,假设计算机内部把某个片段表示成数字序列:
ids = [231, 154, 132, 32, 97]窗口大小取2,步长取1,滑动一次得到一对数字:
- 第一次窗口落在位置0和1,得到
(231, 154) - 第二次窗口落在位置1和2,得到
(154, 132) - 第三次窗口落在位置2和3,得到
(132, 32) - 第四次窗口落在位置3和4,得到
(32, 97)
这一步就像拿一个扫描仪贴着序列移动,每个相邻数字都不会漏掉。窗口重叠的部分恰恰是BPE能发现跨边界组合的关键。如果步长不是1而是2,那会漏掉(154, 132)这类发生在相邻位置之间的组合,统计出来的频次就会失真,某些该被合并的高频组合可能直接消失。
所以“滑动窗口”在这里的核心意义是:用最简洁的方式保证所有相邻数字对都有机会被当作候选样本。
1.3 数字采样:统计的不是词,而是数字样本
“数字采样”这个说法在中文社区里容易产生歧义,因为它听起来像从某个概率分布里随机抽数。但在文本编码这一步,我理解并强调的是它的工程含义:把窗口里出现的离散数字组合当成样本采集下来,喂给计数器做频率统计。
每个token在内部只是一个整数ID。初始阶段,ID就是字节值,范围0到255。合并之后,新生成的token会拿到一个大于255的新ID。整个训练过程不断给这些数字补充新成员,采样空间也跟着变化。我们关注的是数字对的共现频次,不是具体字符对不对得上。
如果你语料特别大,也可以先做随机下采样,只统计一部分文本片段,那属于“语料采样”。本文说的数字采样是在选定语料基础上,用滑动窗口全量收集数字样本。两者可以叠加,但不要混为一谈。
2. 设计一个最小可跑的BPE数字采样器
要把上面的思路落地,得先确定整个训练管线、窗口参数和数据结构。这个阶段多花点时间想清楚,后面写代码就是顺手的事。
2.1 从原始语料到频次统计的完整管线
一个最小可用的BPE训练器通常长这样:
- 把训练语料用UTF-8编码成字节数组,得到0到255之间的整数序列。
- 初始化基础词表,包含256个字节对应的token。
- 在当前整数序列上做滑动窗口数字采样,得到所有相邻数字对的出现次数。
- 找出出现次数最高的数字对。
- 用一个新的token ID替换序列中所有该数字对出现的位置。
- 把新token加入词表,记录本次合并规则。
- 重复步骤3到6,直到词表达到目标大小。
这里有一个容易踩坑的隐含逻辑:新token的ID不能复用旧的ID,而是从256开始递增分配。因为旧ID已经被语料序列中的数字占用了,如果改动ID含义,解码和编码都会乱。每个合并产生一个新ID,词表大小就等于256 + 成功合并的次数。这也是后来保存词表时的重要依据。
2.2 为什么窗口大小取2,步长取1
BPE的全称是Byte Pair Encoding,核心单位就是一对相邻元素。所以采样窗口取2是最自然的选择。窗口取3得到的虽然是三元组,但BPE的合并永远发生在两个相邻token之间。哪怕一个完整token最终由三个字节拼成,也必须先合并前两个字节,再合并新token和第三个字节,等于经过两轮pair merge。直接统计三元组既不符合BPE的合并逻辑,也没法指导合并顺序。
步长取1是为了保证相邻pair的覆盖完整性。如果把步长改成2,窗口会跳着走,边界处的重叠pair就丢了。可以这样理解:BPE的候选集合就是“序列上所有连续的二元组”,而只有步长1、窗口大小2的滑动窗口才能完整枚举出这个集合。
2.3 计数结构:用字典还是二维数组
实现数字采样代码之前,先决定用什么数据结构存频次。
理论上,初始阶段数字范围只有0到255,两两组合最多65536种,用二维数组完全可以。但合并一旦开始,新token ID持续加入,二维数组的维度就得跟着扩大,维护成本直线上升。Python里直接用defaultdict(int)最省事:键是数字对(id_a, id_b),值是该数字对出现的次数。
如果数据量大到对性能敏感,可以把数字对编码成一个整数来提升哈希效率,比如用id_a * 当前词表大小 + id_b作为字典键。但教学阶段先不要过度优化,数据结构清晰比跑得快重要。我自己写生产级实现时会单独做一个计数类,对外只暴露add(pair, count)和top_pair()方法,方便以后换成堆或优先队列。
2.4 目标词表大小怎么定
目标词表大小决定采样器跑多少轮合并。这个词表大小并不是越大越好。词表大,嵌入表的参数就多,显存占用直线上升;词表小,合并轮次少,token序列长,注意力计算成本高。两者本质上是在“词表参数”和“序列长度”之间做权衡。
对一个中文语料的小型测试,先把目标词表定在512或1024就够了。要跑一个能发论文级别的模型,通常几万到十几万。GPT-2当年的字节级BPE词表大约5万,这个量级到今天仍是很多模型的起点。
3. 核心代码实现:从零写滑动窗口采样、计数、合并
下面直接进入正题。我的习惯是先实现一个教学版,保证逻辑能跑通,再谈优化。
3.1 把文本变成数字序列
第一步,把原始文本转成数字列表。这一步没有魔法,就是用UTF-8编码之后逐个取字节值。
def text_to_ids(text): return list(text.encode("utf-8"))比如在Python里测试:
print(text_to_ids("低"))输出会是[230, 137, 142],因为“低”这个字在UTF-8里占3个字节。这一步把所有字符都统一成了数字,为滑动窗口做好准备。
为什么要用UTF-8而不是直接对Unicode码点做数字采样?因为有两点好处:UTF-8可以编码任意文本,不会遇到未知字符;同时字节层的组合空间固定是256个基本单位,模型能从底层重新学习出合适的中文token、英文子词甚至标点组合。
3.2 滑动窗口采集相邻数字对
核心采样函数只需要一个for循环:
from collections import defaultdict def count_pairs(ids): counts = defaultdict(int) for i in range(len(ids) - 1): counts[(ids[i], ids[i + 1])] += 1 return counts这段代码就是“滑动窗口的数字采样”最直接的实现。窗口在整数序列上的位置由索引i和i+1决定,每轮循环向右滑动一步。range(len(ids) - 1)保证最后一个元素右侧没有邻居时不会越界。
返回值是字典,键是相邻数字对,值是该数字对在整个序列中出现的次数。比如counts[(231, 154)] = 12表示数字对(231, 154)在序列里出现了12次。这个字典就是下一轮BPE选择合并对象的投票箱。
3.3 合并函数:把选中数字对合成一个新ID
采样结束后,我们会选出频次最高的数字对,接下来要做的是把序列中所有出现该数字对的位置替换成新token ID。
def merge_ids(ids, pair, new_id): result = [] i = 0 n = len(ids) while i < n: if ( i + 1 < n and ids[i] == pair[0] and ids[i + 1] == pair[1] ): result.append(new_id) i += 2 else: result.append(ids[i]) i += 1 return result这个函数从序列左边开始扫描,一旦发现当前位置和下一个位置组成的数字对就是目标pair,就把它合并成new_id,然后跳过两个位置继续扫描。跳过两个位置很重要,否则同一个旧数字可能被重复利用,导致一轮合并里产生错误结果。
如果你熟悉字符串替换,可能会想问:为什么不用replace把某一个token替换掉?因为现在操作的对象是数字序列,数字对可能出现各种跨token组合,而且新ID会继续参与下一轮合并,用数组替换更可控。每次调用这个函数,序列长度都会减少,减少的量恰好等于该数字对被合并掉的次数。
3.4 完整BPE训练循环
把采样和合并串起来,就是一个最简BPE训练器:
def train_bpe(texts, target_vocab_size): ids = list(" ".join(texts).encode("utf-8")) vocab = {i: bytes([i]) for i in range(256)} merges = [] next_id = 256 while next_id < target_vocab_size: counts = count_pairs(ids) if not counts: break best_pair = max(counts, key=lambda p: counts[p]) ids = merge_ids(ids, best_pair, next_id) vocab[next_id] = vocab[best_pair[0]] + vocab[best_pair[1]] merges.append((best_pair, next_id)) next_id += 1 return ids, vocab, merges训练循环里做的事情和第一节设计的流程完全一致:采样、找最高频pair、合并、记录词表映射、继续下一轮。
这里我故意省略了并列最高频时的排序规则,真实复现时不能省。同一批语料,如果两个pair计数相同,选谁先合并会直接影响最终词表。后面第5节我会专门讲怎么处理。
训练结束后,vocab里保存的是每个token ID对应的字节片段,merges按顺序记录了每一步合并规则。保存和加载tokenizer时,这两样东西缺一不可。
4. 手跑一遍:重复字场景下的字节对合并观察
只看代码还是太抽象。我建议你找一个非常小的语料实际跑一遍,能看到字节级BPE怎么把一个中文单字“拼”出来。
4.1 准备一个最小语料
先构造一个看上去刻意但非常有效的语料:
text = "低" * 30这个字符串在内存里就是60个字节,因为“低”占用3个字节。送到text_to_ids之后,得到的是重复出现的三个数字:[230, 137, 142, 230, 137, 142, ...]。
第一轮数字采样后,候选pair中比较显眼的是:
(230, 137)出现约30次(137, 142)出现约30次(142, 230)出现在相邻字的边界上,出现约29次
如果你跑出来发现前两个计数一样,不要慌,这说明并列最高频的情况真的会发生。无论tie-break策略选哪一个,经过两轮合并,字的首字节中间字节和末字节都会被拼到一起。
4.2 观察数字样本如何拼出常用字
第一轮合并后,序列里出现一个新ID,假设是256,它表示字节对(230, 137)。此时每个“低”字在序列里变成两个数字:[256, 142]重复出现。继续做第二轮滑动窗口采样,(256, 142)会变成最高频pair,合并成ID 257。
这时候ID 257对应的字节片段就是完整的b'\xE6\xB4\x8E',也就是“低”这个字的UTF-8编码。你看,一个三字节字符变成单个token,只需要两次合并。
这个现象放到更大的中文语料里非常有意思。常见汉字在UTF-8中大多占3个字节,它们的高频字节组合会先被BPE合并,所以最终词表里会自然出现大量“单字token”。如果把“低”和另一个高频字反复共同出现,BPE还会继续把它们合并成双字词。字节级BPE不需要专门为中文设计,却能自动适应中文形态。
4.3 合并后的序列长度变化与词表扩展
运行过程中,建议你打印每一轮的len(ids)和next_id。观察几次后,你会看到一条明确规律:每成功合并一次,序列长度就减少该pair的出现次数,ID池则增加一个成员。
拿上面“低”字重复30次的实验来说:
- 原始bytes长度60
- 第一轮合并后长度变成30
- 第二轮合并后长度变成30,错了,应该是30个字符每个变成1个token,所以也是30?等等,需要重新算。
让我把数字说准确。30个“低”字,原始bytes长度是90。第一轮把每个字的前两个字节(230, 137)合并成256,序列从[byte, byte, byte]变成了[256, byte],长度从90变成60。第二轮把(256, 142)合并成257,序列长度从60变成30。这才是对的。
所以说,每个三字节中文字变成一个token,总共会把序列长度压缩到原来的三分之一。换到真实大语料里,原始语料经过BPE之后token数会显著减少。模型处理长文本的能力,某种程度上就是被这个压缩过程放大的。
5. 常见问题与性能排查实录
这个环节是我最想分享的,因为我在自己手搓tokenizer时,几乎所有bug都出在看似简单的采样和合并上。
5.1 统计结果和预想不一致
有段时间我统计出来的pair频次总和总是比len(ids) - 1少,说明滑动窗口没有覆盖全部相邻位置。最常见原因是range(len(ids))写错,导致最后一对没统计进去;其次是步长填了2,窗口跳着走,漏掉了重叠边界。
排查方法很简单:写一个校验函数,把counts里所有value加起来,断言它等于len(ids) - 1。如果对不上,采样逻辑一定有问题。这个小断言值得长期保留,后续做增量计数优化时也能帮你验证正确性。
5.2 合并后序列里出现解释不了的数字
如果你发现合并后的序列里冒出了某个不该出现的新ID,或者旧pair的相邻关系变得很怪,多半是合并函数没有正确跳过已经合并的位置。
我建议用最小的例子单测merge_ids:
assert merge_ids([1, 2, 3, 1, 2], (1, 2), 99) == [99, 3, 99]如果输出不是这个,就检查索引是否跳了两格。这种基础函数越简单越好,不要在里面做额外业务逻辑。
5.3 语料大时越训越慢
教学版在每一轮合并时都重新扫一遍完整序列,这个复杂度是O(合并轮数 * 序列长度)。语料一旦变大,后面的合并轮会非常慢。
应对思路有三个:
- 设置最低频次阈值,采样后直接丢弃低频pair,减少候选数量。
- 对超大语料做随机下采样,让滑动窗口只跑在有代表性的子集上,而不是全量数据。
- 使用优先队列维护高概率候选,只在合并发生时更新受影响的相邻pair计数,而不是每轮全量重算。
前面两点改动不大就能提升速度。第三点是生产级BPE的常见优化方向,但我建议先把教学版跑通再去碰。
5.4 同频pair导致词表不一致
BPE训练里的一个隐藏坑是并列最高频。两个pair出现次数相同,选哪个先合并会影响后续所有合并路径。不同机器、不同Python版本下,字典遍历顺序都有差异,如果不固定选择规则,同一个语料可能训练出两份不同词表。
解决办法是给排序规则加一个确定性tie-break。比如:
best_pair = min(counts, key=lambda p: (-counts[p], p))这样会先按出现次数从大到小选,出现次数相同时按pair本身从小到大选。规则一旦固定,实验就能复现。词表保存时最好连合并顺序一起存,因为新文本编码时必须按完全相同顺序执行合并规则。
5.5 句子边界处理不到位
最容易忽略的是语料拼接方式。如果你把所有训练文本直接拼成一整段再编码,滑动窗口就会把上一句末尾和下一句开头的字节组合到一起,产生跨句子的垃圾token。
我自己的做法是在数字采样前把长语料按句切好,句与句之间放一个自定义分隔token。采样时,窗口不跨句移动;合并时,只合并句内出现的数字对。这样BPE学到的词表会更贴近真实语言结构,不会学到“句号+下一个字”这种伪组合。
6. 我的实操体会与一个小建议
自己动手写过一遍滑动窗口数字采样之后,我最大的体会是:BPE的难点从来不在“合并”这个动作本身,而在于每一步训练都依赖一份干净的计数统计。计数来自滑动窗口,窗口参数错一点,后面整个词表全偏。
建议你拿到代码后,先用一个100字以内的语料把流程跑通,重点看三件事:counts加总是否等于序列长度减1、merge_ids是否通过最小用例、vocab里前几个新token是不是你能理解的字节组合。这三关过了,再放心去扩语料也不迟。
后面如果要继续做BPE的端到端编码和解码,这节做好的采样器和merge规则可以直接复用。只要你把单词表和merge顺序保存下来,新文本就能走“转字节 -> 按merge顺序反复合并”这条路,得到同一套数字序列。从这里往下,就是文本编码真正进入模型输入前的最后一步了。