1. 对话系统“失忆”之后我决定为上下文设计模式今年年初我在维护一个 AI 客服对话系统时被同一个问题反复折磨用户明明还在同一个会话里AI 却开始“答非所问”。用户在第三轮提过的偏好到了第十五轮再追问模型已经把关键信息忘得干干净净。更头疼的是同一个会话内同时掺杂着闲聊、参数确认、历史单据查询好几类任务不管你往上下文里塞多少内容模型总会在某个节点开始抓不住重点。我当时把问题拆了很多遍最后锁定在一个设计缺失上我们一直在往上下文里“塞东西”却从来没有为上下文本身设计“模式”。这个思路在内部后来统一叫context-mode——把上下文当成一个有状态、可切换模式的状态机来管理。上下文不再是“一堆历史消息的堆砌”而是“根据当前对话目标动态选择的最优信息集合”。如果你也在做大模型对话类应用或者正在纠结“为什么上下文塞得越多效果反而越差”这篇内容应该能给你一些直接的启发。我会从根因、模式设计、代码落地、实测数据、踩坑复盘这几个角度把一个可复现的 context-mode 方案完整拆开。这不是某个开源框架的说明书而是我自己从业务里趟出来的实践路径适合小规模项目、内部工具、以及所有被上下文爆炸折磨过的开发者参考。先说一个反直觉的结论上下文管理的关键不是“尽量多记住”而是“尽量少而准地选择”。大模型在长上下文里存在明显的“注意力稀释”现象信息越堆越多关键信息被淹没的概率就越大。context-mode 的核心任务就是给每一段对话内容标注它“此刻是否该被使用”而不是让模型在一堆垃圾信息里硬找答案。2. 对话失忆的根因没有为上下文设计模式2.1 我们最初的做法简单粗暴地拼接历史这个项目第一版的上下文处理非常原始每个新问题来的时候把最近二十轮对话全部拼接进 prompt不加筛选、不做摘要、不分主次。上线后很快就出现三个典型问题。第一个是信息稀释。二十轮对话里可能只有三轮真正相关但模型要从二十轮的噪声里自己筛出那三轮。尤其是当用户中途换个话题再接回之前的话题时模型经常把新话题的上下文错误地带入旧话题的推断里。第二个是token 成本失控。客服系统每天几千次调用每次调用多几千 token成本差距就非常明显。更麻烦的是当上下文接近模型窗口上限时极端情况下会出现拼接失败或者响应时间暴涨。第三个是记忆漂移。同一个用户的会话如果跨天了我们把之前所有历史都塞回去模型反而会因为“早期对话”和“当前对话”在风格或意图上差异过大产生错误的偏好判断。比如用户周一说“我预算低”周三问“有什么高级方案”模型却因为旧记忆存在而固执地推荐低价方案。2.2 失忆的本质上下文缺少“生命周期”和“角色”这些问题表面上是工程实现粗糙但根子在于一个认知缺失对话上下文是有生命周期的不同阶段的内容应该承担不同角色它们的“新鲜度”“重要性”“可丢弃性”完全不同。我把一个长期会话里的信息分成这么几类当前目标信息用户正在表达的需求、这一轮要解决的参数。这是上下文里优先级最高、必须完整保留的部分。近期延伸信息前面几轮为当前目标做铺垫的内容比如条件约束、偏好修正。这类信息需要保留但不必全量保留关键约束即可。背景知识信息用户在这个会话里提过的历史需求、身份标签、历史操作。这类信息和当前话题可能无关但在跨话题回溯时会被用到。过期噪声信息闲聊、已经结束的话题、纠正前的错误表述。这些不但没用反而会拉低模型注意力。问题在于旧方案把所有内容一视同仁地堆进上下文等于强迫模型自己去做这个信息分类。模型确实有一定能力但它在分类的同时还要完成推理任务效果自然大打折扣。context-mode 的思路就是把这层分类工作从模型手里接过来我们定义好不同上下文模式每个模式对应一种“窗口策略”——当前该看什么、该隐掉什么、该压缩什么、该丢弃什么。模型只拿到当前模式认为“最有用”的那部分上下文推理负担大幅下降输出质量自然更稳定。3. 四种模式的设计取舍聚焦、漫游、压缩、重建context-mode 不是一个单一开关而是一组可切换的运行状态。我在项目里定义了四种模式每种模式对应不同的上下文窗口策略。3.1 聚焦模式Focus只保留当前任务的最高优先级信息聚焦模式适合正在处理一个明确任务时的默认状态。比如用户正在确认订单信息那上下文窗口里只放以下内容当前轮次的用户问题最近一轮系统回复当前任务相关的结构化信息订单号、商品参数、用户已确认的条款不超过两轮的直接对话上下文这个模式的核心理念是“截断一切非必要信息”。表面上冒进但实测中聚焦模式在单任务连续对话里效果最好因为模型不需要在庞大的历史里翻找焦点。它就像你在写代码时只打开当前文件不把整个项目几十个文件都贴在屏幕上。实现上聚焦模式需要给每轮对话标记一个 task_id 或者 intent_id每次只捞当前 task 相关的消息。这个标记可以在应用层用简单规则比如自定义意图识别完成也可以交给模型做一次轻量分类。3.2 漫游模式Drift需要跨话题回溯时启用漫游模式解决的是“用户突然问起十轮之前说过的一件事”的场景。它的窗口策略是优先把最近两轮完整对话放进去再根据关键词或向量相似度从整段会话历史里检索出与当前问题最相关的若干条内容一起拼进上下文。漫游模式和“全量拼接”的区别在于它不是把历史全部塞进去而是只找回“最相关的那几块碎片”。我在项目里用了两种检索方式关键词倒排适合客服系统这种垂直领域命中率可控实现简单。向量检索适合开放式对话但需要额外维护 embedding 服务成本高一些。漫游模式不能一直开着否则就退化成全量拼接了。它的启用条件是“检测到当前问题和最近两轮话题不一致同时和更早的历史消息存在潜在关联”。检测逻辑可以用意图标签比对也可以用 embedding 相似度的差值来判断。3.3 压缩模式Compress控制 token 预算的关键手段压缩模式负责把“需要长期保留但不适合全文展示”的信息浓缩成短摘要。它的触发时机有两个一是上下文累计长度超过预设阈值比如接近窗口的 60%二是会话跨度超过一定轮次比如超过二十轮。压缩的常见做法有三种用模型对过期内容做二次摘要把十轮对话压成两三句。抽取出关键结构化字段更新到用户画像里例如“预算低”“地区上海”。保留原消息的时间戳和指向 ID替换正文为超短占位符。压缩模式最容易被忽略的一点是摘要本身也会迭代失真。你压缩一次之后第二次压缩基于上次的摘要信息层层递减。这个问题我在后面的踩坑章节会专门展开。3.4 重建模式Rebuild会话恢复与跨越时间线的对齐重建模式应对的是“会话长期挂起后重新激活”的场景。比如上午聊到一半下午用户又回来了直接说“继续刚才那个事”。这时候上下文窗口如果只保留最近两轮完全无法接上如果全量保留又会被上午的噪声干扰。我的处理方式是在会话挂起时把当时的关键摘要、结构化信息、未完成任务清单快照下来存在本地。恢复时把快照注入上下文开头再往后拼接最近几轮内容。这样模型能快速“对齐状态”不需要重新读一遍整个上午的记录。重建模式的精髓在于“快照的设计”不只是存摘要还要存“未完成目标”。比如“用户正在比较 A 和 B 两个套餐尚未决定待确认预算”。这个未完成任务清单比任何摘要都更适合引导模型快速回到工作状态。四种模式不是孤立的它们组合起来形成一条上下文生命周期管理的流水线聚焦模式处理当下任务漫游模式负责跨时空召回压缩模式控制规模膨胀重建模式完成会话重启。接下来我给出具体的代码实现让你能直接落地这个思路。4. 核心实现从零搭建一个 ContextManager4.1 数据结构把对话拆成带权重的片段要让上下文可管理第一步是把“消息列表”升级成“带元信息的片段集合”。我在项目里用一个简单的ContextSegment结构from dataclasses import dataclass, field from datetime import datetime from enum import Enum from typing import List, Optional class ContextMode(Enum): FOCUS focus DRIFT drift COMPRESS compress REBUILD rebuild dataclass class ContextSegment: seg_id: str role: str # user / assistant / system content: str timestamp: datetime task_id: Optional[str] # 关联的任务标识用于聚焦模式 intent: Optional[str] # 意图标签用于漫游模式触发判断 weight: float 1.0 # 重要程度用于压缩时决定保留顺序 summary: Optional[str] None # 压缩后的摘要 anchor: List[str] field(default_factorylist) # 锚点关键词防失真这个结构的核心设计是task_id、intent和weight三个字段。task_id让聚焦模式可以快速筛选intent让漫游模式可以判断“话题是否跳跃”weight让压缩模式知道哪些片段优先保留原文。4.2 模式切换的触发条件与状态转移模式切换不能拍脑袋我在ContextManager里设计了一个规则引擎根据当前对话和新输入自动判断是否切换模式。规则优先级从高到低如果会话挂起超过 30 分钟切到REBUILD先恢复快照再继续。如果当前片段累加 token 数超过窗口的 60%切到COMPRESS先压缩再响应。如果当前问题的intent与最近两轮不同且与更早某段历史intent相同切到DRIFT。否则保持FOCUS不变。class ContextManager: def __init__(self, max_token_budget: int 6000): self.max_token_budget max_token_budget self.segments: List[ContextSegment] [] self.current_mode: ContextMode ContextMode.FOCUS self.mode_switched_at None self.min_switch_interval_sec 30 # 冷却期防止频繁切换 def add_turn(self, role: str, content: str, task_id: str None, intent: str None): seg ContextSegment( seg_idfseg_{len(self.segments)}, rolerole, contentcontent, timestampdatetime.now(), task_idtask_id, intentintent, ) self.segments.append(seg) self._maybe_switch_mode() def _maybe_switch_mode(self): # 冷却期检查切换太频繁会引入震荡 if self.mode_switched_at and ( datetime.now() - self.mode_switched_at ).total_seconds() self.min_switch_interval_sec: return next_mode self._decide_next_mode() if next_mode ! self.current_mode: self.current_mode next_mode self.mode_switched_at datetime.now()冷却期的设计很关键。最初没有冷却期时系统会在漫游和聚焦之间来回跳每次跳变都要重新组装上下文既增加延迟又让输出不稳定。加了 30 秒冷却之后这个震荡问题基本消失。4.3 构建当前上下文的完整流程核心方法是build_context()根据当前模式把片段组合成最终交给模型的 prompt 前缀def build_context(self) - str: if self.current_mode ContextMode.FOCUS: return self._build_focus_context() elif self.current_mode ContextMode.DRIFT: return self._build_drift_context() elif self.current_mode ContextMode.COMPRESS: return self._build_compress_context() elif self.current_mode ContextMode.REBUILD: return self._build_rebuild_context() def _build_focus_context(self): # 只保留当前任务相关的片段最多补上最近1轮完整对话 focus_segments [s for s in self.segments if s.task_id self._current_task_id()] recent self.segments[-2:] merged focus_segments recent return self._format_segments(merged) def _build_drift_context(self): # 最近两轮全量 检索到的历史相关片段 当前任务关键摘要 recent self.segments[-2:] query self.segments[-1].content retrieved self._retrieve_relevant_segments(query, top_k5) task_summary self._get_task_summary(self._current_task_id()) return self._format_segments(task_summary recent retrieved) def _build_compress_context(self): # 压缩阈值控制优先压缩最早且权重最低的片段 compressed [] for seg in sorted(self.segments, keylambda x: (x.timestamp, x.weight)): if seg.summary: part seg.summary else: part self._summarize(seg, max_chars80) seg.summary part compressed.append(part) return \n.join(compressed[: int(len(compressed) * 0.7)])_retrieve_relevant_segments可以接关键词匹配也可以接向量检索。为了不引入额外基础设施我在客服场景里先用关键词加权效果达到预期后才考虑升级。代码如下def _retrieve_relevant_segments(self, query: str, top_k: int 5): # 简易检索先按 intent 过滤再按关键词重合度排序 scored [] q_words set(self._tokenize(query)) for seg in self.segments: if seg.intent ! self._current_intent(): continue seg_words set(self._tokenize(seg.content)) score len(q_words seg_words) if score 0: scored.append((score, seg)) scored.sort(keylambda x: (-x[0], x[1].timestamp)) return [seg for _, seg in scored[:top_k]]这个简易检索虽然简陋但有一个很大的优势可解释性极强。如果你发现某次召回不对可以直接打印出命中的关键词和片段快速定位问题。向量检索虽然省心但排查“它为什么召回这一段”会非常痛苦。4.4 快照机制重建模式的数据基础重建模式的实现依赖快照我在每次会话挂起时调用snapshot()def snapshot(self) - dict: return { task_id: self._current_task_id(), unfinished_goals: self._collect_unfinished_goals(), key_facts: self._extract_key_facts(), last_segments: [s.content for s in self.segments[-4:]], timestamp: datetime.now().isoformat(), } def _extract_key_facts(self): # 用规则抽取包含“预算、地址、姓名、日期”等实体关键词的片段 facts [] keys [预算, 地址, 姓名, 日期, 需求, 偏好] for seg in self.segments: if any(k in seg.content for k in keys): facts.append(seg.content) return facts[-5:]快照不是简单的摘要而是一个结构化的小型记忆包。unfinished_goals是重建时最关键的引导项——它告诉模型“上次离开时还没做完什么事”模型就能快速进入“继续干活”的状态而不是先困惑半天。5. 实测结果模式切换在真实任务上的差异5.1 测试设计与任务类型划分光有设计还不行我在内部做了一个受控对比测试。测试会话池来自客服系统的真实脱敏对话共抽取 120 个会话覆盖两类典型任务信息寻址类用户在第 1~5 轮提供某个偏好信息之后隔了 10 轮以上再问回这个信息模型能否正确引用。推理决策类需要在上下文里综合多个约束做判断例如“预算不高、对性能有要求、品牌不考虑 X”最后给出推荐。对比对象是三套上下文方案旧的“最近 20 轮全量拼接”、纯聚焦模式不切换、完整 context-mode四种模式可切换。5.2 结果数字与关键指标对比实验跑了大概两周下面是收敛后的数据方案信息寻址正确率推理决策正确率平均每轮上下文 token平均响应延迟全量拼接 20 轮68%61%82002.4s纯聚焦模式74%79%24001.1s完整 context-mode86%82%37001.4s两个最值得注意的结论一是纯聚焦模式在推理决策上已经大幅超过全量拼接。原因不难理解——推理任务最怕噪声你给模型一堆无关历史它要做两件事先过滤再推理注意力分散错误率自然上升。聚焦模式强制让模型只看到当前任务相关的信息推理负担轻得多。二是完整 context-mode 在信息寻址任务上拉开了明显差距。纯聚焦的问题是当用户问回一个十轮前的问题时聚焦模式找不到信息而 context-mode 通过漫游模式触发检索精确找到历史片段正确率直接拉升到 86%。5.3 token 效率的长期收益token 数据同样有说服力。全量拼接方案的 8200 token 里真正被模型有效利用的往往不到一半。context-mode 用更少的 token 换来了更高正确率这就是“少而准”策略的直接回报。在成本层面按每 1000 token 约 0.002 美元计算单次调用从 8200 降到 3700直接省约 55% 的上下文成本。如果每天调用量上万次这个差距会非常可观——对自费跑模型的小团队来说context-mode 不只是效果优化更是成本优化。我再强调一遍这个实验的绝对值肯定受特定业务影响但趋势是一致的——上下文管理从“堆砌”转向“调制”之后效果只会更好不会更差。6. 模式切换踩坑笔记断层、误触发与失真6.1 切换瞬间的上下文断层第一个坑出现在模式切换的瞬间。原来的实现是切换成功之后下一次用户提问时直接按新模式构建上下文。结果出现了一个尴尬局面——用户在某次提问触发模式切换后紧接着又说了一句话模型居然完全不知道上一轮在聊什么。排查后发现原因漫游模式拼接上下文时为了控制长度只保留了最近两条完整对话加上检索片段。但如果检索出的历史片段与上一轮的当前任务重叠度不高模型相当于“失忆”了。修复方案是加一个过渡缓冲区切换模式时先把最近一轮的完整上下文原样保留在新上下文的底部再叠加新模式的内容。这样即使检索结果不理想最近一轮的完整信息仍在模型不会断层。6.2 模式误触发把闲聊当成跨话题回溯第二个坑更隐蔽。漫游模式的触发条件是“当前问题与最近两轮 intent 不一致”。但用户有时候只是随口一句“那个东西怎么样了”没有任何明确 intent系统却因为“检测到话题漂移”切到漫游模式触发一次全历史检索。结果就是响应变慢、token 消耗升高而模型拿到一堆无关历史之后反而开始胡猜。排查链路是这样的我发现日志里漫游模式的切换频率异常高达到全部会话的 37%。正常预期应该在 12% 左右。逐条查看触发记录后发现大量触发都发生在用户输入非常模糊、几乎没有任何意图标签的场景下。修复方式是给切换规则加意图置信度阈值只有当新问题的 intent 置信度大于 0.6 且与最近两轮不一致才允许切换到漫游模式。没有明确意图的输入一律留在聚焦模式只做最近两轮拼接。6.3 压缩迭代失真摘要被二次摘要“洗掉”了关键信息第三个坑花了我最长时间去定位因为它不报错、数据也正常但用户反馈“AI 越来越没记性”。后来我单独写了个脚本把同一会话每次压缩后的摘要拉出来对比才发现问题摘要被反复迭代压缩后关键实体信息在逐轮衰减。第一轮压缩时原文是“用户预算 3000 元左右倾向静音需要无线”摘要变成“预算低、静音、无线”。第二次压缩时上一层摘要继续被压缩变成了“低预算、静音”。第三次之后“3000 元”这个具体数字彻底消失模型只能凭“低预算”去推断价值取向完全偏离。修复方案是在压缩时引入锚点标签在切分摘要时通过实体识别把“数字、日期、人名、地点、型号”等硬信息摘出来单独存进 anchor 列表。压缩时 anchor 字段永不参与二次摘要始终原样保留。重建时先把 anchor 拼回上下文头部再补摘要。这个改动很小但对长期会话的效果提升非常显著。我后来把所有会话的“信息存活率”做了一次对比压缩模式引入 anchor 前三十轮之后关键信息存活率大概只有 45%引入后能维持在 85% 以上。7. 从单会话到多智能体context-mode 的扩展方向这套模式虽然是为单会话对话设计的但它的思路延伸出去之后在多智能体场景里也有很强的借鉴价值。我在另一个项目里尝试了一种简化版多个子 Agent 共享同一个记忆池但每个 Agent 只允许用自己角色对应的上下文模式。比如负责售前的 Agent 永远用聚焦模式只看当前询单相关的片段负责跨部门协调的 Agent 使用漫游模式需要检索多个会话的历史负责周报汇总的 Agent 用压缩模式只拿摘要不需要细节。这个做法让上下文隔离做得非常干净子 Agent 之间的干扰大幅降低。还有一个值得探索的方向是模式重放把一天内用户的会话模式切换轨迹保存下来作为第二天会话重建的参考。例如用户习惯早上查订单、下午进行售后沟通重建模式就可以预判性地把订单相关快照优先装入上下文而不是等模型被问到了才去检索。context-mode 不是什么高深理论它就是把“上下文应该怎么组织”这件事从模型手里接回来用可解释的规则、可维护的结构、可观测的模式切换来管理。如果你也在和上下文爆炸、记忆漂移、token 成本做斗争可以从最小实现开始先试聚焦模式再逐步加漫游和压缩。要做的也只是给对话标上 task_id 和 intent让上下文第一次拥有“模式”这个维度。我最后的体会是模型的能力再强也顶不住你在上下文里喂垃圾信息。给上下文设计模式本质上就是替模型先把“该看什么”这个决定做掉它才能把力气花在“怎么回答好”上。