1. 上下文管理到底在管什么
第一次听到“上下文管理”这个词,很多人会下意识觉得它是个很虚的概念,像是某种管理学的黑话。但如果你真正动手做过对话系统、智能助手,或者哪怕只是用过大模型接口写过一个小工具,你就会发现,上下文管理是决定一个应用“好不好用”的分水岭。它管的不是别的,就是在有限的资源里,让系统记住该记住的,忘掉该忘掉的,并且在需要的时候把正确的信息送到正确的位置。
我打个生活化的比方。你去一家常去的面馆,老板记得你“不要香菜、面硬一点”,这是长期记忆;你这次进门说“今天想喝点热的”,这是短期上下文;老板把这两条信息合在一起,给你端上一碗不要香菜、面硬一点的热汤面,这就是一次成功的上下文管理。如果老板把你三年前说过的一次“多加辣”也翻出来,那这碗面就毁了。上下文管理的核心矛盾就在这里:信息太少,系统显得健忘;信息太多,系统又会跑偏,还烧钱。
这个内容适合谁来参考?我认为有三类人。第一类是正在做对话类产品的开发者,你大概率已经被“聊到第十轮模型就忘了前面说过什么”这个问题折磨过。第二类是做智能客服、知识问答、文档助手的工程同学,你们面对的是长文档和长会话的双重压力。第三类是对大模型应用感兴趣、想自己搭个小工具的技术爱好者,理解上下文管理能让你少走很多弯路。不管你是哪一类,接下来的内容都会围绕一个目标展开:把“上下文管理”从一个模糊的概念,变成一套你能直接上手操作的方案。
需要先说明一点,下面涉及的具体参数、阈值、代码示例,都是基于行业里常见的实践做法进行的合理补充,不同模型和框架的细节会有差异,但底层思路是相通的。你完全可以根据自己的场景做调整。
2. 整体设计思路与方案选型
2.1 为什么不能“全都塞进去”
最朴素的做法是把所有历史对话原封不动地拼接到每次请求里。这个方案在对话只有三五轮的时候完全没问题,甚至可以说是最优解,因为它没有任何信息损失。但问题会随着轮次增长迅速暴露出来。
首先是成本。大模型的计费基本都跟输入输出的token数量挂钩,你每轮都把全部历史带上,输入token会像滚雪球一样越滚越大。假设每轮对话平均200个token,聊到第50轮,单次请求的输入就接近1万个token,而其中绝大部分信息对当前这轮回答根本没有帮助。
其次是效果。这一点很多人会忽略。模型的注意力是有限的,当上下文里塞了大量无关的旧信息,模型反而更容易抓错重点。我实测过,同样一个问题,在干净上下文里回答得很准,在塞了二十轮无关闲聊之后,回答质量明显下降。这不是模型变笨了,是噪声把它淹没了。
最后是窗口限制。每个模型都有上下文窗口上限,超过就直接报错或者被截断。截断这件事最坑的地方在于,如果你截得不好,可能把最关键的系统指令或者用户的核心诉求给截掉了。
所以整体设计的第一条原则就是:上下文不是越多越好,而是越相关越好。我们要做的所有事情,本质上都是在“保留信息”和“控制规模”之间找平衡。
2.2 分层管理:把上下文拆成几块来对待
我在实际项目里最常用的思路是分层。不要把所有消息看成一锅粥,而是按性质和生命周期分成几层,每层用不同的策略处理。
| 层级 | 内容类型 | 生命周期 | 处理策略 |
|---|---|---|---|
| 系统层 | 角色设定、规则约束、输出格式要求 | 整个会话不变 | 永远保留,放在最前 |
| 长期记忆层 | 用户偏好、关键事实、历史结论 | 跨会话或长期 | 摘要化存储,按需召回 |
| 短期对话层 | 最近几轮问答 | 当前会话 | 滑动窗口保留 |
| 即时层 | 当前这轮的输入和检索结果 | 单次请求 | 动态拼装 |
这么分的好处是,每一层的处理逻辑是独立的,你可以单独优化某一层而不影响其他层。比如系统层几乎不用动,短期对话层用滑动窗口,长期记忆层用摘要加向量召回。这种模块化的思路让整个系统更容易调试,出问题的时候你能快速定位是哪一层出了岔子。
2.3 三种主流策略的取舍
具体到“怎么压缩上下文”,行业里常见的有三种策略,我分别说说它们的适用场景和坑。
滑动窗口是最简单的,只保留最近N轮对话。它的优点是实现成本极低,几行代码就能搞定。缺点是硬截断,如果用户在第3轮说了一个关键信息,聊到第20轮时这个信息早就被挤出去了。它适合那种对话主题频繁切换、旧信息价值不高的场景,比如闲聊机器人。
摘要压缩是把旧对话用模型总结成一段简短的文字,替代原始对话。优点是信息密度高,能保留很久以前的关键点。缺点是有信息损失,而且摘要本身也要消耗一次模型调用,有额外成本和延迟。它适合长会话、需要记住用户长期诉求的场景,比如个人助理。
向量召回是把历史消息存进向量库,每次根据当前问题检索最相关的几条拼进上下文。优点是精准,能跨越很长时间找到相关信息。缺点是需要额外的存储和检索组件,工程复杂度最高。它适合知识密集、需要频繁引用历史内容的场景,比如文档问答。
实际项目里,这三种往往不是三选一,而是组合使用。我通常的做法是:短期用滑动窗口保证连贯性,长期用摘要保证不丢关键信息,再叠加一层向量召回处理“用户突然问起很久以前的事”这种情况。
3. 核心细节解析与实操要点
3.1 Token预算怎么算才不翻车
上下文管理的本质是一个预算分配问题。你得先知道总共有多少预算,再决定每一层分多少。这里的关键是给输出留足空间。
假设你用的模型上下文窗口是8000个token,你要预留至少1000到1500个token给模型的输出,因为回答本身也要占位置。那么真正能用于输入的预算大概是6500到7000个token。在这个输入预算里,我一般的分配比例是这样的:系统层占10%到15%,长期记忆层占20%左右,短期对话层占50%到60%,剩下的留给即时检索结果和当前输入。
这个比例不是死的,要根据场景调。比如客服场景,系统层的规则约束很重要,可以多给一点;闲聊场景,短期对话层可以占更多。关键是你要有一个明确的预算表,而不是每次拼上下文的时候凭感觉。
提示:不同模型对token的切分方式不一样,中文里一个汉字通常对应1到2个token,英文一个单词大概1到1.3个token。做预算的时候最好用对应模型的分词工具实际算一下,别拍脑袋估算,误差会很大。
3.2 摘要压缩的触发时机和写法
摘要压缩最忌讳的是“每轮都摘要”,那样成本和延迟都受不了。我的经验是设置一个触发阈值,比如当短期对话层的token数超过预算的80%时,才触发一次摘要。
摘要的写法也有讲究。不要简单地让模型“总结一下”,而要给它明确的指令,告诉它保留什么、丢弃什么。我常用的提示词结构是这样的:请把以下对话压缩成不超过200字的摘要,重点保留用户明确表达的偏好、已确认的事实、待办事项和未解决的问题,丢弃寒暄、重复确认和无关闲聊。
这样得到的摘要才有用。我踩过的一个坑是早期让模型自由发挥,结果它把“用户说今天天气不错”这种废话也总结进去了,真正重要的“用户要求周五前交付”反而丢了。后来加了明确的保留项和丢弃项,摘要质量稳定了很多。
3.3 滑动窗口的边界处理
滑动窗口看起来简单,但边界处理不好会出问题。最常见的问题是把一问一答拆散了。比如窗口大小是10条消息,正好在第5条是用户提问、第6条是助手回答的位置切开,结果上下文里只剩一个孤零零的问题,模型就会莫名其妙地重新回答一遍。
解决办法是按“轮次”而不是按“消息条数”来滑动。一轮指的是用户提问加助手回答这一组。保留最近5轮,就完整保留这5组问答,不会出现半截对话。另外,系统层的消息永远不参与滑动,它始终在最前面。
还有一个细节是,如果某一轮的回答特别长,比如助手输出了一篇长文,这一轮就可能占掉大量预算。这时候可以对超长的单条消息做单独截断,保留开头和结尾,中间用省略标记,因为开头通常是结论,结尾通常是补充说明,中间往往是展开论述,优先级相对低。
3.4 向量召回的检索质量把控
向量召回的效果高度依赖检索质量。我见过很多项目,向量库建了,但召回出来的东西驴唇不对马嘴,反而污染了上下文。问题通常出在两个地方。
一是切块粒度。如果把一整轮长对话当成一个块存进去,检索出来的块里可能只有一句话是相关的,其余都是噪声。我的做法是把长消息按语义切成200到300字的小块,每块单独存,检索时按块召回,这样精度高很多。
二是相似度阈值。不要无条件地把检索到的前K条都塞进去,要设一个相似度下限。低于阈值的宁可不要,因为不相关的召回比没有召回更糟糕。这个阈值需要根据你的向量模型实测调,一般从0.7左右开始试。
注意:向量召回和摘要压缩不要同时作用于同一批消息,否则会出现信息重复。我的做法是,已经被摘要覆盖的旧消息就不再进向量库,两者职责分开。
4. 实操过程与核心环节实现
4.1 搭一个最小可用的上下文管理器
下面我用Python写一个简化版的上下文管理器,把前面说的分层、预算、滑动窗口、摘要触发都串起来。这段代码是示意性的,你可以直接拿去改。
class ContextManager: def __init__(self, max_input_tokens=6500, window_rounds=5): self.system_prompt = "" self.long_term_memory = "" # 摘要文本 self.short_term = [] # 最近几轮对话 self.max_input_tokens = max_input_tokens self.window_rounds = window_rounds def set_system(self, prompt): self.system_prompt = prompt def add_round(self, user_msg, assistant_msg): self.short_term.append({"user": user_msg, "assistant": assistant_msg}) # 超出窗口就触发摘要 if len(self.short_term) > self.window_rounds: self._compress() def _compress(self): # 取出最旧的一轮,合并进长期记忆 oldest = self.short_term.pop(0) new_content = f"用户: {oldest['user']}\n助手: {oldest['assistant']}" # 实际项目中这里调用模型做摘要,这里用拼接示意 self.long_term_memory = (self.long_term_memory + "\n" + new_content).strip() def build(self, current_input): parts = [self.system_prompt] if self.long_term_memory: parts.append("历史摘要:\n" + self.long_term_memory) for r in self.short_term: parts.append(f"用户: {r['user']}\n助手: {r['assistant']}") parts.append("用户: " + current_input) return "\n\n".join(parts)这段代码里,_compress方法是最简陋的版本,直接把旧对话拼进长期记忆。真实项目里你应该在这里调用模型做摘要,并且控制长期记忆的总长度,超过一定字数就再压缩一次,形成“摘要的摘要”。
4.2 预算校验和动态裁剪
光有结构还不够,每次拼装完上下文,一定要做一次预算校验。我通常会在build方法最后加一段逻辑:估算拼装结果的token数,如果超过预算,就按优先级从低到高裁剪。
裁剪的优先级顺序是:先砍即时检索结果里相似度最低的,再砍长期记忆里最旧的内容,最后才动短期对话。系统层永远不砍。这个顺序背后的逻辑是,越靠近当前问题的信息越重要,越久远、越边缘的信息越可以先牺牲。
估算token数的时候,如果不想引入分词库,可以用一个粗略的系数,中文按字数乘1.5,英文按单词数乘1.3,加起来做个近似。虽然不精确,但对于预算控制来说够用了,留一点安全余量就行。
4.3 一次完整的请求流程
把上面的东西串起来,一次完整的请求处理流程大概是这样几步。
- 接收用户当前输入。
- 用当前输入去向量库检索相关历史片段,按相似度阈值过滤。
- 检查短期对话层是否超出窗口,超出则触发摘要压缩。
- 按系统层、长期记忆、短期对话、检索结果、当前输入的顺序拼装。
- 做token预算校验,超预算则按优先级裁剪。
- 发送给模型,拿到回答。
- 把这一轮问答加入短期对话层,等待下一轮。
这个流程里,第2步和第3步是有先后依赖的,因为摘要压缩会改变短期对话层的内容,进而影响最终拼装。实际实现时要注意顺序,别搞反了。
4.4 参数选择的实测记录
我在一个文档问答项目里调过一组参数,记录一下供参考。模型窗口8000 token,输入预算设6500。滑动窗口最初设10轮,发现长会话下经常超预算,后来降到5轮,配合摘要压缩,稳定性好了很多。向量召回的相似度阈值从0.6开始试,0.6时召回太多噪声,0.75时又经常召不回东西,最后定在0.68比较平衡。摘要触发阈值设在短期对话层占预算80%的时候,实测下来既不会太频繁调用模型,也不会等到超预算才手忙脚乱。
这些数字不是标准答案,你的场景不同,最优值也会不同。但调参的思路是一样的:先定一个保守的初始值,然后根据实际表现微调,每次只动一个参数,观察效果变化。
5. 常见问题与排查技巧实录
5.1 模型“失忆”了怎么办
最常见的抱怨就是“聊着聊着它就不记得前面说过的了”。排查这个问题,我一般按这个顺序看。
先确认信息是不是真的被裁掉了。把每次实际发送给模型的完整上下文打印出来看,很多时候你会发现关键信息在某一轮被滑动窗口挤出去了。如果是这个原因,要么加大窗口,要么把关键信息提前抽出来放进长期记忆。
再确认信息是不是被摘要弄丢了。摘要压缩有信息损失,如果关键信息正好在被压缩的那一轮里,就可能丢失。解决办法是在摘要指令里明确要求保留“用户明确提出的要求”这类内容,或者在压缩前先把关键信息单独提取出来存一份。
最后确认是不是模型本身没抓住。有时候信息在上下文里,但模型就是没用好。这种情况可以调整信息的摆放位置,把最重要的内容放在上下文的开头或结尾,这两个位置模型的注意力通常更强。
5.2 回答越来越慢、越来越贵
如果发现响应时间变长、费用上涨,八成是上下文膨胀了。先看输入token的走势,如果每轮都在涨,说明裁剪逻辑没生效。检查一下预算校验那一步是不是真的在跑,有时候代码里写了但条件判断写错了,等于没裁。
另一个常见原因是摘要没有二次压缩。长期记忆如果只增不减,聊得越久它越大,最后反而成了负担。解决办法是给长期记忆也设一个上限,超过就再摘要一次,把旧的摘要合并压缩。
5.3 常见问题速查表
| 现象 | 可能原因 | 排查方向 | 解决思路 |
|---|---|---|---|
| 模型忘记前文 | 信息被裁或摘要丢失 | 打印实际上下文 | 调整窗口或摘要保留项 |
| 响应变慢变贵 | 上下文膨胀 | 看输入token走势 | 检查裁剪逻辑和二次压缩 |
| 回答跑偏 | 无关信息过多 | 看召回内容相关性 | 提高相似度阈值 |
| 重复回答旧问题 | 窗口切断了问答对 | 检查滑动边界 | 按轮次而非条数滑动 |
| 摘要越来越长 | 没有二次压缩 | 看长期记忆长度 | 设上限并合并摘要 |
5.4 几个我踩过的坑
第一个坑是把系统提示词也放进滑动窗口。早期图省事,所有消息一视同仁地滑,结果滑着滑着系统设定没了,模型开始胡说。系统层必须单独拎出来,永远置顶。
第二个坑是摘要时把助手的话也当重点。助手说的话很多是展开论述,真正重要的是用户表达的诉求。摘要时应该以用户信息为主,助手内容只保留结论性的部分。
第三个坑是向量召回不做去重。同一件事在短期对话和向量库里各有一份,拼进去之后模型看到重复信息,有时候会以为是两件事。召回前要先跟当前上下文比对,已经存在的就别再塞了。
第四个坑是忽略多轮之间的指代。用户说“就按刚才那个来”,这个“刚才那个”如果被裁掉了,模型就懵了。处理指代类表达时,要特别小心,宁可多留一轮上下文,也别把指代对象弄丢。
6. 进阶玩法与场景延展
6.1 让上下文管理更聪明一点
基础的滑动加摘要能解决大部分问题,但如果你想做得更细,可以试试给消息打标签。比如把消息分成“事实类”“偏好类”“待办类”“闲聊类”,不同类型的消息用不同的保留策略。事实和待办优先级最高,闲聊优先级最低,预算紧张时先丢闲聊。这个思路实现起来不复杂,就是在存消息的时候多存一个类型字段,裁剪时按类型排序。
另一个进阶方向是动态窗口。不要固定保留5轮,而是根据当前问题的复杂度动态调整。问题简单就少留几轮,问题复杂、需要大量背景就多留几轮。判断复杂度可以用当前输入的长度和是否包含指代词做个粗略估计。
6.2 不同场景的策略差异
客服场景和创作场景对上下文的需求完全不同。客服场景里,用户的身份信息、订单信息、历史工单是关键,这些应该抽出来放进长期记忆,每轮都带上。创作场景里,前面写过的内容、设定的风格、人物的设定是关键,这些更适合用摘要加向量召回的组合。
知识问答场景又不一样,它的重点不在对话历史,而在检索到的文档片段。这时候上下文管理的主要精力应该放在检索质量上,对话历史反而可以压缩得很狠,留个一两轮保证连贯就够了。
6.3 监控和迭代
上下文管理不是一次配好就完事的,它需要持续监控。我一般会记录几个指标:每轮实际输入token数、摘要触发频率、向量召回命中率、用户对回答的反馈。这几个指标能帮你判断当前策略是不是合适。
如果发现输入token长期贴着预算上限跑,说明预算太紧或者裁剪不够,得调。如果摘要触发太频繁,说明窗口太小或者摘要压缩率不够。如果召回命中率低,说明切块粒度或阈值有问题。这些都需要你根据实际数据去调,没有一劳永逸的配置。
我个人在实际操作中的体会是,上下文管理最难的从来不是技术实现,而是判断“什么该留、什么该丢”。这个判断依赖你对业务场景的理解,依赖你对用户真实需求的把握。技术方案只是工具,真正决定效果的是你对场景的理解深度。所以别急着堆技术,先想清楚你的用户最在意什么,再倒推该保留什么上下文。这个顺序搞对了,后面的事情会顺很多。