我最早被“失忆”坑到,是在做一个客服机器人项目。线上跑了两个月,前20轮对话一切正常,到第35轮左右,机器人突然开始一本正经地编造订单状态,甚至把A用户的数据安到B用户头上。我第一反应是模型能力不行,差点去换底座大模型。后来把请求日志翻出来对比才发现,问题根本不在推理,而在上下文——发给模型的消息列表里,最早的约束已经被挤出了窗口,模型压根没“看见”它。
从那天起,我开始认真研究context-mode,也就是上下文管理模式。简单说,就是你决定“把哪些内容放进模型这一次请求里”的策略。它解决的核心问题不是模型会不会推理,而是模型能不能拿到足够且正确的信息来推理。这篇文章我会把我在实际项目里落地的几种context-mode方案、代码、量化对比和踩过的坑全部写出来,希望能帮正在做Chatbot、AI Agent、RAG应用的人少走一点弯路。
1. context-mode要解决的是哪个“失忆”问题
1.1 一次让我尴尬的项目事故
先说前面提到的那个客服机器人。业务方要的是一个能处理“订单查询、退换货、催开发票”的助手,我们早期实现非常简单:把聊天历史全部塞进prompt,丢给模型,让它回答。demo阶段一切美好,因为大家测试最多聊十轮八轮。但上线后真实用户不会按你的剧本走,有人连着聊了五六天,同一个会话里反复追问不同订单的状态。
然后事故就来了。第30轮之后,模型开始“忘记”用户最开始强调的“我是白金会员,所有优惠都要按最高折扣算”。更离谱的是,有一轮我手动翻日志,发现模型把用户A的收货地址当成用户B的来比对。Root cause非常清晰:模型一次请求的上下文窗口有限,消息列表只能容纳最近的十几轮内容,早期关键信息被后面的对话给“挤没”了。
这个事故让我意识到,做AI应用和做传统后端完全不同。你不只是在写一套接口,你是在替模型决定“它应该看什么材料”。这个决策本身就是应用的核心逻辑。
1.2 上下文窗口的本质:模型并没有“记住”
很多人对上下文窗口有个错误理解,觉得模型聊得多了就“记得”前面聊了什么。实际上,大语言模型的上下文窗口更像是一个临时的“阅读稿”。它只在这一次推理时把窗口里的内容全部读一遍,然后生成回复,生成完了这次读过的内容就丢了。下次推理,你又重新把材料递进去。
所以所谓“长记忆”,根本不是模型的能力项,而是应用层不断把历史内容搬回窗口里。窗口总是有限的:GPT-4o一类的模型常见上下文是128K token,看着很大,但真实业务里塞几个长文档、几十轮历史、一段工具返回结果,很快就能吃满。而且窗口越大,单次请求的成本和延迟也跟着涨。
context-mode的本质,就是在这个“有限的阅读稿”里做取舍决策。它需要回答三个问题:哪些内容必须进窗口?哪些内容可以降级处理?哪些内容可以直接丢弃?
1.3 context-mode的三个核心指标
我在项目里给自己定了三个可量化的指标,所有模式优化都围绕它们来评估:
- 信息保留率:模型回答需要的关键实体、约束、指令,最终是否还留在上下文里。我一般用一组构造好的“必须知道”测试题来验证,比如把用户设置的折扣约束放进第40轮历史,看第50轮模型还能不能复述出来。
- 单轮token成本:每发一次请求,实际消耗的输入token数量。这直接决定账单大小,特别是高频客服场景,成本差一倍,月结单就完全不同。
- 响应延迟:上下文越长,prefill阶段耗时越长。正常情况下,几百token和几千token差距不大,但一旦塞入上万token,首字延迟会明显上升。
所有context-mode方案,本质都是在三者之间找一个适合业务场景的平衡点。没有最优,只有最合适。
2. 三种主流context-mode的取舍:滑动窗口、摘要压缩、检索增强
2.1 滑动窗口:把最近N轮留下
滑动窗口是最容易想到的方案,也是我当时第一个实现的方式。思路很简单:每条消息按时间排序,构造请求时只取最新的若干条,直到接近token预算就截止。
它的最大优势是行为可预期。代码量小,逻辑直白,不会出现“模型因为摘要过拟合而胡说”的情况。你放进去的就是原文,模型读到的东西没有经过二次加工,信息失真风险低。
但它有个非常致命的短板:早期关键信息会被无差别丢弃。客服例子里的白金会员约束,如果是第2轮说的,第35轮已经不在窗口里了,模型根本不知道这个约束存在。所以滑动窗口只适合那种“最新内容最重要”的场景,比如闲聊机器人、短期任务助手。
2.2 摘要压缩:把对话变成“工作笔记”
摘要压缩是我第二个尝试的模式。它的动机很简单:模型真正需要的往往不是对话的逐字原文,而是其中蕴含的“事实和意图”。与其保留每一句话,不如定期让模型把历史对话整理成一份结构化摘要,下次请求时把摘要加原文一起放进窗口。
举个例子,用户在第3轮说“我周五要去上海出差,帮我订虹桥附近的酒店”,后面又聊了一堆茶叶价格之类无关话题。第20轮用户说“帮我看看之前说的酒店”,这时候模型需要的是“周五、上海、虹桥附近”这几个信息,而不是中间17轮闲聊。
我的实现方式是滚动摘要:每隔一段时间或一定token量,把当前摘要与新消息一起丢给模型,生成更新的摘要。这类似工作里记笔记,不断往里补增量。
摘要压缩的优点是信息保留率高,能把大量历史浓缩成几百字,并且保留全局语义。缺点也很明显:摘要本身有损,压缩过程中可能丢掉关键细节,而且摘要调用本身也要花token和时间。
2.3 检索增强:让上下文不再连续,而是“按需取用”
检索增强是我后来最常用的模式,也就是把上下文从“连续的切面”变成“可查询的知识库”。核心思路是:每条历史消息都向量化存入向量数据库,当用户提出新问题时,先对问题做语义检索,只把相关的历史消息片段取出来放进上下文。
这个模式和RAG在外观上很像,但目的不同。RAG检索的是外部知识库,检索增强检索的是对话自身的记忆。它解决的是“历史很长但高度离散”的场景,比如一个用户零零散散地在不同时间问过多个不同订单的问题,每个问题之间没有强关联。
检索增强的优势是信息保留率可调:你可以只取top-k相关片段,也可以额外加一个关键词过滤条件。它不会像滑动窗口那样牺牲早期的关键信息,也不会像摘要那样把细节抽象掉。缺点是系统复杂度明显上升,需要维护向量库、 embedding、检索链路,并且检索质量直接决定回复质量,检索不到,模型就真的不知道。
2.4 三种模式的量化对比
我把自己项目里的实测数据整理成了一张表,方便你在方案选型时直接参考(基于128K窗口、中文对话场景):
| 模式 | 信息保留能力 | 单轮token成本 | 延迟影响 | 实现复杂度 | 典型适用场景 |
|---|---|---|---|---|---|
| 滑动窗口 | 低,早期信息易丢 | 低 | 低 | 最简单 | 闲聊、短期任务、客服的“最近意向”判断 |
| 摘要压缩 | 中高,但细节有损 | 中,额外摘要调用 | 中 | 中等 | 长会话总结、需要全局语义、关键约束密集 |
| 检索增强 | 高,但依赖检索质量 | 低-中 | 中,检索耗时 | 较高 | 知识密集、问题高度离散、长期多主题对话 |
需要注意,这三种模式不是互斥的。我在最终版本里做的是混合模式:全局摘要保底 + 滑动窗口保近期 + 向量检索补细节,后面我会写具体实现。
3. 我的落地实现:一套可切换的context-mode框架
3.1 数据结构:给每条消息打上“标签”和“权重”
在写任何模式之前,我先把消息数据结构重新设计了。这一步非常重要,后期所有策略都建立在消息的meta信息之上。我的每条消息包括五个字段:
@dataclass class ChatMessage: role: str # system / user / assistant / tool content: str msg_id: str # 全局唯一 ts: float # 时间戳,用于排序和时效判断 category: str = "chat" # chat / constraint / fact / tool_result / greeting retention: int = 1 # 保留权重,0=可选丢弃,1=默认,2=重要,3=绝不可丢category和retention是我自己加的。“constraint”是用户明确的约束性指令,比如“不要推荐含糖饮料”;“fact”是事实型实体,比如订单号、日期、金额;“tool_result”是工具返回的原始结果;“greeting”是寒暄。retention用于告诉上下文构造器,这条消息的丢弃优先级。
有了这两个字段,构造上下文时就灵活多了。系统约束永远保留(retention=3),用户明确指令保留(retention=2),工具结果和事实按需保留(retention=1),寒暄直接丢弃(retention=0)。
3.2 token预算的精确计算
构建上下文的第一步永远是算预算。我封装了一个token计数器,用tiktoken来估算:
import tiktoken enc = tiktoken.encoding_for_model("gpt-4o") def count_tokens(text: str) -> int: if not text: return 0 return len(enc.encode(text))然后定义预算结构。核心原则是:先留出模型回复的空间,再装system prompt,再装本次新消息,最后剩下的空间才分给历史内容。
def build_sliding_context( system_prompt: str, history: list, pending: list, max_tokens: int = 16000, reserve_output_tokens: int = 2000, ): budget = max_tokens - reserve_output_tokens system_msg = {"role": "system", "content": system_prompt} budget -= count_tokens(system_prompt) # 本次必须带上的新消息 for msg in pending: budget -= count_tokens(msg["content"]) # 从历史最末端(最新的消息)往回收集,塞满剩余预算 selected = [] for msg in reversed(history): cost = count_tokens(msg["content"]) if budget - cost < 0: break selected.append(msg) budget -= cost selected.reverse() return [system_msg] + selected + pending有两个细节容易被忽略。第一,reserve_output_tokens不能设得太小,否则模型生成到一半就被截断。我一般根据业务回答长度估算,客服场景设2000,长文生成场景至少4000。第二,budget - cost < 0才break,而不是<=0,这样能尽量利用最后一点剩余空间,避免白白浪费。
3.3 摘要压缩的实现细节
摘要模式我并没有简单地把整段历史一次性丢给模型总结,那样很容易超窗口。我采用的是“分批+滚动摘要”的方式:
def incremental_summary(prev_summary: str, new_messages: list, client) -> str: content = "\n".join(f"{m['role']}: {m['content']}" for m in new_messages) prompt = f"""你正在管理一段长时间对话的记忆。请合并以下两部分内容,输出一份新的结构化摘要。 要求: 1. 保留所有用户明确的指令、偏好和约束; 2. 保留关键实体(订单号、日期、金额、人名、地址等); 3. 去掉寒暄、重复表达和低信息量内容; 4. 摘要保持在300字以内。 【已有摘要】 {prev_summary} 【新对话】 {content}""" resp = client.chat.completions.create( model="gpt-4o-mini", messages=[{"role": "user", "content": prompt}], temperature=0.2, ) return resp.choices[0].message.content每次调用前,我先统计新消息的总token量,如果超过4000就自动切成多个小批,然后循环调用incremental_summary,把上一轮的摘要作为下一轮的“已有摘要”传进去。这样做的好处是无论对话多长,摘要调用的token开销都是可控的,不会出现“为了省token反而烧掉更多token”的尴尬。
另外一个我踩出来的经验:摘要生成用mini模型就行,不必用旗舰模型。因为摘要任务本身对推理要求不高,关键是提取准确性。我个人用gpt-4o-mini,实测信息保留率和旗舰模型相差不大,但成本低了差不多一个数量级。如果你用的是开源模型做底座,摘要生成也可以复用同一套模型,只是速度会慢一些。
3.4 自动切换策略:什么情况用什么模式
做了三种模式之后,新的问题来了:每次请求到底用哪个?我最后写了一个简单的策略决策器,规则不复杂但足够有效:
def decide_mode(app_state): # 历史占总窗口预算的比例 ratio = app_state.history_tokens / app_state.max_tokens if ratio < 0.4: return "full" # 历史远没到预算,直接全量 if app_state.fact_density < 0.2: return "sliding" # 事实密度低,大多是无关联的闲聊 if app_state.query_discrete: return "hybrid" # 问题之间离散,单点查询居多,用检索 return "summary" # 默认用摘要压缩这个决策器依赖两个额外信号:
- fact_density:我给每条消息打了category标签,统计最近N条消息里“fact”和“constraint”占比,占比高说明这段对话信息密集,不能随便丢。
- query_discrete:用户在连续多轮里是否在问完全不同的问题。这个可以通过向量相似度来算,如果相邻问题间的平均相似度低于阈值,就认为是离散多头查询。
hybrid模式是我最终推荐的方式:系统约束全量保留,近期消息用滑动窗口,再叠加一层向量检索补充细节。它稍微复杂一点,但在长会话场景里效果最稳。
4. 实测中踩过的坑与优化建议
4.1 上下文污染:摘要把工具输出当成了“事实”
第一个坑出现在摘要模式上线当天。客服机器人在查询天气工具时返回了“明日有暴风雨”,摘要把它记成了一条事实。第二天用户问“明天适合户外活动吗”,模型根据摘要里的“暴风雨”给出否定建议。问题在于,那条工具结果是2小时前的,天气早就变了。
这种问题我称为“上下文污染”:模型把某个时刻的工具输出当成了永久事实。工具结果天然有时效性,不能进入长期摘要,至少必须带上时间戳。
我的解决方案是给工具结果单独设置category=tool_result,并且默认retention=1。在做摘要压缩时,把tool_result排除在“摘要对象”之外,只保留“这条工具结果回答了什么问题”这样的元信息。比如不记“明日有暴风雨”,而是记“用户询问了明日天气,已返回预报结果”。等到需要精确数据时,走检索增强去拿原始记录。
4.2 摘要的“歧义塌缩”:关键实体被抽象掉了
第二个坑比较隐蔽,也最难发现。在某次长会话里,用户先提到“张总”,后来又提到“张经理”,其实指的是同一个人。摘要模型在压缩时统一成了“张总”,这没问题。但另一段对话里,“张经理”是另一个部门的人,摘要也统一成了“张总”。结果模型在回答“张经理负责什么业务”时,把两个部门的信息混在了一起。
这就是摘要压缩的“歧义塌缩”。模型为了把内容压缩进有限字数,会倾向于做实体合并,而合并产生的歧义水平无法被下游轻易察觉。
我的对策是双重的:第一,摘要prompt里强制要求“实体出现时保留完整称谓,不缩写,不合并,若不确定则并列保留”;第二,在摘要之外,单独维护一个“关键实体表”,用正则和命名实体识别抽取出订单号、人名、日期、金额,这类数据不做压缩,始终原始保留。
这个实体表非常值得单独做。需要精确查询时,它比摘要可靠得多,而且是结构化数据,可以直接参与规则判断,不一定要经过模型。
4.3 token成本失控:摘要调用反而把账单推高了
第三个坑和钱有关。我最初设计是“每当历史超过阈值就触发一次摘要”,结果遇到一个话痨用户,几乎每三四轮就触发一次摘要调用,而且每轮主请求还是全量发送。月底一看账单,摘要花费占了总成本的35%。
这个教训是:摘要不能是“高频操作”,它应该是“低频兜底操作”。我把触发阈值从4000token调高到12000token,并且加了冷却时间——两次摘要之间至少间隔30分钟或20条新消息。另外一个优化是,不把摘要结果立即放进下一轮请求,而是先让它存储在内存,只有当历史即将撑爆窗口时才加载。
4.4 精心设计评估:怎么判断context-mode改好了还是改坏了
context-mode做得对不对,不能靠感觉。我后来搭了一套专门的评估集,这里分享一个最值得做的“关键信息保持”测试:
- 准备一组20个“关键约束”,分别散布在对话历史的第1、第10、第30、第50轮。
- 让测试脚本自动运行对话到第60轮,然后向模型提问,每个约束对应一个问题。
- 统计答对率,对比不同模式下的得分。
我实测下来,单纯用滑动窗口时,第30轮之前的约束答对率只有约40%;摘要压缩能到75%左右;混合检索模式可以稳定在85%以上。这组数据很有说服力,也很容易让业务方理解“为什么需要做context-mode”。
同时还要监控两个反向指标:回复的“串线率”(把不同主题的信息混在一起的比例)和每条消息的平均成本。有时候一个方案信息保留率很高,但成本高得离谱,那就得调整策略。
5. 场景化落地建议:不是所有应用都需要最强模式
5.1 先判断你的业务属于哪一型
我见过很多团队一上来就上向量检索,结果项目跑了一个月,发现瓶颈根本不在上下文,而在意图识别。这里我把常见应用分成四类,你可以对照着选模式:
| 应用类型 | 典型特征 | 推荐context-mode |
|---|---|---|
| 客服辅助 | 会话长、问题离散、事实密集 | 混合模式:摘要+检索 |
| 个人助理 | 短期任务多、最新意图重要 | 滑动窗口+轻量摘要 |
| 文档问答 | 外部知识为主、对话历史短 | 滑动窗口即可,重点是RAG |
| 角色扮演/闲聊 | 全局人设重要、细节容忍度高 | 摘要压缩 |
这个表不是绝对标准,但能帮你快速定位。最重要的是,先量化你的历史特征,再选模式。我每次接到新项目,都会先跑一遍真实对话日志,统计平均会话长度、事实密度、问题相似度,然后才动手设计。
5.2 给初次落地的人一个靠谱的推进路径
如果你是第一次做context-mode,我建议不要直接堆复杂方案。按下面的顺序演进:
- 第一版只做滑动窗口,但把消息加好category和retention标签。这步很快,一两天就能完成。
- 上线后收集真实日志,统计“最早出现的关键约束在第几轮被挤出窗口”,找到一个可复现的失忆案例。
- 加入摘要压缩,配上实体表。先让摘要只保留“关键指令和事实”,不要贪全。
- 如果还有离散查询场景回答不好,再上向量检索。
每一步都留出观察时间,至少跑一周真实流量,用上一节说的评估集量化效果。不要跳步,否则出了问题你根本没法定位是摘要丢信息,还是检索没召回。
5.3 后续可以继续扩展的方向
context-mode这套东西写完,并不代表一劳永逸。我接下来计划做几件事:
第一,把摘要和检索的触发条件从规则改成可学习策略。现在已经积累了不少“哪些消息最终帮助正确回答”的日志数据,后续可以用这些数据训练一个轻量级模型来决定上下文构成,而不是靠人工阈值。
第二,做跨会话记忆。现在的context-mode只解决单会话内部的消息管理,但很多用户会多次回访,下一次会话其实也应该带上之前会话的关键摘要。这个方向我已经在规划,本质上是把“会话级摘要”升级成“用户级长期记忆”。
第三,把上下文预算做成可观测的可视化仪表盘,让运营人员能实时看到每一轮请求里“system占多少、历史占多少、检索片段占多少”,这样调参就有数据支撑。
如果让我重新来一次,我会在项目第一天就加一个“输入输出token计数”的中间件,把每条消息的类别和保留权重在入库时打好。context-mode不是一个开关,而是一套工程习惯:在写代码之前先想清楚,哪些信息不能丢,哪些可以压缩,哪些压根不用看。你把这个想明白了,后面所有模式实现都会顺很多。