context-mode:多轮对话 Agent 的上下文管理工程化实践
第一次被“上下文写爆”支配是凌晨两点还在调一个多轮对话 agent 的时候。前几轮回答都正常第六轮调用返回的内容开始和用户上一句提问完全脱节甚至把上一轮自己编的数据当成事实继续往下说。我换上更大的模型精简了提示词试了各种温度参数问题依旧。现在回头看根源根本不在模型是我从来就没有把“上下文”当成一种需要刻意设计的数据结构来管理。我管自己后来写的这套东西叫 context-mode也就是“上下文模式”。它不是什么新框架也不是某个开源库而是一组约定和实现套路核心思路一句话让每一轮请求都只在该有的上下文里工作而不是把聊天记录整个倒给模型。这篇文章不打算讲高深理论我会把整个思路、关键代码、踩过的坑都摊开。适合正在做对话 agent、客服机器人、知识库问答或者 Copilot 类应用的朋友你大概率会遇到同样的问题。1. context-mode 的定位与核心思路很多人会问上下文管理不就是保留几轮对话历史吗事情没这么简单。你要是只保留最近的几轮确实能应付短对话但稍微复杂一点的任务比如帮用户查订单、改预约、写一段代码每轮之间都有依赖单纯裁剪历史就会把任务状态剪没。context-mode 做的事情就是把这些依赖关系显式地建模而不是让模型在浓雾里猜。1.1 先回答一个最基础的问题上下文到底是什么我观察到一个现象技术讨论里提到“上下文”时大多数人默认指多轮聊天记录。这个理解太窄了。真实的系统里上下文至少由四部分构成。第一部分是系统层指令也就是角色设定、业务规则、禁止事项、输出格式这一类稳定信息一般是写死在 system prompt 里的。第二部分是多轮会话历史这是用户和助手最近的原始对话。第三部分是外部事实包括检索回来的文档片段、数据库查询结果、实时接口返回的数据。第四部分是隐含状态比如用户的偏好、已经确认的订单号、当前正在填写表单的哪一步。这四部分里外部事实和隐含状态最容易被忽略。我问过不少做 agent 的团队他们的上下文直接就是 system prompt 拼上 memory 列表结果模型的表现会飘忽不定。context-mode 的第一步就是把这几类信息分开建模、按优先级管理并且在每轮请求发出前决定哪些信息必须进提示词、哪些信息可以存到外部存储里。1.2 做这个模式前我碰过的三面墙把这个模式总结出来之前我在实际项目里至少吃过三次大亏。第一次是窗口塞爆。模型的上下文窗口是有硬上限的8k、32k、128k 看起来都很大可真聊起来烧得非常快。尤其是每次回复还要预留 token 给模型生成如果我不设置预留区请求一到长对话阶段就报 400 超限。网上很多教程说“超限就裁剪”可等我自己实现裁剪才发现如果按最简单的“只保留最近 N 条”来裁很可能把关键信息也裁掉下一轮模型就忘记用户已经确认过的事。第二次是上下文污染。聊天记录里混着噪音比如用户误输入的一句“算了不要了”模型会把这句话当成决策来执行上一轮模型自己答错了错误的答案又会被当作下一轮的事实。最麻烦的是污染会扩散一条错误信息能影响后续好多轮输出而且你很难定位它到底是从哪个环节进到上下文里来的。第三次是状态断档。用户的会话在服务端是有 session_id 的但我早期偷懒把所有用户共用一个全局上下文对象结果两个用户聊着聊着就串了。这个在后面我会详细说但先提醒一句上下文管理绝不只是拼字符串它本质上是一个状态管理问题。1.3 为什么不用现成框架而要自己定义模式现在框架很多有专门做记忆管理的库也有可视化编排平台那为什么还要自己定义 context-mode因为大部分框架都默认了“记忆越多越好”会把所有历史塞进某个存储里需要时再全量召回。这个思路对 demo 有效但对生产环境来说过于粗糙因为你要控制的不只是存了什么更重要的是每一轮请求到底送了什么给模型。自己定义模式的好处就是逼你思考三个边界哪些信息是永久有效的哪些是只在当前会话里有效的哪些是这一轮用完就可以丢的。想清楚这三个边界代码结构会简单很多排查问题也容易。框架反而会把这三个边界模糊掉。所以我宁愿用很朴素的 Python 类来搭底盘把审批流程、召回逻辑全部显式表达在代码里。2. 上下文结构的搭建设计一套可维护的 context 容器如果上下文是一个箱子那箱子里面的东西必须是有序的。乱塞一气的后果是模型读取时不知道该信哪条。我最后稳定下来的方案是三层结构。2.1 三层模型基座、记忆、瞬时第一层叫基座层是系统提示词的加强版。里面放角色定义、业务流程、强制规则、安全限制、输出格式。这一层权重最高任何情况下都不能被裁剪。调试的时候也省事你要改行为只需要改这一块不用在一堆历史记录里翻来翻去。第二层叫记忆层也就是跨轮次共享的状态。包括用户明确说过的偏好、当前任务进度、关键实体引用等。这层需要随对话实时更新而且不应该存原文应该存提炼后的短摘要。比如用户说“我上次那个订单希望能加急”记忆层里应该存“订单编号 xxx状态待加急”而不是把 20 个字原样堆积进去。第三层叫瞬时层它只对当前轮有效。比如用户从页面上带过来的筛选条件、临时选择的选项这些信息用完就清。如果不清它们会一直躺在记忆层里成为一个又一个噪音。这里有个挺符合直觉的逻辑模型能记住的细节是有限的你把不重要的东西也塞进去重要的东西被淹没的概率就会变大。三层对应的代码结构通常是三个独立字段。组装消息时按优先级拼接基座最前记忆其次瞬时最后。有人担心 system prompt 放前面会不会被模型“忽视”实测下来不会至少远比放在后面可靠。我曾经把系统规则放在消息数组末尾结果模型越来越像在跟规则对话而不是跟用户对话一度让我怀疑是模型版本出问题。2.2 token 预算怎么分才算够用上下文窗口再大你也不能把全部空间都用来装历史。我给一个可以直接抄作业的分配方式假设模型窗口是 32k最多使用量设置成 24k剩下的 8k 给模型输出和接口余量。在 24k 里基座层控制在 2k 以内记忆层动态调整大概 4k瞬时层很少不到 1k剩下的大约 17k 才是会话历史。历史还要继续滚动裁剪每条消息从旧到新逐个淘汰直到总量低于预算。这里要特别强调一句token 计算不要用字符数去猜。同样一组汉字在不同分词器下的 token 数可能差很多。我最早图省事用len(text) * 0.6估算结果一个用户密集提问的晚上直接被打爆因为实际 token 数比估算高出近一倍。之后就老老实实接上模型对应的 tokenizer所有写入历史的消息统一先过一遍统计函数。也许你会问24k 这个数字不够怎么办如果你的应用天然就是超长对话比如玩文字游戏或者写长篇小说那应该把“完整历史”从提示词中搬出来用检索来做。提示词里永远只放最近几轮加提炼摘要这是硬规则。2.3 一个可直接参考的上下文快照示例我随便构造一个场景帮助你直观感受三层结构到底长什么样。假设用户进入一个订餐机器人的页面选了火锅然后问“能预约今晚六点吗”。组装出来的消息数组大概是这样的system基座: 你是订餐机器人负责处理预约和推荐菜品。 禁止输出不存在的优惠信息。 当用户需要改约时必须确认原订单编号。 system记忆: 当前用户意图预约。 火锅用餐人数 2 人偏好辣锅。 user瞬时: 能预约今晚六点吗历史里如果没有更早的原始轮次这里 base、memory、transient 拼起来就可以发请求了。这个例子看起来很简单但你会发现基座和记忆分得清清楚楚。如果用户下一句说“改成七点”瞬时层被替换记忆里没有动模型就知道用户仍然想预约火锅只是时间变了。如果不拆层模型得从“能预约今晚六点吗”这句原文里重新推理多一步就多一个出错的概率。2.4 工具选型向量库、摘要还是滚动窗口很多人一听“上下文管理”就立刻想到上向量数据库我的建议是分场景。如果做的是多轮任务型对话不涉及大规模文档召回向量库大概率是过度设计。你维护一套向量库的代价包括切分、embedding、召回、更新远远大于收益。这种情况下滚动窗口加摘要就够了简单而且可控。如果做知识库问答那确实需要向量检索但注意别把检索到的结果直接全量塞给模型。正确的做法是先做 rerank只截取 top k 个片段再把这些片段压缩成摘要式描述而不是一股脑堆进上下文。如果对话又长又杂比如写作助手我会把历史做成“事件摘要 关键原文 最近几轮原文”的复合结构。这样模型既能拿到概括后的场景也能在需要时看到具体的原始措辞。最忌的就是“什么都要索引”。第一版我也试图把所有历史打到向量库里后来发现向量召回对“语义相似”有效但对“最近决策状态”这种强时序信息并不友好一个很早之前说过的话换种措辞就召不回来了白白增加系统复杂度却没什么收益。3. 从零实现一个 session-aware 的 context-mode 模块理论说了半天直接上代码更痛快。我把 project 里的核心结构拆出来讲代码已经简化过但核心骨架保留。3.1 先定义消息和容器我用 Python dataclass 定义消息因为类型清晰序列化方便。后面要做持久化直接转 dict 存 JSON 就行。from dataclasses import dataclass, field from typing import List, Dict, Any dataclass class Message: role: str # system / user / assistant / tool content: str extra: Dict[str, Any] field(default_factorydict) dataclass class ContextMode: base: List[Message] field(default_factorylist) memory: List[Message] field(default_factorylist) transient: List[Message] field(default_factorylist) history: List[Message] field(default_factorylist) max_tokens: int 24000 reserve_output: int 8000 history_budget: int 16000这里base对应基座层memory对应记忆层transient对应瞬时层history放原始多轮对话。我特意没有把history并进memory因为二者职责不同。history是最近的真实原始记录memory是提炼后的状态摘要。模型读取时先看摘要需要细节时再回看原始记录。这样长对话也能保持稳定。3.2 用户输入的解析与记忆更新每轮用户输入进来先别急着拼提示词应该先经历三个步骤解析输入、更新记忆、写入历史。这个顺序不能乱因为解析需要从最新输入里抽取信息写历史是为了下一轮还能看到原始表达。下面是一个最小实现class ContextEngine: def __init__(self, ctx: ContextMode): self.ctx ctx def ingest_user(self, text: str): parsed self.parse(text) self.update_memory(parsed) self.ctx.history.append(Message(roleuser, contenttext)) def parse(self, text: str) - Dict[str, Any]: entities {} if 订单 in text: entities[intent] order_status if 加急 in text: entities[urgent] True return {text: text, entities: entities} def update_memory(self, parsed: Dict[str, Any]): ents parsed.get(entities, {}) if ents: self.ctx.memory.append( Message(rolesystem, contentf用户状态{ents}) )真实项目里解析逻辑不要依赖正则写死除非你的场景极其固定。更通用的做法是单独调一次小模型做槽位抽取把用户说的意图和关键实体提炼出来。小模型如果错了会带偏后续所以要带一个置信度阈值置信度低于阈值就不写入记忆。这里也算一个经验宁可记忆少一点也不要记忆脏一点。脏记忆比没记忆还麻烦因为模型会自以为是地把错误信息当事实。3.3 压缩和裁剪策略的落地压缩是整个结构中我花时间最多的地方也是最容易写出 bug 的部分。我采用“双阈值”策略一个是硬性上限超过就立即压缩一个是软性水位超过之后先做摘要但不立刻裁剪。双阈值的好处是避免每轮都触发压缩减少延迟抖动。def token_count(messages: List[Message]) - int: # 这里接入模型的 tokenizer比如 tiktoken 或模型自带的编码器 return sum(len(m.content) for m in messages) # 示意实际要用 tokenizer def trim_and_compact(self): if token_count(self.ctx.base self.ctx.memory self.ctx.history self.ctx.transient) self.ctx.history_budget: return recent self.ctx.history[-6:] older self.ctx.history[:-6] summary self._summarize(older) self.ctx.memory.insert(0, Message(rolesystem, contentf历史摘要{summary})) self.ctx.history recent_summarize是核心。一个省钱的办法是只在超过软性水位时才调用大模型并且用固定 prompt 让它输出 JSON 摘要。摘要里必须包含任务、已确认事项、用户的显式偏好这三类信息。缺失任何一项后面基本等于白压缩。当时我踩的坑是只让模型“总结一下”结果模型把订单号丢了用户下一轮问“那我那个单子怎么处理”机器人一脸茫然整个流程就卡住了。3.4 与上层 Agent / 工具调用的衔接context-mode 不是一个独立的服务最终要接上 agent 框架。我习惯在发送给模型前提供一个build_messages()方法把基座、记忆、历史和瞬时层组装成完整的消息数组。def build_messages(self) - List[Dict[str, str]]: parts self.ctx.base self.ctx.memory if self.ctx.history: parts parts self.ctx.history parts parts self.ctx.transient return [m.__dict__ for m in parts]组装完之后要先计算 token。如果超限就调用trim_and_compact再重新组装一次。这个顺序我建议固定下来不要每次请求都去做压缩只有在超限时才动。压缩后摘要可能不够新所以摘要代码要带时间戳。下次生成摘要时如果发现旧摘要时间落后太多就丢弃旧摘要、重新从原始记录算一遍。丢失的细节还能找回来但模型如果基于错误摘要推导错误就会不断扩散。3.5 加一点可观测性日志和快照最后这一个技巧我觉得比整个模块都重要给每一轮请求留日志。我在build_messages返回之前会把最终发给模型的消息数组、token 消耗、时间戳一并存到一个本地 JSONL 文件里。问题一旦发生我第一件事不是猜而是打开日志看那一条请求到底送了什么。很多看起来像“模型智力下降”的问题最后发现都是上下文组装不对。有日志之后这类问题定位时间从小时级下降到分钟级。def send_with_log(self, engine, user_text, response): msgs engine.build_messages() log_entry { user: user_text, prompt: msgs, response: response, token_usage: estimate_tokens(msgs), ts: time.time(), } with open(context_snapshot.jsonl, a) as f: f.write(json.dumps(log_entry, ensure_asciiFalse) \n)这条日志不需要存到数据库就是一个简单的文件就可以。真到出问题那天你会发现它比任何监控面板都好用。4. 高发问题与排查实录接下来这部分是想念给所有正在做类似项目的人听的这些坑我几乎都踩过而且每一个都耗掉过至少一个完整的周末。4.1 上下文污染模型“记得”了不该记得的东西现象是模型突然在回答里带上用户很久以前提过的一个错误偏好或者把系统提示当成了用户输入。原因基本都在记忆层。我见过很多团队往 memory 里塞东西太随意用户说了句“我不太确定”就被写成了“用户对自己的需求不确定”模型接下来的回答就带上了犹豫的语气。这种脏记忆会一直潜伏直到问题放大到你才注意到。解决方法是给 memory 字段加来源标识写清楚记忆是“从本轮用户输入提取”还是“从历史摘要推断”。来源标识最大的价值是问题发生后可以反向排查顺着标识把脏记忆删掉而不是把整个会话清空重来。4.2 摘要压缩丢关键细节摘要压缩最常见的坑是丢数值比如金额、日期、订单号。模型生成的摘要喜欢用模糊的表达“用户提到一个之前咨询过的订单”看起来很自然但订单号早没影了。我后来用强制 JSON schema 来约束摘要输出必填字段包括confirmed_facts和user_constraints。压缩 prompt 里直接写“如果历史里出现过订单号、时间、地点必须原样保留”。同时压缩前会扫一遍历史里的关键实体如果发现新摘要里遗漏了就放弃这次摘要重新生成。虽然这样多花一点 token但比丢状态崩溃后的调试成本低得多。4.3 多用户会话的串号问题做服务端应用时你很可能同时维护几百个会话。最容易犯的错误是直接用一个全局ContextMode实例几个用户聊着聊着就串了。千万别这么干。至少要按 session_id 隔离实例可以用一个字典管理sessions: Dict[str, ContextEngine] {} def get_engine(session_id: str): if session_id not in sessions: sessions[session_id] ContextEngine(ContextMode()) return sessions[session_id]生产级别还要考虑内存回收和持久化。用户量上来之后字典会膨胀所以要做定时清理。我习惯把 active 的 session 设一个过期时间比如 30 分钟没有活动就释放等用户再次进来时从持久存储里重建上下文。这样既省内存又不会丢数据。4.4 高频问题速查表现象常见原因快速处理模型忽略系统限定基座层被后置或裁剪把基座移动到消息数组最前面禁止裁剪多轮后逻辑飘移摘要丢失已确认状态检查摘要字段增加必填项接口报 400 token 超限预算预留不足硬上限往下调统一 token 统计用户信息串话全局共享会话实例按 session_id 隔离模型复述错误历史脏陈述进入 memory加来源标识做置信度过滤这张表里的每一条都不是凭空写的全是我实际做过并解决过的问题。有一段时间我甚至在怀疑是不是模型选型不对结果查下来全是上下文管理的问题。所以这里想多说一句遇到模型“胡说八道”第一反应不是换模型不是调温度而是先看当前请求里到底塞了什么。4.5 排查思路别急着换模型我总结出一套排查路径顺序很重要。先打开日志看上下文快照确认是不是哪一层出了问题。如果基座层缺失补回去如果记忆层有脏数据删掉如果历史被裁剪太狠放宽预算。然后再看是不是检索到的外部事实有问题比如网页内容编码错误导致乱码进入了上下文。最后才轮到“调 prompt”、“换模型”这些影响面的操作。这个顺序能帮你节省大量时间。我发现很多人一遇到异常就立刻换模型换了之后确实能好一会儿但过几天又有另一个问题冒出来因为根因从来没解决。上下文管理得好很多看起来是模型能力的问题都会消失。5. 几个个人习惯和后续想补的方向现在做新的对话型项目时我基本都会按照这套 context-mode 的骨架来搭。它没有变成一个库但是这套约定已经被我固化成项目模板一个新项目开始时会自动带上这些模块。5.1 调试习惯推荐我特别推荐一个看起来很笨但极其有效的习惯把每一轮请求的完整上下文快照、模型输出、token 消耗落到本地日志。很多人为了“干净”不开日志觉得有监控面板就够了。但实际上对话型应用的问题非常依赖时序数据你必须要能看到“上一轮发的是什么模型回了什么这一轮又发了什么”才能定位问题。监控面板能告诉你系统挂了但不会告诉你模型的回答是错在哪个逻辑节点上。另一个习惯是遇到模型表现不对先区分是 prompt 的问题还是上下文拼装的问题。大多数时候是后者。改上下文比改话术高效得多因为很多问题并不是表达不清而是模型压根没有看到它需要的信息。5.2 值得继续扩展的两个方向我后续还想做两件事。第一是把记忆层从简单 JSON 升级成带版本管理的状态结构加一个冲突检测当新抽取的实体和旧记忆冲突时先暂存不让它直接覆盖旧信息。这能避免用户改主意但旧记忆还赖着不走的情况。第二是给摘要生成加上定时刷新。现在的方案里摘要只在触发压缩时生成如果对话持续很久全局摘要会变得越来越旧。我想做成一个后台任务每隔若干轮自动刷新摘要这样长对话也不会因为吃老本而失忆。context-mode 不是银弹它的价值更多是逼着你把上下文的生命周期从“模糊的对话记录”变成“清晰的数据管线”。如果你正在做一个容易“聊着聊着就崩”的应用不妨先停下来盘一盘当前这个请求里到底放了哪些信息哪些其实不该放哪些已经过期了。这一步想清楚了后面所有优化都会有抓手。