前几天复盘一个多轮对话项目时我翻后台日志看到一个特别典型的失败 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 字以内的结论摘要放入全局层的长期记忆区然后丢弃原始任务层。栈深永远不超过 5prompt 体积也就可控了。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_factorytime.time) last_active_at: float field(default_factorytime.time) # 三层内容 global_layer: dict[str, Any] field(default_factorydict) domain_layer: dict[str, Any] field(default_factorydict) task_layer: dict[str, Any] field(default_factorydict) # 摘要轮产物 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_idmode_id, stateModeState.ACTIVE, parent_mode_idself.current_mode_id, global_layerself._inherit_global(), domain_layerdomain_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(fmode {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[fconfirmed_{key}] value else: node.task_layer[fpending_{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_asciiFalse, ) 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_asciiFalse, ) 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_asciiFalse) 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华东、delay7 这两个参数。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 中区分不同模式我们在拼接时加了类似modeinventory_audit/mode的标签。结果模型在输出里也会引用“inventory_audit”这个字符串甚至出现“根据 inventory_audit 模式的数据显示”这种话。这在用户看来完全是黑话。问题出在标签放的位置标签和业务内容混在同一段文本里模型没法区分“控制指令”和“业务内容”。修复方式是把模式标签放到 system prompt 层业务内容放 user 层同时在格式化模板最前面加一行“内部标识不得出现在回答中”。还得在评测集里加一项“回答中不应出现 mode_id”防止回归。5.5 坑五跨模式引用历史任务直接查栈结构查不到最后一个坑是产品需求层面的。用户聊完库存任务后切到周报任务然后问“我上次那个库存结论能不能加到周报里”。此时库存任务已经被压缩或完成任务层不在了。我们最初的设计里这种跨模式引用是查不到的助手只能抱歉。后来补了长期记忆索引层。每个任务完成时把结果摘要同步写入长期记忆区并给每条摘要建一个主题标签。跨模式引用时先用当前任务层的相关关键词检索长期记忆再把命中的摘要作为“引用资料”注入当前任务层。这样既保证了当前上下文的干净又实现了跨任务信息复用。这里有一个平衡点长期记忆不能全量注入否则又退回到“全量上下文”的坑。我们的规则是一次最多注入 2 条相关摘要每条不超过 200 字而且只作为参考素材不作为当前任务的硬性事实。6. 不是所有场景都适合用 context-mode6.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重点是建立起“上下文有边界、切换有规则、历史有生命周期”的意识。这套机制我们后来还扩展到了数据分析助手、售后客服机器人和企业内部的知识问答系统里每一次适配都比上一次顺。如果你也在做类似的多轮产品我建议先拿一个真实任务清单手推一遍上下文切换的过程再写代码收益会大得多。
阅读完成 · 觉得有帮助?