ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

从Context-Mode到分层记忆:大模型多轮对话上下文管理实践

从Context-Mode到分层记忆:大模型多轮对话上下文管理实践

1. 从“context-mode”说起:一个被忽视却决定体验的细节

写这篇文章的起因,是我最近在调一个项目时遇到了一件挺揪心的事。某跨平台系统里接了一个智能对话助手,用户聊着聊着,突然发现助手“失忆”了——前五分钟还在确认的订单信息,下一刻它竟然一本正经地反问用户“您刚才说的需求是什么来着”。排查了半天,最后定位到的根因跟模型没关系,跟接口也没关系,出问题的是一个我最初完全没放在心上的设计:context-mode 的实现方式。

很多人第一次听到“context-mode”这个概念,大概率是在某个开发文档或者配置界面里扫到一眼,然后迅速跳过。它的字面意思很直白——上下文模式,或者语境模式,看起来不就是“记住之前的对话内容再回复吗”?但真正在工程实践里把它做好做实,你会发现水比想象中深得多:上下文该存什么、不该存什么,存多久,重不重要,满了之后怎么淘汰,多轮对话之间怎么切换模式,这些全都是隐藏的坑。

这篇文章,我想把自己实际折腾 context-mode 的经验完整梳理一遍。既会聊它的核心原理和设计思路,也会给出能直接落地的实现方案和参数配置,后面还会把我在真实项目里踩过的坑和排查过程一并记录下来。无论你是准备在自己的应用里接入类似能力的新手,还是已经被“上下文管理”折磨过的老开发者,这篇文章应该都能提供一些值得抄走的作业。

1.1 context-mode 解决的真实问题

先把话说透:context-mode 解决的,本质上是“拟人感”和“记忆一致性”的问题。任何一段自然的对话,都依赖语境的延续。你跟人聊天时会自然地说“那个东西”,对方能接住,是因为彼此共享上文语境;如果你家智能助手每次都把你的话当第一次听到来处理,用户只会觉得对面是一个每回合重置的问答机。

在实际业务里,context-mode 通常分两层看待。第一层是会话上下文,也就是多轮对话本身的历史消息,谁在什么时间说了什么,助手的回复又是什么。第二层是业务上下文,用户当前处在什么页面、什么任务状态、操作过哪些关键动作,这些外部信息往往决定了助手如何理解当前这句话。只做第一层,是最初级、也最容易翻车的“伪上下文”,因为用户的语言和实际场景脱节时,助手会表现得非常笨拙;只有两层都覆盖到位,context-mode 才算真正成立。

1.2 为什么我建议你重新审视这个名词

我见过不少团队把 context-mode 简单理解成“把历史消息全部拼进 prompt 再发给大模型”。这个方案在 demo 阶段跑得通,但一旦进入生产环境,问题就接二连三地冒出来:token 开销直线上升、响应速度变慢、上下文一长模型反而被无关信息干扰,甚至出现“分不清哪句话是用户说的,哪句话是系统提示的”这种低级错误。

context-mode 更准确的理解,是一套完整的管理策略——它决定哪些信息进入上下文、以什么结构进入、在什么时机被更新或淘汰,以及如何在不同模式之间平滑切换。这不是一个简单的开关,而是一个需要认真设计的模块。我们用生活里的场景来类比:你跟一个聪明的助理开会,助理不会把你从认识他那天起的每句话都记着,他会记住你最近的需求、当前的重点、你的偏好,以及本项目相关的关键细节,然后把无关紧要的寒暄自动过滤掉。context-mode 要做的事情,就是把这个“聪明助理”的记性在代码里实现出来。

2. context-mode 整体设计的核心思路与方案选型

在设计一个带 context-mode 的系统之前,我建议先画一张“上下文流转图”,把信息从产生到消费的路径理清。这里不画 mermaid,我就用文字描述一下我常用的流转链路:用户输入事件进入系统后,先经过意图识别层,初判本次请求是否依赖旧上下文;然后进入上下文构建层,系统根据当前模式从短期记忆和长期记忆里取出相关信息,按特定模板组装成模型输入;模型返回结果后,再经过后处理层,把新的对话内容写回记忆仓库并更新状态。

2.1 先搞清楚你的业务需要哪种上下文形态

我做过的几个项目里,context-mode 的形态大致可以归成三类,你需要先明确自己属于哪一种,后续实现才有落点。

第一类是严格回话模式。这种模式要求模型每次都完整理解当前会话全部历史,多见于客服、咨询类场景。特点是上下文强依赖对话轮次,历史信息一条都不能丢,而且要按时间顺序组织。

第二类是任务跟随模式。常见于智能表单、引导式流程、多步骤操作场景。这里上下文的主角不是聊天记录,而是“当前任务状态”——用户已经填到第几步、中间跳过了哪些字段、有没有异常输入。聊天历史退居其次,任务是第一性。

第三类是知识检索模式。典型出现在包罗大量文档资料的知识库问答场景。模型不需要记住完整的用户历史,但需要结合当前问题和用户画像去检索最相关的知识片段。上下文是被检索结果动态填充的,而不是持久化累积的。

这三种形态不是互斥的,一个好的系统往往会同时支持多种模式,并在合适的时机切换。比如一个综合助手,用户正常闲聊时用回话模式,一旦检测到用户开始填写申请表单,就自动切到任务跟随模式;用户突然开始问“你们的产品规格怎么样”,又会触发知识检索模式。这就是我在标题里强调的“切换”二字背后的复杂性。

2.2 为什么说“全量拼历史”是最偷懒也最危险的方案

接大模型开发相关功能时,很多初学者第一反应就是把所有对话记录按“user/assistant”的格式全文拼进 system prompt 里。我当时在早期项目里也这么干过,还一度觉得效果不错。但等测试数据到了 30 轮以上,问题就暴露了。

第一个问题是上下文污染。模型不是人类,它不会自动区分哪些历史是决定性的,哪些是客套话。用户在前面随口说了一句“我应该不需要这个功能了”,后面又反悔说“还是要吧”,如果历史全量保留,模型很容易被前面那句冲突信息干扰,给出模棱两可的答复。更危险的是一些敏感信息,比如用户曾经输入过身份证号、地址,这些内容留在上下文里,不仅带来记忆负担,还有合规风险。

第二个问题是成本非线性增长。模型接口的计费基本都按 token 算,上下文越长,每次请求的成本就越高。对话轮数每涨一倍,输入 token 数几乎也在翻倍涨。对于日活量级上万的系统,这笔开销不是小数目。更关键的是,很多模型的输入长度有上限,超过上限只能截断,而粗暴截断会把最关键的早期信息丢掉,导致“越聊越蠢”。

第三个问题是置信度稀释。大模型处理超长文本时,对文本中后段信息的注意力会下降,这就是业界常说的“迷失在中间”现象。你把一万两千字的聊天记录往前一拼,模型真正关注的,往往变成了末尾最近几条,老早就把关键设定忘光了。因此,全量堆积不仅不省事,实际上是在系统性地削弱模型表现。

2.3 分层记忆架构:一次性说清楚短期、长期和工作记忆

真正可落地的 context-mode,底层通常是一套分层记忆架构。这个思路借鉴自认知科学,实现上却很简单也很实用。

工作记忆是模型当前这次请求实际能看到的内容,它的体量受限于模型的上下文窗口,我们每一次调用接口时组装出的 prompt,就是工作记忆的体现。工作记忆的组装策略,决定当前这次回答的质量。

短期记忆一般指当前会话内的历史消息,但它不是毫无筛选地全存,而是按相关性和时间做加权。比如最近 10 轮对话完整保留,更早的内容压缩成摘要,或者只保留其中提取出的“关键信息实体”(例如用户提到过的数字、日期、产品名)。短期记忆一般存在内存或 Redis 这类高速存储中,会话结束可以视业务情况选择清理或归档。

长期记忆则用于跨会话的个性化信息,比如用户的偏好设置、历史订单数据、常驻的地址、习惯的表达方式。长期记忆通常需要持久化存储,并且要有独立的写入策略——不能什么东西都往长期记忆里写,否则它很快就会变成一个大杂烩,失去意义。实践中我们会给长期记忆里的每条信息打上 confidence(置信度)分数和时间戳,只有重复出现且稳定的信息才有资格升级为长期记忆。

这套分层的核心价值,是把“无限的历史”压缩成“有限的、高价值的上下文”。模型不关心你存储了多少历史,它只关心你这一次喂给它的内容质量高不高。

3. 核心细节:上下文组装规则与关键参数设计

方案选型定了之后,真正的难点全在细节里。这一节我挑几个最关键的实现点来讲,每一个我都在项目里实际调试过,有些还付出了不小的代价。

3.1 上下文组装的三大区块与权重分配

我实践中比较稳定的 prompt 组装方式,是把 context-mode 生成的内容分为三个区块,按固定顺序拼接。

第一个是系统设定区,用来放项目级指令、助手角色定义、全局规则。这块内容在所有场景下都一样,一般不会因用户而变。第二个是核心语境区,这是 context-mode 的精华,里面存放从长期记忆里调取的该用户画像信息、当前业务状态,以及从对话历史中提取的关键实体和事件。第三个是近期对话区,放最近几轮原始对话,保证模型可以理解用户刚刚在说什么,给出自然的衔接。

为什么是这样一个顺序?因为大模型对最前和最后的内容关注度最高,中段内容容易被稀释。把最稳定的系统设定放最前面,让模型开篇就知道自己的身份;把最近的对话放最后,让模型直接接话;中间放核心语境,既有足够篇幅展开业务信息,又不至于抢占首尾的重要位置。实测下来这个布局比“历史全堆在前面”的结构稳定得多。

3.2 如何选择窗口大小:轮次、字符还是 token

窗口大小直接决定了工作记忆的容量。我见过不少人问“到底该保留多少轮对话”,说实话,没有标准答案,因为不同业务差异巨大。我一般用两个指标来综合考虑。

先看任务复杂度。如果只是简单问答,保留 5 到 8 轮就够;如果是复杂业务办理,比如客户申请理赔,那么流程会跨很多步骤,需要保留 20 轮以上。再看单轮消息长度。用户如果经常发长消息、粘贴大段文字,那么轮次少一点,但单轮内容质量很重要;如果用户习惯短句,则可以增加轮次上限。

我在项目中的一个实用做法,是放弃“轮次”而改用“token 预算”。给工作记忆设定一个硬预算,比如 4000 token,然后按“系统设定区固定占用 500,核心语境区最多占用 1500,近期对话区最多占用 2000”来分配。新消息进入时,对话区超出预算,就触发压缩策略,把最早的连续消息折叠成摘要。这个方案的好处是无论对话多火爆,接口成本永远是可控的,不会出现某个用户聊天太长导致单次请求超限的情况。

3.3 摘要压缩与关键信息提取的取舍

当历史消息超出预算时,你需要把旧消息“忘掉”。忘掉的方式,决定了模型记忆的质量。这里有两种常用策略,可以结合使用。

策略一是滚动摘要。每积累 N 轮对话,就调用一次模型,把之前的全部对话总结成一段 200 字以内的摘要,下一次组装时用摘要替换原始消息。这个方案效果好,摘要能保留语义精华,但代价是每次摘要需要额外消耗一次模型调用,而且摘要本身也存在信息损耗。

策略二是关键信息提取。在对话过程中实时维护一个结构化的信息表,比如“用户姓名、意向产品、预算金额、时间节点、特殊要求”。每当用户的新消息里包含这些槽位的信息时,就更新信息表。组装上下文时,把信息表整体放入核心语境区。这个方案成本低、结构稳定,非常适合任务型对话。

实际项目里我最常用的组合是:任务型信息用结构化提取,保证核心槽位不丢;非任务的寒暄、解释、反悔类内容,用滚动摘要去压缩。结构化提取兜底关键数据,滚动摘要保留叙事脉络,两者搭配,基本能满足大多数业务场景。

3.4 模式切换的触发条件与状态保持

前面提到过,一个完善的系统需要支持多模式切换。这里有一个实践技巧:切换不能只靠规则硬编码,需要给模式加一个置信度评估过程。

比如用户问“帮我看看上次说的那款产品还在不在”,这句话本身没明确说要切换模式,但“上次说的”表明强依赖历史,“产品”表明需要知识库检索。我常用一个小小的分类模型或规则引擎,在每轮用户输入到达时计算三个分数:回话相关性、任务相关性、知识相关性,取最高分作为当前模式的候选。如果候选模式与当前模式不同,并且置信度超过阈值,才执行切换;否则仍保持原模式。这种设计能避免用户偶尔一句话就被错误地从一个模式带偏到另一个模式,整体体感会自然很多。

状态保持方面,还要注意切换后不能让核心语境区内容丢失。我在实现时会把“用户画像信息”“任务状态表”“知识检索命中的文档编号”分开存储,切换模式时只替换其中对应的部分。这样一来,用户从任务模式切到知识检索模式,他之前填过的表单数据不会丢,只是暂时不进入当前工作记忆;等他切回任务模式时,状态又能无缝恢复。

4. 实操记录:一个可复用的 context-mode 实现方案

这一节我分享一个裁剪后的真实项目实现。模拟项目X是一个面向企业客户的智能售后服务助手,用户会咨询产品问题、提交故障报修、查询服务进度。它有典型的混合模式需求,适合拿来讲解完整实现。

4.1 基础环境与工具选型

技术栈我选了 Python + Redis + Postgres,模型接口采用主流大模型 API。选 Redis 存短期会话记忆,选 Postgres 存长期用户画像和业务数据,两者各有分工。

  • Redis:存当前会话的消息队列、滚动摘要、近期对话缓存,Key 用会话 ID,设置过期时间,会话结束 24 小时后自动清理。
  • Postgres:存用户表、用户偏好表、关键事件表,通过 user_id 关联,生命周期远长于单次会话。
  • 模型接口调用层之外,封装一个 ContextManager 类,对外只暴露三个方法:ingest(user_id, session_id, message)、build(user_id, session_id)、update_feedback(user_id, session_id, feedback)。这样的接口设计让上层业务代码能完全屏蔽上下文管理的复杂度。

4.2 关键代码结构解析

下面这段代码是 ContextManager 的核心骨架,我在项目里反复迭代后定为这个形态。它不算复杂,但每个方法都对应前文提到的一个关键环节。

class ContextManager: def __init__(self, redis_client, db_client, model_client): self.redis = redis_client self.db = db_client self.model = model_client self.system_setting = "你是某公司的售后智能助手,负责解答产品咨询、处理报修、查询进度。" self.window_token_budget = 4000 self.dialog_token_limit = 2000 def ingest(self, user_id, session_id, message): # 1. 追加原始消息到 Redis 队列 self.redis.rpush(f"session:{session_id}:messages", message) # 2. 尝试结构化提取关键信息并写库 slots = self.extract_slots(message) if slots: self.db.update_user_slots(user_id, slots) # 3. 如果队列总长度超过阈值,触发滚动摘要 if self.redis.llen(f"session:{session_id}:messages") > 12: self.rollup_summary(session_id) def build(self, user_id, session_id): # 组装三区块 prompt system = self.system_setting context = self.build_context_zone(user_id, session_id) dialog = self.build_dialog_zone(session_id) return system + "\n" + context + "\n" + dialog def rollup_summary(self, session_id): old_messages = self.redis.lrange(f"session:{session_id}:messages", 0, -2) summary_prompt = "请将以下对话压缩成120字以内的摘要:" + "\n".join(old_messages) summary = self.model.chat(summary_prompt) self.redis.set(f"session:{session_id}:summary", summary) self.redis.delete(f"session:{session_id}:messages")

注意几个关键设计意图:

  • 每次 ingest 时同时做结构化提取,这保证了即使后续消息队列被压缩,核心槽位信息依然存在数据库里,不会丢失。
  • 摘要触发条件是“队列条目数超过 12 条”,而不是按字符数。原因是我们已经通过消息内容长度控制了单条消息规模,按条数触发摘要的逻辑最简单直观。对于单条消息特别长的场景,你还需要再加一个字符阈值来配合。
  • build 的过程不直接操作原始消息队列,而是通过 build_context_zone 和 build_dialog_zone 两个方法分别读取不同数据源,再组装。这样一来,后续替换数据源或者加新的上下文维度,不会影响整体结构。

4.3 参数配置与效果对比:我调试出的几组经验值

把核心逻辑跑通后,真正的打磨在于调参数。这里分享三组我在不同场景下实测对比过的配置,你可以作为起点,再根据自己业务微调。

配置项简单问答场景任务办理场景混合场景(本项目)
系统设定区 token300300500
核心语境区 token3008001500
近期对话区 token8008002000
队列摘要触发条数101612
结构化提取关闭或不依赖必须开启开启
模式切换置信度阈值不启用0.750.7

从对比中可以看出一条规律:上下文窗口整体充足的时候,固定预算的方式并不会浪费模型能力,反而让每次请求的开销可预测。混合场景下核心语境区给到 1500 token 是一个比较均衡的点位,既能放下用户画像和当前任务状态,又不会挤压近期对话区。如果给太低,模型对用户业务背景的理解会变弱;给太高,近期对话区被挤占,接话的连贯性又会下降。

4.4 上线前的联调清单

项目上线前,我习惯按这份清单逐项检查 context-mode 相关功能,每一条都是真实踩坑换来的。

  • 多轮对话超过 20 轮时,核心槽位信息是否依然被正确携带。
  • 用户中途反悔修改过信息,上下文里是否以最新值为准,而不是旧值新值并存。
  • 大量历史被摘要压缩后,模型是否仍然能回答“你之前提到过某件事吗”这类跨轮问题。
  • 模式切换后,上个模式的关键状态是否不丢失、不回滚。
  • 并发场景下,同一 session 同时有两笔请求时,Redis 中的数据读写是否一致。

以上每一项,我在项目里都至少改过一轮。最典型的是信息冲突:用户开始说要 A 型号产品,聊到一半改成 B 型号。早期版本里结构化槽位只记录了“产品型号”一个字段,没有版本历史,导致后端的业务接口拿到的是最新值 B,但大模型在近几轮对话里还在回看 A,于是出现了模型回答和实际业务状态不一致的情况。后来我在槽位更新时加了一条规则:大模型回复必须基于最新槽位值,并且在核心语境区里额外写一行“用户最近一次意向调整为:B型号,以该信息为准”。就是这么一行提示,彻底解决了冲突问题。

5. 常见问题与排查技巧实录

这一节把我在不同项目里总结出的高频问题和排查过程记录下来。每个问题都给出症状、原因、解决办法三层说明,方便你对照自己的场景去索引。

5.1 症状:模型“忘了”很久之前的关键信息

最常见的一种线上反馈,是用户说“我前面不是跟你说过了吗”,但助手毫无反应。排查时先确认是不是历史消息已经被摘要压缩,而摘要里没有保留该信息。滚动摘要的目标是压缩文本,天然会丢失细节,尤其是那些在当时看起来不重要、后来却成为关键的信息。

解决思路分两步。第一步,对业务关键实体建立独立的“永不遗忘”存储,不走摘要流程。比如订单号、报修编号、预约时间这类强业务字段,提取后直接写库,每次组装上下文时都从库里读。第二步,如果摘要丢了信息,考虑把摘要周期调短,或在摘要 prompt 里明确指定必须保留的实体类别。这个方法改动小,效果立竿见影。

5.2 症状:模型答非所问,语义被“上下文污染”

很多次线上问题,模型突然开始答非所问,并不是模型本身变笨了,而是上下文里混入了干扰信息。我把最常见的污染源列出来:

  • 系统提示与用户真实意图冲突:排查时优先看核心语境区是否有前后矛盾的规则。
  • 过老的短会话片段残留:比如上一轮聊天气,这一轮问天气之外的事,上轮信息仍然在工作记忆里,模型容易被带偏离。
  • 多模式切换时保留了上个模式的专有指令:这是我最开始做切换功能时最容易犯的错,切换后旧指令没有及时清理。

针对这些,我的排查顺序是:先把工作记忆里除了系统设定区之外的内容全部清空,只保留最近一条用户消息,如果模型回复恢复正常,说明问题一定出在历史上下文组装逻辑上。然后再逐步加回各个区块,找出具体是哪一块污染了回答。这个“二分定位法”效率非常高。

5.3 症状:token 消耗突增,成本失控

会话长度增加带来的成本上升是预期内的,但如果是“突增”,就需要排查。常见原因之一是内容被重复拼接。例如代码里某个中间层又把完整历史拼进了调用,和 ContextManager 组装的内容叠加了,导致发给模型的消息里出现两遍同样的对话记录。

另一个原因是结构化提取触发了额外模型调用,而且每次都用了大模型来做提取。早期我在 extract_slots 里调模型做槽位解析,后来发现完全可以用正则加词典的方式覆盖 80% 的情况,只有遇到正则无法处理的长句才升级到模型提取。这个优化之后,提取成本直接降了一半以上。核心思路是:能用规则的绝不调模型,模型调用越少,成本和延迟都越可控。

5.4 症状:模式切换后上下文错乱

这个问题比较隐蔽,表面上看模型回复正常,但业务方反馈“助手好像换了一种语气”。最后定位到原因,是模式切换后,系统设定区没有随之切换。不同模式下,助手的“人格”应该是一致的,但约束规则可以不同。比如任务办理模式下,应该要求模型只围绕当前任务转,不主动展开闲聊;知识检索模式下,应该要求模型优先引用检索到的文档内容,不编造。

我的解决方案是在系统设定区里加入一个动态模式声明块,每次切换模式时重写这一小块内容,其余人格描述保持不变。切换时还需要把之前模式下生成的一些中间指令,比如“请向用户确认地址信息”这类临时指令,从历史消息里标记为失效。否则模型在任务模式下生成的临时指令,被误当成长期规则,就会一直影响后续所有回复。

5.5 排查技巧:善用“上下文快照”

最后分享一个我至今仍在用的调试习惯:为每次模型调用保存一个上下文快照文件,内容包含完整的组装 prompt、当时的时间戳、模式信息、各区块 token 占用。线上出问题时,直接调取快照回放,比对着日志猜半天高效得多。

快照的具体实现很简单,在 build 方法返回最终 prompt 之前,把它序列化存到本地文件或日志系统里。虽然多了一些存储开销,但为了定位那些“灵异”级别的上下文问题,这笔开销绝对值得。

6. 写在最后的经验沉淀与扩展方向

说实话,context-mode 这套东西,真正难的不是某个技术点,而是整体思维上的转变。很多项目把它当成一个“顺便做一下”的小功能,结果每次修改都牵一发动全身。我自己的体会是,从项目第一天就把上下文管理当成一个独立的核心模块来设计,后面省下的调试时间远超前期投入。它就像是整个对话系统的地基,地基不牢,后面楼盖得越高越容易慌。

如果你准备在自己的系统里引入或重构 context-mode,我建议从最简单的分层记忆架构开始,先把短期、长期、工作记忆的边界划清楚,再一步步叠加模式切换和摘要压缩。不要一开始就上最复杂的方案,因为在没有足够数据验证之前,复杂往往只是给自己添堵。

按照这个思路往下走,后续还可以扩展的方向有不少:跨会话的长期记忆如何做衰减和更新,用户明确要求“忘记之前聊过的东西”时如何执行遗忘逻辑,以及多用户共享知识库时如何做上下文隔离。这些场景本质上还是在围绕“context-mode”做深化,只是在不同业务维度上长出更多枝条。希望这篇文章能给你一些可复用的参考,让你在真正动手时少踩几个不必要的坑。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进