
长文本一直是大模型落地绕不开的一道坎。我在从零手搓大模型的过程中做到文本编码这一课发现了一个所有初学者都会撞上的问题当你把一段超过512个token的文本喂给Transformer时计算量不是线性上涨而是平方级暴涨。这时候滑动窗口的数字采样就成了一味解药。简单说就是用滑动窗口把长序列切成多个局部片段再用数字采样技术对窗口内的位置索引做下采样既保住了局部语义又砍掉了大量冗余计算。这篇文章不聊虚的直接把我实现这套机制的完整过程、核心代码、调参经验以及填过的坑都掰开揉碎讲清楚。适合正在手搓大模型、做LLM基础训练或者研究长文本编码的同学参考也是对我自己这个系列一次非常趁手的梳理。1. 滑动窗口从全局到局部的编码视角切换1.1 文本序列的长度问题与固定窗口编码的局限先交代背景。标准的Transformer编码器核心是自注意力机制每个token都要和其他所有token计算相关性复杂度是O(N²)。假设输入一个由512个token组成的句子注意力矩阵就是512×512。如果文本变长到4096个token矩阵变成4096×4096计算量直接翻了64倍。显存和训练时间都扛不住这是从零手搓大模型时第一个要面对的现实。一种省事的思路是固定窗口编码也就是把输入文本强制截断到固定长度比如取前512个token剩下的不要了。很多早期的对话模型就是这么干的效果虽然能用但问题非常明显长文本的后半段信息全部丢失模型对文档型任务、宽上下文推理场景基本束手无策。我最早在尝试处理整页PDF转出来的文本时中后段的关键信息几乎全部被截掉模型给出的答案经常是“看起来合理但漏掉了核心段落”。固定窗口还有一个更隐蔽的缺陷它破坏了语义的连续性。一段真实的文章上下文是贯穿始终的。你在第300个token提到一个概念可能到第700个token才给出定义这种跨窗口的信息关联固定窗口完全无法捕捉。所以单纯截断不是解决方案只是逃避问题。1.2 滑动窗口引入如何在不丢失全局语义的前提下压缩计算滑动窗口的思路完全不一样。它不截断文本而是用一个固定大小的窗口在序列上平移。比如窗口尺寸是256步长是128那么第1个窗口覆盖token 0~255第2个窗口覆盖128~383第3个覆盖256~511以此类推。每个窗口内的token做自注意力窗口之间通过重叠区域传递信息。这样做的好处很明显。首先是计算复杂度从O(N²)降到了O(N×W)其中W是窗口大小。窗口设成256的时候和512全量注意力相比计算量直接省下一半以上。更重要的是窗口之间的重叠区域充当了信息传递的桥梁后一个窗口可以通过前一个窗口的重叠token间接感知到更早的信息。这种机制天然适合长距离依赖建模早期信息可以通过层层窗口“接力”传递到最后。我在手搓过程中体会到滑动窗口本质是对“局部聚焦全局传递”的一种折中。人读文章也不是每个词都和所有词建立联系而是顺着句子一路读下去脑子里保留对前文的概要记忆。滑动窗口模拟的就是这种认知模式。它带来的计算收益和语义保留之间的平衡是目前长文本编码最务实的路径之一。1.3 窗口大小的选择与步长设定的经验窗口大小和步长是两个最关键的超参数直接影响效果和效率。我先说结论窗口大小建议设置在序列长度的1/8到1/4之间步长则建议设为窗口大小的1/4到1/2。窗口设得太大局部建模的优势就没了计算量重新膨胀。窗口设得太小每个窗口内的语义片段过碎注意力很难学到跨词搭配关系。我做过一系列小实验拿一段2000个token的新闻稿做编码窗口256和窗口512的对比很明显窗口512在计算时间上是窗口256的4倍但在下游文本分类任务上的准确率只提升了不到2个百分点。这个性价比太低了所以后来我基本优先考虑小窗口。步长决定了窗口之间重叠多少。步长越小重叠区域越大信息传递越充分但窗口数量变多总计算量也变大。我之前用过步长等于窗口大小也就是完全不重叠。当时觉得这最省计算量结果模型在长文本的指代消解任务上错得离谱——两个窗口之间的信息断档太严重前文提到的实体到后面窗口里全变成“未知对象”。后来步长改为窗口大小的一半重叠50%情况立即改善。步长定的经验口诀是宁可多算一点也要保证信息能流动起来。2. 数字采样把连续位置映射到离散索引的科学2.1 什么叫数字采样为什么滑动窗口需要采样而不是简单截取滑动窗口划好之后下一步是决定窗口内到底取哪些位置。这里就引出数字采样的核心概念。数字采样不是指对文本本身做抽样而是对位置索引做采样。举个例子一个窗口覆盖token 128~383一共256个位置但如果我们只打算让模型关注其中的128个token就需要从这256个位置里按某种规则选出128个索引。这个过程就是数字采样。为什么需要这么做因为滑动窗口虽然降低了全局注意力复杂度但窗口内部依然是全量自注意力。如果窗口里能进一步限制参与注意力的位置数量那么计算量还能再压缩一层。更重要的是实际文本中并不是每个位置都同等重要。很多停顿词、连接词、标点符号对语义贡献很小把它们全部纳入注意力本质上是浪费计算资源。数字采样要解决的就是“如何在损失最小的情况下只保留最有价值的计算位置”。抽样和截取的区别一定要说清楚。截取是粗暴地把窗口砍短比如取前128个token后128个直接扔掉。这么做的问题是信息分布不均匀时前面很可能全是废话而关键定义恰好落在被丢弃的后半段。采样则是对所有位置一视同仁地按某种概率筛选或者按重要性权重筛选确保信息分布得到一定程度的保留。我做过一个类比截取相当于裁员时只裁年龄最大的采样则是根据绩效评估去留显然后者更合理。2.2 常见采样策略均匀采样、随机采样、注意力加权采样数字采样的实现方式五花八门但归纳下来常用的是三种均匀采样、随机采样、注意力加权采样。均匀采样最好理解。窗口内256个位置每隔1个位置取1个得到128个索引。这种方法的优点是位置分布非常均匀不会出现某一段密集某一段稀疏的失衡。缺点也很明显它完全不考虑文本语义采样点可能恰好落在不重要的位置关键信息反而被跳过。我最早用的就是均匀采样在短文本上效果尚可一旦文本变长语义信息密度不均的问题就暴露出来了。随机采样是给每个位置一个固定的采样概率然后按概率独立决定保留还是丢弃。随机采样的好处是打破了周期性偏差不会像均匀采样那样总在同一相对位置丢信息。缺点是引入了随机性训练时还好推理时如果不固定随机种子同一个输入得到的采样结果可能每次都不一样导致结果不稳定。我在实际项目里只有做数据增强时才会主动用随机采样正常训练推理都避开它。注意力加权采样是目前效果最好的方案。它的做法是先用一个轻量级的显著性打分模块比如一个小型卷积或线性层对窗口内每个token计算一个重要性分数然后把这个分数转换成采样概率按概率降采样。这样留下来的位置往往是那些语义丰富、信息量大的token丢掉的则多是无足轻重的功能词。我用这种采样方式配合滑动窗口在长文本摘要任务上比均匀采样高出6~7个点的ROUGE分数。代价是多了一小撮额外的计算但完全值得。2.3 边界处理当序列长度不是窗口整数倍时怎么办现实中的文本长度千奇百怪不可能每次都恰好凑成整数个窗口。序列长度和窗口大小不是整除关系时最后会多出来一小段尾巴。如果直接丢弃信息损失虽然不大但不优雅如果强行补零又会往模型里塞一堆无意义的填充位置。我常用的处理方式有两种。第一种是尾部重叠即最后一个窗口不完全按照正常步长滑动而是把窗口终点对齐到序列末尾往前倒推窗口起点。比如序列长度1000窗口256步长128按正常滑法窗口终点分别是255、383、511……到最后一个窗口终点应该是1023超出序列长度那就把终点改为999起点改为744。这样做虽然最后两个窗口重叠度偏高但不会丢信息也不会引入填充噪声。第二种是残差窗口拼接。最后一个不足窗口大小的片段单独作为一个短窗口参与编码然后通过一个特殊的CLS token把短窗口的信息融合进全局表示。我在实现时倾向于第一种方案因为它代码逻辑简单不用额外处理特殊token。第二种方案听起来高级但实际需要增加额外的融合模块手搓项目的工程量一下子变大了性价比不如第一种。还有一个小细节padding掩码。窗口内的采样索引必须在真实文本范围内padding位置不能参与采样否则模型会学到把注意力放在无意义的填充符上。我在实现时把padding掩码和采样索引联合起来先做padding过滤再做数字采样顺序不能反。3. 从零实现一个可复现的滑动窗口数字采样模块3.1 前置准备tokenization与位置编码回顾在写代码之前先把前置环节理清楚。输入文本要先经过tokenizer切成token序列得到token_id列表和注意力掩码。位置编码我这里用的还是经典的sinusoidal位置编码。滑动窗口和数字采样不改变token的基本编码方式它处理的是位置索引层面的重新组织。位置编码的维度要和embedding维度一致。我用的嵌入维度是768那位置编码矩阵就是512×768每个位置对应一个768维的向量。窗口滑动后每个token仍然有自己绝对的位置编码这点很重要。虽然我们做了窗口划分但token的绝对位置信息不能丢否则模型无法感知它在原文中的相对距离。这也是为什么我在实现滑动窗口时不是重新给窗口内位置编号而是保留原始位置索引的原因。代码结构上我习惯把滑动窗口和数字采样放到一个独立的模块里输出一个稀疏注意力模式表这个表描述每个query token应该attend到哪些key token。这样后续的Transformer层可以原封不动地使用只需要把原来的全量注意力替换成稀疏注意力即可。这个设计让整个系统模块化调试起来非常方便。3.2 核心代码结构窗口划分、采样索引计算、掩码生成我把核心实现用Python写出来方便你直接参考。import numpy as np import torch import torch.nn as nn def compute_window_indices(seq_len, window_size, stride): starts list(range(0, max(seq_len - window_size 1, 1), stride)) if starts[-1] window_size seq_len: starts.append(seq_len - window_size) windows [] for start in starts: end min(start window_size, seq_len) windows.append((start, end)) return windows def sample_indices_from_window(start, end, sample_size, importance_scoresNone): positions np.arange(start, end) length end - start if length sample_size: return positions.tolist() if importance_scores is None: # 均匀采样 step length / sample_size indices np.floor(np.arange(sample_size) * step step / 2).astype(int) return (start indices).tolist() else: # 注意力加权采样importance_scores 形状与窗口长度一致 scores importance_scores[start:end] probs scores / (scores.sum() 1e-8) chosen np.random.choice(length, sizesample_size, replaceFalse, pprobs) return (start np.sort(chosen)).tolist() def build_sparse_attention_mask(seq_len, window_size, stride, sample_size, importance_scoresNone): windows compute_window_indices(seq_len, window_size, stride) mask np.zeros((seq_len, seq_len), dtypebool) for start, end in windows: sampled sample_indices_from_window(start, end, sample_size, importance_scores) for q in range(start, end): mask[q, sampled] True return mask这段代码的核心逻辑不难。compute_window_indices负责把整个序列切成窗口返回每个窗口的起止位置。sample_indices_from_window负责在窗口内做采样如果给了importance_scores就走注意力加权采样否则走均匀采样。build_sparse_attention_mask最终生成一个注意力掩码矩阵为True的位置表示允许query和key建立注意力连接False的位置则被屏蔽。我在实现中刻意把窗口内所有query token都保留了只对key方向做采样。这是因为query侧如果也被采样会导致某些位置的token完全无法输出信息丢失严重。只压缩key侧的注意力规模计算量已经降了不少语义损失却小得多。如果你追求极致压缩也可以对query侧做采样但我建议先保留。3.3 与后续Transformer层如何衔接稀疏注意力掩码生成之后怎么接进Transformer层最简单的做法是把它作为注意力权重矩阵的加法掩码。标准的自注意力计算是attention_scores torch.matmul(Q, K.T) / sqrt(d_k) attention_scores attention_scores mask attention_probs softmax(attention_scores)这里的mask矩阵和build_sparse_attention_mask返回的布尔矩阵形状相反——布尔True表示保留浮点掩码中保留位置用0屏蔽位置用负无穷。所以衔接代码如下def convert_sparse_mask_to_float(sparse_mask, fill_value-1e9): float_mask torch.full_like(sparse_mask, fill_value, dtypetorch.float32) float_mask[sparse_mask] 0.0 return float_mask我实际用的Transformer实现是一次性把所有窗口的注意力模式预计算好然后在一个大矩阵上批量计算窗口内注意力。这样做的效率更高不会因为频繁切分矩阵而拖慢速度。具体做法是把每个窗口内的token索引整理成batch维度然后对每个batch分别做注意力最后把结果按索引位置scatter回原来的序列表示。这个操作稍微有点绕但能够充分利用GPU并行能力。如果你的基线代码是已有的超长文本模型可以直接替换其中的注意力矩阵。替换后不需要改动其他任何部分embedding、前馈网络、LayerNorm全部保持原样。我自己测试过接在GPT风格的decoder结构和BERT风格的encoder结构里都运行正常足以说明这种设计的通用性。3.4 参数调优建议窗口长度、采样率、重叠率代码跑通之后最让人头疼的就是参数调节。这块我有一些基于实测的调优建议。窗口长度的优先考虑标准是下游任务类型。摘要、翻译这类对局部语义依赖强的任务窗口可以小一点128或256就够。长距离推理、文档问答这类任务窗口尽量不低于256。我标准配置是窗口256采样率50%步长128。采样率指窗口内实际保留的key占比。50%是一个甜点值既能把计算量砍半又能保留足够的信息。降到25%时计算量更小但长文本分类准确率明显下滑。升到75%效果提升不明显计算量却增加了50%。如果你资源紧张建议优先调低采样率而不是窗口大小因为采样率降低对效果的影响相对温和。重叠率就是我前面说的步长与窗口大小的比例。重叠率50%是安全选择低于30%时建议在下游任务上加个验证集专门观察。我在一个10000 token的长文本上试过重叠率0训练速度确实上去了但最终模型对跨段落的信息整合能力明显不足生成的文本前后矛盾。所以除非你的任务本身就不需要长期记忆否则别轻易追求零重叠。还有一个不可忽视的参数窗口数量上限。有些超长文本动辄十万token即使按步长128滑动窗口数量也会达到数百个。窗口过多时信息经过层层传递早期内容会被稀释。我的做法是限制最多不超过64个窗口超出部分做粗粒度全局池化。这个策略牺牲了一点点细粒度信息但保证了模型不会因为过长链条而崩溃。4. 实测踩坑与效果分析4.1 长文本任务中滑动窗口采样与全局编码的对比实验为了验证这套机制到底值不值得用我做了一组对比实验。数据集选的是开源的中文长文档分类数据集文档平均长度在3000 token左右。对比对象有两个一个是用标准全局注意力编码另一个是用我的滑动窗口数字采样编码。先看训练效率。全局注意力在单张A100显卡上batch size只能设到2还频繁耗尽显存。滑动窗口版本batch size直接拉到8训练速度提升了3倍以上。这个提升主要来源于注意力的稀疏化显存占用量从O(N²)降到了O(N×W)。再看效果。分类准确率上全局注意力达到83.2%滑动窗口采样达到了82.6%差距不到1个百分点。这个结果让我很满意。花更少的计算量拿到几乎一样的准确率这在模型规模扩大后收益会越来越明显。如果硬要追求完全无损可以稍微降低采样率加大窗口重叠但那样的话效率优势就缩小了。还有一个有意思的发现滑动窗口编码在局部语义敏感的任务上比如细粒度情感分析反而比全局注意力略好一点点。我推测是局部窗口更容易聚焦到情感词周围的上下文而全局注意力容易把焦点分散到不相关的长距离内容上。4.2 常见问题信息丢失、窗口错位、大数溢出实现过程中我踩过不少坑这里挑三个典型的讲。第一个坑是信息丢失。最初我把采样率设到10%去做极端压缩结果模型表现断崖式下跌。后来分析发现问题不在于采样率本身而是采样位置选择太随机经常把连续几个关键动词都丢掉。解决方案是给采样模块加一个“保底机制”——如果一个窗口内有强语义token比如通过TF-IDF或者词性标记识别的关键内容这些token必须保留不参与采样竞标。这样即使采样率很低关键信息也不会被意外牺牲。第二个坑是窗口错位。我曾在代码里直接用整除取窗口起始位置没有考虑窗口末端越界的问题。有个测试样例序列长度恰好是窗口大小加1结果最后一个窗口的结束索引超出了序列长度导致矩阵索引越界崩溃。这个NPE错误排查了很久。后来我统一用clamp强制限制索引范围并且在compute_window_indices里显式检查越界情况问题才彻底解决。建议你在自己实现时边界条件一定写清楚最好用单元测试覆盖几个典型长度。第三个坑是大数溢出。当序列长度超过20000时注意力权重矩阵里的数值经过多轮缩放后浮点精度开始出现问题。softmax前的logits可能出现极端大数导致梯度爆炸。我的处理是用压缩注意力分数的技巧在scale之前先对logits做一次最大最小值拉普拉斯平滑本质上类似数值稳定的softmax实现。这些细节平时不会触发一旦触发就是灾难所以长文本场景下数值稳定性必须提前做。4.3 实战建议和技巧总结结合几次完整的手搓经验我总结了一套实战建议供你直接参考。第一模块化设计。把滑动窗口、采样、掩码生成、稀疏注意力封装成独立组件这样你可以自由替换窗口策略和采样策略不需要动Transformer主干代码。我最初把所有逻辑写在一个大文件里改一个参数要顺着依赖链条追好几个函数后来拆分之后维护成本直线下降。第二重视可视化。我强烈建议把生成后的稀疏注意力模式可视化输出成类似热力图的图片。不用多复杂的工具Matplotlib画一个二维矩阵热力图就行。你能非常直观地看到窗口是否均匀覆盖整个序列、重叠区域是否合理、采样点是否过于集中。我盯着热力图调了几轮参数比盲调准确率高效得多。第三度量工具要提前设计。在做滑动窗口采样时有一个通用指标叫“信息覆盖率”计算方式是采样索引对应的token中有多少比例属于TF-IDF排名前20%的高信息词。我每次调参都会记录这个指标用它来指导选择采样策略和采样率。信息覆盖率越高模型下游效果通常越好。这个指标实现起来简单但价值很大。第四训练和推理的参数设置可以不一样。我建议训练时使用略高一点的采样率和重叠率让模型多“看到”一些上下文学习更充分。推理时再调低采样率、减少重叠换取更快的响应速度。由于推理阶段的注意力模式不需要反向传播可以静态预计算一次缓存起来性能还能进一步上升。这个技巧在实际产品落地时非常有用。5. 写在最后的实战心得从零手搓大模型这个系列走到文本编码这一节滑动窗口和数字采样是我认为性价比最高的一组优化组合。它不像那些黑科技一样需要海量数据预训练也不需要对模型结构伤筋动骨仅仅靠稀疏化注意力就能让长文本处理能力发生质变。我做完这套实现之后最大的感触是很多复杂问题并不需要玄学解法回到计算本身把复杂度降下来把关键信息保住就成功了大半。如果你正在跟着手搓大模型或者准备改造现有编码器去适配长文本建议你按我文章中说的顺序来先在短序列上把滑动窗口跑通再逐步加长序列花样调一调采样策略。这套方案的稳定性相当好至少在我手里没有出现过不明原因的崩溃。后续我还会继续更新这个系列到时候聊聊如何把这个稀疏编码结构扩展到encoder-decoder架构以及如何在推理阶段进一步做缓存加速。也欢迎你在评论区或者社群里分享自己踩过的坑我们一起把这条路走得更顺畅。