大模型多轮对话的上下文管理模式 context-mode 实战解析
最近在做 AI 助手产品的时候一个念头反复在脑子里转为什么明明模型能力很强跑起来却总是“聊着聊着就忘了”后来我把问题定位到 context-mode 这个容易被忽略却决定体验的设计上。context-mode简单说就是上下文管理模式。它不是一个炫酷的算法也不是某个大厂开源的框架而是一套管理“模型能看到的对话信息”的策略。说白了就是决定每一轮请求发给大模型时哪些内容该带、哪些该压缩、哪些该丢掉。如果你正在做智能客服、编程助手、知识库问答、多轮 Agent或者任何基于大模型的对话系统这个模式的设计好坏几乎决定了你的产品是“聪明”还是“愚蠢”。这篇文章不是理论科普而是我基于一个小型客服机器人项目完整梳理 context-mode 从问题分析、方案设计、代码实现到线上踩坑的全过程。希望能给同样被上下文问题折磨的人一点参考。1. 为什么需要 context-mode1.1 我在项目中遇到的“上下文断裂”我接手的项目是一个售前客服机器人用户会问产品参数、价格、物流、退换货政策经常一场对话要来回问好几轮。最开始实现得很朴素把用户所有历史消息一股脑拼到 system prompt 后面随请求发给模型。上线第一天就出问题。用户在第三轮说“那这个能开发票吗”模型完全不知道“这个”指的是哪个商品。我一看请求日志发现系统只传了当前这轮消息前面的历史根本没传。原因很简单当时为了省 Token写了个非常粗暴的逻辑只在用户明确提到商品名称时才带上一轮消息其余情况只发当轮问题。这就是典型的上下文管理缺位。后来我把历史消息全量带上问题更明显对话超过十轮后请求体积越来越大响应时间从 1 秒涨到 5 秒费用翻了快十倍。更诡异的是上下文越长模型反而越“糊涂”会把早期聊过的商品型号和后来问的价格搞混。翻模型文档才知道超长上下文不仅慢而且模型对中间部分内容的注意力会明显下降。也就是说我花了更多钱换来了更差的效果。这个阶段让我明白一个道理上下文不是“越多越好”而是“需要的关键信息不能丢无关信息尽量少”。于是我开始认真研究 context-mode想要一个能主动管理上下文的中间层。1.2 context-mode 到底解决什么问题大模型接口本身是无状态的。每一次调用模型都只看到你这次传进去的 prompt不记得上一次你是谁、聊了什么。你所谓的“多轮对话”其实是客户端把历史消息一遍遍重复塞给模型。整个系统的记忆能力完全取决于你如何构造这份“发送给模型的消息列表”。context-mode 要解决的问题就是在有限的上下文窗口和 Token 预算内尽可能保留对当前任务有用的信息同时控制请求体积和成本。它本质上是一个信息筛选与压缩决策系统决定三个核心问题哪些消息必须原样保留哪些消息可以压缩为摘要或结构化事实哪些消息应该彻底丢弃如果没有明确的模式最常见的做法是“全量携带”或“只带最近几轮”。全量携带会遭遇我刚才说的费用和注意力衰减问题只带最近几轮则会导致用户提过的偏好、历史决定、未完成事项全部丢失。context-mode 的目标就是在这两者之间找到一个性价比最高的平衡点。它适合所有调用大模型的业务场景尤其是对话轮次不固定、用户可能随时回看前面信息的场景。比如客服机器人需要记住用户订单号编程助手需要记住用户选用的技术栈写作助手需要记住用户偏好的语气和风格。哪怕你只是做一个简单的 AI 聊天玩具提前设计好这个模式也会省掉后面很多返工。2. context-mode 的整体设计思路2.1 四种主流上下文组织模式做方案调研时我接触了几种常见组织方式每种都有自己的适用环境。我把它们整理成一张对比表模式名称核心思路优点缺点典型场景全量模式所有历史消息原样携带信息无损失实现简单Token 消耗大长对话效果下降短对话、调试场景滑动窗口模式只保留最近 N 条消息简单可控响应快早期关键信息丢失闲聊、即时问答摘要压缩模式超过阈值后把历史消息总结成摘要控制 Token 总量保留大概语义摘要可能丢失细节生成有延迟长对话、知识库问答关键记忆模式只提取用户画像、订单号、决定等结构化字段精确保留要害信息成本极低提取逻辑复杂开放域场景容易漏客服系统、业务助手全量模式我实际用下来只在对话少于 5 轮时靠谱。滑动窗口模式适合那种“用户问完就走”的场景比如一次性翻译、一次性代码解释用户不指望你记住上一句。摘要压缩模式是业界用得最多的但要注意摘要本身的生成时机和存储方式。关键记忆模式最有意思可以把一个 5000 Token 的对话压成 200 Token 的键值对比如“发货城市上海”“退货期限30天”。2.2 为什么我选择混合模式单个模式其实很难覆盖真实场景。客服对话里用户既可能在第 2 轮给出订单号也可能在第 20 轮问“我之前说的收货地址改了吗”。如果只用滑动窗口第 20 轮时早就忘了地址如果只用摘要订单号这种精确信息很容易在摘要里被模糊化如果只用关键记忆模型又会缺少对对话氛围和用户措辞的感知。所以我最终选了混合模式也算是我自己的 context-mode 思路底层是滑动窗口保留最近 6 轮完整消息上层是动态摘要把更早的消息压缩成一页固定长度的总结再叠加一层结构化关键记忆把订单号、商品型号、地址、决策这类硬信息单独存储并永远注入。这个设计的理由很实际。最近几轮消息是当前意图的直接依据必须原样保留不能有一丝损失。再往前的对话模型只需要知道“大概聊过什么”就能保持连贯用摘要足够。而硬信息是业务正确性的底线比如用户说“我要改到北京”如果模型把地址记混后续所有回答都会出错必须用结构化字段兜底。值得一提的是这个组合并不是一次性定死的。我一开始也试过摘要层只保留一屏后来发现用户连续追问历史细节时摘要覆盖不住于是又在摘要里加入了“最近一次主题变化”标记。设计 context-mode 的真正难点是找到适合你业务的那套组合比例。3. 核心实现一个可落地的 context-mode 管理器3.1 数据结构与模式参数确定方案后我实现了一个叫ContextModeManager的线程安全管理器。它的核心数据结构分三块原始消息队列、摘要文本、关键事实字典。import threading import time from collections import deque from typing import Dict, List, Optional, Any class ContextModeManager: def __init__( self, window_size: int 6, summarize_threshold: int 12, max_summary_chars: int 500, key_fact_style: str business ): # 最近消息缓存超过 window_size 后进入待压缩区 self.messages: deque[Dict[str, str]] deque(maxlenwindow_size) self.summary: str self.key_facts: Dict[str, str] {} self.window_size window_size self.summarize_threshold summarize_threshold self.max_summary_chars max_summary_chars self.key_fact_style key_fact_style self._lock threading.Lock() self._history_count 0这里的关键设计是把“缓存队列”和“全量历史计数”分离。messages只存最近几轮_history_count记录会话开始以来总共聊过多少轮。当_history_count超过summarize_threshold时就触发一次摘要合并。之所以不用队列长度做阈值是因为窗口内消息被消费后长度会变没法准确反映整个会话的“年纪”。参数选择我参照了对线上对话长度的统计大约 65% 的客服会话在 10 轮以内结束超过 12 轮的会话只占 10% 左右。所以我设window_size6summarize_threshold12这样大多数短会话根本不会触发压缩逻辑最简化长会话才会启动摘要把成本控制在大约每人每次 0.02 元左右。如果你的产品主要面向技术问答用户可能会连续问 20 轮那就应该把阈值调低到 8避免前期 Token 浪费。3.2 上下文消息的构建与摘要触发管理器每次接收用户消息和回复时都要执行一次格式化入库然后生成最终的模型请求消息。def add_message(self, role: str, content: str, raw: Optional[Dict] None) - None: with self._lock: self._history_count 1 self._extract_key_facts(content) self.messages.append({role: role, content: content, ts: time.time()}) if self._history_count self.summarize_threshold: self._refresh_summary() def _should_summarize(self) - bool: return self._history_count % self.summarize_threshold 0 def _refresh_summary(self) - None: # 合并旧摘要与当前窗口外消息生成新摘要 source_text self.summary \n.join( m[content] for m in self.messages ) self.summary self._call_summarizer(source_text) self.messages.clear() def _build_messages(self) - List[Dict[str, str]]: with self._lock: sys_meta self._build_system_meta() recent_messages list(self.messages) full_messages [] if self.summary: full_messages.append({ role: system, content: f[历史摘要]\n{self.summary} }) if self.key_facts: full_messages.append({ role: system, content: f[关键事实]\n{self._format_facts()} }) full_messages.extend(recent_messages) return full_messages def send_and_get_response(self, user_content: str) - str: self.add_message(user, user_content) messages self._build_messages() # 实际调用模型 API此处省略 response_text call_llm(messages) self.add_message(assistant, response_text) return response_text这段代码里最危险的是_refresh_summary里的self.messages.clear()。这么做会清掉窗口内消息但摘要已经包含了它们的内容不会完全丢失。清空是为了防止下一次请求时又把窗口内消息重复放入导致摘要与最近消息内容重叠。重叠内容会造成模型重复理解相同信息既浪费 Token 又可能产生矛盾。我遇到一个细节问题如果用户在第 12 轮时刚好触发摘要而第 12 轮的消息还没有被模型看到那么摘要里会包含这条还没答过的消息而窗口清空后又不会再发它。这会导致模型回答时看不到最开始的原始表述只能依赖摘要。解决方法是在_refresh_summary之前先把当前消息暂存到独立变量摘要完成后重新放回窗口。代码如下def _refresh_summary(self) - None: pending list(self.messages) source_text self.summary \n.join(m[content] for m in pending) self.summary self._call_summarizer(source_text) self.messages.clear() for msg in pending[-2:]: # 只保留最后两轮避免重复过多 self.messages.append(msg)这个“摘要后回填最近两轮”的小改动让我线上响应正确率提升了不少。它看起来像是一个细节但恰恰是这类细节决定了一个 context-mode 到底能不能真正落地。3.3 关键记忆提取与摘要生成关键记忆提取是整个方案里业务属性最强的一环。我没有用统一的 NLP 库而是给模型写了一段带示例的 prompt让它从消息中抽取固定字段。以客服场景为例字段包括商品型号、订单号、地址、金额、决策。def _extract_key_facts(self, content: str) - None: fact_prompt f 请从对话内容中抽取关键业务字段输出为 JSON。只抽取明确出现的值不要猜测。 字段商品型号、订单号、地址、金额、决策。 示例1 输入我的收货地址改成上海市浦东新区世纪大道100号。 输出{{地址: 上海市浦东新区世纪大道100号}} 示例2 输入我决定换货不想退款了。 输出{{决策: 换货}} 现在输入 {content} try: result_json call_llm_json(fact_prompt) for k, v in result_json.items(): if v: self.key_facts[k] v except Exception: pass这个抽取每次调用都会消耗一次模型请求所以我只在add_message时对用户消息执行不对助手回复执行成本会减半。另外我加了去重逻辑同一个字段如果已经存在就用时间戳更新但要保留第一次出现的值来做一致性校验。摘要生成则更简单我把旧摘要和窗口外消息拼接后发给模型要求“用 500 字内总结对话要点保留商品、价格、时间、状态”。摘要 prompt 我迭代了三次最终发现加入“不要重复用户原话”这条指令能显著减少摘要冗长问题。def _call_summarizer(self, source_text: str) - str: summary_prompt f 把下面的对话内容压缩成不超过{self.max_summary_chars}字的摘要。 要求保留商品、价格、时间、物流状态不要重复用户原话用第三人称叙述。 对话内容 {source_text} 摘要 return call_llm_text(summary_prompt, max_tokensself.max_summary_chars)说实话这段代码不算最优但胜在简单可维护。我见过有人用向量数据库做记忆检索效果也不错但对小型项目来说显式结构化字段在很多场景下比向量搜索更可靠。向量检索适合开放域“语义联想”而客服场景更需要的是“用户上次说的订单号是什么”这种精确召回。4. 实战中的坑与调优记录4.1 五个容易翻车的细节第一摘要生成的频率不能太小。我一开始设置每 5 轮就重新摘要结果摘要中频繁出现“用户此前询问过物流”这种内容反而干扰了后续判断。后来改成每 12 轮触发一次并且触发时只合并旧的摘要和新增部分效果稳定很多。触发频率越高模型生成的中间总结就越多这些总结相互叠加信息失真概率也越大。第二系统提示词里的关键事实不要一股脑全塞。最初我把五个字段全都放进 system prompt模型总在回答里复读订单号。分析后发现不同场景关注的信息不同。比如用户问退货政策时需要的是“决策”而不是订单号。所以我根据当前轮次的语义动态选择要注入的关键事实子集。实现方式是在_build_messages里根据最近一条用户消息包含的关键词查询字典。第三一定要给摘要文本加上独立的 system 标记而不是拼在用户消息里。我踩过一个坑把摘要和当前问题直接拼成一条 user 消息发出去模型会以为摘要内容是用户现在说的话导致回答里出现“您说请联系客服请问具体是指什么”这种反常识回复。分开 system 和 user 之后角色混乱问题立刻消失。第四注意并发写的安全。客服机器人会有多个用户同时访问如果每个会话共用一个管理器实例字段就会互相污染。我的做法是把ContextModeManager对象绑定到 session id 上并且用threading.Lock保护messages、summary和key_facts的读写。否则日志里会出现不同订单号串到同一次回答中的诡异情况。第五评估上下文模式效果时不要只盯着单轮准确率。单轮准确率是静态指标无法反映“模型是否记得三天前用户说过的收货地址”这种跨会话能力。我建议构造一组“回访问题集”正常问完 20 轮后故意问“我之前说的地址是什么”“我决定退还是换”。用这套测试集来对比不同 context-mode 参数才能看出真实差异。4.2 常见问题排查速查表我把线上常见问题整理成了一张速查表方便遇到症状时快速定位。现象可能原因排查思路解决操作模型回答与历史无关历史消息未注入请求检查_build_messages是否返回完整列表确认窗口消息与摘要都被加入回答中出现重复内容摘要与窗口消息重叠检查摘要生成前的 pending 回填长度只回填最后 1-2 轮避免重复冗余Token 消耗增长过快摘要未生效或窗口过大查看日志中 summary 是否持续更新调小summarize_threshold保证触发压缩关键事实串号会话对象未隔离查看 session id 与 manager 的绑定关系每个会话独享实例加锁保护字典摘要丢失商品型号摘要 prompt 未强调精确字段检查摘要 prompt 是否列出必留字段在摘要指令中增加“必须保留型号、价格”用户连续追问导致窗口刷掉窗口太小检查窗口大小为 6 是否覆盖最近连续追问增加window_size到 8-10这个表不是理论推导而是我从日志里一条条翻出来的真实情况。每个问题出现时第一步都是先看请求 payload 里到底传了什么而不是怀疑模型。因为 context-mode 的所有问题最后都会体现在“发给模型的 messages 列表”上。只要把这个列表打印出来看一眼摘要、关键事实、最近消息是否正确问题基本就能定位到具体环节。5. context-mode 的扩展与个人体会5.1 从单会话到多 Agent 场景上面的设计是针对单个用户会话的。但如果你的系统里有多个 Agent 协作比如一个研究 Agent、一个写作 Agent、一个审查 Agentcontext-mode 就变成了“跨 Agent 的上下文共享模式”。这时候不能简单复用一套管理器因为每个 Agent 的关注点不同。我目前在做的一个尝试是为每个 Agent 维护独立的关键记忆字典同时共享一份全局会话摘要。比如研究 Agent 关注“用户想研究什么主题”写作 Agent 关注“用户偏好的文风”审查 Agent 关注“用户是否有特殊限制”。三个 Agent 各自的 context-mode 参数不同但都在同一个会话对象里拿到摘要。实际操作下来这种做法比把所有上下文都传给每个 Agent 要省 40% 左右的 Token。如果将来接入多模态输入context-mode 还可以扩展为“图片描述摘要”。用户发了一张截图模型可以把截图内容先转成文字摘要再放入关键记忆后续对话不需要重复携带图片。这个方向我已经在测试效果不错只是图片摘要的生成成本和准确率还需要更多数据支撑。5.2 我的个人体会把 context-mode 从概念变成线上稳定运行的系统之后我最大的体会是这个问题没有标准答案只有适合业务的答案。不要一上来就追求“完美记忆”先保证“不犯低级错误”——比如角色混乱、字段污染、Token 炸裂。之后再根据真实对话数据一点点调整窗口大小、摘要阈值、关键字段。我踩坑踩得最值的一次是终于明白“摘要不是越短越好而是越关键越好”。500 字的摘要看起来比 2000 字的原始对话经济但如果它丢了订单号模型后面所有回答都白搭。与其纠结压缩率不如先让关键记忆层兜底再让摘要层去承担模糊的语义连贯。如果你正在做类似的项目我建议从一个小型 context-mode 开始一个 6 条消息的滑动窗口加一个 500 字的摘要加一张关键字段表。跑通后再慢慢调整参数。这个组合不惊艳但足够稳足够让你把更多精力放到业务本身。等哪天你的机器人能在第 30 轮准确说出“您上次让改成上海那个地址”你会觉得之前所有折腾都值了。