开头部分:
做 AI 对话应用的人,应该都遇到过这个场景:模型聊着聊着就“失忆”了,前几轮你明明告诉过它关键信息,它转头就忘;或者历史消息稍微一多,直接报 token 超限,整个对话被迫中断。我之前调一个客服问答机器人时,就被这个问题卡了整整两天,后来才意识到,问题的关键不在模型本身,而在于我没管好交给模型的那些“上下文”。这个管好上下文的思路,业内叫context-mode,直译过来就是“上下文模式”。
简单说,context-mode 是一整套决定“哪些上下文该被保留、哪些该被压缩、哪些该被丢弃”的规则和机制。它不是一个具体的库或框架,而是一种设计模式,落地的时候会用到 token 预算计算、消息截断策略、摘要记忆、结构化上下文注入等手段。只要你做的是带多轮对话能力的产品——不管是客服机器人、AI 编程助手、智能文档问答,还是给 Agent 用的记忆模块——都会碰到 context-mode 的设计问题。它解决的是产品体验里最基础但也最容易拖垮迭代的环节:让模型始终“看得到”且“看对了”它需要的信息。
这篇文章我会从原理讲到落地,把我踩过坑之后的完整方案拆开说明,包括 token 怎么算、历史怎么存、摘要怎么更新、长对话为什么会崩,以及一套可以直接抄作业的 Python 实现思路。适合正在做对话类 AI 应用、或者在调 Agent 记忆模块的开发者参考。
1. 什么是 context-mode:先理解问题出在哪
1.1 模型的“记忆”其实是窗口,而不是硬盘
要理解 context-mode,先要摆正一个认知:大语言模型的记忆不是硬盘,而是一块会随对话流动的“工作台”。模型能记住的信息,完全取决于当前请求里带了多少 token。你说过的历史、系统指令、检索到的文档、工具返回结果,全都堆在这同一个窗口里,窗口一满,后面的请求要么报错,要么由框架强行截断,截断时丢哪些内容,直接决定了模型的回答质量。
我刚开始调机器人时,总觉得“多传历史总没错”,结果到了第 30 轮对话,请求体已经从 10KB 涨到 40KB,接口时不时返回 400。更离谱的是,错误提示只说是 token 超限,但到底超在哪、该丢什么、能不能压缩,全得自己试。后来我才意识到,与其等报错再救火,不如一开始就把 context 当成一个“预算有限的资源”来管理,而 context-mode 就是管理这个资源的方式。
这里有个关键点很多人忽略:模型不是对话多了才变笨,而是上下文窗口里的信息变得杂乱和冗余之后,模型才抓不住重点。哪怕窗口没满,如果把 80% 的 token 都花在早期的客套话和反复重复的内容上,模型对最新问题的理解也会被稀释。所以 context-mode 的核心不只是“少传”,而是“传得对”。
1.2 context-mode 到底管哪些事
我习惯把 context-mode 拆成三个子问题,这样设计起来不会乱:
- 选什么进窗口:系统提示词、用户消息、助手回复、工具返回值、检索文档,哪些是当前轮次必需的。
- 不选时放哪去:被挤出去的旧消息不能直接丢,得切成可检索、可压缩、可回溯的存储,比如 Redis 里的会话记录、向量库里的摘要。
- 窗口满了怎么办:是先丢最旧的消息,还是把旧消息揉成一段摘要,还是把中间某几轮完整保留但压缩掉不重要的附件内容,这需要一套确定的策略。
这三个问题互相影响,比如你选了摘要策略,就得有一个摘要更新的触发条件;你选了向量检索,就得操心 embedding 的时效性。所以 context-mode 从表面看只是“截断历史”这么简单,但真正做起来,它是一套包含存储结构、触发逻辑、预算控制的状态机。后面我会一步一步展开。
2. 核心细节解析:context-mode 的四种常用策略
2.1 滑动窗口:最简单,但别指望它解决一切
滑动窗口的思路是“只保留最近 N 轮对话”。它的优点是实现起来极其省事,一个 deque 就能搞定,也不会有摘要失真之类的风险。缺点是模型的长期记忆基本为零——用户第 2 轮提过的偏好,到第 40 轮窗口里早就没了,模型会一本正经地用错误信息回答你。
我之前做过一个内部知识助手,最初就用的滑动窗口,固定保留最近 10 轮。测试的时候发现一个典型场景:用户先问“我们公司的报销流程是什么”,聊了 20 轮其他问题后,又问“那我刚才说的报销单要几份”,模型居然回答“请提供报销单数量要求”,因为它压根不知道“刚才”指的是哪个表单,也不知道用户已经问过报销流程了。这种体验用户是不可能接受的,所以滑动窗口虽然简单,但只适合极轻量的场景,比如一次性咨询、单轮问答工具。
2.2 摘要压缩:用一轮对话的 token,保住几十轮的记忆
摘要压缩的思路是:当对话超过一定轮数,就把早期消息发给模型,让它生成一段紧凑的“历史纪要”,之后请求只带这段纪要,而不是原始消息。
这个方法我用下来是“性价比最高”的。比如原始对话有 3000 token,摘要后可能只有 200 token,压缩比接近 15 倍。而且摘要里保留的不是逐字逐句的聊天记录,而是用户的核心意图、已确认的事实、待办事项。模型读摘要比读原始记录更容易抓住重点,回答反而会更稳。
当然,它也有代价。一是摘要生成本身会消耗一次模型调用,要花时间和 token;二是摘要会有信息损失,如果用户在第 10 轮说过一个细节,而摘要只提了“用户有报销需求”,那第 50 轮再用到这个细节时就可能出错。所以摘要策略一般要配合“关键信息预留”,我后面会讲怎么在摘要里强制保留必要字段。
2.3 结构化上下文:把系统提示词和临时记忆分开
这是一个经常被忽略的策略。很多人做多轮对话时,把所有信息一股脑塞进 messages,但更稳的做法是把 context 分成几个有固定角色的区块:
- system 区:放产品规则、人设、能力边界、输出格式要求。
- 核心事实区:放用户预设的信息,比如姓名、公司、偏好,这些内容必须长期保留,哪怕其他历史全删。
- 会话动态区:放最近的对话历史或摘要,这部分可以滚动更新。
- 工具返回值区:如果是 Agent 场景,工具返回的结果单独放,并在下一轮及时清理,避免残留在窗口里误导模型。
这样分区的意义在于,模型读上下文时不是均匀对待的,系统提示词和核心事实必须稳定,如果它们被厚厚的聊天记录挤到窗口边缘,模型对规则的遵守度会大幅下降。用结构化上下文后,即使历史被截断了,最关键的规则和事实还在,回答的稳定性会明显提升。
2.4 混合模式:生产环境下我更推荐的做法
我现在的项目基本不会只用某一种策略,而是把前面几种组合起来:
- 最近 5 轮对话完整保留,保证模型对当前话题的精细理解。
- 再往前的对话压缩成摘要,保留核心事实。
- 系统提示词和关键业务字段单独存,永远不参与截断。
- 摘要更新时,如果检测到用户在反复强调某个内容,就把这个内容提升为“核心事实”,并放入长期区。
这套混合模式在成本、效果、实现难度上是比较平衡的。它不像纯滑动窗口那样容易失忆,也不像纯摘要那样每一轮都要调用模型生成总结,整体 token 消耗可控,用户侧感知也比较好。
3. 实操过程与核心环节实现:一个可落地的 context-mode 方案
3.1 先算清楚 token 预算
做 context-mode 之前,第一件事不是写代码,而是算预算。拿 OpenAI 的 gpt-3.5-turbo 举例,它的上下文窗口是 4096 token,我们最好只用到 80% 左右,留下余量给模型计算本身。假设这个余量是 819 token,那么实际用于上下文的预算是 3277 token。
在这个预算里,我一般按经验分配:
| 区块 | 预算占比 | 说明 |
|---|---|---|
| 系统提示词 | 15% | 约 491 token,写死规则、人设、输出要求 |
| 核心事实区 | 10% | 约 328 token,保存用户预设的关键信息 |
| 最近对话完整区 | 50% | 约 1638 token,最近 4-6 轮原始消息 |
| 早期对话摘要区 | 20% | 约 655 token,滚动更新的会话纪要 |
| 预留/其他 | 5% | 约 164 token,给工具返回值或临时内容 |
这个比例不是死的,你要根据业务场景调。比如你做的偏单轮检索问答,检索文档占比就要调高,最近对话占比可以调低;你做的偏长对话陪伴,摘要区占比就要调高。关键在于,预算必须在进请求之前就算好,而不是等服务端报错再补救。
3.2 设计会话存储结构
存储方面,我建议不要只在内存里维护上下文,因为服务一重启就全丢了。至少要有一个持久化层,记录完整的消息历史,方便以后回溯和重建 context。
这是我的简化版存储结构:
# 会话数据结构(用类 dict 示例,实际可存 Redis 或数据库) session = { "session_id": "abc-123", "system_prompt": "你是一个客服助手,回答要简洁。", "core_facts": { "user_name": "张三", "preferred_language": "中文", "ticket_id": "T-20240601" }, "summary": "用户咨询报销流程,已确认需要发票。", "recent_messages": [ {"role": "user", "content": "报销单要几份?", "ts": 1717228800}, {"role": "assistant", "content": "两份,一份财务留存,一份自存。", "ts": 1717228805} ], "full_message_log": [], "updated_at": 1717228810 }其中full_message_log存所有原始消息,用于生成摘要和排查问题;recent_messages只留最近几轮;core_facts负责保存必须长期记住的信息;summary是早期对话的压缩结果。
实际存储时,我可以把recent_messages和summary放内存或 Redis,key 用 session_id,读取速度快;完整日志放到数据库里,方便后续做数据分析。这样 context-mode 的读写不至于成为接口性能瓶颈。
3.3 摘要更新触发逻辑
摘要不能每一轮都生成,成本太高,也不能从不生成,否则窗口迟早爆掉。我建议设置一个触发条件链:
- 当前最近消息的总 token 数超过预算上限(比如预设 2000 token)。
- 或者,会话轮数超过 N 轮(比如 12 轮)。
- 或者,用户显式要求“总结一下我们聊过的内容”。
一旦触发,就把summary和最近消息中较早的部分一起发送给模型,让它生成一个新的摘要,替换旧的summary。这里有个关键技巧:生成新摘要时,要把旧的 summary 也喂给模型,而不是只喂原始消息,这样摘要能保持连续性,不会每次生成都从零开始。
我给出的摘要提示词大致是:
你是一个对话记录员。请把下面的对话历史整理成一段简洁摘要,要求: 1. 保留用户已经确认的事实、偏好、待办事项。 2. 保留与业务相关的关键字段,如单号、金额、日期。 3. 不要在摘要中编造原文没有的信息。 4. 如果已有旧摘要,请结合旧摘要和新增对话进行合并更新。 旧摘要: {old_summary} 新增对话: {new_messages} 请输出更新后的摘要。注意第 2 条,业务关键字段必须强制保留。我就遇到过摘要里把“T-20240601”这个工单号丢了,导致后续询问工单状态时模型一头雾水。强制字段是摘要策略里很重要的兜底。
3.4 组装请求时的完整流程
组装一个最终请求,我总结成四步:
- 从存储里读 session,取出 system_prompt、core_facts、summary、recent_messages。
- 计算出各区块的 token 占用,判断是否超过预算。如果超了,先把 recent_messages 里最旧的几轮折叠进 summary,直到满足预算。
- 构建 messages 列表:
- 第一条永远是 system,内容是 system_prompt 加上 core_facts 的序列化文本。
- 中间是 summary 和 recent_messages 里较早的消息,summary 可以用一个特殊角色消息包一层,避免和用户消息混在一起。
- 最后是 recent_messages 里最新的几轮。
- 将 messages 发送给模型,拿到返回后,把用户消息和助手消息追加到 recent_messages 和 full_message_log。
这里有一个容易遗漏的细节:用户当前这个最新问题,一定要放在消息列表的末尾,千万别因为预算问题把它截断了。我见过有同学把最新问题放中间,模型理解起来就特别迟钝,因为注意力机制更侧重前后部信息。
3.5 实际运行的性能数据
我用这套方案跑过一个客服问答机器人,用 gpt-3.5-turbo,压测了 200 组多轮对话,每组 20-50 轮不等。结果大致如下:
| 指标 | 纯滑动窗口 | 混合 context-mode |
|---|---|---|
| 平均请求 token 数 | 3500-4200 | 1200-1800 |
| 回答相关度(人工打分 1-5) | 3.1 | 4.4 |
| 第 30 轮后“失忆”概率 | 43% | 8% |
| 接口超时率 | 6% | 0.5% |
请求 token 减少带来的直接收益是成本下降,原先一次请求经常奔着 4000 token 去,现在稳定在 1500 左右,成本差不多省了一半多。更关键的是回答质量稳住了,测试用户反馈“助手像真的记住了我一开始说的东西”。这个对比让我坚定了混合模式的路线。
4. 常见问题与排查技巧实录
4.1 摘要压缩后信息漂移,关键数字记不准
这是摘要策略最常见的坑。模型生成摘要时,如果提示词没强调“保留原文关键字段”,它就会用自己的话重述,把数字、日期、单号“编”得差不多但不对。比如原文是“预算 5800 元”,摘要可能变成“预算约 6000 元”。
我的排查思路是:让摘要模块单独输出一个key_factsJSON 字段,和自然语言摘要分开。这样就能在代码侧对关键字段做规则校验,比如字段类型是数字,就先格式化再比对原文,不一致就回退到原始文本。加了这个兜底后,“预算 5800 元”变“6000 元”的问题基本不再出现。
4.2 最新问题被截断,模型答非所问
我之前发现模型偶尔会莫名其妙地重复旧回答,排查到最后发现是组装 messages 时,按 token 从旧到新遍历,结果把最新那一条用户消息给截掉了。这个问题很隐蔽,因为请求体看起来还是有内容的,没有报错,只是回答不对。
排查建议:在请求日志里打印最后一条 message 的 role 和 content 前 50 个字符,确认最新输入一定在。我后来在代码里增加了一个断言,一旦发现 messages 的最后一条不是 user 或最终的 assistant 消息,立刻报警。这算是 context-mode 里最值得加的防御性逻辑。
4.3 系统提示词被历史挤掉,模型人设崩坏
另一种常见问题是系统提示词明明写了“不要编造数据”,但一旦上下文变长,模型就开始胡编。原因往往是系统提示词作为消息列表第一条,被截断逻辑当成了普通旧消息,优先级不够高,或者虽然没被截断,但它距离当前轮次太远,模型对它的“关注度”已经下降了。
解决方法是把 system 内容拆到两个地方:短而核心的规则放第一条,业务性描述放进一个每次请求都重复注入的固定段落。同时,在组装消息时,绝对不让系统提示词参与“超预算裁剪”。系统提示词的 token 占用是固定成本,要算进预算,但不能算进可裁剪区。
4.4 Agent 场景下工具返回值污染上下文
做 Agent 时,每次工具调用都会把很长的返回结果塞进消息列表。有一个插件调用返回了几百 KB 的数据,token 直接爆了,模型开始胡言乱语。更麻烦的是,有些工具返回值只在当轮有效,下一轮根本不需要,却还留在上下文里,白白占预算。
我的习惯是:工具返回结果单独放一个字段,并设置有效期——只在当前轮次拼接进 messages,下一轮就不带了。如果后续轮次真的需要这个结果,就让模型在回答里生成一个精简的结论,把它写进 summary。这样既保留了对模型有用的信息,又避免了工具原始返回拖垮请求。
4.5 长对话里产生“幻觉记忆”
还有一类问题不是截断导致的,而是模型把摘要里的猜测当成了事实。比如摘要写着“用户可能偏好邮件通知”,后续轮次模型直接说“用户偏好邮件通知”,看起来很像记忆,其实是幻觉。
这种问题没有完全消除的办法,只能缓解。我会在摘要提示词里明确要求:摘要内容必须标注“确定”和“不确定”两类,不确定的信息不要写进 summary,而要写进一个open_questions字段。这样模型在后面的轮次里,就不会把推测变成笃定的记忆了。
4.6 排查问题时的日志技巧
最后说一个排查 context-mode 问题非常有用的技巧:在每次请求前,把最终组装好的 messages 按区块导出成 JSON,存一份本地文件。排查时不看模型回答对不对,先看 messages 里的内容对不对。很多所谓“模型变笨了”的问题,其实是上下文里信息错了、少了、或位置不对。日志里把system_prompt、core_facts、summary、recent_messages分开打印,能让你一眼定位问题出在哪个环节。
5. 进阶扩展:context-mode 与记忆系统的融合
5.1 把摘要升级成可检索的记忆区
当会话数量多了以后,你会发现单条摘要虽然省 token,但跨会话信息联动的需求往往接不住。比如用户在这个会话里提过“喜欢蓝色主题”,下一次开新会话,模型可能就不记得了。这时可以考虑把摘要和核心事实抽出来,做成一个“长期记忆区”,每次生成后把摘要转成向量,存入向量库。
这样在组装 context 时,可以先对用户当前问题做向量检索,把最相关的历史记忆找出来,并注入到核心事实区里。这种模式实际上是带检索增强的 context-mode,我试过之后效果很好。需要注意的一点是,向量检索会引入一定的延迟和额外的存储成本,所以触发条件要控制好,不要每个问题都去查库,可以只在会话开始时或用户提起旧话题时触发。
5.2 不同模型窗口大小不一样,策略要跟着调
市面上不同模型的上下文窗口差别很大,有的 8k,有的 128k,甚至更大。大窗口不是万能的,窗口再大,如果内容太乱,效果照样不行。我见过一个团队换成 128k 窗口后,干脆把完整历史全塞进去,结果模型反而开始出现严重的注意力偏移。
我自己的经验是:窗口越大,越需要“分区”,而不是越需要“多塞”。即使有 128k 的预算,我也建议把最近对话控制在 6-8 轮内,其余全部摘要化。大窗口的优越性更多体现在可以容纳更多检索文档和工具返回结果,而不是用来无限堆积聊天记录。
5.3 什么时候该上专门的记忆框架
如果你不想从零实现,社区的 LangChain memory 模块、LlamaIndex 的 chat memory 设计,或者国内一些 Agent 框架自带的记忆机制,都内置了 context-mode 的思路。它们封装了 ConversationBufferMemory、ConversationSummaryMemory 等组件。我试用过其中的摘要记忆组件,基础场景下开箱即用,但它的定制灵活性一般,比如我想自定义“关键字段强制保留”逻辑就很麻烦。
所以我的建议是:先想清楚你要用什么策略,再决定用框架还是手写。早期验证阶段可以用框架快速跑通,等业务逻辑复杂了,尤其是涉及多个业务字段强制记忆时,还是要自己实现一个轻量 context 管理模块,才最可控。我最终就是保留了框架的调用方式,但把 context 组装逻辑换成了自己的实现。
5.4 评估 context-mode 效果好坏的三个指标
最后,评估这套机制做得怎么样,我建议盯着三个指标:
- 记忆准确率:用户在后续轮次提到早期信息时,模型能否正确引用。这个可以人工构造测试集来打分。
- 平均请求 token 数:同样的业务量,token 花费下降多少,直接关系成本和响应速度。
- 截断触发后的回答降级率:模拟对话长度暴增的场景,看模型是否因为丢上下文而乱答。
这三个指标其实对应了 context-mode 的三个目标:记得住、花得少、降级得优雅。如果你的方案在这三项上都在变好,那说明方向是对的。
我个人在实际项目里体会最深的,是 context-mode 不是一个“配一次就万事大吉”的东西,它需要跟着业务会话模式持续调。用户平均聊几轮、会经常提到哪些关键字段、哪些场景要求强记忆,都会影响预算分配和摘要策略。每次看到机器人准确说出用户在 40 轮前提到的一个细节,我都会觉得当初在上下文管理上花的时间特别值。这篇文章里的思路和代码,都是我在真实线上项目里跑过的方案,你直接照着搭一套跑通没有太大问题,但更建议根据你的业务场景把触发阈值和字段强制保留逻辑调一遍。context-mode 说到底,就是想办法用最省的 token,保留最需要的信息,让模型始终在线。