ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

context-mode:大模型对话上下文的工程化管理策略

context-mode:大模型对话上下文的工程化管理策略 1. 先搞清楚一件事context-mode解决的是哪种“上下文焦虑”先说我遇到的实际问题。我一直在做智能助手类的应用早期版本上线后收到最多的用户反馈不是“功能太少”而是“AI怎么聊着聊着就忘了”。前五分钟还在讨论项目排期聊到具体任务时它却把约定的截止时间全忘了上午让它帮忙整理的数据口径下午再问细节它给出的是完全不同的两套结论。用户不会管你底层用了多大的模型、多少K的上下文窗口他们只会觉得“这东西不靠谱”。后来我把问题拆开看发现根子不在模型能力而在应用层没有做好上下文的组织和管理。所有的对话历史是一股脑塞给模型的没有区分哪些该保留、哪些该压缩、哪些该丢弃最后窗口被无关内容塞满真正关键的信息反而被挤了出去。这就是我启动“context-mode”这个小项目的直接原因——我想在应用层做一套可控的上下文管理模式让“模型记住什么”这件事从玄学变成工程。context-mode不是一个开箱即用的开源库也不是某个模型的参数开关它是一套关于“如何管理对话上下文”的策略集合。简单说它回答三个问题每轮对话结束后哪些信息应该留下以什么形态留下下次生成回复时应该把哪些内容交给模型这三个问题想清楚了AI在长对话里的表现会稳定非常多。这个项目主要适合两类人一类是在做大模型应用、尤其是客服机器人、Copilot类产品的开发者对话轮次一多就明显感觉模型变笨另一类是刚接触LLM集成、对“temperature怎么调”之外的事情还比较陌生的入门者把context-mode这套思路看完你对“上下文”这三个字的理解会比很多人扎实。我在这篇文章里不会讲太多悬空的理论而是把我在实际项目中用到的数据结构、策略判断和踩坑教训完整摊开。你不需要先有什么基础只要做过最简单的API对接跟着思路走就能看懂。看完之后你可以直接参考这套设计实现一个属于你自己的上下文管理器。2. 上下文模式的设计不是拍脑袋四种基础策略的定位与取舍开始写代码之前我花了很长时间纠结一个问题context-mode到底要做成什么样这个模式是给用户选的还是应用自己智能判断的如果给用户选选项怎么设计才不容易被误解如果自己做判断判断依据又是什么我最终的做法是把上下文模式拆成四层能力来设计而不是一个孤立的开关。这四层各自解决不同的问题合在一起才构成完整的“模式”。它们分别是最近轮次保留、关键信息长期记忆、历史摘要压缩、相关性动态检索。下面我把每一层展开说清楚这样你后面看代码的时候就知道每一步是在干什么了。2.1 最近轮次保留对话连续性的地基这是最简单也最基础的一层。它的逻辑很朴素——如果一个信息在最近几轮对话里刚出现过那么它大概率还是当前话题的一部分应该原样保留不做任何压缩或变形。这里有一个看似简单但很容易踩坑的决策点保留多少轮合适我见过有些项目直接把最近20轮全量塞进上下文理由是“模型窗口大无所谓”。但实际上最近N轮保留是一个跟成本和效果强相关的问题。N太小自然语言里的指代“那个方案”“刚才说的那个接口”会大面积落空N太大噪声比例上升而且每轮消耗的token数会线性增长。我自己的经验是常规对话场景下保留最近6到10轮比较合适。做客服场景可以把阈值压低到4到6轮因为客服问题的目标性强翻旧账的概率低做创意协作、代码评审这类需要来回推敲的场景可以放宽到10轮以上。这个参数不应该写死应该做成可配置的。2.2 关键信息长期记忆把承诺、偏好和事实单独存如果只靠最近轮次保留对话一旦超过窗口范围早期的重要约定照样会丢。所以第二层是“关键信息抽取”把对话里的实体、偏好、约束条件抽出来单独存成结构化的记忆项。我最初的做法很土——用正则表达式去匹配日期、数字、人名这类明显的模式但效果很差。因为真实对话里的关键信息往往没有固定句式。后来我换成了LLM抽取每轮对话结束后把当前轮和上一轮的内容拼起来让模型抽出“用户明确表达的偏好、约束、待办事项、事实陈述”这几类信息输出成JSON。这样做准确率高了很多而且天然结构清晰方便后面检索。这里有两个细节值得注意。第一抽取动作不能每轮都做否则成本会让你怀疑人生。我通常是每两到三轮做一次或者在检测到用户语气明显变化比如从“讨论”切换到“确定”时触发一次。第二抽取出的记忆项一定要带时间戳和来源轮次不然后面做衰减、做冲突消解的时候会非常被动。2.3 历史摘要压缩窗口里的信息密度要比长度更重要长对话绕不开的问题就是缩窗口。不管你怎么保留总会有超出窗口的内容。这时候如果你直接把最早的内容丢掉信息就永久丢失了。我的方案是“分层压缩”超过保留轮次的内容不直接删除而是触发一次摘要生成把这一小段对话浓缩成两三句话存到摘要池里。有人会问为什么不用一次性总结全篇对话两个原因。第一一次性总结长对话的输入本身就可能超出窗口上限模型容易忽略中间细节我实测下来总结结果会丢掉大量具体数据。第二对话是渐进发生的增量式摘要可以让你随时知道“截至某个时间点我们聊了什么”而全量总结只能给你一个静态快照。增量摘要的维护逻辑是这样的维护一个滑动窗口当窗口内的原始对话超过一定长度后就把最早的一段对话用LLM压缩成摘要然后从原始对话区移除。下次再触发压缩时是把“旧摘要新对话”一起压缩成一条新摘要。这样摘要越滚越厚但不会超过一定的量级。2.4 相关性动态检索让长期记忆真正可被唤起有了摘要池和记忆项最后一步就是“按需取用”。模型生成回复前不能把所有的摘要和记忆一股脑塞进去那样你又回到了起点——上下文被塞爆。正确的做法是只把与当前问题相关的记忆和摘要挑出来拼进本轮对话。这层的技术选型很灵活。如果项目规模不大可以用简单的关键词匹配或者Embedding向量相似度取Top K。如果追求效果我更推荐混合检索——先通过关键词把候选范围缩到几十条再用向量相似度做精排这样兼顾了准确率和检索速度。我自己的实现里用的是开源Embedding模型因为国内环境的语言支持更好一些。向量检索的Top K取值也需要调一般在3到6之间比较合适取太少召回不够取太多又会把不相关的噪声带进来。到这里context-mode的四层基础策略就介绍完了。你可能会觉得“这不就是RAG吗”——某种程度上可以这么理解但它跟常规RAG有一个本质区别RAG的检索对象通常是静态文档库而context-mode的检索对象是动态生长的对话记忆它需要同时处理时效性、冲突消解和遗忘策略复杂度高得多。3. 把策略变成能跑的代码一个可落地的ContextManager设计前面的策略听起来都不复杂但真正落地的时候你会发现数据结构的设计比算法本身更关键。我前后重构了两版核心数据结构踩了不少坑这里把最终版本完整写出来你可以直接照着搭建。3.1 上下文状态的数据结构怎么设计我采用的是三段式结构对应前面讲的三层策略原始对话区recent、摘要池summary_pool、记忆项列表memory_items。dataclass class MemoryItem: 关键信息记忆项。 content: 记忆内容 category: 类别preference/constraint/todo/fact created_at: 创建时间戳 source_round: 来源轮次 last_accessed_at: 最后被检索到的时间 access_count: 被检索到的次数 expired: 是否已过期 content: str category: str created_at: float source_round: int last_accessed_at: float 0 access_count: int 0 expired: bool False dataclass class SummaryBlock: 摘要块。 content: 摘要文本 start_round: 起始轮次 end_round: 结束轮次 created_at: 创建时间戳 token_estimate: 估算token数 content: str start_round: int end_round: int created_at: float token_estimate: int dataclass class ContextState: 完整的上下文状态。 recent_messages: 原始对话列表 summary_pool: 摘要块列表 memory_items: 关键信息记忆项 max_recent_tokens: 原始对话区token上限 max_summary_tokens: 摘要池token上限 recent_messages: list[dict] summary_pool: list[SummaryBlock] memory_items: list[MemoryItem] max_recent_tokens: int 6000 max_summary_tokens: int 2000 current_round: int 0这个结构的关键在于原始对话、摘要、记忆项三块是分开维护的。原始对话区负责“保真”摘要池负责“压缩”记忆项负责“长期结构化存储”。三者之间通过round编号关联这样任何一条摘要都能追溯到它覆盖的对话范围任何一条记忆项也能找到它最初的来源。3.2 核心更新流程每轮对话结束后的三件事ContextManager最核心的方法是add_exchange()每轮对话结束后调用。它按顺序做三件事追加原始对话、触发摘要压缩、抽取关键信息。class ContextManager: def __init__(self, llm_client, embedder, config: ContextConfig): self.llm llm_client self.embedder embedder self.config config self.state ContextState( recent_messages[], summary_pool[], memory_items[] ) def add_exchange(self, user_input: str, assistant_output: str): 每轮对话结束后调用 self.state.current_round 1 # 第一步追加原始对话保留真实性 self.state.recent_messages.append({ round: self.state.current_round, role: user, content: user_input }) self.state.recent_messages.append({ round: self.state.current_round, role: assistant, content: assistant_output }) # 排除纯功能性语句避免污染记忆和摘要 if self._is_functional_exchange(user_input, assistant_output): return # 第二步检查原始对话区是否超限超限则触发增量摘要 self._maybe_compress() # 第三步定期触发关键信息抽取 if self.state.current_round % self.config.extract_interval 0: self._extract_memory_items()_maybe_compress()的逻辑是从最早的对话开始把超过max_recent_tokens的部分取出来生成摘要块然后从原始对话区移除。摘要生成时要把上一条相关摘要也带上保证信息能“滚动”过去。def _maybe_compress(self): 把超过最近窗口的部分压缩进摘要池。 current_tokens self._estimate_tokens(self.state.recent_messages) if current_tokens self.state.max_recent_tokens: return overflow_messages [] overflow_tokens 0 while overflow_tokens self.config.compress_batch_size: if not self.state.recent_messages: break msg self.state.recent_messages.pop(0) overflow_messages.append(msg) overflow_tokens self._estimate_tokens([msg]) # 找到与这批消息相关的旧摘要大概率是最近一条 related_summaries [ s for s in self.state.summary_pool if s.end_round overflow_messages[0][round] - 3 ] combined_input for s in related_summaries: combined_input f旧摘要{s.content}\n for m in overflow_messages: combined_input f({m[role]}){m[content]}\n new_summary self.llm.summarize(combined_input) new_block SummaryBlock( contentnew_summary, start_roundoverflow_messages[0][round], end_roundoverflow_messages[-1][round], created_attime.time(), token_estimateself._estimate_tokens([{content: new_summary}]) ) # 移除被合并掉的旧摘要避免信息重复堆积 self.state.summary_pool [ s for s in self.state.summary_pool if s not in related_summaries ] self.state.summary_pool.append(new_block) # 如果摘要池也超限把最旧的摘要压缩成更粗粒度的一条 self._maybe_compress_summary_pool()这里有个小细节值得说明为什么我压缩时要把旧摘要也带上因为如果只压缩新对话而不带旧摘要新的摘要会缺少前因后果信息链会断。把旧摘要一起拼进去让模型重新提炼虽然多花一点token但摘要的连贯性和完整性会好非常多。3.3 生成回复时的查询组装按相关度取Top K当用户发起新问题时ContextManager需要组装出交给LLM的上下文。这个组装过程不是把全部内容拼起来而是分批查询。def build_context(self, user_question: str) - str: 组装当前问题的上下文。 返回拼好的上下文文本交给LLM作为system prompt的一部分。 # 1. 原始对话区全量保留因为它的长度已经被控制在窗口内 recent_text \n.join( f({m[role]}){m[content]} for m in self.state.recent_messages ) # 2. 摘要池按相关性检索 question_embedding self.embedder.encode(user_question) summary_scores [] for s in self.state.summary_pool: s_emb self.embedder.encode(s.content) # 实际使用时应缓存embedding score cosine_similarity(question_embedding, s_emb) summary_scores.append((score, s)) summary_scores.sort(keylambda x: x[0], reverseTrue) top_summaries summary_scores[:self.config.top_k_summaries] summary_text \n.join(s.content for _, s in top_summaries) # 3. 记忆项先做关键词粗筛再按相关度精排 keyword_hits [ item for item in self.state.memory_items if not item.expired and any( kw in item.content for kw in extract_keywords(user_question) ) ] memory_text \n.join(item.content for item in keyword_hits) # 4. 组装最终上下文 sections [ 【最近的对话】, recent_text, 【历史要点摘要】, summary_text, 【长期记忆】, memory_text ] return \n\n.join(sections)这里我故意把“检索到的摘要”和“按关键词命中的记忆项”分开呈现而不是全部混在一起。原因很简单模型对不同类型的上下文信息理解权重是不一样的。“最近的对话”必须逐字保真“历史要点摘要”可以容忍概括“长期记忆”则要精确到细节。混在一起写模型容易把摘要里的近似表述当成精确事实来用。3.4 遗忘机制不删掉的信息最终一定会反过来咬你记忆项如果不加控制地累积检索效果会越来越差而且冲突会越来越多。所以context-mode里必须有一个遗忘机制。这个机制的设计原则是不物理删除而是打上expired标记让它在查询中被过滤掉。def _run_forgetting(self): 周期性执行遗忘策略。 now time.time() for item in self.state.memory_items: if item.expired: continue # 三个月内从未被访问过且访问次数少于3次视为不活跃记忆 if now - item.last_accessed_at 90 * 86400 and item.access_count 3: item.expired True continue # 超过一年没被访问无论访问过几次都归档 if now - item.last_accessed_at 365 * 86400: item.expired True continue遗忘参数的设定需要跟业务场景强绑定。你做一个必须精确遵守用户指令的工具类应用遗忘策略要非常保守宁可多留也不急着标过期你做一个闲聊陪伴类应用遗忘策略反而可以激进一点让模型更专注于当下的话题而不是突然提起几个月前聊过的小事。我在自己的项目里把这两个参数做成了配置项上线后可以根据用户反馈随时调整。4. 效果到底怎么样我实测的对比数据和优化过程设计是一回事跑起来是另一回事。我做了两组实测来验证context-mode的效果一组是“无上下文管理”和“有上下文管理”的对比另一组是项目上线后的真实用户反馈。这两个维度都重要前者回答“它真的有用吗”后者回答“它在真实场景里有什么问题”。4.1 相同提示词下的效果对比我准备了一个测试任务先让AI“记住”三个事实一个日期、一个偏好、一个约束然后穿插10轮无关的闲聊最后在第12轮问它这几个事实。每次测试跑5遍取平均结果。测试场景无上下文管理仅最近轮次保留context-mode完整方案日期记忆准确率10%窗口被闲聊挤爆60%10轮内还能覆盖100%偏好记忆准确率0%40%100%约束记忆准确率20%50%90%单次请求平均token消耗~5000~4000~2200第三组数字值得展开说。无上下文管理的方案之所以token消耗最高是因为每轮都是把全部历史塞进去到第12轮时历史已经很长了。context-mode完整方案反而最低因为原始对话区被限制在6000 token以内摘要只保留Top K记忆也只取相关项三者加起来的长度比全量历史短得多。约束记忆准确率只有90%我检查了失败案例发现是用户在后续对话里用了一种跟最初表述完全不同的方式说了同一件事比如“周五前”变成了“本周最后一个工作日之前”基于关键词的粗筛没有命中。这个问题目前靠单纯的相似度检索还无法完全解决暂时通过增加同义改写归一化模块来缓解。4.2 真实场景里的翻车现场与修复测试环境的数据再漂亮上线后该翻的车一辆都不会少。我记录了三个比较典型的翻车场景每个都是我代码里真实踩过的坑。第一个坑摘要压缩时把关键数字弄丢了。压缩任务是“把这些对话浓缩成摘要”模型为了控制长度把具体数字和精确时间直接省略了。后来我改进了summary的prompt明确要求“保留所有数字、日期、价格、百分比、姓名、产品名即使会让摘要变长”。这个改动立竿见影数字保留率从55%直接涨到95%以上。第二个坑记忆抽取的频率太高。最初我设定每两轮就抽取一次记忆结果同一件事被抽成好几条内容相似但措辞不同的记忆项检索时全部命中上下文一下子被重复信息塞满。后来我把抽取间隔改成5轮并加了一层去重逻辑——新抽取的记忆项如果跟现有记忆项的embedded距离小于某个阈值就不新增而是把时间戳刷新到最新。第三个坑时间衰减策略误杀关键记忆。最初我把“90天未访问就过期”这个策略用在了所有类型的记忆上结果发现一个用户三个月前交代的“以后所有报表都用美金结算”这种偏好类记忆因为期间没被触发过直接被标过期了。后来我按类别区分处理——偏好类和约束类的记忆不做时间衰减只做重要性衰减事实类和时间类信息才做时间衰减。4.3 参数调整经验不同业务场景怎么配置context-mode的配置项不少我把核心参数的推荐值整理成一张表方便你在自己的项目里照着设置。这张表是我在不同场景里实测出来的经验值不是从文档里抄来的。参数客服机器人个人助手代码协作者闲聊陪伴max_recent_tokens2000400080005000extract_interval轮4538top_k_summaries2342记忆过期天数3090180365摘要是否保留数字必须尽量必须可以省略客服机器人场景的max_recent_tokens设得最小是因为客服对话每轮都比较长而且用户在每一轮里表达的信息密度大保留太多轮反而会让意图识别变慢。代码协作者的max_recent_tokens设得最大是因为代码片段本身占token多而且跨轮引用很频繁。闲聊陪伴场景的记忆过期天数设得最长是因为闲聊本来就无所谓对错多记一些反而能让用户觉得“AI真的有在听我说话”。5. 再往深走一步如何把context-mode扩展成多会话与多Agent并发的上下文管理如果你只想做一个单会话应用前面四节的方案已经够用了。但我的项目做到后面遇到了一个更复杂的问题——用户同时开多个会话每个会话有不同的主题或者一个任务被拆给多个Agent协作完成每个Agent有自己的上下文。这时候一个单例的ContextManager就撑不住了。我把这个扩展方向也做了验证这里直接分享结果。核心思路是把ContextManager从“一个全局实例”变成“按会话ID隔离的多个实例”同时在会话之上加一个“跨会话记忆层”。5.1 会话隔离与共享记忆的平衡代码层实现很简单维护一个defaultdictkey是会话IDvalue是对应的ContextManager实例。每个会话的对话历史、摘要池、记忆项互相隔离互不干扰。class SessionManager: def __init__(self): self.sessions: dict[str, ContextManager] defaultdict(ContextManager) self.global_memory: list[MemoryItem] [] def get_session(self, session_id: str) - ContextManager: return self.sessions[session_id] def add_global_memory(self, item: MemoryItem): self.global_memory.append(item) def build_global_context(self, question: str, session_id: str) - str: 跨会话记忆只在question明确提到其他会话时触发 mentions_other self._detect_cross_session_reference(question, session_id) if not mentions_other: return hits [item for item in self.global_memory if not item.expired] return \n.join(item.content for item in hits)这里最值得注意的不是代码本身而是build_global_context()里的判断逻辑。跨会话记忆不能无条件注入否则会出现一个很尴尬的场景你在这个会话里聊工作模型突然把另一个闲聊会话里的内容拼进来非常出戏。我用了一个简单的意图检测只有当用户明确提到其他会话的主题词时才把跨会话记忆加进来。5.2 多Agent场景下的上下文隔离多Agent协同的场景里context-mode的设计思路会有一个微妙的变化。每个Agent要有自己的上下文视图但它们之间需要通过一个共享的黑板blackboard来传递关键信息。这个黑板本质上就是一个跨Agent的共享记忆池。我的做法是每个Agent的ContextManager负责管理它自己的对话历史同时它产生的所有“关键信息”比如“子任务A已经完成输出了xx文件”“用户对xx方案表示认可”都会以MemoryItem的形式写入一个全局共享池。其他Agent在执行任务前先查询共享池里跟自己的当前任务相关的记录形成“协作上下文”。这个方案遇到的最大问题是信息重复多个Agent会把同一件事写成措辞不同的记录导致共享池里冗余严重。目前的缓解办法是要求Agent在写入共享池之前先搜索是否已有类似记录如果有只更新时间戳不新增条目。5.3 版本演进的一些经验总结从单会话到多会话再到多Agent每一步都会带来新的复杂性。我的体会是不要一开始就设计一个特别宏大的框架而是先用单会话方案跑通业务确认价值后再逐步扩展。反面教材是我自己——最开始我照着“企业级多Agent协作框架”的规格设计结果写了一个月代码跑不起来因为过度设计带来的调试成本远超预期。后来砍掉重来从最简单的单会话做起反而一周就上线了。如果你也想在自己的项目里用context-mode这套思路我的建议是从第三节的代码骨架开始跑通了再加后续的高级功能。一个能用的上下文管理器远好过一个完美但永远写不完的上下文管理器。6. 沿着这条路继续优化几个我还在尝试的方向context-mode做到现在这个版本已经稳定跑了一段时间但我知道它还有不少可以优化的空间。这里写几个我目前在探索的方向给想做深一步的朋友一些参考。第一个方向是Embedding模型的迭代。现在我用的开源Embedding模型对中文支持还凑合但在代码混合场景、专业术语多的领域比如医疗、法律检索精度会下降。我准备测试几款更新的中文优化模型同时加上领域微调。如果你也遇到检索召回不准的问题优先检查Embedding模型在目标领域的效果而不是急着调参。第二个方向是摘要质量的自适应控制。现在的摘要压缩是“每个块无论多重要都一视同仁”。但现实中“用户确定了最终方案”这种信息的重要性远高于“用户提到了某句话”。我正在尝试在摘要prompt里引入“重要性分级”让模型在压缩时输出“必须保留/尽量保留/可以省略”三类标签后续检索时按标签加权。目标是让上下文里的每一个token都有它存在的理由。第三个方向是上下文的一致性校验。我遇到过一个让人哭笑不得的情况用户早上说“我喜欢简洁的回复风格”下午说“你能不能多说一点细节”。两条记忆都躺在记忆池里模型检索时可能同时命中于是它的回答风格会摇摆。我计划加一个“冲突检测”模块当新记忆项与旧记忆项语义矛盾时不是直接覆盖而是用LLM判断哪条更符合用户的近期意图。这些方向都还在验证中短期内不会有成熟结论。但有一点我可以确定context-mode这套思路本身是站得住的。用户感知到的“AI是否聪明”很大程度上不取决于模型参数有多大而取决于应用层有没有把上下文管理好。同样的模型一套精心设计的上下文模式和一股脑塞历史的方式体验差距可能是天壤之别。我最后想说的是上下文管理没有银弹没有哪个参数组合适用于所有场景。这篇文章最大的价值是给你一套完整可参考的设计框架和代码骨架你拿到之后一定要结合自己的对话场景去调、去试、去踩坑。只有你自己业务里的数据才能告诉你最优的配置是什么。
返回列表