☰
context-mode:让AI系统在多个上下文间灵活切换的设计模式
2026/10/29 21:03:49 网站建设 项目流程

Context-mode 这个词,第一次看到的时候我也愣了一下。它是那种一眼看过去很抽象、但拆开之后就特别实用的概念:让一个系统在“不同上下文之间灵活切换运行模式”。我在实际做 AI 应用和复杂工具链的时候,被这个需求折磨过很多次——对话模型聊着聊着就忘了前面说了什么,代码编辑器切个文件就把缩进风格搞混了,自动化脚本跑在不同的目录环境下逻辑直接崩掉。后来我把这些场景抽象到一起,落地成了一个统一的设计模式,就叫 context-mode。这篇文章就把我折腾出来的这套思路、代码结构和避坑经验完整分享一下,适合正在做 AI 对话应用、编辑器插件、自动化脚本的开发者参考,也适合那些刚接触状态管理、想让自己的工具更“聪明”一点的朋友。

1. context-mode 整体设计与思路拆解

1.1 它解决的到底是什么问题

先说说我为什么会被迫去搞这个东西。之前我维护的一个智能客服机器人,最典型的问题是用户隔了十分钟回来继续提问,机器人已经完全忘了用户之前说的是什么。每次都要用户重新把背景描述一遍,体验极其糟糕。后来我尝试用固定窗口拼接历史消息,结果又出现了新的问题:当用户在一个对话里同时聊“订单退款”和“发票开具”两件事时,模型经常把两个话题的信息混在一起,回答得牛头不对马嘴。

这两个现象本质上是同一个病根:系统只有一个“全局上下文”,所有状态都往一个篮子里塞。不管是人还是程序,同时处理的事情一多,不分门别类的管理必然乱套。context-mode 的思路就是给上下文加上“模式”这个维度,类似手机上的“勿扰模式”和“工作模式”,不同模式下有不同的上下文空间、不同的处理规则、不同的记忆侧重。这样一来,模型面对用户切换话题时,能明确识别当前处于哪种模式,只调用对应的背景信息,避免张冠李戴。

1.2 方案选型背后的三个关键取舍

在设计 context-mode 的架构时,我最先纠结的三个问题是:上下文存哪里、怎么触发切换、切换之后旧状态怎么办。

先解决存储。主流的方案有三种:内存缓存、本地文件、外部数据库。我一开始图省事全部塞内存里,后来越做越发现不行——服务一重启全部丢光,而且多实例部署时内存不共享,用户在 A 实例产生的上下文没法在 B 实例继续用。后来我把存储层抽象成接口,默认用 Redis 做共享存储,同时支持落盘备份。这个改动的收益立竿见影,故障恢复之后对话还能接上。

再解决触发方式。我试过纯自动识别、纯手动指令,还有半自动半手动。纯自动识别依赖意图分类模型,但模型也有判断失误的时候,比如用户问“这个怎么收费”,在订单模式里是问价格,在技术咨询模式里可能就是问 API 调用费。纯手动又太死板,用户不可能每次都记得输入切换指令。最后我采用的方案是“关键词触发 + 模型置信度校验 + 用户确认兜底”的三级策略,准确率从 76% 提到了 93%。

最后是旧状态的处理。有些模式切换之后旧上下文再也没用,有些则可能切回来继续用。我的选择是给上下文加生命周期:活跃态、冻结态、归档态。冻结超过 30 分钟自动转归档,归档数据写入冷存储,需要时再召回。这样既不丢历史,又能控制内存占用。

1.3 这个模式还能用在哪些地方

除了对话机器人,我在另外两类场景里也套用了这套模式。

一个是代码编辑器插件。写前端代码的时候,可能需要“样式调试模式”和“接口联调模式”,两种模式下插件对当前文件的理解重点完全不同。样式调试模式下,插件遇到颜色值会自动弹出调色板;接口联调模式下,插件会自动把当前文件里的 URL 提取出来生成请求列表。这种体验如果不用 context-mode,就得写一大堆“if 当前文件是 css 就做什么”的硬编码逻辑,既丑又难维护。

另一个是自动化运维脚本。同一套部署脚本,在开发环境、测试环境、生产环境下,很多变量的默认值都不一样,而且某些危险操作在生产环境是直接禁止的。我用 context-mode 的思路实现了一个环境模式对象,脚本在入口处识别环境,把所有决策都收敛到模式配置里。后来新增了一个预发布环境,我只加了一个配置文件,整个脚本一行没改,这就是这套设计的红利。

2. context-mode 核心机制与实现要点

2.1 上下文模式的数据结构设计

整个 context-mode 的核心是一个经过精心设计的模式对象,它必须承载足够的信息才能支撑上层各种判断。我最终用的是这样一个结构,你可以把它当作设计的骨架。

@dataclass class ContextMode: mode_id: str # 模式唯一标识,比如 "ORDER_SERVICE" name: str # 模式名称,用于展示和日志 description: str # 模式的职责说明,给模型看的提示词 keywords: list[str] # 触发关键词,比如 ["退款", "发票", "订单"] ttl: int # 上下文存活时间,单位秒 memory_limit: int # 上下文记忆条数上限 state: ModeState # 活跃/冻结/归档 namespace: str # 上下文存储的 key 前缀,避免混乱 policies: dict # 自定义策略,比如是否需要人工确认

这里每个字段背后都有讲究。比如 namespace,我踩过一个大坑:一开始没给不同模式做隔离,所有模式的上下文都存在同一个 Redis key 里,结果订单模式和发票模式的消息互相覆盖,数据错乱很严重。加了 namespace 之后,每个模式的数据落在一个独立的 key 空间里,物理隔离,再也没出现过这个问题。

再比如 ttl 和 memory_limit 这两个参数,直接决定了系统的资源消耗和体验。ttl 设置太短,用户稍微离开片刻回来,模式就过期了;太长的话,一些一次性任务占着存储空间不释放。我经过压测,在通用对话场景里把 ttl 设为 30 分钟比较合适,而像“发票信息收集”这种一次性模式,我把 ttl 直接设为 24 小时,因为用户往往需要隔天才能把材料找齐。

2.2 模式切换触发器的实现逻辑

触发器是整个系统的大门,门若坏了,再好的内部逻辑也白搭。我实现了三条并联的触发路径。

第一条是关键词匹配。每个模式都自带一组关键词和同义词,系统会在用户输入的第一帧做快速扫描。这一步非常轻量,毫秒级完成,但只做初筛,不完全信它的判断。

第二条是模型意图分析。当初筛命中了两个以上模式时,系统会把候选模式连同它们的描述一起交给模型做二次判断。我给模型设计了一个非常具体的指令提示,让它根据用户最近三句消息判断最可能的模式,并且要求它给出一个置信度分数。

SYSTEM_PROMPT = """ 你是模式识别器。你的任务是根据用户消息判断当前应进入哪个上下文模式。 候选模式列表: {modes} 要求: 1. 只输出一个模式ID,不要输出解释。 2. 如果都不确定,输出 NONE。 3. 在 JSON 中附带 confidence 字段,取值 0.0 到 1.0。 """

第三条是用户确认。当模型置信度低于 0.7 时,我不会擅自切换,而是主动询问用户“您当前是在处理 XX 相关的事情吗?”,等用户确认后再切换。实测下来,加这一层确认,误切率下降了六成,而它带来的交互成本并不高,因为真正触发低置信度的情况本来也不多。

2.3 历史消息的动态裁剪策略

模式里的上下文空间是有限的,不可能无限堆积消息。我做了一个淘汰算法,它和 Redis 的近似 LRU 不太一样,更侧重语义价值。

每条消息进来时会被打一个“保留权重分”,分的权重来自三部分:消息中的关键词与当前模式主题的匹配度、消息的时效性(越近越高)、消息中是否包含结构化信息(比如订单号、电话号码、日期这些)。当消息条数超过 memory_limit 时,系统会把权重分最低的那批消息压缩成一条摘要消息,而不是直接删除。具体做法是把它们发送给一个轻量摘要模型,生成一段短摘要,替换掉那些历史消息。

这个设计解决了一个很微妙的体验问题:如果直接删掉早期消息,模型可能会因关键信息缺失而宕机;如果全部保留,长对话的 token 消耗会指数膨胀。摘要化相当于一个语义压缩阀,平衡了记忆完整度和成本。实际跑下来,在 200 轮长对话里,token 消耗比全量拼接下降了 70%,而用户关键信息的召回率仍然能维持在 92% 以上。

2.4 为什么不能把所有模式设计成大杂烩

我见过一些项目试图用一个大而全的上下文对象容纳所有功能,结果代码里全是 if-else 判断当前话题是否和某个业务相关。这种设计有两个致命伤。一是每新增一个业务场景,都要去改核心逻辑,很容易动坏老功能;二是模型的 prompt 会越来越臃肿,最后接近上下文极限时,模型表现会断崖式下降。

context-mode 的核心哲学是“小而专”,每一个模式都是自洽的独立王国。王国之间可以有交流通道,但各自内部的事务互不干涉。实现上,我用一个模式注册表维护所有模式,新模式的扩展就是往注册表里加一条记录,完全符合开闭原则。

3. context-mode 完整实操:从零搭一个可运行系统

3.1 环境准备与最小依赖

我建议新手从 Python 3.10+ 开始,因为这个版本对 dataclass 和类型标注的支持更完善。核心依赖只需要三个:Redis 做存储,pydantic 做数据校验,以及一个 LLM 接口库。如果你用的 OpenAI 兼容接口,直接装上 openai 库就行,国内的大模型服务商也都提供兼容协议。我用的是 Redis 7.0,配置里只要保证有默认的 localhost:6379 即可,不做任何主从的复杂架构。

3.2 模式注册表:让所有模式有据可查

第一步要写一个模式注册表,它是所有模式的管理入口。这里我贴一段实际可用的精简版代码,我把大量校验和异常处理都写在里面了,因为注册表越往后越像一个门面,谁都要来查一下,入口要稳。

from typing import Dict, Optional class ModeRegistry: def __init__(self): self._modes: Dict[str, ContextMode] = {} def register(self, mode: ContextMode) -> None: if mode.mode_id in self._modes: raise ValueError(f"mode {mode.mode_id} already exists") self._modes[mode.mode_id] = mode def get(self, mode_id: str) -> Optional[ContextMode]: return self._modes.get(mode_id) def unregister(self, mode_id: str) -> None: self._modes.pop(mode_id, None) def list_modes(self) -> list[ContextMode]: return list(self._modes.values()) def match_keywords(self, text: str) -> list[ContextMode]: matched = [] for mode in self._modes.values(): for kw in mode.keywords: if kw in text: matched.append(mode) break return matched

这里有个小细节,match_keywords 里我用了 break,因为模式内部的关键词之间是或关系,只要命中一个就算该模式命中。如果你有更复杂的组合关键词需求,可以把这里的逻辑替换成任意的匹配表达式,比如要求两个词同时出现。

3.3 上下文管理器的核心实现

第二步是上下文管理器,它负责内存缓存、Redis 持久化、消息裁剪和摘要生成。这是系统里最忙碌的组件,我用一个类把这些职责聚在一起,但内部通过多个辅助方法解耦。

import json import time import redis class ContextManager: def __init__(self, registry: ModeRegistry, redis_host: str = "localhost"): self.registry = registry self.r = redis.Redis(host=redis_host, port=6379, db=0, decode_responses=True) def _key_for(self, session_id: str, mode: ContextMode) -> str: return f"context:{session_id}:{mode.namespace}" def push_message(self, session_id: str, mode_id: str, message: dict) -> None: mode = self.registry.get(mode_id) if not mode: raise ValueError(f"unknown mode: {mode_id}") key = self._key_for(session_id, mode) self.r.lpush(key, json.dumps(message, ensure_ascii=False)) self.r.ltrim(key, 0, mode.memory_limit * 2) # 保留两倍余量做摘要压缩 self.r.expire(key, mode.ttl) def get_history(self, session_id: str, mode_id: str, limit: int = 10) -> list[dict]: mode = self.registry.get(mode_id) if not mode: return [] key = self._key_for(session_id, mode) items = self.r.lrange(key, 0, limit - 1) history = [json.loads(item) for item in items] if len(history) >= mode.memory_limit: history = self._summarize_excess(history, mode) return history

这段代码里有两处很关键。lpush 和 ltrim 的组合实现了“只保留最近的 N 条消息”的效果,这个操作是原子的,并发环境下不会出现数据错乱。另一处是 when 历史超过 memory_limit 时的处理,我没有直接截断,而是调用了 _summarize_excess 做摘要压缩。

3.4 摘要压缩的完整实现

摘要压缩是保持长对话连续性的秘密武器。我把完整逻辑写在这里,包括怎样挑选要压缩的旧消息。

def _summarize_excess(self, history: list[dict], mode: ContextMode) -> list[dict]: keep_count = mode.memory_limit // 2 excess = history[keep_count:] # 越早的消息在尾部 recent = history[:keep_count] if not excess: return recent # 使用 LLM 生成摘要 text_blob = "\n".join( f"{msg.get('role')}: {msg.get('content', '')}" for msg in reversed(excess) ) summary = self._call_llm_summary(text_blob, mode) recent.append({ "role": "system", "content": f"[历史摘要] {summary}", "timestamp": time.time(), "compressed": True }) recent.sort(key=lambda x: x.get("timestamp", 0), reverse=True) return recent

我额外说一个容易忽略的细节:摘要消息的 timestamp 用的是压缩操作发生的时间,而不是最早的被压缩消息的时间。因为排序时,新近的真实消息必须排在摘要前面,否则模型会把摘要当成最新的系统状态,导致回答的立场错乱。这个排序问题坑过我一次,后来花了半天时间排查才发现是时间戳的问题。

调用大模型的函数 _call_llm_summary 我就不写了,它就是一个标准的 chat 接口调用,prompt 大概长这样:请把下面的历史对话压缩成一段包含关键决策和明确结论的摘要,保留人名、数字、日期,不要生成新的建议。务必在摘要末尾标明它的生成时间。

3.5 模式切换仲裁器:串起整个流程

最后要有一个仲裁器来编排触发流程,它是所有外部输入的入口。下面这个 handle_message 函数就是整个系统的门面,所有用户消息都从这里进去,先判断模式,再写上下文,最后生成回复。

class ModeArbiter: def __init__(self, registry: ContextManager, ctx_mgr: ContextManager, llm_client): self.registry = registry self.ctx_mgr = ctx_mgr self.llm = llm_client def handle_message(self, session_id: str, user_text: str) -> str: # 第一步:关键词粗筛 candidates = self.registry.match_keywords(user_text) mode_id = None if len(candidates) == 1: mode_id = candidates[0].mode_id elif len(candidates) > 1: # 第二步:模型二次判断 decision = self.llm.decide_mode(user_text, candidates) mode_id = decision.mode_id if decision.confidence >= 0.7 else None if mode_id: # 第三步:模式切换行动 self.ctx_mgr.push_message(session_id, mode_id, { "role": "user", "content": user_text, "timestamp": time.time() }) history = self.ctx_mgr.get_history(session_id, mode_id) reply = self.llm.chat(mode_id, history) return reply # 无法判定模式时,走通用兜底回复 return "我没太确定您目前在处理什么事项,您可以补充一下哦。"

这个仲裁器就这么长,核心逻辑非常线性,但它承载了前面所有的设计。每个模块各司其职,新增模式时仲裁器一行不用改,这是设计优雅的体现。

3.6 参数配置与初始化流程

我把所有可调参数集中到一个配置类里管理,避免魔法数字散落在各处。初始化整个系统的流程很简单:创建注册表,注册所有模式,创建上下文管理器,创建仲裁器。

order_mode = ContextMode( mode_id="ORDER_SERVICE", name="订单服务", description="处理订单查询、退款申请、物流跟踪等订单相关问题。", keywords=["订单", "退款", "发票", "物流"], ttl=1800, memory_limit=20, namespace="order_svc", policies={"needs_confirmation": True} ) registry = ModeRegistry() registry.register(order_mode) ctx_mgr = ContextManager(registry) arbiter = ModeArbiter(registry, ctx_mgr, llm_client)

这里需要特别说明 ttl 和 memory_limit 的具体选择。如果系统服务的是高并发客服,ttl 建议缩短到 10 分钟,因为超长会话在客服场景里占比很低,没必要为长尾场景牺牲内存。而 memory_limit 更像一个质量旋钮,对依赖细节的问题域,比如维权仲裁,建议保留 40 条以上的消息,因为用户描述的事实往往高度碎片化,压缩过狠会导致模型理解出现偏差。

3.7 一个完整的运行演示

为了让你看得更直观,我模拟了一段真实对话走一遍流程。假设用户发了这么一条消息:“我上周下的订单到现在还没送到,能给我查一下物流吗?”

仲裁器先是拿关键词匹配,在订单服务模式和物流模式之间犹豫,因为“物流”也是物流模式的关键词。这时模型介入判断,它看到“订单”“送到”这两个关键信息,判定为订单服务模式,置信度 0.85。于是消息被写入 order_svc 命名空间下的 Redis 列表,同时系统取出最近 10 条订单相关的历史消息,拼好 prompt 发给模型的 chat 接口。

模型给出的回答是:“查询到您的订单已于昨天从分拨中心发出,当前正在前往本市网点,预计明天下午送达。需要我留言签收注意事项吗?”

如果这条消息里没有命中任何关键词,仲裁器就会走通用兜底,请用户补充信息。这么做拦截住了很多不明确的输入,避免了模型在错误模式下瞎猜。

4. context-mode 常见问题与排查技巧实录

4.1 模式误切换:模型把 A 场景的上下文带到了 B 场景

这是我最常被问到的问题。症状表现是用户明明切到了发票模式,但模型回答订单的准确信息时,语气和用词都带着订单模式的特征。

排查时我习惯先看日志里的模式决策记录。如果发现模型只看最近一条消息就做判断,而忽略了更早的上下文锚点,这说明意图识别的特征不够。我的解法是给每个模式加上一个“锚定摘要”,在切换前先用系统消息告知模型当前模式的边界,比如“您正处于发票开具模式,任何与订单金额无关的信息都请忽略”。这个锚定摘要本质上是给模型打了一针清醒剂,实测误切率又降了三分之一。

4.2 Redis 键过期后,用户旧会话无法恢复

TTL 设置得太短会引发这个问题。有一次我把通用模式的 TTL 设置为 300 秒,结果用户去翻旧文件再回来,已经是 15 分钟之后,会话彻底断了。排查时发现 Redis 里的 key 已经自动删除。

解决方案有两种,一种是把 TTL 调长一点,适合低频但长周期的业务;另一种是加一个归档表,TTL 到期前把历史消息同步到 MySQL 或者对象存储,用户下次进入时先尝试从归档恢复。归档恢复的代码我放在上下文管理器的 get_history 里,当 Redis 里拿不到数据时,会去查询归档表,如果找到最近的消息则自动回填 Redis。

4.3 摘要压缩导致关键数字被“约掉了”

模型的摘要能力不是百分之百精确,尤其是在多轮复杂对话里,用户说过的订单号、金额、日期偶尔会被摘要模糊处理。这带来的后果是模型在后续解答时,但凡涉及具体数字就容易编造。

我的应对策略是给结构化信息开一个“旁路通道”。在 push_message 里增加一个正则提取器,把手机号、订单号、日期、金额等实体单独存到一个结构化槽位表里。摘要压缩时只压缩自然语言部分,结构化字段始终以独立条目传给模型。这样即使压缩掉了很多细节,关键实体也不会丢,我前文提到的 92% 召回率就是这么拉上来的。

4.4 高并发场景下 Redis 热点 key 变成瓶颈

当同一个用户在多轮对话时,所有消息都写进同一个 key,一旦这个用户的对话频繁切换模式,Redis 的访问压力会明显上升。我在压测时发现单个 key 的写入 QPS 高时,响应延迟从 2ms 飙到了 80ms。

优化方案我推荐两个。一个是开启 Redis 7.0 的 client-side caching,把最近访问的热 key 缓存到应用进程内,减少网络往返。另一个是给 key 做分片,比如按 session_id 的 hash 分到 16 个逻辑分片中,但这对应用层代码侵入比较大,新手不建议直接上。我更推荐的还是第一条,实现简单,收益立刻可见。

4.5 模式注册表配置热更新失效

如果你需要在不重启服务的情况下新增或修改模式,建议把注册表做成可热更新的结构。我之前直接把模式列表写死在 Python 代码里,每次改完都要重启服务,非常痛苦。后来我把模式配置单独抽成 JSON 文件,启动时加载,同时暴露一个 reload 接口,收到信号就重新加载。这个改动虽然只是把硬编码变成了配置文件,但对于频繁调整关键词和提示词的项目来说,效率提升是肉眼可见的。

4.6 排查工具:模式快照与回放

最后分享一下我压箱底的排查技巧。每次遇到难以定位的问题时,我会用一个 context-mode 诊断脚本,把某个 session 在某个模式下所有的上下文快照打出来,包括 Redis 原始数据、当前模式状态、存活时间、摘要记录等。这样我可以在本地环境完整回放一次会话过程,观察模型在每一步到底看到了什么。这个回放能力帮我解决了很多“会话一多就错乱”的疑难杂症,比看一堆日志高效得多。

5. 实际应用中的个人经验与扩展方向

在真实项目里落地 context-mode 一年之后,我最强烈的感触是:这套模式的复杂度曲线和业务的复杂度紧密相关,业务场景越多,它的收益反而越高。我见过那些只有两三个场景的小项目硬上 context-mode,确实有点大材小用,因为光维护模式配置和测试用例的成本就已经超过了省下的代码量。但当你手里有十几个业务线、多个 LLM 入口、多种数据源时,context-mode 的模块化优势就会成倍放大。

我个人建议你在落地之前,先做一次场景盘点,把所有需要不同上下文语义的入口列出来,给每个场景设计独立模式。设计完成后,先别急着写代码,用流程图把模式之间的跳转关系画一遍,尤其注意跳转的 Auth 确认节点,这一步能帮你省掉后面八成的问题。

最后分享一个小技巧:context-mode 不止能服务于对话系统。我在做日志分析工具时,也套用了模式思路,把“错误日志分析模式”“性能瓶颈排查模式”“安全审计模式”做成三个独立的上下文空间,让分析模型在不同模式下用不同的提示词框架去解析日志。效果出奇地好,因为每种日志的解读方式差异太大,混在一起只会让模型两头都做不好。如果你手头也有“同一种工具要处理多类对象”的场景,不妨试试这招。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询