☰
手搓大模型S07:滑动窗口数字采样原理与工程实现
2026/9/29 17:10:51 网站建设 项目流程

继续说从零手搓大模型。这一篇是S07文本编码环节里的一个实战小模块:滑动窗口的数字采样。文本编码做完之后,手里拿到的不是字符串,而是一串带序号的数字token;但模型的上下文长度往往只有几千,长文档动不动就是几万甚至几十万token,这时候就得靠滑动窗口把这串数字切成一段一段,再决定哪些段落进入训练或推理。这个“切”的过程,本质上就是一个数字采样问题,值得单独拿出来讲清楚。

我写这篇的初衷很简单:很多人在微调长文本、构造SFT数据、做检索切片时,都遇到过“上下文超长被截断”“训练数据重复”“样本边界错位”这类问题,最后定位到源头,全是滑动窗口采样写得不对。这一篇会把数字采样从设计到实现、从参数选择到踩坑排查完整过一遍,适合正在手搓模型的同学,也适合做数据工程、上下文工程的人作参考。

1. 为什么要做滑动窗口的数字采样

1.1 从文本编码到数字序列:先对齐概念

大模型的输入链路里,文本编码是第一道工序。原始字符串先被分词器切分成token,每个token再映射成词表里的整数ID,最后得到一维数字序列。这个过程看起来简单,但它决定了后续所有模块看到的“文本”到底是什么形态。

我早期犯过一个理解偏差:以为编码完成后,模型天然能“记住”整篇文档。实际上,文本编码只是把文字变成数字,而真正限制模型能看多远的是位置编码和注意力机制的窗口范围。比如一个模型最大支持4096个token,你手头有一段1万token的会议纪要,直接全部喂进去就会触发截断,后6000个token直接消失。

这时候就需要一种机制:把长数字序列切成若干个短窗口,每个窗口长度不超过模型上限,窗口之间保持一定重叠,再逐个交给模型处理。听起来像切香肠,但切法不同,信息保留差异很大,这就是滑动窗口数字采样要解决的核心问题。

1.2 滑动窗口要解决的三件事

滑动窗口采样不是简单地“从前往后每512个切一刀”,它要同时回答三个问题:窗口取多长、窗口之间隔多远、切出来的窗口如何与训练标签对齐。

窗口取多长,通常直接取模型的最大上下文长度,或者略小于这个值,留点余量给特殊token。窗口之间隔多远,就是步长stride:步长越小,相邻窗口重叠越大,信息冗余越高,训练样本量也越大;步长越大,冗余越低,但可能把原本连贯的上下文切断。

第三个问题最隐蔽。做因果语言建模时,每个窗口内部要生成标签,labels是输入序列右移一位;做分类或序列标注时,每个窗口要带着原始文本的位置信息,才能回溯到它在原始文档中的位置。这三个问题如果处理不好,训练时loss曲线会非常怪,甚至模型完全学不到东西。

从这个角度看,滑动窗口数字采样更像是一个“采样策略调度器”,它的输入是数字序列,输出是一批定长窗口样本,每个样本都带着完整的元信息。理解了这一层,后面代码怎么写就有数了。

1.3 一个直观类比:安检测物传送带

滑动窗口的机制用生活场景类比最好理解:机场安检的X光机传送带一直在走,安检员通过视野有限的屏幕观察行李。如果行李太长一次看不完,就要让传送带前进一段,再看下一段;为了不遗漏行李之间的衔接部位,前进的距离通常会小于屏幕视野的宽度,让前后两段有重叠。

这个“前进一段再看一段”的动作,就是滑窗;前进多少,就是步长;屏幕视野宽度,就是窗口大小。数字采样要做的事,是精确控制这个传送带的节奏,保证长文档每个区域都被看到,同时不重复看太多。

有一点和传送带不同,数字采样器可以“跳跃式前进”,甚至可以在不同训练轮次之间随机调整起点,这些策略变化对模型训练影响很大,后面我会专门展开。

2. 采样方案设计:窗口大小、步长与起始偏移

2.1 三个核心参数先定下来

先给出一组我在实操里的默认经验值,后续按需调整:

参数含义常见取值说明
window_size每个采样窗口的token长度模型max_len的80%到100%需要扣除特殊token占位
stride相邻窗口起点之间的步长window_size的25%到75%越小重叠越大,样本量越大
start_offset第一个窗口的起始位置0,或随机抖动打破固定切点造成的周期偏差
max_samples每篇文档最多采样窗口数不限或按需设定控制超长文档的样本爆炸

window_size的设定原则不是“越大越好”。模型支持的上下文长度是硬上限,但实际训练时通常要留出一小段给特殊token,比如指令格式里的<s>、</s>、<pad>等。如果模型最大长度是4096,窗口设置成4032或3968比较稳妥,保证拼接特殊token后不超限。

stride的选择需要权衡样本量和信息冗余。stride等于window_size时,窗口完全无重叠,相当于硬切块;stride小于window_size时,相邻窗口重叠,重叠部分会被重复训练。重复训练不一定是坏事,它可以强化窗口衔接处的上下文建模,但代价是训练效率下降。我在实际项目里,预训练阶段常用stride为window_size的一半,微调阶段则倾向于更小的重叠,因为微调数据量小,宁可多几个样本。

2.2 窗口数量计算:一个例子说清楚

假设一段文本编码后有10000个token,window_size设为512,stride设为256。第一个窗口覆盖token 0到511,第二个窗口起点前进256,覆盖token 256到767,依此类推。窗口数量可以这样算:

n_windows = (10000 - 512) // 256 + 1

这里//是整数除法,结果是37。也就是说,10000个token能切出37个完整窗口。最后一个窗口的起点是36×256=9216,覆盖到9727,距离10000还有273个token,这273个token不在任何完整窗口里。

如果希望文档尾部信息也被覆盖,有两种处理方式:一是把最后一个窗口的起点强行调整到n - window_size,让尾部对齐;二是额外补充一个尾部窗口。我个人更推荐前者,也就是“最后一段右对齐”,这样不会在尾部制造一个超短残缺样本,又能保证尾部被看到。

重叠率也可以算一下:重叠率等于1减去stride除以window_size,这里就是1 - 256/512 = 0.5。重叠率0.5意味着相邻窗口共享一半内容,这个数值在训练长文档时是个不错的起点。如果文档内部逻辑连贯性强,比如论文正文、合同条款,我会把重叠率提到0.75,牺牲一点算力换取上下文连贯性。

2.3 起始偏移:被很多人忽略的细节

固定从token 0开始滑窗,会让切点位置永远相同。如果语料里存在周期性结构,比如每500个token恰好是一个章节边界,固定滑窗很可能反复把章节标题拦腰截断,或者总是把同类型的边界位置切到窗口末端,模型就会学到一种虚假的“位置偏好”。

解决办法是引入起始偏移。最简单的方式是:在训练每个epoch时,给第一个窗口的起始点加一个随机偏移量,偏移范围在0到stride之间。这样每个epoch切出来的窗口边界都不同,相当于对同一篇文档做了数据增强。代码上只改动一行,收益却很明显,尤其是语料有强结构时。

我还有一次教训:推理阶段千万不要加随机偏移。推理时希望结果稳定可复现,起点必须固定,否则同一个问题两次回答可能差异很大。随机偏移只用于训练阶段的数据增强,这是训练和推理的一个重要区别。

3. 从零实现一个滑窗数字采样器

3.1 一个极简文本编码管线

为了不依赖过多框架,我先写一个最简版本:用字典做token映射,模拟文本编码过程。

# 极简词表:仅用于演示,实际请使用分词器 vocab = {"我": 1, "爱": 2, "大": 3, "模": 4, "型": 5, "滑": 6, "动": 7, "窗": 8, "口": 9, ".": 10} vocab_size = len(vocab) + 1 def encode(text: str) -> list[int]: return [vocab.get(ch, 0) for ch in text] text = "我爱大模型.滑动窗口.数字采样." token_ids = encode(text) print(token_ids)

这里每个中文字符映射成一个token,实际工程里要用真正的BPE或SentencePiece分词器,但原理一样:文本编码的结果就是一段数字序列。接下来所有采样操作都针对这段数字序列进行。

实际项目中我会直接使用AutoTokenizer,它的返回值里除了input_ids,还有attention_mask和可能的token_type_ids。滑动窗口采样时,这三个序列要一起切,保持对齐,不能只切input_ids。

3.2 核心采样类的代码实现

下面这个SlidingWindowSampler是我在多个项目里反复使用的一个版本,按生成器方式实现,惰性产出窗口,不会把整篇文档的所有窗口一次性载入内存,这对超长文档很重要。

from typing import Iterator class SlidingWindowSampler: def __init__( self, token_ids: list[int], window_size: int = 512, stride: int = 256, start_offset: int = 0, drop_short: bool = False, ): self.token_ids = token_ids self.window_size = window_size self.stride = stride self.start_offset = start_offset self.drop_short = drop_short self.total_len = len(token_ids) def __iter__(self) -> Iterator[tuple[int, list[int]]]: n = self.total_len if n < self.window_size: if self.drop_short: return yield 0, self.token_ids[:] return start = min(self.start_offset, max(n - self.window_size, 0)) while start + self.window_size <= n: yield start, self.token_ids[start:start + self.window_size] start += self.stride # 保证最后一个窗口覆盖到文本尾部 if start < n and not self.drop_short: tail_start = max(start, n - self.window_size) if tail_start + self.window_size > n and tail_start != start - self.stride: tail_start = n - self.window_size if tail_start >= 0 and tail_start + self.window_size <= n: yield tail_start, self.token_ids[tail_start:tail_start + self.window_size]

这段代码里有两个关键点。第一,start_offset不能大于n - window_size,否则第一个窗口就越界了,所以要做一次min钳制。第二,尾部窗口采用“右对齐”策略,而不是简单地再往前推一个stride,这样才能确保文档最后的内容一定被某个窗口覆盖。

如果你需要返回的不只是input_ids,可以在这个生成器的基础上扩展成返回一个字典:

def __iter__(self): for offset, window in self._generate_offsets_and_windows(): yield { "offset": offset, # 原始文档中的起始位置 "input_ids": window, "attention_mask": [1] * len(window), "position_ids": list(range(offset, offset + len(window))), }

position_ids这里带上了原始偏移,意味着你选择了“保持原始位置”策略;如果你希望每个窗口独立从0开始算位置,就把position_ids改成list(range(len(window)))。两种方案各有适用场景,后面会细说。

3.3 标签对齐:滑动窗口最容易翻车的环节

滑动窗口切完后,标签怎么处理,取决于训练任务类型。我只说两种最常见的情况。

第一种是因果语言建模,标签就是当前窗口的input_ids右移一位,模型预测每个位置的下一个token。这时直接对单个窗口内部做shift就可以了,不需要跨窗口,因为相邻窗口本来就有重叠,各自内部的标签都是自洽的。这个操作我在训练时通常会写成:

labels = input_ids[1:] + [pad_token_id] input_ids = input_ids[:-1]

第二步是带监督信号的任务,比如文本分类、序列标注、检索相关性判断。这时候,窗口必须记录它在原始文档里的位置偏移,也就是offset,否则你根本不知道这个窗口对应原文的哪一段。我做检索模型时,会把每个窗口连同offset、原文档ID、段落切分信息一起存成元数据,训练时通过offset回溯原文。

这里有一个容易踩的坑:如果你在滑动窗口采样之后,又对窗口内部做了随机打乱、截断或reorder,那么offset和标签对齐信息就全部失效了。任何改变窗口内部token顺序的操作,都必须在采样之前完成,或者在采样之后同时重算标签和位置信息。

3.4 边界情况处理

边界情况是采样器最容易出bug的地方,我整理了几类必须处理的场景。

短文本:文本长度小于window_size,常规滑窗一个窗口都产不出来。这时需要决定是丢弃还是保留。我一般默认保留,把它当做一个不足长的样本直接输出,靠padding补齐;但如果做的是定长批处理,drop_short=True直接丢弃更省心。

空文本:长度为0,任何窗口都不该产出。采样器要在开头加一次空序列判断,否则后续代码会炸。

正好整除:文档长度恰好是stride的整数倍,最后一个起点刚好卡在n - window_size,这时代码里的“尾部右对齐”逻辑要避免重复产出同一个窗口。我在上面的代码里增加了一个判断,防止tail_start和上一次循环的起点重叠。

超长文本:几十万token的文档,如果完全展开会占用大量内存。用生成器惰性产出,一次只保留一个窗口,是工程上必须做的选择。

4. 采样质量对模型训练的影响

4.1 固定滑窗的周期偏差问题

固定步长切窗的最大隐患,是切点与文本结构产生某种固定相位关系。我在处理一批格式非常规整的合同文本时发现,每隔固定步长切出来的窗口,大量落在同一个字段附近,导致该字段附近token被反复强化,而其他位置的上下文建模明显偏弱。

解决这个问题可以用随机起始偏移,也就是给每次滑窗的起点加随机数。具体操作是把start_offset设为random.randint(0, stride),每处理一篇文档重新随机一次。这样做相当于给采样器引入了随机性,代价是相邻窗口不再严格对齐,但对于提升训练数据的多样性非常有帮助。

纯随机不可取,完全固定也不可取,我现在的做法是“全局固定+局部随机”:同一个epoch内所有文档共用一个随机偏移,不同epoch用不同偏移。这样每个epoch看的切法都不同,但如果需要复现实验结果,只要固定随机种子即可。

4.2 步长过大时,上下文会断在哪里

stride如果设置得太大,比如等于window_size,相邻窗口完全不重叠。直观上觉得省算力,实际上模型在窗口边界处失去了“跨窗口学习”的机会。对于句子级语义理解,句子内部信息或许够用;但一旦任务需要跨窗口建模,比如指代消解、关系抽取、长距离情感推理,效果会显著劣化。

我做过一次对比实验:同一个长文本情感分类数据集,stride设为window_size的100%(无重叠)时,F1在83%左右;stride设为50%(半重叠)时,F1能到87%。差距接近4个点,模型结构、训练轮数、batch size全都没变,唯一变的是采样器步长。这个实验让我意识到,采样器从来不是一个简单的“切数据”工具,它在很大程度上决定了模型能学到的依赖关系。

当然,stride也不是越小越好。重叠率90%意味着90%的数据被重复计算,训练效率低下。我一般把重叠率控制在25%到75%之间,文本连续性要求越高,重叠率越往高处走。

4.3 与自然段落的冲突:语义感知滑窗

纯按固定token数滑窗,最大的毛病是会把语义完整的段落拦腰截断。比如一段逻辑完整的代码示例、一个表格、一段对话,被切到两个窗口各占一半,模型在任何一个窗口里都看不到完整结构。

实践中更好的做法是“语义感知滑窗”,分两步走:先把文本按段落边界、句子边界划分成块,再把这些块拼接进窗口。具体来说:

  • 用段落或句子边界作为“软约束”,滑窗的窗口边界尽量落在边界处;
  • 如果窗口内塞不下的剩余段落,整体移动到下一个窗口,而不是强行截断;
  • 长段落超过窗口大小时,才在段落内部做硬切。

这样做会让窗口长度不完全相等,batch内需要padding,但样本质量明显提升。我在构造SFT数据时经常用这个方案,因为指令、代码、表格这类结构一旦被截断,模型几乎不可能学好。

4.4 与微调、上下文工程的一些衔接心得

滑动窗口采样不只是预训练会用,微调和上下文工程里它同样无处不在。指令微调时,长指令-响应对如果用固定滑窗乱切,可能把用户指令切丢一半,响应标签也对不上。我通常把指令和响应作为不可拆分的最小单元,先保证这条完整数据被放进同一个窗口,再处理滑动切分。

做提示词工程和上下文工程时,滑动窗口的角色更接近“上下文编排器”:给定一个超长外部知识库,先粗滑窗切块,再按相关性筛选topk窗口,最后把选中的窗口拼进上下文。这里的窗口大小、重叠率直接影响检索召回的粒度,窗口太小语义碎片化,太大则可能超过上下文上限或混入噪声。

我自己的经验是,方法的关键在于窗口大小要和下游任务粒度匹配。比如做K线图分析,每根K线本身是一个语义单元,窗口就按“一个单元内”处理;做长篇小说摘要,摘要信息往往分布在多个段落,窗口就应该跨越多个段落,保持较高重叠率。

5. 常见问题排查与采样器校验

5.1 常见问题速查表

下面这张表总结了我在实际项目中遇到过的采样相关问题和排查方向,可以直接对照使用:

现象可能原因解决方法
窗口数量比预期少很多前半段下标计算越界,被while条件吞掉打印起点序列,逐项核对窗口数量公式
文本尾部从未被覆盖窗口起点固定步进,至最后一段不足窗口长增加尾部右对齐窗口,起点设为n-window_size
训练loss忽高忽低窗口内包含padding,padding位置的loss也被计算在loss计算时根据attention_mask屏蔽padding位置
切出来窗口内容错位只切了input_ids,没同步切attention_mask三个序列一起切,用同一个起点和长度
多个epoch采样结果完全一样没有随机起始偏移,切点固定每个epoch设置随机start_offset,固定随机种子
分类标签对不上窗口被reorder后未同步更新标签和位置信息任何序列变换必须在采样前完成
效果不如直接截断好窗口太碎,语义完整段落被切破改为语义感知滑窗,先按段落拼接再切

5.2 快速自检脚本

写完采样器后,别急着丢进训练脚本,先跑几个断言检查,能在几分钟内拦住绝大部分低级错误。

def check_sampler(sampler: SlidingWindowSampler, doc_len: int, expected_min_coverage: float = 0.95): covered = set() n_windows = 0 last_end = -1 for offset, window in sampler: assert len(window) <= sampler.window_size, "窗口超长" n_windows += 1 covered.update(range(offset, offset + len(window))) assert offset >= last_end, "窗口起点回退,出现了重复或乱序" last_end = offset coverage = len(covered) / doc_len print(f"窗口数: {n_windows}, 覆盖率: {coverage:.2%}") assert coverage >= expected_min_coverage, "覆盖率过低,文本大量区域未被采样"

覆盖率是个特别容易忽略的指标。我之前遇到过一种情况:窗口数量看着正常,但把头尾去掉中间一截从未被任何窗口覆盖,模型训练时这部分内容始终没见过。加上覆盖率的断言后,这类问题一眼就能发现。

自检还有一个笨但很有效的方法:把采样出的窗口还原成字符串,肉眼抽查。把前5个窗口打印出来,看看开头、结尾、重叠位置是否符合预期。这一步对新手尤其重要,很多数字层面的问题其实在还原成文本后立刻就能看出来。

5.3 工程化调优与扩展方向

基础滑动窗口跑通之后,还可以往两个方向扩展。

第一个方向是多尺度滑窗。固定窗口大小只能捕获单一粒度的上下文,可以用一组不同大小的窗口并行采样,比如512、1024、2048三个尺度,小窗口捕获局部细节,大窗口捕获长距离依赖。多尺度窗口各自编码后拼接成表示,在文本分类、检索任务里通常比单尺度更稳。

第二个方向是文本编码阶段与模型推理的联动。现在很多高效注意力机制本身就带滑动窗口概念,比如局部注意力限制每个token只看附近若干token。这种情况下,文本编码阶段可以直接按滑窗产出的位置信息组织计算,而不是先完整编码再切。这样能减少大量重复计算,对超长文本训练尤其有价值。

这两个方向我都只是实践到“能用”的程度,还没有形成一套完善的工程框架,后续如果再深入,会单独写篇出来分享。

一点个人经验收尾

做了这么多滑动窗口采样,我最深的体会是:这个模块的代码量很少,但它决定了训练数据的边界、重复度和信息覆盖率,直接影响模型收敛速度和最终效果。千万别因为它“看起来简单”就随手写,步长、重叠率、尾部对齐、随机偏移、标签同步,每一步都值得认真核对一遍源码输出。最后分享一个我自己养成的习惯:任何一次数据管线的改动,都要先打印5个采样窗口还原成文本人工过目,这一步能省掉后面大量排查时间。手搓大模型的路上,这类“小模块”往往才是决定成败的细节。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询