context-mode:多轮对话与Agent任务中的上下文分层切换机制
前几天复盘一个多轮对话项目时,我翻后台日志,看到一个特别典型的失败 case:用户先让助手查华东区某型号库存,第二个任务紧接着问“能不能在周报里自动带上这个型号的销售趋势”。结果模型把“华东区”一起带进了第二个任务,生成的周报建议全是华东区视角。这还真不是模型笨,是我们把不同类型的上下文全部拼在一个窗口里喂给了它。后来我把这套处理方式整理成一套可复用的设计,内部代号就叫 context-mode。
context-mode 不是什么新模型,也不是某个大厂的框架,它是一种“上下文管理模式”:把全局设定、业务领域资料、当前任务状态拆成不同层,按任务边界显式切换、入栈、出栈,而不是一股脑线性拼接。它解决的是多轮对话、复杂工具调用、Agent 任务编排里最头疼的“上下文污染”和“上下文丢失”问题。适合正在做对话系统、企业知识助手、AI Agent 或类似长链路任务产品的开发者和架构师参考。下面我把从模型设计到落地踩坑的完整过程拆开讲。
1. 为什么需要一种专门的“上下文模式”
1.1 问题现场:用户的一句话让整个上下文失真
多轮对话系统的上下文管理,大多数团队的第一版做法都是“把历史消息全拼进 prompt”。这种做法在 demo 阶段完全没问题,轮次一多问题就暴露出来了。
我们当时的系统是这样的:用户和助手连续对话,助手背后会调用库存查询、销售报表、周报生成等多个工具。前几轮对话里,用户完成了“查询华东区某型号库存”这个任务,下一轮说“帮我把这个型号的销售趋势加到周报模板里”。从人的角度理解,第二句话只跟“这个型号”有关,跟“华东区”没关系。但模型从拼接的上下文中读到的信息是:用户一直在聊华东区,那么“周报”大概率也是华东区的周报。
于是生成的周报结构、筛选条件、注释全部带上了华东区色彩。用户看完直接来了一句:“我没让你做华东区啊。”这种情况不是偶发,而是轮数一长、任务一多就必然出现的规律性问题。
我们当时试过两种补救方案。第一种是裁历史——只保留最近 N 轮。结果就是任务做到一半,前几轮确认过的关键条件被裁掉了,助手开始“失忆”。第二种是给 prompt 加一句“请忽略前面已完成任务中的地域信息”。结果更灾难,模型把正在进行的任务信息也忽略了,而且每次都要在 prompt 里写这种矛盾指令,越写越乱。
这两个方案的本质问题是一样的:把上下文当成一条连续的时间线处理,却没有区分这条时间线上哪些内容属于哪个任务、属于哪个业务域、有效期到什么时候。上下文的“边界”在工程上不存在,模型自然只能靠猜测。
1.2 从失败方案到 context-mode 的演进
踩过上面两个坑之后,我们做了一个关键转变:不追求“把所有信息都给模型”,而是追求“把当前任务需要的信息准确给模型”。这个转变听起来简单,落地却很麻烦,因为你需要一套机制来回答三个问题:
- 当前这个回合属于哪个任务?
- 这个任务要看到哪些历史信息?
- 任务结束后,哪些信息要带走,哪些信息要丢弃?
于是我们设计了 context-mode。它借鉴了操作系统里进程上下文切换的思路:进程切换时,CPU 保存当前进程的寄存器状态,加载下一个进程的状态,等切换回来再恢复。对话系统也一样,每次任务切换,我们保存当前任务的上下文状态,加载目标任务需要的历史状态,被切走的状态不参与后续生成。
这套思路的产物就是一套两层栈结构加三条触发规则,后面章节我会展开讲实现。先记住结论:要解决上下文混乱,就得让上下文有边界、有生命周期、有显式切换策略,而不是让模型在混沌文本里自己找重点。
2. context-mode 的模型设计:把上下文拆成可切换的独立层
2.1 三层上下文模型:任务层、领域层、全局层
对实际业务建模时,我把上下文拆成了三层:全局层、领域层、任务层。这三层不是物理上存三份,而是逻辑上严格隔离开,拼接进 prompt 时按固定顺序组装。
全局层存放最稳定、最不经常变的内容:Agent 的角色设定、企业知识助手的品牌话术、用户的基本身份信息、全局性的偏好(比如“所有金额统一保留两位小数”)。全局层和具体业务任务无关,任何模式下都要带,所以我把它放在 context-mode 的最外侧,等同于操作系统里的全局寄存器。
领域层存放当前业务域的背景资料。比如这是一个供应链知识助手,领域层可能就是物料主数据、库存规则、采购周期说明;如果切到财务域,领域层就换成财务审批流程、税率规则、预算口径。领域层解决了“任务必须了解领域常识”的问题,它不随单次任务结束而销毁,而是随模式切换而替换。
任务层是最动态的一层,只存放当前任务进行中产生的临时状态:已经确认的查询条件、用户的中间输入、工具调用返回的中间结果、当前执行到第几步。任务结束,这一层直接出栈,不留给下一个任务。
这三层划分带来的直接收益是:prompt 里的信息密度大幅提升。以前一份 prompt 可能有 60% 是无关历史,现在每个模式只会带当前任务层 + 当前领域层 + 全局层,有效信息占比接近 90%。
2.2 上下文生命周期与切换时机
分层只是静态结构,真正让 context-mode 跑起来的是生命周期管理和切换时机。我把它拆成三个阶段:进入模式、运行模式、退出模式。
进入模式发生在用户开启一个新任务时。系统识别到用户意图从“查库存”变成“生成周报”,就为“周报生成”这个任务创建一个新的任务层节点,记录 mode_id、父模式 ID、创建时间,并把相关的领域层加载进来。原来的“查库存”任务层不会销毁,而是压入栈中,等用户切回时再恢复。
运行模式就是正常的多轮对话。每一轮用户的输入都会更新当前任务层:已经确认的信息标记为“confirmed”,还没确认的标记为“pending”,工具返回的结构化数据存入临时变量。
退出模式有两个触发条件。一是用户明确表达当前任务结束,比如“就这些吧”“好了”。二是检测到明确的新任务意图,此时当前任务层被压栈,新任务层成为当前层。如果用户三天后又回来问“上次那个库存查得怎么样了”,系统就把原来的“查库存”任务层弹栈恢复,之前的中间状态还在。
我特别想强调的一点是:模式切换的时机不能只靠关键词,否则会误伤。用户可能只是随口问一句“周报是什么”,并不真的要切换任务。所以我们在最终实现里加了意图置信度阈值,低于阈值时按当前模式继续,高于阈值才切换,处于中间带时可以让用户确认“你是要切到周报任务吗”。这个抑制机制非常重要,后面踩坑章节我会展开讲。
3. 核心实现:从状态机到触发规则
3.1 上下文栈的入栈出栈规则
context-mode 的底层数据结构是栈,但不是简单的一个栈,而是双层栈:领域栈和任务栈。领域栈通常深度很浅,一般一两个;任务栈深度最多五层,超过就强制做摘要折叠。
入栈规则很简单:新任务意图触发时,push 一个新的任务节点。出栈规则需要小心,分两种情况。第一种是用户明确结束当前任务,直接 pop。第二种是新任务的意图置信度很高,但当前任务还有一些未完成动作,这时不直接 pop,而是标记当前节点为 suspended(挂起),然后 push 新节点。挂起的节点不代表任务丢了,而是可以被恢复。
这套设计里最关键的参数是栈深上限。我们刚开始不设限,结果用户连续开了多个任务后,栈里的任务节点越积越多,prompt 里即便只放任务层,也会把好几个任务的关键状态带出来,又出现了上下文污染。后来我们把任务栈上限设为 5,第 6 个任务进来时,对栈底最旧的任务做“完结式摘要”,把它变成一段 200 字以内的结论摘要放入全局层的长期记忆区,然后丢弃原始任务层。栈深永远不超过 5,prompt 体积也就可控了。
3.2 上下文窗口压缩与摘要轮
除了任务切换,还有个必须处理的场景:单个任务内部轮数太多,token 接近模型窗口上限。这种情况不能简单截断历史,否则最前面的任务目标、已确认条件会丢失。context-mode 的做法是“摘要轮”。
摘要轮是一个特殊机制:当累计 token 达到阈值(比如窗口上限的 70%),系统暂停正常对话,自动触发一次压缩。压缩不是让模型重写全部历史,而是从任务层里提取四类关键信息:
- 任务目标(一句话描述用户最终想要什么)
- 已确认条件(用户明确说过的参数、偏好)
- 进行中步骤(已经执行到哪一步,下一步计划)
- 未解决问题(悬而未决的矛盾、待用户确认的点)
这四类信息按固定模板压缩成结构化摘要,替换掉原来的对话历史,继续后续回合。模型看到的不再是零散逐字对话,而是一份“任务进行状态报告”。实测下来,摘要轮之后的继续对话质量比纯截断高很多,因为摘要强制筛选了决策型信息,而不是靠模型在冗长历史中自己找。
需要补充一个实现细节:摘要轮产生的摘要本身也要带 mode_id 和时间戳。如果任务切换后再次恢复,恢复时不应该生硬地把摘要放回窗口,而是要判断摘要的时效性。超过两天未活动的挂起任务,恢复时先问用户“是否继续之前未完成的任务”,而不是直接拿旧摘要续聊。这点我们在上线后被用户反馈教育过,后面踩坑部分会细说。
3.3 可复现的核心代码骨架
下面这段代码是我在项目里实际用到的核心类精简版,去掉了业务耦合,保留了完整机制。语言是 Python,框架无关,可以直接抄走改。
from dataclasses import dataclass, field from typing import Any, Optional from enum import Enum import time import json class ModeState(Enum): ACTIVE = "active" SUSPENDED = "suspended" COMPLETED = "completed" COMPRESSED = "compressed" @dataclass class ContextNode: mode_id: str state: ModeState parent_mode_id: Optional[str] = None created_at: float = field(default_factory=time.time) last_active_at: float = field(default_factory=time.time) # 三层内容 global_layer: dict[str, Any] = field(default_factory=dict) domain_layer: dict[str, Any] = field(default_factory=dict) task_layer: dict[str, Any] = field(default_factory=dict) # 摘要轮产物 compressed_summary: Optional[str] = None class ContextModeManager: def __init__(self, max_depth: int = 5, compress_threshold_ratio: float = 0.7): self.stack: list[ContextNode] = [] self.current_mode_id: Optional[str] = None self.max_depth = max_depth self.compress_threshold_ratio = compress_threshold_ratio self.long_term_memory: list[dict[str, Any]] = [] def switch_mode(self, mode_id: str, domain_layer: dict[str, Any]): """切换到新任务模式,当前任务挂起""" if self.current_mode_id: current_node = self._get_current_node() current_node.state = ModeState.SUSPENDED current_node.last_active_at = time.time() if len(self.stack) >= self.max_depth: self._compress_oldest_task() new_node = ContextNode( mode_id=mode_id, state=ModeState.ACTIVE, parent_mode_id=self.current_mode_id, global_layer=self._inherit_global(), domain_layer=domain_layer, task_layer={}, ) self.stack.append(new_node) self.current_mode_id = mode_id def resume_mode(self, mode_id: str): """恢复一个被挂起的任务模式""" for node in self.stack: if node.mode_id == mode_id and node.state != ModeState.COMPLETED: node.state = ModeState.ACTIVE node.last_active_at = time.time() self.current_mode_id = mode_id return raise LookupError(f"mode {mode_id} not found") def update_task_layer(self, key: str, value: Any, confirmed: bool = False): node = self._get_current_node() if confirmed: node.task_layer[f"confirmed_{key}"] = value else: node.task_layer[f"pending_{key}"] = value node.last_active_at = time.time() def complete_current_mode(self): node = self._get_current_node() node.state = ModeState.COMPLETED summary = self._build_summary(node) self.long_term_memory.append( { "mode_id": node.mode_id, "summary": summary, "completed_at": time.time(), } ) self.stack = [n for n in self.stack if n.mode_id != node.mode_id] self.current_mode_id = node.parent_mode_id if self.current_mode_id: self.resume_mode(self.current_mode_id) def need_compress(self) -> bool: """根据当前 token 估算判断是否需要触发摘要轮""" node = self._get_current_node() estimated_tokens = self._estimate_tokens(node) return estimated_tokens >= self.max_window_tokens * self.compress_threshold_ratio def compress_current_mode(self): """摘要轮:把当前任务层压缩成四要素摘要""" node = self._get_current_node() node.compressed_summary = json.dumps( { "goal": node.task_layer.get("goal", ""), "confirmed": node.task_layer.get("confirmed", {}), "progress": node.task_layer.get("progress", ""), "open_issues": node.task_layer.get("open_issues", []), }, ensure_ascii=False, ) node.state = ModeState.COMPRESSED def _get_current_node(self) -> ContextNode: for node in self.stack: if node.mode_id == self.current_mode_id and node.state == ModeState.ACTIVE: return node raise RuntimeError("no active mode") def _compress_oldest_task(self): """栈溢出时把最旧任务做完结式摘要,放入长期记忆""" oldest = self.stack[0] summary = self._build_summary(oldest) self.long_term_memory.append( {"mode_id": oldest.mode_id, "summary": summary, "completed_at": time.time()} ) oldest.state = ModeState.COMPLETED self.stack = self.stack[1:] def _build_summary(self, node: ContextNode) -> str: return json.dumps( { "goal": node.task_layer.get("goal", ""), "result": node.task_layer.get("result", ""), "user_pref": node.task_layer.get("user_pref", {}), }, ensure_ascii=False, ) def _inherit_global(self) -> dict[str, Any]: return self.stack[0].global_layer.copy() if self.stack else {} def _estimate_tokens(self, node: ContextNode) -> int: raw = json.dumps(node.task_layer, ensure_ascii=False) return len(raw) // 3 # 中文场景粗略估算代码不复杂,核心就一个栈加几个状态。真正的工作量在触发规则和 prompt 组装策略上,这块我在下一节用实测数据说明它为什么值得做。
4. 实测效果与直观收益
4.1 我们自己的评测场景说明
上线前,我们搭了一套专门压测 context-mode 的评测集,覆盖三条业务线:库存查询、销售周报、供应商协同。评测集包含 18 个多轮任务场景,每个场景 6 到 12 轮对话,中间都会穿插一次任务切换。
对比组有两个:A 组是旧的“全量历史拼接”方案,B 组是“最近 5 轮截断”方案,C 组是我们这套 context-mode。三个方案用同一个模型底座,同一个工具调用层,只替换上下文组装逻辑。评测指标有三个:任务完成率(用户最终目标是否达成)、工具调用参数正确率(模型传给工具的筛选条件是否正确)、平均 token 消耗。
4.2 三个核心指标的变化
先说任务完成率。在 6 轮以内的短任务里,三组差距不大,A 组甚至因为信息全而略胜 3 个百分点。但任务一旦超过 8 轮,差距立刻拉开。A 组掉到 55%,B 组 48%,C 组稳定在 82%。关键原因就是任务切换之后,context-mode 会把旧任务的状态清出窗口,模型不会再把“华东区”这种旧条件带进新任务。
第二个指标是工具调用参数正确率。这个指标更能说明问题。比如“查一下所有华东供应商交期超过 7 天的记录”这种任务,模型需要正确填入 region=华东、delay>7 这两个参数。A 组在切换场景里经常把上一轮任务的参数也带上,导致一次查询混合多个业务条件。C 组因为每个任务有独立任务层,参数都从当前任务层读取,正确率从 58% 提到 86%。
第三个指标是 token 消耗。C 组平均每会话 token 比 A 组低了 28%。原因很好理解:窗口里不再堆积无关注释、已完成任务的中间话术、重复的领域常识。用户看不到 token 减少了,但响应速度实打实上来了,P95 响应时长从 2.3 秒降到 1.7 秒。
这里要特别说明一个容易误读的地方:token 消耗降低不是因为模型变聪明了,而是因为我们的 prompt 变干净了。同样的模型能力,喂给它的信息更精准,输出质量自然就更高。这也是我把 context-mode 称为“工程侧收益”而不是“模型侧收益”的原因。
5. 我在落地时踩过的五个坑
5.1 坑一:全局上下文优先级过高,反而覆盖了任务上下文
第一版实现里,全局层拼在最前面,并且我们很自信地认为“全局层的一定是对的”。结果出现了一个很怪的现象:任务层里用户明确说了“这次只看华东区”,全局层里的用户偏好却是“默认展示全国数据”,两条信息冲突时,模型按全局层执行,把华东区忽略掉了。
排查链路是这样的:先怀疑是任务层信息没传进来,打了日志发现任务层确实有华东区。然后又怀疑是 prompt 顺序问题,把任务层挪到全局层前面,情况稍好但仍有冲突。最后定位到根因:全局层里那个“默认展示全国数据”是产品初期写死的规则,它不该是用户偏好,而应该是一条“无特殊说明时生效”的默认值。
修复方式:把全局层拆成硬性规则和默认偏好两类。硬性规则(企业品牌话术、安全限制)优先级最高;默认偏好只在任务层没有明确信息时才注入 prompt。任务层有明确值时会显式覆盖默认偏好。区间上做了一个“任务层优先、默认值兜底”的拼接顺序,冲突率降到了接近零。
5.2 坑二:嵌套任务切换后,摘要真的“丢了”
这个坑是我们自己设计缺陷导致的。当时摘要轮只在 token 阈值触发的压缩场景里执行,但嵌套任务切换时,子任务被压栈,等父任务恢复时,子任务里已经确认过的关键条件没有同步回父任务层。
举个例子:父任务是“生成七月的经营分析周报”,子任务是“查一下七月的退货率数据”。子任务查到退货率 2.6%,用户说“好的,继续吧”。在嵌套切换场景里,这个 2.6% 只存在子任务的任务层,父任务的任务层没有收到写入。结果周报生成时,退货率数据缺失,助手甚至没意识到自己查过这个数。
排查后发现,问题本质上是缺少“子任务结果回传”机制。后来我加了同步规则:子任务被挂起或完成时,如果任务层里有标记为 result 的字段,自动写回父任务层的对应字段。相当于 CPU 中断处理里的现场保存和寄存器回写。补上这个机制后,嵌套场景的数据断裂就再没出现过。
5.3 坑三:意图切换被误触发,用户话没说完任务就切走了
context-mode 最怕的不是切换失败,而是切换太激进。上线早期,我们切到“识别到新任务关键词就切换”的策略。用户正在查库存,随口问了一句“这个型号和周报里的趋势图有关系吗”,结果“周报”两个字触发了切换,当前库存任务被挂起,用户本来想多看两眼的库存详情页上下文被清走了,助手开始一本正经地回答周报结构。
这个问题的本质是:关键词能表达意图,但表达不了意图强度。修复方案分两层。第一层是置信度阈值,只有意图置信度连续 N 轮高于阈值才切换。第二层是“可撤销切换”,切换后公开两个动作:用户说“等等,我还在看刚才的库存”,系统就弹栈恢复。后来我们把可撤销切换做成了手动按钮,用户点进去可以直观看到当前挂起的任务列表。这个功能用户反馈很好,因为它把切换行为从“模型替你决定”变成了“模型建议、用户确认”。
5.4 坑四:模式标签被模型当成了业务信息
为了在 prompt 中区分不同模式,我们在拼接时加了类似<mode>inventory_audit</mode>的标签。结果模型在输出里也会引用“inventory_audit”这个字符串,甚至出现“根据 inventory_audit 模式的数据显示”这种话。这在用户看来完全是黑话。
问题出在标签放的位置:标签和业务内容混在同一段文本里,模型没法区分“控制指令”和“业务内容”。修复方式是把模式标签放到 system prompt 层,业务内容放 user 层,同时在格式化模板最前面加一行“内部标识不得出现在回答中”。还得在评测集里加一项“回答中不应出现 mode_id”,防止回归。
5.5 坑五:跨模式引用历史任务,直接查栈结构查不到
最后一个坑是产品需求层面的。用户聊完库存任务后切到周报任务,然后问“我上次那个库存结论能不能加到周报里”。此时库存任务已经被压缩或完成,任务层不在了。我们最初的设计里这种跨模式引用是查不到的,助手只能抱歉。
后来补了长期记忆索引层。每个任务完成时,把结果摘要同步写入长期记忆区,并给每条摘要建一个主题标签。跨模式引用时,先用当前任务层的相关关键词检索长期记忆,再把命中的摘要作为“引用资料”注入当前任务层。这样既保证了当前上下文的干净,又实现了跨任务信息复用。
这里有一个平衡点:长期记忆不能全量注入,否则又退回到“全量上下文”的坑。我们的规则是一次最多注入 2 条相关摘要,每条不超过 200 字,而且只作为参考素材,不作为当前任务的硬性事实。
6. 不是所有场景都适合用 context-mode
6.1 适合的场景与判断标准
在推广这套方案的过程中,我越来越明确一点:context-mode 不是万能的,它有明确的适用边界。
适合的场景有三个特征。第一,任务边界清晰,用户可以明显区分“这件事”和“那件事”,比如查库存、生成周报、审批采购单。第二,多轮对话的轮次长,任务内部有中间状态,上下文不能简单靠“最近几轮”裁掉。第三,工具调用密集,模型需要从上下文里提取准确参数,错一个值整个任务就崩。
最简单的判断标准是:如果你的对话系统退一步只看“最近一轮输入”就能做对,那 context-mode 只会增加复杂度,没有收益。如果必须靠前面五轮以上的信息才能正确执行当前操作,那你就需要它。
不合适的场景我也列一下吧。单轮问答型产品(比如纯文档问答)、闲聊型机器人、一次性 RAG 检索场景,这类场景的事件之间没有强依赖,引入上下文栈只会让系统变慢,还增加了误切换的风险。
6.2 和主流 Agent 编排方案的边界
很多人问我们:LangGraph、Multi-Agent 框架里已经有 graph state 和 memory 的概念了,为什么还要自己搞一套 context-mode?
我的回答是:它们解决的不是同一个层级的问题。LangGraph 这类框架管的是“Agent 的图编排状态”,解决的问题是谁先谁后、节点之间怎么传递消息。context-mode 管的是“喂给模型的窗口内容”,解决的问题是哪些上下文该出现在当前生成中、哪些该被隔离在外。你可以用 LangGraph 编排任务流,同时在每个节点里用 context-mode 管理 prompt 组装;也可以完全不用编排框架,只用 context-mode 做上下文管理。
从工程实践看,context-mode 和编排框架是互补关系。当时我们之所以没有直接用框架自带的 memory 机制,是因为那个机制还是“全量保存 + 全量恢复”,不解决我们需要“按任务隔离”的问题。context-mode 真正的贡献是建立了一层显式的模式边界和生命周期规则,它让上下文管理从“模型自由发挥”变成了“工程可测试”。这才是它最值得借鉴的地方。
最后分享一点个人体会:上下文管理这种问题,表面上是技术问题,本质上是产品设计问题。你只有先想清楚用户的任务长什么样、任务之间怎么切换,然后才能在工程上做到位。不用非得叫 context-mode,重点是建立起“上下文有边界、切换有规则、历史有生命周期”的意识。这套机制我们后来还扩展到了数据分析助手、售后客服机器人和企业内部的知识问答系统里,每一次适配都比上一次顺。如果你也在做类似的多轮产品,我建议先拿一个真实任务清单手推一遍上下文切换的过程,再写代码,收益会大得多。