大语言模型的上下文窗口并不是无限大的。当模型接收的 token 数量超过它在训练和推理中能处理的最大长度时,就会发生上下文窗口溢出。输入可能直接触发 API 报错,也可能被底层框架静默截断,导致模型只看到一部分信息。面对这类问题,滑动窗口和裁剪是两种最直接、最容易落地的工程策略。前者通过限制注意力范围来降低长序列成本,后者通过选择保留哪些 token 来最大程度保住关键信息。

一、上下文窗口溢出是怎么发生的
上下文窗口的长度通常由两个因素决定。一个是模型位置编码的最大范围,另一个是推理时键值缓存所能容纳的 token 数量。无论是基于 RoPE 的现代模型,还是使用 ALiBi 的模型,它们在训练阶段都只会见到固定长度的序列。推理时如果输入长度显著超过训练长度,模型可能仍能计算,但注意力分数会变得不稳定,生成质量明显下降。
从资源角度看,标准注意力机制的复杂度是 O(n²)。当 n 从 2048 增长到 32768 时,显存和时间成本会急剧膨胀。此外,多轮对话会不断累积历史消息,即使单个问题很短,几十轮之后总 token 数也可能轻松超过窗口。因此上下文溢出不只出现在长文档场景,也频繁出现在长时间运行的智能体和客服系统中。
解决上下文溢出一般有三条路线。第一条是模型架构层,比如引入线性注意力、稀疏注意力或状态空间模型,从根源上降低长序列成本。第二条是检索增强,用向量检索把最相关的片段拿进来,而不是把全文都塞进窗口。第三条是工程裁剪与滑动窗口,不改模型、不改数据管道,只对输入 token 做更精细的组织和删减。本文重点讨论第三条路线。
二、滑动窗口:用固定范围限制注意力
滑动窗口最初是一种注意力机制,它允许当前 token 只关注距离自己最近的 w 个 token,而不是关注整个序列。Longformer、Mistral 等模型都采用过这种设计。对于推理来说,这意味着每一层注意力的计算量不再随总长度 n 线性增长,而是被限制在固定窗口 w 内。显存占用和计算复杂度因此得到控制。
如果不想修改模型,也可以在输入层面模拟滑动窗口:只保留最近 k 条消息或最近 N 个 token,超过窗口的早期内容直接丢弃。这个策略在对话机器人中很常见。比如一个客服机器人只保留最近 8 轮对话,用户问到的旧问题如果不在窗口内,模型就无法回答。它的优点是实现简单、资源占用稳定;缺点是早期信息完全丢失,系统提示、用户偏好或任务约束也可能被一并丢弃。
下面是一个基于 token 数量的滑动窗口实现。它会把前缀部分和最近一段内容拼接起来,避免系统提示被完全挤掉。
def sliding_window_tokens(token_ids, window_size, reserved_prefix=0):
if len(token_ids) <= window_size:
return token_ids
if reserved_prefix >= window_size:
return token_ids[:window_size]
prefix = token_ids[:reserved_prefix]
recent = token_ids[-(window_size - reserved_prefix):]
return prefix + recent
这个函数只处理 token 列表,不关心消息结构。调用时可以把系统提示放在最前面,并把 reserved_prefix 设置为系统提示的 token 数。这样即使早期对话被丢弃,系统提示仍然保留。滑动窗口非常适合流式任务,比如实时翻译、连续语音识别后的文本纠错或短期对话,但它并不适合需要长期敏感信息记忆的场景。
三、裁剪策略:决定哪些内容可以丢弃
裁剪比直接的滑动窗口更灵活。它的核心问题不是“要不要丢”,而是“丢哪部分”。最简单的头部裁剪会删除序列前面的 token,保留尾部内容。这在输入末尾是重要信息时比较有效,比如用户问题在最后,或需要让模型继续生成句子时。尾部裁剪则相反,它保留头部,删除尾部,适合文章开头包含关键背景的情况。
居中裁剪是更均衡的方案。它保留开头和结尾,删除中间一段。研究表明,很多模型对开头和结尾的信息更敏感,中间位置的 token 被遗忘得更快。因此当输入是长文档或大量对话历史时,居中裁剪通常比单纯保留头部或尾部效果更稳定。结构化消息场景下,还可以按照角色赋予优先级,先删除距离当前问题最久的历史,再删除重复表达或低信息量内容。
下面是一个按角色优先级裁剪消息的简化示例。它先固定系统消息和最后一条用户消息,再在预算范围内尽量保留最近的历史。
def truncate_messages(messages, max_tokens, tokenizer):
system_msgs = [m for m in messages if m["role"] == "system"]
history = [m for m in messages if m["role"] != "system"]
if not history:
return system_msgs
def count(msgs):
return sum(len(tokenizer.encode(m["content"])) for m in msgs)
budget = max_tokens - count(system_msgs)
last_msg = history[-1]
budget -= len(tokenizer.encode(last_msg["content"]))
kept = []
for msg in reversed(history[:-1]):
enc = tokenizer.encode(msg["content"])
if len(enc) > budget:
continue
kept.insert(0, msg)
budget -= len(enc)
return system_msgs + kept + [last_msg]
需要注意的是,这段代码在单条消息本身超过预算时不会自动截断内容,只是跳过该消息。真实系统中还需要对超长单条消息做分段或摘要处理。另一个实践要点是,应当尽量在 token 化之前完成结构裁剪,因为不同模型的 tokenizer 对中英文和特殊符号的切分并不一致。若直接用字符长度估算,可能会在边界处出现几十到上百 token 的误差。
四、在真实对话系统中组合两种策略
滑动窗口和裁剪并不冲突,反而可以组合成一个更可靠的上下文管理流程。推荐的做法是:先设置一个安全阈值,当累计 token 接近窗口上限时触发裁剪;再根据消息角色、时间顺序和内容重要性计算保留优先级;最后如果需要进一步压缩,可以对被丢弃的历史生成摘要,把摘要当作系统提示的一部分重新放回窗口。
一个完整的流程可以描述为三步。第一步统计当前输入 token 数,如果低于阈值的 90% 就不做处理。第二步执行结构化裁剪,保证系统提示、任务说明、最新用户问题等关键内容优先保留,中间历史按时间倒序截断。第三步对旧历史生成一句话或多句话摘要,把摘要插入到保留列表中。这样模型既能获得相对新鲜的上下文,又不会完全丢失早期信息。
滑动窗口还可以作为模型本身的注意力策略,与输入裁剪同时使用。即使输入层已经经过裁剪,模型内部仍可能因为某些超长段落而出现显存压力。使用支持滑动窗口注意力的模型或推理框架,可以进一步降低键值缓存占用。两条路径分别对应输入侧和模型侧,配合使用通常比单独处理更稳定。
在实际开发中,不要把所有内容硬编码成固定长度。设置 max_tokens 时需要为生成输出的 token 预留空间。例如窗口为 8192,生成需要 1024,那么输入部分最多只能使用 7168。忽略生成预留空间会导致输入填满窗口,模型没有能力再输出新内容。处理长文本时,最好把消息对象和 token 计数分开维护,并在每次追加消息时动态判断是否触发裁剪。