上下文模式实战:滑动窗口、摘要压缩与长对话记忆管理

发布时间:2026/10/6 10:36:36
上下文模式实战:滑动窗口、摘要压缩与长对话记忆管理
“context-mode”这几个字符第一次出现在我项目文档里的时候我就知道这又是个“听起来简单、做起来头疼”的活。不管是做聊天机器人、AI 编程助手、还是智能 Agent只要让模型和人连续对话超过十轮你迟早会遇到同一个问题模型好像把前面说过的话给忘了。你说的要求它不认它自己刚给的承诺转头就反悔整个对话体验瞬间从“智能”退化到“假装智能”。这就是 context-mode也就是上下文模式要解决的核心问题——并不是把历史消息一股脑全塞给模型就完事而是要想清楚怎么组织、压缩、取舍这些上下文让模型在有限的记忆里始终抓住真正重要的信息。这篇文章我不会讲虚的直接把我自己踩过的坑、对比过的方案、整理出的可复现代码全部摊开来说清楚适合正在做 LLM 应用、或者准备给自己的对话系统加上长期记忆能力的开发者参考。1. 上下文模式的本质与设计思路1.1 先搞清楚我们要解决的是什么问题大语言模型的输入长度是有限的不管是最早的 4096 token还是现在动辄 200k token 的超大窗口总有一个天花板。同时模型的注意力在超长文本里会被摊薄给再大的窗口如果塞进去一万条无关紧要的旧消息那次关键任务该算错的还是算错。所以我们说的 context-mode本质上是一套上下文管理策略它要回答三个问题哪些内容应该进入模型的输入这些内容以什么形式进入当内容超出限制时应该如何裁剪或改写把这三个问题想清楚了你设计的对话系统才能做到“记得住该记住的忘掉该忘掉的”。我见过太多项目一开始只有简单的“把历史消息全部拼进 prompt”刚开始聊几轮效果不错等对话一长要么 token 超限直接报错要么模型被大量冗余信息干扰回答质量肉眼可见地下滑。几乎所有长对话产品的技术债都是从这个阶段欠下来的。1.2 三种典型上下文组织模式既然目标是管理上下文那实际落地的方案一般逃不开下面三种模式我把它们叫做滑动窗口式、摘要压缩式、混合记忆式。滑动窗口式是最直接的做法固定保留最近 N 条消息超出部分直接丢弃。它就像短信聊天记录只显示最近 20 条往下翻就看不到了。这种模式实现简单、计算开销小但缺点是模型会“失忆”早期对话里的关键约束很容易丢掉。摘要压缩式是在对话进行到一定程度时让模型把前面的内容归纳成一段摘要然后只用摘要加最近几条消息来继续对话。这相当于你在开会时让秘书在旁边记纪要时间长了你只需要看纪要就能回忆起前面的决策。混合记忆式则是前两种的融合长期信息用摘要维护短期细节用原始消息维护中间可能还要挂一层检索机制把历史消息向量化后按需召回。这是工业级产品里最常用的方案但也是搭建成本最高的一种。这三种模式没有绝对的好坏关键要看你的业务场景。客服机器人对话轮次短滑动窗口就够用知识型助手对话跨度长摘要压缩是底线真正的数字人或 Agent 级别的产品那就绕不开混合记忆式了。1.3 为什么 context-mode 不是简单的“把历史消息都塞进去”这里必须单独强调一个误区。很多人以为既然模型的上下文窗口足够大那就把历史消息全塞进去好了。我在项目初期也是这么干的然后被现实狠狠教育了一轮。第一成本问题。模型按 token 计费每次请求都把几万 token 的历史记录发上去单价再低也扛不住高频调用。第二性能问题。输入越长模型生成首个 token 的延迟越高用户会明显感觉到新版对话界面“比以前卡”。第三注意力稀释问题。这个才是最要命的——当输入里塞满了日常寒暄和无关讨论时关键指令会被淹没模型会越来越容易忽略你在第 3 轮提到的硬性要求。有个实验我印象很深同样一个任务使用完整历史的模型回答准确率不到 60%改成滑动窗口加摘要后准确率提到了 90% 以上。原因很简单模型终于“看清”了那些真正重要的信息。所以说 context-mode 的设计不是为了省 token而是为了帮模型做注意力管理。2. 核心参数token 预算、窗口大小与截断策略2.1 快速理解 token 与上下文的对应关系在真正动手设计 context-mode 之前你必须先建立起对 token 大小的体感。很多新手对 token 完全没有概念我简单给一个估算基准在中文场景下1 个汉字大约对应 1 到 2 个 token1 条 20 字的短消息大概占 40 token一段 500 字的产品需求文档大概占 800 到 1000 token。为什么要强调这个体感因为你在配置上下文窗口大小时如果心里没数很容易把预算设置得特别不靠谱。比如你打算留 2000 token 给上下文结果光系统提示词就占了 1500留给对话历史的空间就少得可怜模型只能看到最近两三轮的对话这显然不够用。所以我一般会先做一次“摸底测试”把自己业务里最长的系统提示词、最常见的单轮对话长度、最复杂的一次工具调用结果都分别跑一遍 token 统计拿到这几个数字后再来定上下文预算才靠谱。2.2 上下文窗口预算的计算方法假设你用的模型上下文窗口是 32k token那实际请求的预算要按下面的公式拆上下文预算 窗口上限 - 最大输出长度 - 安全缓冲举个例子窗口 32k你想让模型最多输出 4k token再留 2k token 作为安全缓冲防止批量处理时超出那真正能拿来放系统提示词和的历史上下文的预算就只有 32k - 4k - 2k 26k。如果系统提示词占了 2k那历史上下文的可用空间是 24k。有了这个 24k 的硬约束你再去设计滑动窗口的长度就简单了。拿上面的估算基准来说假设你的业务单轮平均 200 token那 24k 大约能放 120 轮对话。听起来很多对吧但如果你有工具调用、有大段文件内容插入撑不了几十轮就满了所以这时候就得靠摘要和检索来顶上。我的习惯是在代码里直接做一个 budget manager每次拼接 prompt 前动态统计当前占用而不是用写死的轮数。流量上来后你会发现靠“多少轮”控制根本不靠谱必须靠“多少 token”控制。2.3 截断策略的选择从头砍还是从中间砍当上下文超限时大多数人第一反应是把最早的对话丢掉。这思路没错但有个隐藏问题如果那几条最早的对话里包含了你对模型设定的关键背景或硬性要求直接砍掉会导致模型后面突然“变傻”。我更推荐的做法是分优先级砍优先砍冗长的工具返回结果把大段 JSON 精简成一句话结论。其次砍寒暄类消息“好的”“明白了”这种直接合并或丢弃。再次砍历史中间部分尽量保留最近对话的最新状态和最开始设定的关键约束。从中间砍这个操作听着奇怪但实际是有道理的。大部分对话的信息密度呈现两边高中间低——开头是系统设定和背景结尾是当前诉求中间则是过程性的来回沟通。所以我会把历史消息按区域分桶中间区域优先做摘要压缩而不是直接丢弃这样关键信息还能保留一个“压缩形态”不至于完全消失。3. 实操在项目里落地一个自带 context-mode 的对话模块3.1 整体架构与数据结构设计接下来进入真正能抄作业的部分。我这里用一个 Python 实现的核心类来演示一个支持滑动窗口与摘要压缩的对话上下文管理器。设计的核心数据结构很简单消息按下面的格式存储from dataclasses import dataclass, field from typing import List, Optional import time dataclass class Message: role: str # system / user / assistant / tool content: str timestamp: float field(default_factorytime.time) msg_type: str normal # normal / summary / tool_result这里单独提一下 msg_type 字段。我在早期版本里没做这个区分导致后续想对工具返回结果做特殊处理时只能靠字符串匹配非常痛苦。给消息打上类型标记之后无论是压缩、截断还是检索都能按类型快速过滤强烈建议一开始就加上。整体结构的逻辑是一个 ContextManager 内部维护两个列表一个是长期记忆列表 long_memory存摘要和关键背景一个是短期消息列表 short_memory存最近的具体对话。每次构造请求时把长期记忆放在前面短期记忆放在后面中间用一条提示语分隔让模型理解这两块内容的区别。3.2 实现滑动窗口上下文管理先看滑动窗口的实现。这里不能简单按条数截断因为每条消息的长度差异可以很大。所以我的做法是从最新的消息往前遍历累加 token 数直到达到预算上限剩下的更早内容就放进待压缩区。class ContextManager: def __init__(self, max_context_tokens: int 24000, summary_tokens_limit: int 4000): self.max_context_tokens max_context_tokens self.summary_tokens_limit summary_tokens_limit self.long_memory: List[Message] [] # 摘要 关键背景 self.short_memory: List[Message] [] # 最近原始消息 def estimate_tokens(self, text: str) - int: # 生产环境建议换成 tiktoken 等真实 tokenizer return len(text) // 2 1 def build_sliding_window(self) - List[Message]: budget self.max_context_tokens selected: List[Message] [] total 0 # 从最新到最老遍历 for msg in reversed(self.short_memory): cost self.estimate_tokens(msg.content) if total cost budget: # 如果单条消息已经超过剩余预算且不是用户最新消息则丢弃 continue selected.insert(0, msg) total cost return self.long_memory selected注意一个细节这里遍历是从最新到最老但是插入时用 insert(0, msg)目的是保持最后返回的消息列表仍然按时间从早到晚排序。这个顺序很重要模型对顺序敏感如果你把新旧顺序颠倒会影响它对对话脉络的理解。实际落地时我还会给用户最近一条消息开一个“白名单”保证不管预算多紧张当前用户问题一定会完整进入上下文否则整个对话就没有意义了。3.3 实现摘要压缩上下文管理滑动窗口能解决短期问题但解决不了“开了六个小时的长会话”这种极端场景。当最早的对话被判定为“值得留但没必要全留”时就该让摘要压缩模式上场了。我的做法是在滑动窗口构建完后检查被丢弃的消息是否达到某个阈值。如果被丢弃的内容太多就把它们交给模型生成一段结构化摘要然后存入 long_memory。def compress_memory(self, dropped_messages: List[Message]) - None: if not dropped_messages: return raw_text \n.join( f{m.role}: {m.content} for m in dropped_messages ) summary_prompt ( 请将以下对话内容压缩为一段简洁的中文摘要 保留所有关键的用户要求、业务约束和已确认决策 不要遗漏数字和名词\n\n raw_text ) summary call_llm(summary_prompt) # 实际项目中接入你的模型调用 self.long_memory.append( Message(rolesystem, contentf[历史摘要] {summary}, msg_typesummary) )这里有几个细节经验。摘要并非越长越好我一般把摘要限制在 300 到 500 token 以内超过这个长度摘要本身就变成了新的噪音。我还会在摘要前面加上固定的标记“历史摘要”这样模型看到就知道这部分内容是经过压缩的二手信息不要把它当成当前正在进行的对话。另外一个非常容易踩的坑是压缩后的摘要放回上下文时顺序要排在所有原始消息之前而不是追加到末尾。一旦放错位置模型会以为摘要内容是刚刚发生的对话逻辑就全乱了回答问题时会出现明显的时空错乱感。3.4 两种模式的切换逻辑你不要把滑动窗口和摘要压缩看成两个孤立的模式实际项目中它们往往是组合使用的。前面那些被窗口挤掉的内容正好是摘要的输入而摘要生成后存储的位置又会在下一次对话时占据 long_memory 的位置。我推荐的切换逻辑是分档触发当上下文占用达到预算的 60% 时开始对被挤掉的消息做轻量截断只丢寒暄类消息。当占用达到 80% 时触发摘要压缩对中间段消息做分析归纳。当占用超过 95% 时强制丢弃最早的非关键消息防止请求超限报错。用一个状态机来管理这几个档位比每次请求时临场计算要稳得多。而且状态机的好处是你可以把压缩操作做成异步的在低峰期预生成摘要等高峰期真正用到时直接读取用户完全无感知。我在生产环境里就是把摘要生成放到后台任务队列里去执行的每次对话结束把增量消息推给摘要服务摘要结果写回数据库。这样主链路只负责读取延迟特别稳定你可以根据自己的并发量决定要不要采用这个方案。4. 常见问题与排查技巧实录4.1 上下文越长回复反而越差这是最反直觉、也是最多人问我的一个问题。明明我给模型的上下文更完整了为什么回答质量反而下降我的排查经验是先看上下文里是否有冲突信息。比如工具返回的旧数据和最新数据同时出现在上下文里模型不知道信哪个就容易给出模棱两可的回答。解决办法是当有更新信息出现时把旧信息从短期记忆里标记为过期或直接删除而不是指望模型自己判断。另一个原因就是注意力稀释。上下文里的每一条内容都会占用模型的注意力资源无关内容越多关键信息被注意到的概率越低。这时宁可把上下文砍短一点也要保证每条留下来的信息都是高价值的质量永远比数量重要。4.2 长对话中的角色漂移长对话进行到一半模型忽然变得不像一开始设定的角色了说话风格和记忆全部走样我称之为“角色漂移”。这通常是因为最初的系统提示词在多次滑动窗口截断中被挤掉了。如果你发现最短的历史记录里有系统提示词就一定要把它放进 long_memory并且在每轮构建上下文的最后检查系统提示词是否存在。最简单的方法是给系统提示词单独建一个永不丢弃的槽位不参与滑动窗口的淘汰。这个槽位的 token 成本并不高但能换来模型行为的一致性这笔账怎么算都划算。4.3 token 超限报错标题里提到的上下文管理最直接的目标就是避免 token 超限。坦白说开发阶段我见过的最常见错误就是 400 错误里的 “maximum context length exceeded”。排查这类问题有个非常实用的技巧不要看平均 token 数要看峰值。很多时候你平均只用了上下文的一半但某一次工具调用返回了超长文本直接把请求推过了线。解决方法是在插入任何内容之前先对内容做长度预估如果单条内容超过预算的 10%就要先做截断或摘要。这里有个成本收益的权衡与其让模型消化一整份冗长的工具返回结果不如把关键结论提取出来再送入模型。另外提醒一点不同的 tokenizer 在估算中文时的误差很大生产环境务必使用模型厂商提供的正式 token 统计接口而不是用字符数简单换算。4.4 记忆污染与脏数据最后一个常见问题是上下文里的历史数据本身包含错误或过时内容导致模型被污染。比如用户上一轮说错了自己的部门名称模型把这句错话记下来了之后所有回答都在错误的前提下进行。我的处理策略是每轮对话结束后对用户消息做一个轻量的“关键事实校验”把涉及时间、数量、名称等硬信息提取出来一旦后续消息与旧信息冲突就以新消息为准并把旧消息做替换而不是追加。这个策略本质上就是给记忆系统加了一层非常简单的冲突消解不需要复杂的模型推理纯靠规则就能拿下大部分问题。最后再分享一个小的实战经验。我在最初做 context-mode 时总想着把方案设计得越复杂越好既要做摘要又要做检索还要做记忆打分结果系统复杂度上去了效果反而没有提升。后来我换了个思路先只做滑动窗口跑通主流程然后遇到具体问题再逐步加摘要、加检索。每一次只加一种能力对比前后效果这样每步改动到底带来了多少收益心里清清楚楚。如果你也正在做类似的对话系统我建议你也走这条路——把上下文管理当做一个渐进迭代的模块而不是一次到位的完美设计。