ARTICLE DETAIL

资讯详情

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

大模型上下文管理实战:从Context Mode到对话式AI记忆体系

大模型上下文管理实战:从Context Mode到对话式AI记忆体系 我刚才说的那个方案你直接给个结论吧。用户这句话发过来的时候我盯着后台日志看了半天。AI助手回复的是请问您指的是哪一个方案我还需要更多上下文。那一刻我就知道这个助手又失忆了。这已经是这周第三起同类客诉。翻看代码原因一目了然我们把最近20轮对话全部塞进系统提示词里窗口一满就直接清空没有任何分级、压缩、检索机制。用行业的话说这个对话系统完全没有设计context-mode上下文模式。过去一年我一直在做对话式AI应用最大的体会是模型选型决定能力上限上下文管理决定体验下限。一个再强的模型如果喂给它的上下文是乱七八糟的、过期的、超限的它照样给你输出幻觉和错误结论。这篇文章我就把context-mode这件事彻底讲透包括它是什么、有哪些实现思路、核心代码怎么写、以及我实际踩过的坑。1. Context Mode到底是什么先搞清楚我们为什么需要它1.1 大模型的金鱼记忆与上下文窗口困境如果你做过对话类产品一定遇到过这种情况用户连续聊了七八轮之后助手开始答非所问把之前的约定、偏好、中间结论全忘了。原因很简单大模型本身是无状态的——它不记得任何历史对话每一次请求都需要你把上下文完整塞进去它才能看起来记得。但塞多少是个问题。ChatGPT刚出来时上下文窗口是4K token后来一路卷到8K、32K、128K甚至更大但窗口再大也是有限的。更现实的问题是窗口越长的模型推理成本越高、首字延迟越大。你不可能无限制地往里塞东西。context-mode就是解决这个问题的运行策略集合。它管的是三件事哪些内容应该放进来、哪些内容应该压缩、哪些内容应该丢出去。我见过很多团队做AI应用第一版都是无脑拼接把聊天记录字符串拼一拼拼到截断为止。这在Demo阶段完全没问题用户聊三五轮也看不出毛病。但一旦真实上线用户会长时间对话、会穿插新问题、会反复引用早期信息。上下文模式没做好产品体验就是崩塌的。1.2 三种主流上下文模式的思路对比我把它分成三类基本覆盖了市面上绝大多数对话系统的做法。第一种全量窗口模式Full-Window Mode把最近N轮对话全部拼接进上下文超过窗口就从最老的开始丢弃。实现最简单但问题也很明显丢弃即遗忘早期关键信息可能被丢掉而且大量低价值闲聊霸占窗口token浪费严重。第二种滚动摘要模式Rolling Summary Mode维护一份历史摘要随着对话推进不断调用模型把旧内容浓缩成摘要每次请求把摘要最近几轮原始对话一起塞进窗口。这是目前最主流的方案LangChain的ConversationSummaryBufferMemory就是典型实现。第三种混合检索模式Hybrid Retrieval Mode把历史对话向量化存储每次请求根据当前用户问题从历史中检索最相关的片段加入上下文。优点是不再局限于最近早期关键信息也能被翻出来缺点是引入检索链路有一堆自己的坑。三种模式不是互斥的成熟系统通常是摘要检索窗口三合一。我把它们的差异整理成了一张表方便对比模式优点缺点典型场景全量窗口实现快、零失真窗口利用率低、早期信息必丢短对话、评测Demo滚动摘要信息密度高、成本可控摘要会丢细节、必须调摘要触发点客服助手、多轮任务混合检索能召回早期信息检索噪声、延迟和成本增加知识密集、长对话场景无论选哪种核心都在于一个东西上下文管理器Context Manager。下面我重点讲怎么设计它。2. 上下文模式的核心设计从消息记录到可控记忆2.1 消息数据结构别把上下文当成普通日志很多初学者写上下文管理直接用List[String]存消息。一旦要做压缩、检索、按角色隔离这种结构就废了。我用的是带元数据的消息对象。每一个消息至少应该包含角色user/assistant/system、内容、时间戳、消息ID。如果有条件把来源渠道比如来自哪个页面、哪个客户会话也加上。为什么要这些字段有了时间戳你才能做时间衰减或者按时间范围裁剪有了消息ID你才能做去重和引用关系链有了来源字段你在做检索时才能按业务维度过滤。我甚至见过有的团队在消息里加importance_score字段人工标记某些关键节点消息比如用户下订单、修改配置的重要性权重压缩时优先保留。这个思路很实用。字段设计得多细致直接决定了后面压缩策略和你能做出的检索效果。我建议从第一版就按结构化消息来规划别等到系统上线后再重构。这个教训我是付过学费的——一版项目为了赶进度用了纯字符串数组后来加摘要功能的时候全量消息都要迁移费了整整两天。2.2 写入策略什么东西值得占窗口context-mode的另一个核心问题是写入控制。很多人只看淘汰忽略了写入端的设计。实际上你在把一条消息放入上下文之前就应该判断它值不值得进。我推荐做三级分层第一层原始消息存储。所有对话都存放在数据库里不占上下文窗口。第二层热窗口。最近N轮原样保留在上下文中保证模型对当下正在聊的事有完整感知。第三层摘要与索引层。旧消息浓缩成摘要并按主题、实体、时间建立索引方便后续检索。写入策略举例如果用户消息是好的继续谢谢这类短反馈能不能直接并入上一轮而不单独占一条可以直接把前一条消息追加标记而不是新开一条记录。再比如系统主动推送的通知类消息对后续对话没有实质影响就应该直接进摘要层不占用热窗口。这套逻辑用一句话概括就是上下文窗口应该只放对当前决策有用的信息其余进数据库。你在写代码前先想清楚这个原则后续压缩和检索都会轻松很多。2.3 淘汰与压缩窗口满了之后怎么办窗口快满时要有明确的处置顺序。我的经验是四步走按优先级排列去重与合并多轮中重复的表述合并成一条比如用户反复说帮我快点不必在上下文里出现三遍。细节降级保留结论删除过程。用户问这个东西怎么安装AI回复了详细步骤后续对话里保留已告知安装步骤即可。摘要化把最老的若干轮调用模型生成摘要。转移存储如果摘要层也膨胀了就把最早的摘要归档到数据库只保留摘要的摘要层级摘要。压缩之后必须有一个压缩日志。我建议记录每次压缩的时间、原token量、压缩后token量、丢弃了哪些消息ID。为什么因为用户投诉你说过的又忘了的时候你得能追溯是哪个环节弄丢的。我遇到过一种情况压缩策略每次都把上一轮总结直接丢掉了把用户的关键指令比如不要用短信通知我弄丢了结果用户收到短信后炸毛。没有压缩日志这类问题连查都无从查起。3. 手写一个Context Manager架构与关键代码3.1 模块划分存储、策略、生成有了前面的设计就可以落地代码了。我不建议一上来就套LangChain之类的框架先用自己手写的版本跑通你会对context-mode的每一步有更深的体感。整个管理器我拆成三个模块存储模块负责追加消息、按条件读取历史。策略模块负责token估算、压缩触发决策、检索排序。生成模块负责把热窗口、摘要、检索结果组装成发给模型的最终上下文。下面给出一份可运行的简化实现覆盖核心链路。语言用Python数据库先用list模拟方便你理解主流程。3.2 核心实现ContextManager类from dataclasses import dataclass, field from typing import List, Optional import time import hashlib # 消息类型 ROLE_USER user ROLE_ASSISTANT assistant ROLE_SYSTEM system ROLE_SUMMARY summary dataclass class Message: role: str content: str ts: float field(default_factorytime.time) msg_id: str field( default_factorylambda: hashlib.md5( str(time.time()).encode() ).hexdigest() ) importance: int 1 # 重要性评分默认1越高越难被压缩丢弃 class ContextManager: def __init__( self, max_window_tokens: int 4000, summary_trigger_tokens: int 3000, summary_modelNone, embed_modelNone, ): # 热窗口大小上限 self.max_window_tokens max_window_tokens # 触发摘要的阈值 self.summary_trigger_tokens summary_trigger_tokens # 生成摘要用的模型传入一个 callable: (messages) - str self.summary_model summary_model # 向量化模型传入一个 callable: (str) - list[float] self.embed_model embed_model # 原始消息存储模拟数据库 self.all_messages: List[Message] [] # 当前热窗口 token 数 self.current_window_tokens 0 # 摘要缓存 self.summary_cache: List[str] [] def _estimate_tokens(self, text: str) - int: # 粗略估算中文约1.5字符/token英文约4字符/token # 生产环境建议用 tiktoken 或对应模型的分词器 return max(1, int(len(text) / 1.5)) def add_message(self, role: str, content: str, importance: int 1): msg Message(rolerole, contentcontent, importanceimportance) self.all_messages.append(msg) # 只有 user/assistant/system 进热窗口summary 直接放摘要缓存 if role ! ROLE_SUMMARY: self.current_window_tokens self._estimate_tokens(content) else: self.summary_cache.append(content) # 写完之后检查是否要压缩 self._maybe_compress() return msg def _maybe_compress(self): # 还没超阈值不处理 if self.current_window_tokens self.summary_trigger_tokens: return # 按重要性排序先处理 importance 低的消息 # 简化实现把最老的若干轮合并摘要 messages [m for m in self.all_messages if m.role ! ROLE_SUMMARY] # 找出热窗口中最老的、且importance1的消息 to_summarize [] i 0 while i len(messages) and self.current_window_tokens self.max_window_tokens: m messages[i] if m.importance 1: to_summarize.append(m) self.current_window_tokens - self._estimate_tokens(m.content) i 1 if not to_summarize: # 所有消息都重要就只能丢最老的极端情况 m messages[0] to_summarize.append(m) self.current_window_tokens - self._estimate_tokens(m.content) # 调用摘要模型生成摘要 if self.summary_model: summary_text self.summary_model([ {role: m.role, content: m.content} for m in to_summarize ]) self.add_message(ROLE_SUMMARY, summary_text) def retrieve(self, query: str, top_k: int 3) - List[Message]: # 如果没配置向量模型就用简单的关键词重叠评分 if not self.embed_model: q_terms set(query.lower().split()) scored [] for m in self.all_messages: m_terms set(m.content.lower().split()) score len(q_terms m_terms) if score 0: scored.append((score, m)) scored.sort(keylambda x: -x[0]) return [m for _, m in scored[:top_k]] # 配置了向量模型做向量相似度检索 q_vec self.embed_model(query) scored [] for m in self.all_messages: m_vec self.embed_model(m.content) sim self._cosine_sim(q_vec, m_vec) scored.append((sim, m)) scored.sort(keylambda x: -x[0]) return [m for _, m in scored[:top_k]] staticmethod def _cosine_sim(a, b): import math if len(a) ! len(b): return 0.0 dot sum(x * y for x, y in zip(a, b)) norm_a math.sqrt(sum(x * x for x in a)) norm_b math.sqrt(sum(x * x for x in b)) if norm_a 0 or norm_b 0: return 0.0 return dot / (norm_a * norm_b) def build_context(self, current_query: str, include_summary: bool True) - List[dict]: # 组装最终上下文摘要 检索片段 热窗口 当前问题 ctx_messages [] if include_summary and self.summary_cache: combined_summary \n.join(self.summary_cache[-3:]) ctx_messages.append({ role: system, content: f以下是对历史对话的摘要{combined_summary}, }) # 检索相关历史片段 retrieved self.retrieve(current_query, top_k3) if retrieved: hist_text \n.join( f{m.role}: {m.content} for m in retrieved if m.role ! ROLE_SUMMARY ) ctx_messages.append({ role: system, content: f以下是与当前问题相关的历史片段{hist_text}, }) # 热窗口消息 for m in self.all_messages: if m.role ROLE_SUMMARY: continue if self._estimate_tokens(m.content) 0: continue ctx_messages.append({role: m.role, content: m.content}) # 当前问题 ctx_messages.append({role: ROLE_USER, content: current_query}) return ctx_messages这份代码就是context-mode的最小骨架。add_message负责写入并监控token水位_maybe_compress负责在超阈值时触发压缩retrieve负责从历史中召回相关片段build_context负责最终组装。你可以直接跑通再逐步加上数据库、向量存储和更细的压缩策略。3.3 参数配置实测max_window_tokens、summary_trigger_tokens、top_k怎么定代码写出来只是第一步参数才是真功夫。我调参的体感如下max_window_tokens不要设成模型窗口的上限。比如GPT-4o有128K窗口不等于你要用满128K。窗口越大单次请求的推理耗时和费用越高而且模型对超长上下文的注意力分散效应会放大。我的经验是热窗口控制在总窗口的1/4到1/3。比如模型窗口是32K热窗口设8K~10K比较舒服剩下的留给摘要、检索结果、系统提示词和输出token。输出token也要留空间很多模型把输出都算进上下文窗口里不留够就会报错。summary_trigger_tokens这个值建议比max_window_tokens低20%~30%。比如max_window_tokens8000时trigger设6000。为什么留这个缓冲因为压缩操作本身需要时间而且压缩过程中还可能插入新消息如果不设缓冲很容易在压缩完成前窗口就爆掉。我第一版就是triggermax_window_tokens结果在高并发下频繁触顶直接请求失败。top_k检索召回数量也不是越大越好。我试过top_k5和top_k3前者在很多场景下反而效果更差——夹带了太多不相关片段模型被噪声带偏。对于普通客服类场景top_k3够用如果是复杂的研究型问答可以分两次检索一次查用户历史一次查知识库然后各取top_k2再让模型综合去噪。这里说的所有参数都不是一次性定死的。建议你把它们在配置中心里做成可动态调整的开关上线后依据「关键词召回命中率」和「用户追问率」这两个指标持续调。追问率如果升高往往说明上下文丢信息了需要提高trigger阈值或加大top_k。4. 实战中踩过的坑Context Mode问题排查实录4.1 窗口还是爆了预压缩与事中压缩我第一次上线context-mode时是把压缩逻辑放在用户看到回复之后。结果高峰期还是频繁爆窗口。后来想明白了对话是按轮次进行的用户问一句AI答一句如果AI回答的内容特别长比如生成了几千token的代码这条回复会瞬间撑爆热窗口。解决办法是加预压缩——在把当前对话写入热窗口之前先检查现有窗口这条消息会不会超限如果会就先触发一次压缩再写入。另一个关键动作是对模型输出长度做上限约束在system prompt里明确限制输出最长多少token。该限制的没限制context-mode做得再细也扛不住。我还发现一个反直觉的点压缩不是压缩得越频繁越好。频繁压缩会带来两个问题一是调用摘要模型的额外费用二是每次摘要都会损失细节压缩得越频繁累积失真越严重。所以正确的做法是能少压就少压尽量通过写入端的过滤机制减少进窗口的消息量。4.2 摘要后AI变傻关键信息恢复策略有一次版本上线后用户反馈AI突然记不住用企业邮箱注册这个前提条件了。查日志发现这条信息出现在第6轮对话里早就被摘要吞掉了而摘要生成时模型没有强调它。这就是摘要模式的通病摘要模型认为不重要的信息可能恰恰是用户的硬性约束。我的解决思路是加关键信息标记机制。在写入端检测特殊信号比如用户说注意千万别记住不要把这些消息的importance标记为5以上在压缩时优先保留原文。同时在生成摘要时用prompt专门要求如原文包含用户的明确要求、禁止事项、偏好设置必须原样保留而不要改写。这两个动作加起来基本能解决90%以上的摘要丢关键信息问题。4.3 检索带上噪声对话加角色隔离混合检索模式上线测试的时候又出现一个搞笑场景用户在聊天里说我要refer到你说的那个Java问题结果检索系统把用户在某一条无关对话里的随口抱怨这代码真像一坨Java屎也召回进上下文了。模型一看直接理解成用户在抱怨Java回答就偏了。问题出在角色隔离缺失检索应该按角色限权。用户问题和用户历史对话是一类语义助手回复和历史背景是另一类语义。我的修改方案是在向量化存储时把role作为一个过滤字段检索用户问题时只召回roleuser的历史消息在需要召回AI历史回复时再单独带一组roleassistant的检索结果。此外还要在检索时排除纯闲聊消息判据是消息长度太短低于5个字或带大量表情符号。4.4 成本与延迟的双重压力context-mode做完整后系统每次请求要调数据库、计算向量、可能还要调摘要模型延迟比裸拼接版本高了不少。一开始我每轮都清点全部历史并做一次完整检索成本直接翻了三四倍。优化手段有这几个Query改写用户消息先经过一层轻量改写把它那个方案这类指代补充完整再做检索召回准确率高很多。缓存复用摘要不是每次请求都重新生成的只要没有新消息加入就复用上次的摘要结果。检索降频不是每一轮都需要检索。如果当前问题跟热窗口内已有内容高度重叠用向量相似度判断可以直接跳过检索省一次嵌入计算。批量嵌入如果embed_model支持batch模式把多条历史消息批量嵌入比逐条嵌入省一半时间。延迟优化是一个持久战不要指望一次到位。我用一个简单的压测监控组合每次发版前用固定脚本压100轮对话统计平均首token延迟和超时率另加日志监控摘要模型调用次数和检索耗时一旦超过阈值就告警。5. 工具链参考框架内置的上下文模式能不能直接用5.1 LangChainConversationSummaryBufferMemory的局限很多团队做AI应用绕不开LangChain它确实内置了ConversationSummaryBufferMemory翻译过来就是对话摘要缓冲记忆本质上就是滚动摘要模式的一个封装。我试过优点是开箱即用但有三点局限第一它只支持最近轮次摘要的组合不支持检索历史片段。第二压缩触发时机只按token数阈值不支持按重要性评分决定丢弃优先级。第三它对消息结构有抽象约束一旦你要自定义字段比如来源渠道、情感标签就得自己绕开memory接口。所以我的判断是**能用LangChain做demo但没有必要在生产环境把上下文管理的命脉绑死在它上面。**如果你的业务只需要短对话、轻量场景直接用没问题如果你的系统要做精细控制还是自己手写Context Manager更可控。5.2 LlamaIndexChatMemoryBuffer与向量检索结合LlamaIndex在索引和检索方面更强。它的ChatMemoryBuffer负责维护聊天记忆也可以配合VectorStoreIndex把历史消息向量化实现聊天历史按需检索。这种方式很适合已经上了LlamaIndex知识库体系的团队可以减少自研工作量。但要注意LlamaIndex默认的聊天历史检索跟RAG检索并不是同一套体系。如果你既要做知识库RAG又要做历史对话召回需要把两个检索链路明确分离。否则模型会把用户过去说过的话和知识库文档混为一谈回答时就会编造用户的历史信息。这我踩过教训很深刻。5.3 我的选型建议我把这些年看到的团队做法总结一下大致分三档第一档刚起步/短对话直接用框架内置memory跑通流程再说。关键是先别过度设计。第二档有用户画像/较长会话自研结构化消息存储滚动摘要用LangChain或LlamaIndex的组件辅助生成摘要。第三档知识密集/长会话/高复杂度自研完整ContextManager建议用Postgrespgvector做向量存储检索和摘要全部自控。选型决策的核心标准就一句话上下文管理对你的产品是核心壁垒还是边缘支撑如果是核心壁垒一定要掌握底层细节不能只当一个框架调用者如果是边缘支撑用现成工具快速上线然后把精力放在更重要的业务逻辑上。6. 从Context Mode到更广阔的记忆体系最后再分享一个我最近在思考的方向。Context Mode解决的是这次对话怎么写上下文但更长远的问题是用户的跨会话记忆怎么办比如用户周一在客服里说了我习惯用邮箱收通知周五又来了新会话里AI助手是否还记得单靠context-mode解决不了跨会话问题。我的思路是增设一个用户级偏好库在对话中提取用户偏好项比如通知方式邮箱时间段工作日上午存成结构化的profile。每次新会话开始时把与当前场景相关的profile注入系统提示词再配合context-mode正常工作。这套偏好库上下文模式的组合才真正接近人类的记住你。再进一步还可以做知识图谱化的记忆把对话中提到的实体、关系、偏好抽出来构建一张轻量级的知识图。这样用户在后续会话中问起上次说的那个供应商后来又怎样了系统可以借助知识图谱精准定位到对应历史片段比纯向量检索的语义模糊性要强得多。我这里给出一个个人偏好的架构参考会话级Context Manager热窗口摘要检索用户级Profile Store长期偏好、关键约束知识级轻量知识图谱实体、关系、跨会话引用这三层加起来才算是一个相对完整的AI记忆体系。context-mode只是其中第一层但也是最重要的一层——没有会话级上下文管好后面两层的输入都不可靠。在我个人的实践中最让我受用的一句话是上下文不是存得越多越好而是该记住的恰好都在。判断一个上下文系统好不好不是看它的窗口多大而是看它在关键时候有没有把最relevant的信息端到模型面前。如果你正在做对话类AI应用不管用的是闭源API还是开源模型我建议你从第一轮对话就开始考虑上下文管理不要等用户数量上来、客诉堆积了才返工。先把热窗口、摘要触发点、检索召回这三件事跑通你已经超过了市面上大半的同类产品。至于更高级的记忆体系那是在context-mode稳定之后的事。先把地基打好再谈高楼。
返回列表