回到从零手搓大模型这条主线。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步长取1BPE的全称是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和i1决定每轮循环向右滑动一步。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, keylambda 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_idsassert 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, keylambda p: (-counts[p], p))这样会先按出现次数从大到小选出现次数相同时按pair本身从小到大选。规则一旦固定实验就能复现。词表保存时最好连合并顺序一起存因为新文本编码时必须按完全相同顺序执行合并规则。5.5 句子边界处理不到位最容易忽略的是语料拼接方式。如果你把所有训练文本直接拼成一整段再编码滑动窗口就会把上一句末尾和下一句开头的字节组合到一起产生跨句子的垃圾token。我自己的做法是在数字采样前把长语料按句切好句与句之间放一个自定义分隔token。采样时窗口不跨句移动合并时只合并句内出现的数字对。这样BPE学到的词表会更贴近真实语言结构不会学到“句号下一个字”这种伪组合。6. 我的实操体会与一个小建议自己动手写过一遍滑动窗口数字采样之后我最大的体会是BPE的难点从来不在“合并”这个动作本身而在于每一步训练都依赖一份干净的计数统计。计数来自滑动窗口窗口参数错一点后面整个词表全偏。建议你拿到代码后先用一个100字以内的语料把流程跑通重点看三件事counts加总是否等于序列长度减1、merge_ids是否通过最小用例、vocab里前几个新token是不是你能理解的字节组合。这三关过了再放心去扩语料也不迟。后面如果要继续做BPE的端到端编码和解码这节做好的采样器和merge规则可以直接复用。只要你把单词表和merge顺序保存下来新文本就能走“转字节 - 按merge顺序反复合并”这条路得到同一套数字序列。从这里往下就是文本编码真正进入模型输入前的最后一步了。
企业数字化 ERP 产品动态
相关推荐
外卖系统后台架构设计与高并发实战 1. 项目概述:从零搭建外卖系统后台去年接手公司外卖平台重构项目时,我深刻体会到后台系统才是支撑业务运转的隐形引擎。这个被我们内部称为"苍穹外卖"的项目,在第二天就需要完成用户端与商家端的关键对接。不同于前端界面的直观可见… · 2026/11/3 4:52:52
Mac部署OpenClaw对接飞书机器人全指南 1. 项目概述在Mac上部署OpenClaw并实现与飞书机器人的对接,是一个典型的本地开发环境搭建与第三方服务集成案例。OpenClaw作为一款基于Claude模型的命令行工具,能够为开发者提供强大的自然语言处理能力。而飞书机器人则是企业办公场景中常用的自动化助手… · 2026/11/3 4:51:52
2024年TensorFlow环境搭建实战:避坑指南与生态对比 1. 2024年重新审视TensorFlow:它到底还值不值得学我估计很多人的浏览器标签页里现在还躺着十几个"TensorFlow入门教程",结果收藏之后就没再打开过。这个情况太常见了,尤其是2024年,铺天盖地都是PyTorch跟TensorFlow对比… · 2026/11/3 4:51:52
Git与GDB协同开发:从基础配置到高级调试技巧 1. Git与GDB基础概念解析"11.git_gbd"这个看似简单的文件名,实际上暗含了两个开发者日常使用的重要工具组合:Git版本控制系统和GDB调试器。这种命名方式在开发社区中很常见,通常表示某个与Git和GDB相关的配置文件、脚本或项目文档。… · 2026/11/3 5:19:58
iSecure Center安防平台配置实战:从区域组织到门禁视频联动避坑指南 简介:这份PDF文档是海康威视iSecure Center综合安防管理平台V2.0.0的官方配置手册,面向安防系统集成商、运维工程师及平台管理员,用于解决视频监控、停车场、门禁、报警检测等多子系统接入与联动配置中的实操问题。资源包内仅含1个PDF文件&am… · 2026/11/3 5:19:58
Paperclip:轻量级 Node.js + React AI Agent 框架实战指南 1. 项目概述:Paperclip 不是回形针,而是一个轻量级 AI Agent 框架你搜“paperclip”时,第一反应可能是办公桌抽屉里那枚银色小金属件——但最近半年,在 Node.js 和 React 开发者圈子里,“Paperclip”正悄悄成为高频词。… · 2026/11/3 5:19:58
Paperclip:面向本地AI应用的React+Node.js轻量运行时 1. 项目概述:Paperclip 是什么,它解决的不是“又一个前端框架”问题Paperclip 这个名字乍一听容易让人联想到 Office 里的回形针图标,或者某个 UI 组件库——但实际在当前技术语境下,它指向的是一个正在快速演进的、面向 AI 原生应… · 2026/11/3 5:19:58
TeXstudio高效科研写作指南:LaTeX环境配置与实战技巧 1. 为什么科研工作者需要TeXstudio作为一名在学术圈摸爬滚打多年的研究者,我深刻理解论文排版带来的痛苦。记得刚开始写第一篇SCI论文时,整整两周时间都浪费在调整格式上——参考文献编号错乱、公式编号不连续、图表位置飘忽不定。直到实验室师兄推荐了L… · 2026/11/3 5:19:58
REA逆向梳理:用Agent还原登录、存储与更新流程 上个月接手一个交接了三轮的自研客户端项目,代码库能跑,文档停在两年前,登录模块里最醒目的一行注释是“这里别动,动了会炸”。这种项目没法靠读文档上手,只能靠人肉顺着调用链往上爬。我当时的做法是把整件事拆成三条… · 2026/11/3 5:18:58
双极步进电机控制实战:DRV8818PWPR与PIC18F86K22驱动调试 /* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views … · 2026/11/2 13:10:28
STM32F207ZG + DRV8818PWPR 步进电机驱动实战解析 /* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views … · 2026/11/2 4:42:45
DRV8818PWPR+dsPIC30F4013工业级双极步进电机控制方案 /* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views … · 2026/11/2 5:17:18
STM32F411RE与MAX14431实现SPI隔离:工业现场抗共模干扰实战 /* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views … · 2026/11/3 1:01:59
ESP32+大模型AI硬件开发:从Demo到产品的8个工程问题 /* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views … · 2026/11/3 1:01:59
基于MAX14431BAEE+与PIC18F85J10的SPI隔离方案设计与实践 /* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views … · 2026/11/3 1:01:59
双极步进电机控制实战:DRV8818PWPR与PIC18F86K22驱动调试 /* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views … · 2026/11/2 13:10:28
STM32F207ZG + DRV8818PWPR 步进电机驱动实战解析 /* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views … · 2026/11/2 4:42:45
DRV8818PWPR+dsPIC30F4013工业级双极步进电机控制方案 /* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views … · 2026/11/2 5:17:18