ARTICLE DETAIL

资讯详情

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

context-mode:大模型多轮对话的上下文治理实践

context-mode:大模型多轮对话的上下文治理实践 干过大模型应用开发的人大概率都遇到过同一个噩梦用户跟你的智能助手聊了十分钟前面说的需求后面就忘得一干二净。你问它“刚才那个方案怎么样”它能给你回一个牛头不对马嘴的答案。问题不在模型笨而是上下文没管好。今天想聊的这套东西我管它叫 context-mode一套通用上下文管理模式专门解决多轮对话里的记忆混乱、状态丢失和 Token 浪费这三件事。如果你正在做客服机器人、AI 编程助手或者任何带多轮会话的系统这篇文章值得看完。我会把设计思路、代码实现、参数调优和踩坑记录都摊开来讲尽量让你能照着复现。1. 先搞清楚 context-mode 到底在管什么1.1 大模型没有记忆上下文是“临时拼出来的”很多刚接触大模型的人会误以为 ChatGPT 这类产品天生有记忆聊过的事它都记得。实际上完全没有这回事。模型本身是无状态的你每次发消息它看到的只是你这次传进去的那一坨文本。所谓“记忆”完全靠上层应用把历史对话重新拼进 prompt 里。这个过程做得越糙体验就越像拼贴画一段顾客投诉记录、一句闲聊、一个订单编号全被粘在一起塞给模型。我做过的第一个带对话功能的小项目就是这样翻车的。用户一开始说“我要买一款跑步用的无线耳机预算五百以内续航八小时以上最好带降噪”过了几轮他问“那个耳机能加急发货吗”系统直接把原始历史全量拼进去结果模型把“加急发货”理解成了“加急退货”还在回答里贴心提示了退货流程。用户当场就炸了。这就是典型的上下文缺失。不是信息不存在而是系统不知道该用哪些信息、不该用哪些信息更不知道该把当前诉求和哪一段历史关联起来。context-mode 要解决的正是“怎么临时拼出一个靠谱的上下文”这件事。它的本质不是给模型加记忆而是给应用层设计一套上下文治理机制让每一轮请求都能拿到最该被看到的那些信息。1.2 context-mode 的三个职责采集、管理、注入如果只用一句话概括context-mode 就是一套围绕上下文生命周期的处理模式核心动作有三个采集、管理、注入。采集指的是从多轮对话里把有用信息捞出来。不是所有消息都值得记住。“今天天气不错”这种寒暄可以丢“我要红色款”这种偏好必须留。我在实际设计中会给每个信息打标签区分它是用户目标、业务事实、临时状态还是纯闲聊。这一步做不好后面管理得再好也是白搭。管理决定上下文怎么存、怎么过期、怎么压缩。这里要处理的核心矛盾是模型上下文窗口有限而对话历史是无限增长的。无脑全量拼接最省事但 Token 会爆成本会涨模型还会被大量无关信息干扰。context-mode 会把上下文分成不同层级让每类信息待在合适的位置再按预算把它装进 prompt。注入则是最关键的一步怎么把当前用户问的问题、短期记忆、长期画像和业务状态拼成一个结构化的 prompt。顺序、长度、优先级都有讲究。用户当前诉求必须靠前历史摘要可以靠后业务状态字段要固定格式放中间。这些听起来是小事但对模型理解准确度的影响非常大。所以 context-mode 绝对不是“把 history 数组传进 API”那么简单。它是一个有策略的上下文调度系统干的是信息管理员的活。2. 整体设计三层上下文体系让记忆各归其位2.1 方案选型为什么不全量拼接、不硬怼向量库我在设计这套模式之前先试过两种工程上最常见的做法都踩了坑。第一种是傻全量拼接。代码里维护一个数组每轮对话 append 一条消息发请求时整个数组原封不动传进 messages。刚开始聊十几轮没问题到二三十轮之后就开始出怪事。用户随口说的一句“我觉得蓝色也行”模型在下一次回答里会当成新的需求反复确认。更麻烦的是 Token 消耗线性增长按 8000 Token 的预算算大约二三十轮就把窗口挤爆了。再往后要么截断。截断如果只留最新几条用户最初的核心诉求反而丢了。这个方案很容易实现但也最容易失控。第二种是猛上向量库。把所有历史消息和业务数据向量化存进数据库每次用户提问先做相似度检索把最接近的几十条拼进去。听起来很优雅实际用下来有两个问题。一是短时状态很难靠向量检索命中比如用户这轮说“那我要加急”跟上下文里哪一段最相似往往是跟最初的购买意图更接近而不是跟刚说出的加急需求。二是向量召回会带回一堆语义相近但已经过期的信息反而制造噪音。最后我确定了分层策略。参照真实人的记忆方式工作台上只放当前正在处理的事务抽屉里放最近几个月的大事记档案柜里放这个人长期画像。对应到技术上就是短时会话层、业务状态层、长期记忆层。每一层承担不同职责也对应不同的存储和调度策略。这样既能控制 Token 预算又能保证关键信息不丢。2.2 三层上下文模型详解这三层分别是短时会话层、业务状态层和长期记忆层。我把它们的职责和存储方式整理成下面这张表。层级作用典型内容存储方式生命周期短时会话层保留最近几轮对话原文最近 6-8 轮用户与助手对话内存数组或 Redis List单次会话结束即失效业务状态层沉淀当前流程的关键状态订单状态、待补信息、用户明确偏好JSON 对象或状态机跟随业务逻辑推进长期记忆层记录用户跨会话偏好与画像常买品类、价格区间、沟通风格向量库 KV 存储永久保存按需召回短时会话层比较好理解就是“最近发生了什么”。它必须保留原始文本因为摘要会丢失细节模型很多推理其实依赖对话里微妙的措辞。我一般保留最近 6 到 8 轮完整对话超过这个范围就交给压缩逻辑处理。业务状态层是很多人会漏掉的一层但它往往是救命的。做客服机器人时用户的购买流程会经过选商品、确认规格、填收货地址、支付确认这些环节。这些状态如果只靠历史对话去推断模型很容易猜错。我会单独维护一个 JSON 状态机例如{stage: 确认规格, product: {name: 无线耳机, color: red, budget: 500, needs_delivery_before: 明天下午}}。这样一来无论对话进行到哪一步系统都能准确知道用户当前处在哪个节点有哪些已经确认的字段。长期记忆层用于跨会话复用。用户第二次来问“还有没有上次那种耳机”如果长期记忆里有上次的偏好数据系统就能直接响应。这一层适合用向量库做相似度召回但我会加一层过滤只召回和当前意图强相关的条目并且每条都带时间戳防止把一年前的价格区间当成现在的预算。这三层各管各的建设顺序也有讲究。我的建议是先把短时会话层和业务状态层做扎实长期记忆层可以后面再慢慢加。因为前两层解决的是“当前这场对话能不能正常走完”的问题第三层解决的是“老客户能不能被记住”的体验升级。前者是刚需后者是加分项。3. 核心细节拆解编码、窗口与压缩3.1 上下文条目怎么编码才不容易丢信息刚开始做上下文管理时我只记了 role 和 content很快发现信息不够用。比如一条消息是用户多久前发的属于哪个阶段重要程度如何这些信息全都没有压缩时根本不知道先丢谁。后来我给每条上下文设计了一个结构化字段用 Python dataclass 表示大概是这样的dataclass class ContextEntry: entry_id: str # 唯一编号用于定位和删除 role: str # user / assistant / system content: str # 原文内容 ts: float # 消息时间戳用于时效排序 stage: str # 业务阶段比如 order_confirm importance: float # 重要度 0-1用于保留判断 immutable: bool False # 不可压缩标记关键字段必须保留 extra: dict field(default_factorydict) # 附加值比如意图标签字段不重要重要的是编码思维。我给每条消息至少打三个维度时间维度用于判断新旧业务维度用于关联流程重要性维度用于压缩取舍。有了这三个字段后续的裁剪和召回就有了依据。实际操作中我会在用户每次发言后调用一次分析函数把意图标签和关键实体抽出来。比如用户说“那还是改成黑色吧”我会把这条消息标记为 stagespec_changeimportance0.9然后将“颜色红色 → 黑色”这个变更写进业务状态层。这样就算原始对话后面被压缩了状态层依然保留着最新结论。immutable 字段也很有用凡是涉及订单金额、收货地址、时间期限的信息我都会置为 True压缩时无条件保留防止摘要模型把关键数字吞了。3.2 窗口预算分配8000 Token 怎么花模型上下文窗口是硬约束。我做的大部分项目用 8K 上下文部分复杂场景上 16K但无论多大都得先定预算。我的分配习惯是这样系统提示词占总预算的 10%-15%用户当前诉求占 20%-30%业务状态层占 10%-15%历史摘要占 25%-35%近期原始对话占 20%-30%再留 5%-10% 给模型输出的余量。注意这里说的百分比不是固定死的而是每次构造 prompt 前的动态规划目标值。如果用户这轮输入特别长比如贴了一篇需求文档那就要把历史摘要压缩得更狠给当前输入腾地方。举个例子。假设总预算 8000 Token系统提示 1000用户当前输入 1500业务状态 JSON 800那剩下就约 4700 Token 给历史记忆。如果近期原始对话已经占了 2400历史摘要就只能分配 2300。此时如果原始缓冲里塞了 4000 Token 的老消息就必须触发压缩把旧的、低重要度的消息合并成摘要直到降到 2300 以内。Token 估算我采用一个非常朴素的公式中文字符数量除以 1.5英文按空格分词后乘 1.3再取两者较大值。这个估算值不需要很精确只要别超太多就行。真正发送前我会再用 tokenizer 精算一遍如果超了就按“先丢寒暄、再丢旧历史、最后丢低重要级长期记忆”的顺序裁剪。3.3 摘要压缩什么时候压、怎么压才不伤筋动骨压缩是 context-mode 最容易翻车的环节。很多方案会把对话历史丢给模型“请总结一下”然后模型只输出了干巴巴的两行概括把用户真正关心的细节全丢了。我建议压缩触发条件不要设成“对话轮数超过 N”而是按 Token 占比触发。每次新消息进来后计算近期原始对话占总预算的比例一旦超过 60%就对较旧的原始消息执行摘要压缩。这样做的好处是当用户一直在聊一个重要话题时系统不会因为轮数太多而强行压缩如果用户每一轮都很简短又不至于过早压缩导致信息丢失。压缩时我会给模型一个明确的摘要模板要求输出四部分用户的核心目标、已完成事项、未完成事项、关键客观事实数字、时间、地址、产品型号。然后强调一条铁律“不要丢弃任何用户明确说出的数字、时间和否定词。” 举个实际例子摘要如果只写“用户想买耳机”那是失败必须写出“用户想买无线耳机预算 500 元内续航大于 8 小时黑色优先需明天 18:00 前收货否则取消订单”。还有一个很多人忽略的点压缩用的小模型应该和主对话模型分开。如果让同一个模型在生成回复的同时顺带做摘要它很容易把自己的回答方向带偏。用独立的压缩请求来跑还能方便地做单元测试验证每一条关键字段是否都出现在摘要里。4. 实操记录把一个临时方案升级成可用的 context-mode4.1 改造前的灾难现场我接到过一个售后客服场景的需求用户会围绕订单反复追问物流、退款、换货等问题。旧系统的实现是每次请求把整个会话历史全部拼出来请求量一大就出各种幺蛾子。我印象最深的一次事故是一位用户先问“我的订单到哪了”中间又闲聊了几句优惠券然后说“我想把这个订单退了”。结果模型把“优惠券”和“退款”纠缠在一起回复变成了“您可以使用优惠券抵扣这次退款的运费”。用户直接投诉。我后来一查优惠券那条消息时间戳比订单信息新全量拼接时排在了前面模型就被带偏了。我用 30 条真实客服会话做了个基线测试统计“关键事实保持率”也就是模型回答是否准确覆盖了用户在这轮之前已经给出的重要信息。结果只有 61%。这意味着接近四成的对话里模型把用户说过的重要约束给忘了。4.2 从零搭建 ContextManager 核心类改造的第一步写一个独立于业务代码的 ContextManager 类。它负责接收消息、维护三层上下文、按预算构造 prompt。核心代码大概长这样class ContextManager: def __init__(self, session_id: str, max_tokens: int 8000): self.session_id session_id self.max_tokens max_tokens self.buffer: list[ContextEntry] [] # 短时原始对话 self.state: dict {} # 业务状态层 self.long_term: list[dict] [] # 长期记忆条目 self.system_prompt self._load_system_prompt() def add_message(self, role: str, content: str, stage: str None, importance: float 0.5, immutable: bool False): entry ContextEntry( entry_iduuid4().hex, rolerole, contentcontent, tstime.time(), stagestage, importanceimportance, immutableimmutable ) self.buffer.append(entry) if self._buffer_tokens() self.max_tokens * 0.6: self._summarize_old_messages() def _buffer_tokens(self) - int: # 估算 buffer 里所有消息的 token 数 return sum(estimate_token(e.content) for e in self.buffer) def _summarize_old_messages(self): # 取 buffer 里前 70% 的旧消息排除 immutable 和最近 N 条 old [e for e in self.buffer[:-6] if not e.immutable] if not old: return summary self._call_summarizer(old) self.buffer [e for e in self.buffer if e not in old] self.buffer.insert(0, ContextEntry( entry_idsummary_ uuid4().hex, rolesystem, contentsummary, tstime.time(), stagesummary, importance1.0, immutableTrue )) def build_prompt(self, current_query: str) - str: # 按照预算分配组装最终 prompt parts [] remaining self.max_tokens parts.append((system, self.system_prompt, 0.1)) parts.append((user, current_query, 0.2)) if self.state: state_text json.dumps(self.state, ensure_asciiFalse) parts.append((state, state_text, 0.1)) # 分配剩余预算给历史摘要 近期对话 budget remaining - sum(p[2] * self.max_tokens for p in parts) parts.append((history_summary, self._get_summary(), budget * 0.4)) parts.append((recent, self._get_recent_messages(), budget * 0.6)) return self._assemble(parts)实际线上的代码会比这个复杂比如并发锁、Redis 持久化、向量召回方法但骨架就是这样。关键点在于新增消息时先判断要不要触发压缩构造 prompt 时按比例分配预算业务状态层永远作为独立字段拼在中间。我这里单独说明一下_get_summary()和_get_recent_messages()的实现思路。摘要区放的是压缩后生成的 summary 条目这部分用 summary 开头标识成 system 角色避免模型误以为这是用户说的话。近期区则取 buffer 里最后几条原始消息保持完整措辞。两者的先后顺序也有讲究摘要放前面原始对话放后面这样既给了模型宏观背景又保留了最近细节。4.3 关键参数调优与效果验证接好代码之后我跑了两个版本的对比实验旧全量拼接版和新 context-mode 版还是用那 30 条客服会话额外又造了 20 条长会话用例。关键参数初始值我这样设置max_tokens 8000业务上够用成本和延迟相对平衡summary_threshold 0.6缓冲 Token 占用达到 60% 就触发压缩recent_window_size 6保留最近 6 轮原始消息state_required_fields [order_no, stage, confirmed_fields]业务状态层强制存在的字段long_term_top_k 5长期记忆每次最多召回 5 条调参过程中发现recent_window_size设太大也不行。调到 10 时压在 summary 上的预算太少摘要被压缩得更狠反而更容易丢信息。6-8 轮是甜点区间既能覆盖大多数短对话的需求又能给摘要流出足够空间。最终测试结果新方案的关键事实保持率从 61% 提升到了 92%。原来经常出错的“订单状态确认”类问题基本不再出现。Token 消耗方面50 轮长会话从每请求约 7800 Token 降到了稳定在 6100 Token 左右原因是历史摘要取代了大量原文。延迟也相应降了大约三分之一。这里要提醒一句如果项目是多线程或异步环境ContextManager 必须在写入和读取时加锁或者用无锁设计保证每次请求只操作自己的 session 状态。我吃过一次亏两个用户同时触发摘要压缩结果把对方会话的历史给压到一起场面一度非常混乱。5. 常见问题与排查技巧实录5.1 上下文串场A 客户的消息跑到了 B 会话这是多人同时在线时最容易出的问题。现象很诡异用户 A 问了一个订单问题系统却回复了用户 B 的另一个订单信息。排查下来往往是 session_id 没有严格隔离。有些人只用了一个全局变量容器所有用户的消息都往里面塞或者前端每次刷新页面没有正确携带 session_id导致所有请求落到了同一个默认会话。排查思路很简单把 session_id 打印到日志里逐个请求检查是不是一致的。如果看到同一个 session_id 对应了不同用户就是前端传参会话 ID 传错了。解决办法是强制要求每次请求携带唯一的 session_id后端用 Redis 的 key 做会话隔离key 格式建议是session:{session_id}:context。另外在 ContextManager 里加一个字段校验检测到 session_id 不匹配就报错不要默默接受。5.2 关键信息被摘要“吞掉”用户明确说过“预算 500 以内”结果压缩之后摘要里只剩“用户想买耳机”500 这个关键数字就不见了。这类问题通常有三个原因。一是摘要模型能力不够概括时丢失了细节二是压缩时没有保护不可压缩字段三是状态层没有及时把关键信息同步过来。我的处理组合拳是这样的所有数字信息在 add_message 时就抽取出来写入state.confirmed_fields源头就保住摘要模板里强制要求输出“金额、时间、数量、否定词”四类信息immutableTrue的消息跳过压缩。三个措施叠加之后我再也没遇到过数字丢失的事故。5.3 Token 超出接口上限有时候用户单条消息特别长比如复制了一段需求文档直接就把预算撑爆了。如果你在最后拼接时才做截断很容易把模型已经生成了一半的上下文截掉导致结果更难看。我的做法是分层处理如果单条用户消息本身太长就先截断并给出提示如果是历史累积太长就提前压缩如果压缩完还是超才启用最后的硬截断并且硬截断永远从最不重要的寒暄和旧历史开始。你可以在日志里给每个请求记录prompt_tokens / max_tokens的比例。如果经常超过 90%说明压缩阈值设得太晚建议把 summary_threshold 从 0.6 降到 0.5提前动手。这个参数不是越大越好也不是越小越好要根据实际会话长度分布去调。5.4 一个实用排查速查表我把平时遇到最多的几类问题整理成了表格方便现场排查时直接对照。现象定位方向常用解法用户问 A模型答 B检查历史拼接顺序和摘要内容调整摘要模板确保当前诉求排在历史前回答缺少关键数字检查状态层字段是否同步强制抽取数字写入 confirmed_fields聊天越久回答越差检查 token 占比是否超预算调低 summary_threshold加大压缩力度两个用户数据互相串检查 session_id 传递链路统一会话 ID 生成规则Redis 分 key 存储压缩后出现逻辑断裂检查近几轮原始消息是否被误压缩设置 recent_window_size 6 并保护不可压缩字段接口频繁 400 超限检查单条 user 输入长度按输入长度动态压缩历史摘要5.5 一段关于调试的题外话context-mode 这类系统调试起来比较费劲因为问题往往不是“代码报错”而是“模型行为不对”。我建议在日志里把每次请求拼好的 prompt 原文都记录下来方便事后回放。你只要把用户最终看到的回答和当时 prompt 里原始数据放在一起比对很快就能定位出是采集中间丢了还是管理环节压错了还是注入顺序反了。我前端时间排查一个诡异问题用户说“我不要了”模型却回复“好的已为您保留订单”。当时从代码逻辑看完全没问题最后翻了 prompt 日志才发现业务状态层的订单状态字段还停留在“已确认”系统注入的上下文是“订单已确认用户有购买意向”模型自然就往保留的方向生成。把状态层同步成“用户取消意向”之后问题立刻消失。这类问题只有靠状态可视化靠肉眼检查所有层级的当前内容才能解决。这其实也解释了为什么我强烈建议把业务状态层单独抽离出来而不是让模型从历史对话里自己猜。因为业务状态是确定性信息它应该来自你的代码逻辑而不是来自模型的语义推断。凡是可以结构化的信息就尽量结构化只有那些结构化成本过高的信息才交给记忆摘要去兜底。我在实际使用中还有一个习惯每周会抽十几条真实出错会话做复盘把出错的上下文打印出来重新手动拼一次 prompt看如果我是一个人能不能根据这些信息给出正确答案。如果能说明上下文没问题问题出在模型调用参数如果不能那就是 context-mode 某个环节漏了。这个习惯帮我避免了很多“盲目调 prompt”的无效工作。如果你准备在自己的项目里落地这套模式我的建议是别一上来就追求三层架构完整实现。先把短时会话层和业务状态层做出来用最原始的消息数组跑通再逐步引入摘要压缩和长期记忆。 vector 长期记忆完全可以放在第二阶段再去接。先把最近几轮的逻辑理顺你已经能解决 80% 的上下文混乱问题了。最后再分享一个小技巧给每条上下文都写清楚它的触发场景和有效期比如“用户当前在城市 A有效期到本次会话结束”这个小小的元数据能让后续的召回和裁剪都精准很多。
返回列表