
从零手搓大模型最容易被低估的往往不是模型结构而是数据进入模型前那段看不见摸不着的预处理流水线。我推S07文本编码这一阶段时体会特别深文本编码远不是简单的分词加映射ID它真正麻烦的地方在于——文章长短不一模型输入却必须定长那到底该怎么把一篇篇长短不一的文本切成一批合格的训练样本这就是滑动窗口数字采样要解决的问题。这篇文章是本系列S07文本编码部分的E03篇我打算把这个环节的原理、代码、调参经验和踩坑记录一次讲透给正在手搓大模型或者想搞明白数据预训练细节的朋友一个能直接落地的参考。先提醒一句这里说的滑动窗口是大模型文本编码里的序列切分策略别和信号处理里的滑动窗口滤波混淆虽然名字像处理的完全是两码事。我们的窗口滑过的是token序列采出来的样是定长的token片段。1. 文本编码全流程里滑动窗口数字采样到底卡在哪个环节1.1 一条完整的文本编码流水线长什么样在开始写采样器之前得先站在全局看一遍文本编码的完整链路。这是我在自己构建数据处理管道时画出来的实际流程每一步都有对应产出原始文本清洗去掉HTML标签、特殊符号、多余空格统一换行符。分词tokenization把清洗后的文本切成token。这个环节可以使用BPE、SentencePiece、WordPiece等不同算法核心产出是一个token序列。token映射为ID通过词表把每个token映射成整数ID。序列化与采样把ID序列切分成固定长度的多个样本这里就是滑动窗口数字采样的主战场。掩码生成为每个样本生成注意力掩码attention mask区分真实内容和padding部分。前三个环节有大量现成库可以调用大多数教程也集中在这一块。但到了第四步情况开始微妙一篇3400 token的文章模型输入长度限制是1024你该怎么办最粗暴的方案是直接截断——只取前1024个token喂给模型其余全部扔掉。后果显而易见模型永远看不到长文本的后半部分长距离依赖信息彻底丢失。如果你只是在跑通一个demo截断或许可以接受但只要你对模型效果有一丁点要求滑动窗口几乎是必然选择。1.2 数字采样在这里的准确定义滑动窗口的原理不复杂一个长度固定的窗口在token序列上按一定步长滑动每次截取一个片段。但数字采样这个词值得拆开说清楚——在大模型文本编码的语境里它指的是在token序列上以固定的规则确定窗口起始位置从而决定哪些token片段被保留为训练样本。采样粒度是token级的不是字符级所以叫数字采样或者说数字化采样。这里有两个核心参数窗口长度W每个样本包含的token数量直接约束为模型的最大上下文长度。步长S也叫stride相邻两次采样的起始位置之间的距离。当窗口在序列上滑过时相邻窗口之间可能重叠。重叠区域的大小是W - S。这个简单的数学关系直接决定了数据量的膨胀倍率、样本之间的相关性以及模型看到每个token的次数。正因为参数少很多初学者容易看轻它——随便设一个S就把窗口扔给模型跑了最后训练出来的模型语义能力平庸还觉得是模型结构的问题。以我的经验采样策略的不合理往往是隐形的数据质量问题不容易察觉却影响深远。顺带扩充一个概念采样率。对于单个token来说它被多少个样本覆盖等于重叠倍率。比如 W1024、S512 时每个token平均被2个窗口覆盖W1024、S256 时平均覆盖率接近4倍。你实际上是在给模型反复看同一段内容的不同上下文版本这是有代价的——训练数据量膨胀、每个batch的独特信息量下降。2. 从逐token迭代到滑动窗口三种主流采样模式的取舍我自己写第一版采样器的时候代码极其朴素——一个for循环从0开始逐个位置截窗。跑通之后才发现滑动窗口可以用不同的采样方式进行组织每种方式背后对应完全不同的训练需求。2.1 无重叠采样S W这种模式下步长等于窗口长度序列被切成互不重叠的块。比如文本长度4096窗口1024步长1024会得到恰好4个样本。无重叠最显著的优点是样本之间互不重复token不会被反复看到训练时不会因为同一片段频繁出现而产生隐式的过拟合。数据量也是最紧凑的一篇文章产生的样本数就是长度除以窗口的整数部分。缺点同样清晰如果文本长度不是窗口长度的整数倍序列尾部会剩下不足一个窗口的零头。这个零头怎么处理是个头疼的问题——直接丢弃意味着浪费语料补pad到满窗又会在样本里掺入大量无效位置。更隐蔽的问题是如果恰好某个关键句横跨两个窗口边界它在两个样本中都会被截断模型永远学不到这个句子的完整表示。适用场景语料规模庞大处理管道追求吞吐量或者做句子级对比学习SimCSE类时无重叠可以避免正样本对间共享太多token。2.2 固定步长重叠采样S W这是我在预训练中使用最多的模式。窗口1024步长512或256相邻样本共享50%到75%的内容。重叠采样的价值在于信息覆盖的连续性。一个跨窗口边界的语义单元至少会在某个样本里完整出现同一个片段会以不同上下文为背景反复出现模型对它的理解会更立体。对于因果语言模型GPT风格这种重复并不会带来泄露问题——每个token依旧只能看到它前面的内容重复只是意味着更多训练信号。代价是数据量膨胀。W1024、S256时一篇4096的文本会产生大约13到14个样本每个token平均被4个窗口覆盖。训练步数增加信息密度下降。边际收益递减规律在这里很显著——从无重叠到50%重叠收益非常明显从50%到75%收益快速衰减但训练成本却线性上升。2.3 动态步长采样第三种方式是根据某种信号动态调整步长让采样密度跟随文本信息密度变化。比如文章开头和结尾的信息密度通常高于中段可以在这些区域用更小的步长多采样中段过渡性内容多步长拉大也没有明显损失。这种做法的实现复杂度摆在那里你需要定义信息密度的度量——每句话的熵、句子的长度、依赖结构的复杂度——每一种都需要额外的计算。我在处理长文档问答数据集时试过动态步长确实能提升答案覆盖区域的采样密度但收效相比复杂度而言并不划算。在普通预训练场景我更推荐固定步长重叠简单、可控、好调参。三种模式可以拉个对比表模式步长重叠度样本量数据冗余最适合场景无重叠W0%最少无超大规模语料、对比学习50%重叠W/250%约2倍中常规预训练75%重叠W/475%约4倍高小语料、领域微调动态步长变长不定不定不定长文档抽取问答还有一个容易踩的坑如果是双向注意力模型BERT类重叠采样可能导致模型偷看上下文。在做MLM预训练时重复token相当于给某些词提供了多次预测机会最终模型会对重叠区域的词产生位置偏好。我在做BERT类目标训练时发现S小于W/2后验证集上的MLM准确率出现反常波动换成SW或SW/2才恢复稳定。单向注意力模型没有这个问题这也解释了为什么GPT系预训练普遍采用大重叠。3. 手写滑动窗口采样器从最简版本到生产可用3.1 先看最核心的基础实现下面这个函数接收一串token id返回一批定长样本。它是整个滑动窗口数字采样的地基后面所有改进都从这里出发def sliding_window_sample( token_ids: list[int], window_size: int 1024, stride: int 512, min_remainder: int 128, pad_token_id: int 0, ) - list[list[int]]: samples [] seq_len len(token_ids) start 0 while start seq_len: end min(start window_size, seq_len) seg token_ids[start:end] # 尾部过短则丢弃避免大量padding if len(seg) min_remainder: if len(seg) window_size: seg seg [pad_token_id] * (window_size - len(seg)) samples.append(seg) start stride return samples几个值得展开的细节min_remainder参数当序列尾部剩余token数不足这个值时整个片段直接丢弃。我通常把它设为窗口大小的1/8到1/4。比如窗口1024、尾部只剩96个token补全到1024意味着928个位置都是padding实际训练时这些位置全被mask有效信息极少还白占一个样本位置。这个阈值设得太大会丢弃太多可用数据设得太小会产生大量padding样本需要根据语料长度分布来调。pad_token_idpadding使用的特殊ID通常对应词表中的pad。训练时必须配合attention mask使用否则模型会把padding当成真实内容学习。步长与窗口相等时代码自然退化为无重叠采样行为符合预期。3.2 生产化改造先拼接再切窗直接对单篇文章采样有一个明显问题短文本怎么办如果语料里有大量长度只有300 token的短文档每个文档单独采样会产出大量padding浪费极严重。我的做法是先做文档拼接packing把多篇短文拼成一个长序列再滑动切窗。不同文档之间插入一个文档结束符通常是eos或sepdef pack_and_sample( documents: list[list[int]], window_size: int 1024, stride: int 512, eos_token_id: int 2, min_len: int 128, ): packed [] for doc in documents: if len(doc) min_len: continue # 极短文本直接丢弃噪声太大 packed.extend(doc) packed.append(eos_token_id) return sliding_window_sample(packed, window_size, stride)这个先拼接再切窗的思路在RoBERTa和GPT系列的预训练管线里很常见。它让每个样本都尽可能装满真实内容而不是被padding占据。但不是没有代价切窗可能把两个不同文档拼在同一个样本里。对因果语言建模问题不大模型可以正常学习跨文档边界之后的继续预测但如果是需要区分句对归属的任务比如next sentence prediction你必须额外生成segment id来标记每个token属于哪个文档否则模型会把无关内容强行联系到一起。3.3 进阶需求让窗口边界对齐到句子如果你处理的是问答或摘要相关任务窗口边界落在句子中间会破坏语义完整性。比如抽取式问答中一个问题被切成两半答案位置的定位就会出问题。一个有效做法是让窗口起始位置尽量对齐到句子边界import bisect def align_to_sentence_start( seg_start: int, sentence_starts: list[int], window_size: int, ) - int: # 找到最近且不超过seg_start的句首 idx bisect.bisect_right(sentence_starts, seg_start) - 1 if idx 0: return seg_start # 如果回退距离超过窗口的1/4说明句子太长放弃对齐 if seg_start - sentence_starts[idx] window_size // 4: return seg_start return sentence_starts[idx]预先用句号、分号、换行符对应token的位置标记出所有句子的起始下标采样时把窗口起点回退到最近的句首。这个技巧我在做长文档阅读理解时用过解决了一个特别头疼的问题答案横跨窗口边界导致定位偏移。改完句子对齐之后F1分数直接提升了两三个点代价只是启动时多算一遍句子边界位置。4. 采样参数如何取舍窗口大小、步长与样本量之间的关系4.1 样本量的数学公式给定文本长度L、窗口W、步长S理论样本数大约是N ≈ (L - W) / S 1 当 L ≥ W这个公式透露了一个容易被忽略的事实样本量主要由步长决定窗口大小的影响相对次要。比如L10000、W1024、S512时N≈19S改成256N≈36。样本量翻倍但窗口从512加到1024时样本量反而减少。4.2 我的实测参数组合我在统一语料上做过一组对比模型输入窗口分别设512和1024语料规模约120万篇中长文档窗口 W步长 S样本总量验证困惑度训练时长5125123.1M34.61x51212811.8M33.93.8x102410241.6M32.71x10242565.9M32.13.5x几条明确结论增大窗口长度带来的收益明显大于单纯提高重叠比例。512窗口配128步长75%重叠的PPL依然不如1024窗口配1024步长无重叠好。也就是说长上下文本身的收益超过数据重复。训练时长与样本量近似线性相关PPL收益却边际递减。这组数据直接改变了我后续的采参习惯只要显存允许优先拉长窗口再考虑调小步长。4.3 不同训练目标下的推荐起点预训练因果语言模型W1024、S512兼顾上下文能力和数据冗余。对比学习SW无重叠避免正样本对间大量token共享。抽取式问答句子边界对齐优先于任何重叠策略。文本分类如果语料长度差异大先按长度分桶再配置不同步长减少padding浪费。这些数据不是绝对真理只是我在不同任务上验证过的起点。你可以根据自己的语料和显存做微调但方向很清楚窗口长度优先、重叠度次之、步长最后调。5. 一次完整的训练对比实验重叠度到底带来了什么理论讲再多不如跑一次实验。我在一台4卡3090的机器上做了完整的对比测试模型是约3亿参数的GPT风格架构。5.1 实验设置模型hidden size 76812层参数量约3亿输入窗口1024优化器AdamWlr 3e-4warmup 2000步策略A无重叠S1024策略B50%重叠S512策略C75%重叠S256为了让三种策略具有可比性我控制总训练token数保持一致数据量少的策略减少训练步数。5.2 关键结果从验证集loss曲线看前5000步三种策略几乎重合到中后期开始分化。策略B相比策略A最终loss低了约0.08策略C相比策略B只低了约0.03。从无重叠到50%重叠的收益明显大于从50%到75%的收益。在长距离依赖评测上我自己构造的跨段落推理任务策略B和C的表现明显优于A。这说明重叠采样确实帮助模型建立了更连续的长文理解能力。但策略C训练时间比B多出55%换来0.03的loss下降性价比不高。5.3 采样策略与模型容量之间的关系还有一个观察值得写在纸上采样策略的收益与模型容量强相关。千万参数级别的小模型对重复token并不敏感重叠比例拉高后训练曲线反而容易震荡十亿级以上的大模型更能利用重复内容带来的额外训练信号。如果你跑的是普通规模的demo模型SW或SW/2就够用不要盲目追求高重叠。6. 真实数据里的边界场景尾部、超长文档、乱序与mask一致性6.1 尾部不足窗口长度文章最后一段经常出现长度不足的情况。我的默认策略是剩余长度超过min_remainder就补pad否则丢弃。但如果任务对文本末尾敏感——比如生成式任务文章结论和摘要可能集中在尾部——丢弃尾部会导致模型永远学不到结尾信息。这时应该保留整个剩余片段哪怕补大量pad。额外代价是这些样本的注意力矩阵大部分被mask掉训练开销偏高但语义完整性值得这个代价。6.2 超长文档与随机偏移预训练语料里偶尔会出现超长文档一本书、一份完整的行业报告。如果总是从位置0开始切窗模型会逐渐对固定的切分位置产生偏置——它可能会学会每个样本开头往往是文档开头这种伪规律。我的做法是在采样前给序列加一个随机偏移量让每次epoch看到不同的切分方式。这相当于一种数据增强import random def sample_with_random_offset(token_ids, window_size, stride): offset random.randint(0, stride - 1) padded [0] * offset token_ids return sliding_window_sample(padded, window_size, stride)这样做之后训练中后期的loss曲线相对更平滑模型也更不容易过拟合到特定的样本边界。6.3 乱序采样导致的数据泄漏假象采样后的样本如果按文档顺序直接组成batch同一篇文章的多个相邻样本会堆在同一个batch里。对于因果语言模型这问题不大但在对比学习、双塔结构或者需要严格独立batch的任务中这会假性拉高指标——模型看似学会了区分正负样本实际只是记住了同源样本的相近表示。我的解决方法是双重shuffle第一次对所有文档做全局乱序第二次对每个文档内部的样本做乱序。两层shuffle之后同文档样本被彻底打散到整个数据集里。6.4 注意力掩码的一致性每次补pad都必须同步生成对应的attention mask。这个坑我踩过很痛的一次训练时发现loss在validation set上表现正常但自己写的评估脚本里出现padding位置输出异常的怪现象排查了半天发现是采样器只生成了token idmask矩阵没有同步生成模型把padding当成了真实token参与注意力计算。从那次之后我把采样生成样本和采样生成mask绑定在同一个函数里从源头杜绝两者不同步的问题。7. 采样之后的组装分桶、动态batch与磁盘持久化滑动窗口采样产生的是一个个定长样本下一个问题是如何把样本组织成模型真正吃到的batch。7.1 按窗口长度分桶如果采样时用了多尺度窗口比如混用512和1024建议按窗口长度分桶训练。桶内的样本长度一致padding率自然为零。桶之间的样本可能长短不一但每个batch内部保持统一这能明显降低计算浪费。7.2 动态batch在显存允许的前提下我更倾向于固定每batch的total token数而不是固定batch size。比如每个batch固定包含1M token短样本多的时候batch size自动变大长样本多的时候自动变小。这样可以最大化GPU利用率同时让训练步数反映真实的token处理量。7.3 mmap持久化当样本量到千万级别每次启动训练都重新走一遍采样流程非常浪费。我的做法是把采样结果落盘成二进制文件用uint16存储token id——常规词表大小不超过5万uint16完全够用比默认int32节省一半内存。读取时用numpy的memmap按需加载不占用过多物理内存import numpy as np samples np.array(all_samples, dtypenp.uint16) samples.tofile(samples_2048.bin) # 读取时 mm np.memmap( samples_2048.bin, dtypenp.uint16, moder, shape(num_samples, window_size), )这一步在从零手搓大模型的实操中极其关键。很多人前期数据量小没感觉到了几十GB语料训练时CPU内存直接爆掉才来回头找优化方案。提前用mmap做好持久化能省出大量内存给模型训练本身。8. 采样产物如何与注意力掩码和位置编码衔接滑动窗口数字采样的终点是产出一批定长样本。但一个定长样本进入模型之后位置编码和注意力掩码才是决定它能否被正确理解的关键。每个样本虽然长度统一但内部真实内容和padding边界完全取决于采样时的切分。位置编码需要知道每个token在原始序列中的绝对位置——重叠采样让同一个token出现在不同样本的不同位置偏移中你要决定是用样本内相对位置简化实现还是原始文档绝对位置长文理解更友好但实现复杂度上升。注意力掩码需要区分真实token和padding位置如果是packing产生的样本还需要区分不同文档的边界。我建议在采样阶段就同时输出三样东西token id数组、attention mask数组、segment id数组。三个数组绑定存储训练时直接打包使用后续就不需要再回头做任何对齐操作。这样做虽然看起来多写了一些代码但在排查问题时能省下整整天的时间。还有一个建议采样完成后一定要画一个可视化验证脚本随机抽几个样本渲染出窗口切分位置、重叠区间和padding位置。我在正式训练前会专门跑一遍这个脚本检查有没有窗口切在了停止符中间、重叠区域是不是符合预期、padding是否被正确标记。这些细节看似琐碎却是从零手搓大模型过程中最容易积累技术债的地方。文本编码这一环做到这里样本侧的问题基本就清了。接下来要面对的是模型侧的注意力机制——因果掩码、片段掩码和padding掩码如何合成这是S07文本编码部分绕不开的下一块硬骨头等我把这一块整理完再来更新。