context-mode:大模型长对话上下文的分层管理与压缩实战
发布时间:2026/10/7 9:26:39 作者:尧图编辑部 阅读量:1,286

接手过一个客服问答Agent的项目最头疼的问题不是模型不聪明而是聊着聊着它就把前面说过的话“忘了”。用户第10轮已经讲过“我是老客户不想要新品推荐”第20轮模型又开始热情地推新品用户明明问了退货政策模型却把几分钟前的闲聊当成了指令依据。翻了大量上下文管理方案之后我自己沉淀出一套context-mode的上下文管理模式把“上下文”从一堆越堆越长的原始文本变成可分层、可检索、可压缩、可回滚的结构化记忆。这篇文章就把这套模式的完整实现、实测数据和踩坑过程整理出来给正在做大模型应用、AI Agent、长对话系统的朋友做个参考。1. 三个让我决定给上下文“立规矩”的真实故障1.1 故障一二十轮对话后模型把用户说过的话“吸收”了这个故障最容易复现。我在做客服知识库问答时把用户和模型的全部历史消息一股脑塞进上下文一开始效果还行到第20轮左右就开始“失忆”。用户明确说“我是VIP客户已经开通自动退款权限”模型下一轮竟然推荐用户去开通会员服务等于彻底无视了用户刚刚陈述的身份条件。查了日志才发现根因原始上下文里那段关键信息被淹没在大量寒暄、确认语和不相关的FAQ片段里了。大模型感知上下文并不是平均分配注意力信息在长文本中的位置越靠后、被其他内容包裹得越深被遗忘的概率就越大。问题不出在模型本身而出在我没有给上下文分优先级所有内容在上下文里都是一样的权重重要信息和无关信息打架模型自然无所适从。1.2 故障二上下文里混入“杂音”回答开始漂移另一个故障更隐蔽。某天模型对“退货运费谁承担”这个问题连续三个不同时段给出三种答案。我追日志发现用户早前问过“朋友代购的货能退吗”模型当时回答“代购商品不支持七天无理由”。这个旧的对话片段残留下来被模型当成了“退货运费规则”的上下文依据导致规则判断被带偏。这就是典型的上下文污染会话里的疑问句、确认句、否定句和规则声明混淆在一起。系统无法区分“用户想问什么”和“哪些信息是稳定事实”。不该进上下文的噪声进去之后会像一滴墨水滴进清水整杯水的颜色都变了。1.3 故障三Token超额长会话直接把成本烧穿第三个故障是账面上的。同一个用户会话跑到60轮时每轮请求光历史消息就消耗约1.2万token。按当时并发300个会话的量一天光上下文传输的token成本就让人肉疼。最蠢的是这1.2万token里有效信息可能不到三成——大量重复的问候、已结束话题的问答、还有用户根本不关心的系统提示词轮番堆叠。这三点结合在一起让我意识到一个根本问题上下文不能被当成一个只需要“追加”的列表它需要一个模式化的管理机制。context-mode这个思路就是在那时候定下来的。2. context-mode的四层切分与三种工作模式2.1 先给上下文“分门别类”身份层、记忆层、知识层、会话层context-mode的第一步是给上下文里的每一条内容打上“层”的标签。我自己在代码里直接定义了四个Layer分别解决四类诉求。身份层存的是系统人格、角色设定、品牌规范这样的稳定信息。比如“你是XX电商平台的客服助手语气亲切不得承诺超出政策范围的内容”这类信息一旦设定几乎不再变化永远应该放在最前面权重最高。记忆层存的是用户的长期事实比如用户偏好、历史订单、已确认的身份状态。它对应的是“用户已经告诉过你的事”需要有持久化和时效衰减机制不能跟着会话结束就消失。知识层存的是领域知识、产品规则、FAQ条款通常来自知识库检索结果带来源和版本信息。它和记忆层的区别是知识层回答的是“世界是什么样”记忆层回答的是“这个用户是什么样”。会话层存的是当前对话的短期片段包括用户刚说的话、模型刚给出的回答、当前任务进度。会话层信息只在单次会话内有意义一旦会话结束就应该归档或丢弃。分层能直接解决前面故障一和故障二的根本问题你不必再拿“所有内容”去计算注意力而是先按层级决定内容是否允许进入上下文再在每层内部排序权重。上下文的秩序感会明显增强。2.2 三种工作模式快照模式、聚焦模式、流式模式分层是骨架真正发挥作用的是基于分层的三种工作模式。我给它们起的名字是快照模式、聚焦模式、流式模式。快照模式Snapshot Mode的逻辑是“定期给上下文拍一张完整的照片”把当前所有层的有效内容固化成一个结构化快照每次请求都用快照重建上下文。好处是状态稳定、可回溯特别适合需要严格保持人格一致性的角色扮演、需要复现历史状态的调试场景。代价是快照比较大Token开销偏高而且快照拍得越频繁成本越高。聚焦模式Focused Mode的逻辑是“按当前用户输入去检索最相关的上下文片段”。系统把用户当前的问题作为查询条件在记忆层和知识层里做相关性检索只把Top K个高相关片段放进上下文。好处是上下文干净、信息密度高问答类任务的准确率通常最好。风险是检索本身可能漏掉关键信息如果检索质量不行模型会“拿着残缺的上下文强行作答”。流式模式Streaming Mode的逻辑是“保持最精简窗口旧内容边聊边压缩”。系统只保留最近N轮完整对话更早的内容被自动摘要成一行或几句话塞进一个持续更新的“滚动摘要”段。好处是Token消耗最小长会话能一直跑下去。代价是历史细节会被摘要压扁聊到50轮以后第3轮说过的内容基本只剩一个模糊轮廓。三个模式的选型没有绝对优劣取决于你的业务对“完整性、准确性、经济性”哪个更敏感。我个人在真实项目里最常用的是聚焦模式做问答类场景用快照模式保人格一致性用流式模式扛高并发低成本压力。3. 手写一个最小可用的context-mode实现3.1 项目结构两个文件就能跑起来我不太喜欢一上来就引外部框架context-mode最小可用版本其实可以控制在两个文件里context_store.py负责分层存储和预算控制llm_app.py负责把store接进真实的模型调用。文件少逻辑清楚后面要替换成数据库或者向量检索也容易。先看ContextStore的数据结构。我用dataclass定义一个ContextEntry核心字段是layer、content、timestamp、importance。importance是人对信息重要性的主动打分比如“用户身份声明”我给8“用户随口问天气”我给3这个分数会在检索时参与排序。3.2 分层存储与Token预算不让上下文无限膨胀Token预算管理是context-mode的一个关键点。我给ContextStore设置一个max_tokens总预算和一个budget_ratio比例系数。默认情况下max_tokens设为4000budget_ratio设为0.7意思是系统提示词和记忆类内容最多占用总预算的70%剩余30%留给用户当前输入和最新会话内容。代码里用_enforce_budget做硬约束当总条目的估算token数超过预算就按“重要性×新鲜度”的优先级从低到高丢弃直到回到预算以内。这里的“新鲜度”通过时间衰减实现我在实际使用里给importance乘上一个随时间递减的系数score importance * (0.9 ** elapsed_hours)。这样一条半小时前重要性为8的信息会比一条刚写入但重要性为6的信息低一点更符合对话场景里“近期信息往往更相关”的直觉。from dataclasses import dataclass, field from enum import Enum import time, math class ContextLayer(Enum): IDENTITY identity MEMORY memory KNOWLEDGE knowledge SESSION session dataclass class ContextEntry: layer: ContextLayer content: str timestamp: float importance: int 5 meta: dict field(default_factorydict) def estimate_tokens(text: str) - int: # 中文按约1.5字符/token粗估英文约4字符/token return max(1, int(len(text) / 1.5)) class ContextStore: def __init__(self, max_tokens4000, budget_ratio0.7): self.entries [] self.max_tokens max_tokens self.budget_ratio budget_ratio def add(self, layer: ContextLayer, content: str, importance: int 5, **meta): entry ContextEntry(layerlayer, contentcontent, timestamptime.time(), importanceimportance, metameta) self.entries.append(entry) self._enforce_budget() def _score(self, entry: ContextEntry) - float: elapsed_hours (time.time() - entry.timestamp) / 3600.0 return entry.importance * (0.9 ** elapsed_hours) def _enforce_budget(self): budget int(self.max_tokens * self.budget_ratio) while True: total sum(estimate_tokens(e.content) for e in self.entries) if total budget: break self.entries.sort(keyself._score) worst self.entries.pop(0) # 也可以把被淘汰的条目写进压缩队列后面再统一总结 def fetch(self, query: str , top_k: int 8) - list: scored [] q query.lower() for e in self.entries: score self._score(e) if q and q in e.content.lower(): score 2.0 # 简单的关键词命中加分 scored.append((score, e)) scored.sort(keylambda x: x[0], reverseTrue) return [e for _, e in scored[:top_k]] def fetch_layer(self, layer: ContextLayer) - str: parts [e.content for e in self.entries if e.layer layer] return \n.join(parts)这段代码有一个容易被忽略的设计budget是独立于max_tokens计算的。我不让历史记忆把整段上下文预算全吃掉因为模型至少需要一个合理的窗口去容纳“用户当前输入”和“模型即将生成的输出”。如果你的业务场景里用户输入特别长可以把budget_ratio调大比如0.85但我不建议超过0.9否则模型生成质量会明显下降。3.3 检索与压缩用一次LLM调用给上下文“瘦身”分层的ContextStore负责存储和排序真实的压缩动作还是得交给LLM。我的做法是当会话层内容超过设定轮数比如20轮时触发一次compress调用把最旧的一部分会话内容交给LLM提炼成几条要点再以低importance写回记忆层。这里要特别说明的是压缩不是简单的截断或者把整段文字丢掉。我在compress里要求模型“保留所有数字、名字、结论、待办事项把寒暄和重复内容合并成一句话”。刚开始我没加这个约束模型经常把“退款金额是239元原路返回”压缩成“用户问了退款的事情”关键数字全都丢了。加了结构化约束之后压缩质量才有保证。def compress(store: ContextStore, llm_func, keep_rounds: int 20) - str: session_entries [e for e in store.entries if e.layer ContextLayer.SESSION] if len(session_entries) keep_rounds: return old session_entries[:-keep_rounds] transcript \n.join(f[{e.timestamp}] {e.content} for e in old) prompt f请对以下会话记录做信息压缩。要求 1. 保留所有数字、专有名词、结论、明确承诺和待办事项。 2. 寒暄、问候、语气词、重复表达合并或省略。 3. 输出不超过5条要点每条不超过30个字。 会话记录 {transcript} summary llm_func(prompt) store.entries [e for e in store.entries if e not in old] store.add(ContextLayer.MEMORY, 历史会话摘要: summary, importance4, sourcecompress) return summary用store.entries [e for e in store.entries if e not in old]这种写法在数据量大时效率不高但作为最小实现示例是够用的。正式项目里我给每条entry加了uuid用集合做差集删除。3.4 把它接进现有的LLM调用链路有了ContextStore改造现有LLM调用链路其实只需要改一个地方原来从消息列表里拼context现在改成从store分层读取。build_messages函数专门负责这件事。def build_messages(user_input: str, store: ContextStore, llm_func): identity store.fetch_layer(ContextLayer.IDENTITY) memory store.fetch(queryuser_input, top_k6) knowledge store.fetch_layer(ContextLayer.KNOWLEDGE) recent_session [e.content for e in store.entries if e.layer ContextLayer.SESSION][-6:] system_parts [] if identity: system_parts.append(identity) if memory: system_parts.append(关于用户我知道\n \n.join(e.content for e in memory)) if knowledge: system_parts.append(知识规则\n knowledge) system_prompt \n\n.join(system_parts) if not system_prompt.strip(): system_prompt 你是一个乐于助人的助手。 messages [{role: system, content: system_prompt}] for c in recent_session: # 此处简化为轮流压入实际应区分role messages.append({role: user, content: c}) messages.append({role: user, content: user_input}) return messages接入之后整个调用链就变成用户发消息 → 写入store的session层 → build_messages组装上下文 → 请求模型 → 把模型回复写回store。上下文不再是一块只会增长的缓存而是一个随时可以查询、压缩、回收的记忆系统。4. 实测数据与参数调节心得4.1 同一批测试任务三种模式的真实差距架构搭好之后我用了一套内部测试集做横向对比。测试集包含150个客服问答对每个问题都埋了“前文关键信息”——比如用户在第5轮说过自己是Plus会员第18轮问Plus会员运费规则能正确关联到前文的才算命中否则算漏检。还专门加了30个长会话场景要求对话到第40轮时回答前3轮提到的订单编号。结果如下环境为同一LLM、同一温度参数数字是我自己环境里的相对参考指标快照模式聚焦模式流式模式前文线索命中率92%97%71%回答一致性得分95%88%78%平均token/轮580620240首字延迟0.9s1.1s0.4s40轮后细节保留度高中低聚焦模式在前文线索命中率上几乎碾压其他两个模式原因其实很简单每一轮请求都只拿出与当前问题最相关的上下文模型不被打扰引用线索的准确率自然高。但它的一致性得分反而不如快照模式因为聚焦检索每次命中的片段集合可能不完全相同模型的用词和风格在轮次之间会出现轻微漂移。快照模式适合“需要记住全局状态”的任务。我做过一个角色扮演类的陪伴型Agent用户要求“说话语气要稳重不要主动推销”如果切换成聚焦模式这条身份层的语气指令偶尔会被检索挤掉切回快照模式身份层永远在系统提示词最前面语气就稳定了。流式模式的长处是省短处是丢。40轮之后流式模式下模型还能记住“用户是Plus会员”这个结论但已经无法回忆起具体的订单号“ORD-20250317-8821”。如果你的业务必须回溯具体业务编号流式模式需要配合外部数据库查询不能单独承担记忆职责。4.2 几个关键参数到底怎么定这几个参数我调试了很久直接说结论。max_tokens要根据模型上下文窗口的一半甚至三分之一来定。不要直接顶满模型的上下文窗口上限因为模型生成输出也要占用Token两者共用同一块窗口把上下文塞得太满生成空间被压缩回答质量会明显变差。我自己用8K窗口的模型时max_tokens设为4000左右用128K窗口的模型时也不超过60000因为太长的上下文会让延迟线性上升性价比很低。budget_ratio控制记忆类内容占总预算的比例。默认0.7在多数场景可用。如果你大量依赖外部知识库知识层会吃掉很多预算我建议把budget_ratio调到0.5把另一半预算留给知识内容和会话窗口。知识层信息本身不带时序衰减所以当知识条目总量偏大时反而要警惕它把记忆层挤掉。这里没有完美值只能按你的业务配比来试。importance的初始打分要按业务规则定不要靠模型自己判断。我踩过坑让模型给上下文条目自动打分它会把“用户问了一句今天天气怎么样”打成6分把“用户要求以后所有推荐都走企业微信”打成7分重要度排序完全不可用。后来改成规则打分身份声明一律8分业务规则知识一律7分用户明确偏好6分闲聊内容3分压缩摘要4分。规则写死反而稳定可靠。summarize阈值建议定在会话轮数15到20之间。太早压缩会把近期还在使用的信息过早压扁太晚压缩则Token消耗持续高位。我这里还加了一个触发条件只有最近5轮里没有出现“需要历史细节才能回答的新问题”时才触发压缩减少打断上下文的风险。5. 上线一个月我踩到的四个坑5.1 压缩时把关键数字“压没”了这是把我坑得最惨的一个。某个金融业务场景用户说“我要在2026年6月30日之前把12万转到对公账户62938471”压缩之后变成了“用户提到要转账”。数字全丢模型后续跟进完全无法进行。后来我在压缩prompt里加上“必须保留所有数字、日期、账号、金额、ID”并且要求模型输出JSON把数字类信息单独提取到固定字段里才彻底解决。现在我的compress函数已经演化成带有结构化输出的版本{ summary: 用户要求6月30日前转账12万到对公账户, key_numbers: { amount: 120000, deadline: 2026-06-30, account: 62938471 } }写回记忆层时整个JSON会被保留同时单独把key_numbers以“重要数字”开头追加一条独立entryscore提升到7避免后续检索漏掉。5.2 日志里的上下文快照包含敏感信息快照模式最大的副作用是为了支持状态回溯我会定期把上下文快照保存到本地日志。结果有一次安全审查发现日志里躺着用户的手机号、身份证前几位、还有完整对话记录。这属于自己给自己埋雷。我加了一个脱敏过滤层所有进日志的快照先经过正则替换手机号中间四位打码身份证号只保留前六后四银行卡号整体掩码。此外增加了快照加密存储密钥独立管理。如果你做的是多人协作项目这个步骤一定要在写第一行代码前就考虑进去不然后面清理存量日志会让你崩溃。5.3 时间戳用本地时间导致时序错乱context-mode的排序逻辑严重依赖timestamp而我早期直接用time.time()本地服务器时区设置还被人改过。某天线上出现一批奇怪行为同一个用户的记忆条目新写入的内容因为时间戳小于旧条目排序时被当成旧信息压缩时本该保留的新结论反而被丢了。排查之后统一改成UTC时间戳所有入口都明确传UTC只有展示层才转本地时间。另外我在add()里面做了一个保护如果新entry的timestamp小于当前store中的最大timestamp就让它等于最大timestamp加1保证写入顺序的相对单调性。5.4 快照模式在并发场景下的脏读快照模式上线后我遇到过一个诡异问题同一用户在两个端同时发起请求A端拿到的是第10轮版本快照B端拿到的是第9轮版本两边回复内容互相矛盾。原因是快照写入和读取之间没有同步机制store实例在并发请求下被同时读写。把单机ContextStore换成带版本号的方案之后解决每条快照都带一个自增的version读取时lock住当前版本写入完成后版本号加一。如果你用的是Redis这类外部存储直接在key上做原子递增也可以。这个坑在单用户本地调试时完全看不出来但一上并发线就暴露建议从一开始就带上轻量锁。6. 从context-mode延伸出去Agent循环里的下一步玩法把context-mode引入Agent循环之后我觉得它真正的价值才体现出来。Agent的“Plan → Act → Observe”循环本质上就是一连串上下文状态的迁移每一步都要根据前面几步的结果决定下一步动作。没有模式化的上下文管理Agent很容易在第三步就忘掉第一步定下的目标或者被中间某次观察到的噪声带偏方向。我现在的做法是把快照模式和聚焦模式组合使用Agent的计划阶段用快照模式保证目标、约束条件和当前进度永远在上下文最显眼的位置执行阶段用聚焦模式每次只检索“和当前子任务最相关”的历史观察观察阶段把结果写回记忆层再轻量更新快照。这样既保住了整体目标的稳定性又避免了每次行动都被整个历史拖慢。另外context-mode天然支持“回滚”。因为快照保留了特定时间点的完整上下文状态Agent如果发现自己走入了死胡同可以直接load回上一个检查点快照重新开始效果类似于给大模型应用加了存档读档的能力。我在一个自动化报表生成的Agent里用了这个机制当数据源临时出错、Agent产生错误中间结论时自动回滚到数据源校验前的快照避免错误结论污染后续步骤。最近我在尝试把三种模式做成可动态切换的配置项让用户通过对话直接控制上下文管理策略而不是每次改代码重新部署。坦白说这个方向还在实验中目前遇到的主要问题是如何判断“什么时候该从聚焦模式切回快照模式”单纯靠关键词触发太脆弱靠模型判断又增加额外调用成本。如果你也在做类似的事情欢迎一起交流。最后分享一个小习惯我现在每写一个长对话项目都会先画一遍“上下文生命周期图”——内容从哪个层进来、在哪个环节被压缩、在哪个环节被淘汰、如果需要恢复从哪里读。这个图看起来很简单但它逼着你提前想清楚上下文状态流转的每一个节点比上线以后靠日志排查问题省太多事了。