
做文本编码这一层最容易被低估的不是词表设计也不是位置编码而是“怎么把一段长文本送进模型”。大模型时代大家都在卷算力、卷参数量但真正手搓过编码器的人才会明白滑动窗口和数字采样这对组合决定了你喂给模型的内容是完整的语义还是一地碎渣。我今天就把自己在手搓文本编码器时踩过的坑、总结出的经验以及一套可以直接复用的滑窗采样方案原原本本写出来。这套内容属于“从零手搓大模型”系列的文本编码篇这一小节聚焦在滑动窗口的数字采样上。适合正在自己实现分词、编码、预训练或长文本分类的开发者也适合那些已经跑通BERT、GPT但想搞懂底层细节的学习者。读完你至少能回答清楚三个问题为什么非要用滑窗、滑窗里的“采样”到底采的是什么、窗口大小和步长到底应该怎么定。1. 文本编码里的滑动窗口为什么绕不开1.1 长文本是第一道坎绝大多数文本编码器都有最大输入长度限制。BERT时代是512个tokenGPT系列的上下文窗口虽然越做越大但也不可能是无限长。就算没有上限显存和算力也扛不住。拿注意力机制来说标准Transformer的计算量是序列长度的平方2048个token的注意力矩阵就已经是2048乘2048再往上走代价极不划算。所以一旦文本超过模型上限必须做切分。最粗暴的做法是直接截断只取前512或前1024个token。很多老式文本分类项目就是这么干的结果就是长文档后半段的信息全部丢失。更麻烦的是关键信息如果落在中间或末尾模型根本看不到准确率自然上不去。滑动窗口就是为了解决“截断丢信息”这个痛点出现的。它的思路很朴素用一个固定大小的窗口在整段文本上从头滑到尾每次截取一个子序列等把所有子序列都编码完之后再把它们的信息合并成最终表示。窗口滑动时可以选择重叠也可以选择完全不相邻但实际工程里几乎都会保留一部分重叠区域目的是让被切开的上下文有机会在边界处“搭上线”。1.2 滑动窗口的数学表达与参数计算滑动窗口有三个核心参数窗口大小W、步长S、重叠区域长度W-S。重叠率就是重叠部分占窗口的比例公式是重叠率 (W - S) / W给定文本长度L窗口数N的计算方式为N floor((L - W) / S) 1举个例子。假设一段文本被分词后长度L1024模型最大输入长度W128步长S32。那么重叠率就是 (128-32)/128 75%窗口数是 floor((1024-128)/32)1 29个窗口。注意这里L超过W很多所以窗口数主要由步长决定如果L小于等于W那就只需要一个窗口没必要滑。窗口数N直接决定计算量。每个窗口都要过一遍编码器窗口越多前向传播和反向传播的耗时越高。所以参数选择本质上是“信息覆盖度”和“计算开销”之间的拔河。窗口太小局部语义容易碎窗口太大容易超过模型上限还得继续截断。步长太小窗口重叠太多计算冗余大步长太大中间可能漏掉关键片段。我在实际项目里一般遵循这样一个原则先根据显存确定W再让重叠率落在50%到75%之间。也就是说步长取W的25%到50%。比如W128步长取32或64。重叠率太低边界语义很难衔接重叠率太高训练速度肉眼可见地下降。1.3 滑窗中的“采样”不是滑窗注意力很多人一看到滑动窗口就会联想到“滑窗注意力”也就是Sliding Window Attention。那个东西是在注意力层里让每个token只和前后W个token计算注意力从而把复杂度从O(n²)降到O(n·W)。有些文章会把这两个概念混在一起但它们在文本编码流程里的位置和作用完全不同。滑窗注意力是模型内部计算时的优化手段解决的是“序列太长注意力矩阵爆炸”的问题。而我们这里说的滑动窗口采样是发生在编码器之前的输入预处理层解决的是“模型一次吃不下整段文本该怎么分批吃”的问题。两者可以同时使用输入层用滑窗把长文本切成多个子序列模型内部再用滑窗注意力降低计算量。做过长文本预训练的朋友应该知道这两种技术叠起来才真正能扛住几十万token的输入。既然聊到采样就必须把“数字采样”这四个字掰开揉碎讲清楚。下一章专门说它。2. 数字采样窗口里的数据到底怎么“采”2.1 数字采样在文本编码中的真正含义“数字采样”这个词最早来自信号处理把连续模拟信号按照固定时间间隔读取瞬时幅度变成离散的数字序列。比如音频采样每秒44100次每次记录一个电压数值。文本不是电压信号但文本编码的过程在抽象层面和信号采样惊人地相似。原始文本是一串连续字符流滑窗每次截取一段相当于对文本信号做了一次空间上的采样窗口大小是观测区间步长是采样间隔。截取下来的子序列还要进一步把每个token映射成词表里的整数ID这一步才是真正的“数字化”。语言模型看到的不是字符是一串IDID再到embedding矩阵里查表变成稠密向量。所以“滑动窗口的数字采样”不是一句玄乎的口号它具体包含两层操作滑窗采样决定从长文本里取出哪些片段以及片段之间的重叠关系。数字量化把片段里的token从字符串映射成整数ID再去查向量表。这种方式和信号采样有个共同点采样间隔决定信息保留量。如果步长太大很多信息落在窗口之间的空隙里就相当于信号采样率太低高频细节全丢了。反过来步长太小窗口间大量重叠信息冗余高计算成本也跟着涨。你需要找到一个均衡点。2.2 从奈奎斯特采样定理能借鉴到什么信号处理里有个经典结论采样频率必须大于信号最高频率的两倍否则会混叠原始信号无法恢复。把你类比到文本上最高频率可以理解为文本里最密集的关键信息出现的“间隔”。如果一句话里连续出现多个关键短语每个短语平均长度是8个token那么采样步长如果大于16个token就可能完整漏掉某个短语。虽然文本不是严格的频带信号但这个思想足够指导参数设置窗口步长S不能大于你希望捕获的最小语义单元长度的两倍。比如你关心的是句子级别的语义大多数句子在分词后是20到30个token那么步长最好不要超过50。如果关心短语级别步长还要再缩。我一般在设置参数之前会先对自己数据集里的文本做一次分词统计看看句子、从句、关键短语的长度分布。这个分布就是文本的“频谱”据此再定步长比拍脑袋强得多。2.3 窗口内部的采样策略均匀、重叠、加权确定了窗口如何滑动之后还要决定窗口内部的token怎么用。有些实现会把整个窗口的所有token全部送进编码器这样信息最完整但如果每个窗口很长计算开销还是大。更精致的做法是“窗口内部二次采样”。均匀采样每隔固定步长取出一个token。实现简单但可能丢掉关键词适合资源极紧张时的妥协方案。重叠采样让窗口之间有重叠保证边界处的token被多次感知。这是目前最常用的方案因为重叠本身就是一种隐式的平滑能够缓解边界切碎问题。加权采样先给每个token算一个重要性分数比如TF-IDF、信息熵或者用一个小模型预测注意力得分然后按照分数高低保留top-k个token。这个策略效果好但需要额外的计算开销更适合离线处理或对效果要求高的场景。从信号处理角度理解均匀采样就是固定频率抽样重叠采样就是过采样加权采样则类似自适应采样。文本不是均匀分布的信息流重要和次要的密度差异很大自适应采样的理论潜力最大只是工程实现成本高。初学者建议先从重叠采样入手等跑通全流程再慢慢尝试加权策略。2.4 多个窗口怎么合并成一个向量滑窗采样得到了多个窗口每个窗口编码后都会输出一个向量或者一组向量。后续分类、匹配、生成任务都希望拿到整个文本的单一表示。合并方式没有标准答案几种主流做法如下。平均池化把所有窗口的输出向量取平均。简单稳定但对关键窗口不够敏感。最大池化按维度取最大值强调每个维度上的最强响应。适合关键词强相关的分类任务。加权求和每个窗口根据位置或重要性给一个权重再求和。比较灵活但权重需要设计或学习。拼接后接序列模型把所有窗口编码拼接起来再过一个LSTM或Transformer层让模型自己学窗口间关系。效果通常更好但参数也更多。拿一个文本分类例子来说假设有10个窗口每个窗口输出768维向量。平均池化后得到一个768维向量可以直接过全连接分类器。如果改最大池化往往能保留更突出的特征但可能放大噪声。我在实际中会同时跑平均池化和最大池化然后把两者拼接成1536维向量一般比单独用某一种稳定成本只多了一点点。3. 实操从零手搓一个滑窗数字采样编码器3.1 准备工作与设计目标这一章我直接给出一个能跑的Python实现基于PyTorch。目标是实现一个通用模块输入一段原始文本输出一个固定维度的向量表示。整个模块包含分词、滑窗切分、窗口内数字采样、embedding、池化合并几个阶段。代码追求的是“看得懂、能修改”所以没有封装得太复杂。你把重点放在滑动窗口和采样逻辑上后面的编码层可以替换成任何你手头的模型。3.2 极简分词器为了不把篇幅耗在分词上我用一个基于正则的简易分词器。它按字母和数字连续块切分中文则按单字切分。真实项目里建议换成HuggingFace tokenizer或者自己训练BPE词表但流程完全一样。import re from collections import Counter import torch import torch.nn as nn import math class SimpleTokenizer: def __init__(self, vocab_size10000): self.vocab_size vocab_size self.word2id {pad: 0, cls: 1} self.id2word {0: pad, 1: cls} self.unk_id 2 self.word2id[unk] self.unk_id self.id2word[self.unk_id] unk self.counter Counter() def tokenize(self, text): # 简单切分连续英文字母或数字作为一个token中文字符逐字切分 tokens re.findall(r[a-zA-Z0-9]|[\u4e00-\u9fff], text.lower()) return tokens def build_vocab(self, texts): for text in texts: for token in self.tokenize(text): self.counter[token] 1 for token, _ in self.counter.most_common(self.vocab_size - 3): if token not in self.word2id: idx len(self.word2id) self.word2id[token] idx self.id2word[idx] token def encode(self, text): tokens self.tokenize(text) ids [self.word2id.get(t, self.unk_id) for t in tokens] return ids这里要注意词表里保留了pad和cls两个特殊token后续滑窗拼接时会用到。cls放进每个窗口开头方便分类任务取特征。实际应用中build_vocab需要在数据集上先跑一遍你最好至少有几万条样本来构建词表否则OOV太多效果会明显变差。3.3 滑窗切分与数字采样器接下来是核心类负责滑窗切分和窗口内采样。我把采样逻辑抽成一个单独的方法方便你切换不同的采样策略。class SlidingWindowSampler: def __init__(self, window_size64, stride32, sampling_stride1): self.window_size window_size self.stride stride self.sampling_stride sampling_stride def split_windows(self, ids): windows [] start 0 length len(ids) if length self.window_size: windows.append(ids[:]) return windows while start self.window_size length: windows.append(ids[start:start self.window_size]) start self.stride if start self.window_size length and start length: # 最后一段不足窗口长度时从末尾反向取一段保证尾部不被丢掉 windows.append(ids[length - self.window_size:]) break return windows def sample_window(self, window_ids): # 窗口内部二次采样每隔sampling_stride取一个token if self.sampling_stride 1: return window_ids return [window_ids[i] for i in range(0, len(window_ids), self.sampling_stride)]split_windows方法有一个处理细节当剩余文本长度不足一个窗口时我会从尾部反向取一个完整窗口而不是丢弃尾部。这个操作很重要因为长文本的结尾往往包含结论性信息直接丢掉会让模型“没看到结尾”。代价是最后一个窗口会和其他窗口有较大重叠但在实际工程里这是非常值得的。sample_window是标准的均匀采样。你可以把这里的逻辑替换成基于重要性分数的加权采样只需要传入一个importance_scores列表然后按分数从高到低筛选top-k个token位置再还原成ID序列。我这里为了演示清晰只保留了均匀采样版本。3.4 编码器与池化层编码器我使用一个简单的可学习embedding加sinusoidal位置编码再接两层TransformerEncoder。TransformerEncoder的参数量不大足够演示流程。如果你已经有一个预训练模型可以用它替换self.encoder部分。class PositionalEncoding(nn.Module): def __init__(self, d_model, max_len512): super().__init__() pe torch.zeros(max_len, d_model) position torch.arange(0, max_len, dtypetorch.float).unsqueeze(1) div_term torch.exp(torch.arange(0, d_model, 2).float() * (-math.log(10000.0) / d_model)) pe[:, 0::2] torch.sin(position * div_term) pe[:, 1::2] torch.cos(position * div_term) self.register_buffer(pe, pe.unsqueeze(0)) def forward(self, x): return x self.pe[:, :x.size(1)] class SimpleEncoder(nn.Module): def __init__(self, vocab_size, d_model128, nhead4, num_layers2): super().__init__() self.embedding nn.Embedding(vocab_size, d_model, padding_idx0) self.pos PositionalEncoding(d_model) encoder_layer nn.TransformerEncoderLayer(d_modeld_model, nheadnhead, batch_firstTrue) self.encoder nn.TransformerEncoder(encoder_layer, num_layersnum_layers) def forward(self, x): x self.embedding(x) x self.pos(x) x self.encoder(x) return x这里batch_firstTrue输入形状为(batch, seq_len)。padding_idx0对应pad的embedding永远是零向量所以短窗口补零时不会引入噪声。3.5 把整个流程串起来现在把分词、滑窗、编码、池化组装成一个完整的模块。class WindowTextEncoder(nn.Module): def __init__(self, tokenizer, d_model128, window_size64, stride32): super().__init__() self.tokenizer tokenizer self.window_size window_size self.stride stride self.sampler SlidingWindowSampler(window_size, stride) self.encoder SimpleEncoder(len(tokenizer.word2id), d_modeld_model) self.pool nn.AdaptiveAvgPool1d(1) def forward(self, text): ids self.tokenizer.encode(text) if not ids: ids [self.tokenizer.unk_id] windows self.sampler.split_windows(ids) # 给每个窗口加上cls并做padding max_len self.window_size 1 batch_ids [] for w in windows: w [self.tokenizer.word2id[cls]] w # 窗口不足长度时补pad if len(w) max_len: w w [self.tokenizer.word2id[pad]] * (max_len - len(w)) else: w w[:max_len] batch_ids.append(w) batch_ids torch.tensor(batch_ids, dtypetorch.long) out self.encoder(batch_ids) # (num_windows, max_len, d_model) # 池化先对序列维平均得到每个窗口的向量 # 注意需要忽略padding位置这里简化成直接平均因为pad的embedding为0影响不大 window_vec out.mean(dim1) # (num_windows, d_model) # 对窗口维再做一次平均池化 doc_vec window_vec.mean(dim0) # (d_model,) return doc_vecforward方法里的流程值得细说一下。先把整个文本的ID序列取出来调用split_windows得到若干个长度等于window_size的窗口。然后给每个窗口头部加上cls长度不足就补pad。这样构成的batch包含num_windows个样本可以直接过编码器。池化部分我用了两层平均。第一层把窗口内各位置的输出向量平均得到窗口向量第二层把多个窗口向量再平均得到整个文本的向量。如果你的任务更注重关键窗口可以把第二层平均改成加权求和权重来自一个可学习的门控或者直接用最大池化。后面的微调阶段这些池化参数都能通过反向传播学到。3.6 效果验证思路你把这个编码器接到一个分类头之后就可以训练了。为了快速验证滑窗采样的有效性我建议先在短文本上跑通比如IMDb电影评论长度大多200到400词用三种配置对比直接截断前128个token。滑窗窗口大小64步长64无重叠。滑窗窗口大小64步长3250%重叠。在我自己跑过的类似实验里第二种比第一种能提升几个点的准确率第三种又比第二种更稳。原因很直接截断丢掉了尾部信息无重叠滑窗虽然覆盖了全文但窗口边界处的语义被硬切开了。引入重叠之后模型能在相邻窗口里看到重复的上下文相当于给边界信息做了一次平滑分类结果自然更可靠。当然具体提升幅度跟数据集和任务关系很大。如果文本很短根本不需要滑窗如果文本里有大量冗余段落无重叠采样反而能减少干扰。所以做实验时不要只用一个数据集下结论至少跑两三个不同分布的任务再看趋势。4. 常见问题与避坑指南4.1 窗口大小和步长到底怎么调我收到最多的疑问就是“参数怎么选”。这里给一个可执行的经验流程。第一步跑一遍分词统计拿到样本长度的中位数和90分位数。如果绝大多数样本都在模型最大长度以内直接用整句编码不需要滑窗。如果只有少数超长样本可以只对超长部分做滑窗普通样本正常处理。第二步设置W为模型最大长度的70%到100%。比如模型最大512就选W384或512。第三步设置重叠率不低于50%即S不超过W/2。最后用验证集跑一次观察训练曲线如果loss波动大说明窗口间分布不均考虑加大重叠或者shuffle窗口顺序如果收敛很慢考虑减小W或者增大S。千万不要一上来就用很小的W。模型对上下文的理解依赖窗口内足够长的依赖关系W太小会让每个窗口里的信息变得零碎即使重叠率再高也难以拼出完整语义。4.2 边界token被切碎了怎么办滑窗是死的文本是有结构的。窗口边界恰好落在句子中间是常态尤其中文没有空格一个窗口很容易从一个词中间硬切过去。后果是同一个词被拆成两半分属不同窗口两边都拿不到完整语义。处理办法有几种。最简单的是让窗口边界对齐到标点符号。切分时先按句子边界把文本切成句子列表再用滑窗去覆盖句子而不是直接对token序列切。如果滑窗跨过句子边界就允许窗口包含多个完整句子。这种方法能显著降低“一半词”出现的概率。更通用的做法是使用分词器提供的offset_mapping。HuggingFace的tokenizer在编码时能返回每个token对应的原始字符区间你可以利用这些区间判断窗口是否需要扩张或收缩确保边界不落在词的中间。手搓代码时不一定需要这么精细但生产环境一定要考虑对齐。4.3 采样随机性导致训练不稳定有时候你会看到“窗口内随机采样”的训练技巧它相当于一种数据增强能提高鲁棒性。但如果你在实现里加入了随机成分比如随机丢弃部分token或者每次训练随机选择窗口起点就会带来一个问题同一个样本在不同epoch里喂给模型的内容不一样训练过程容易震荡。解决办法是让随机采样只在训练阶段开启推理阶段固定使用确定性采样。如果不想让训练过于震荡可以设置随机种子或者在batch内保证同一个样本的所有窗口使用相同的采样模式。我自己的习惯是先用确定性滑窗把全流程跑通确认基线没有问题再逐步加入随机性。一上来就叠满随机采样出了问题很难定位到底是编码器还是采样器的问题。4.4 “滑动窗口最大值”和这里的采样是一回事吗很多读者是刷算法题了解到滑动窗口的比如LeetCode 239求滑动窗口最大值用单调队列优化。看到“滑动窗口”四个字自然会往那边想。这里明确一下算法题里的滑动窗口是对一个数值数组做固定区间聚合和我们文本编码里的滑窗采样完全不是一个层面的东西。不过单调队列的思想倒是可以借鉴到文本采样里。如果你在窗口内用重要性分数做加权采样需要在窗口滑动时快速维护当前窗口内最高分token的位置单调队列就可以派上用场。它能在O(1)时间内获取当前窗口的最大值这样每个窗口都能快速找到最重要的token位置。我在做过一个长文本关键词提取模块时就是用单调队列维护每个窗口的关键词强度峰值然后按照滑窗内峰值位置来二次采样效果不错。所以算法功底在这里并不是没用关键看你怎么迁移。4.5 算力不够时的降级方案滑窗采样最大的问题是窗口数多训练慢。假设一段2048个token的文本窗口大小128步长32会产生61个窗口。每个窗口都要过编码器一个batch里再堆几十条样本计算量瞬间爆炸。降级方案有三个方向。第一个方向是减少重叠率把步长从32提到64窗口数降到31计算量直接减半当然重叠率也降到50%。第二个方向是并行计算把不同窗口当成batch维度一次性编码而不是循环。我在上面的代码里已经演示了所有窗口拼成一个batch过编码器这一步利用GPU并行能省大量时间。第三个方向是缓存重叠区域的特征。如果两个相邻窗口共享一段token理论上可以复用这段token的编码结果不过实现起来比较复杂需要设计跨窗口的注意力掩码适合有大把时间研究的朋友。我先说No。可扩展方向有两个一是把滑窗采样后的多个窗口向量送入循环神经网络或Transformer encoder来学习窗口间依赖二是用注意力池化取代平均池化。前者适合“文档级关系建模”后者适合“关键句突出”的任务。我自己常用一个可学习的query向量去对所有窗口向量做注意力加权这个query向量通过训练学会“关注哪些窗口”效果要优于平均池化。最后分享一个小技巧如果你在预处理阶段就要对大量长文本做滑窗采样尽量提前把所有窗口的token ID都算好并缓存成文件别在训练循环里实时分词和滑窗。我第一次训练长文本模型时就在数据加载器里实时跑滑窗结果每个step一大半时间耗在纯Python循环里把GPU饿得发抖。后来改成离线预处理训练速度直接翻倍。这些看似不起眼的细节往往比模型结构本身更影响整个项目的进度。