ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

AI多轮对话上下文管理指南:context-mode设计与实现

AI多轮对话上下文管理指南:context-mode设计与实现 1. 为什么我会专门做一版context-mode做AI对话类应用做得久了你迟早会撞上一堵墙模型上下文窗口是有限的但用户的对话历史是无限的。早期我做助手类产品的时候最简单粗暴的方案就是把最近几轮对话一股脑塞进去看起来省事可真跑起来就发现稍微聊得久一点模型就开始“失忆”要么把早先用户明确交代过的事忘了要么在无关紧要的寒暄上浪费大量token回答质量肉眼可见地往下掉。后来我专门花了两周时间给项目加了一个独立的功能模块内部代号就叫context-mode。它不是什么花哨的黑科技本质上是把“上下文该怎么管、该保留什么、该丢什么、该压缩成什么样”这件事从拍脑袋变成一套有规则的、可配置的、能自己运转的机制。这篇文章就把我完整的实现思路、踩过的坑、调参记录都摊开来说希望对正在做智能助手、客服机器人、或者任何带多轮对话场景的开发者有点帮助。我自己当时定了一个目标基于同一套底层模型只靠context-mode这个模块让一个普通助手在10轮以内的对话体验基本不变20轮以上依然能记得关键信息50轮以上的长会话不至于崩掉。最终跑下来的效果超出预期所以决定把方案沉淀成文字。2. 整体方案设计context-mode到底在解决什么问题2.1 先拆解上下文管理的三个核心矛盾动手之前我先想清楚了一件事context-mode不是“把上下文处理好”这么笼统的需求它背后是三个互相打架的矛盾。第一是信息完整性与成本的矛盾。完整保留全部历史最准确但Token开销吃不消尤其接入付费大模型API的时候每次请求都带着全量历史聊到后面可能光上下文就要消耗几千个Token成本直接失控。第二是近期信息与远期信息的优先级。人类聊天的规律是越近的对话越重要但有些远期信息恰恰是关键约束比如用户在第一轮就说了“预算不超过两万”聊到第五十轮你可能早就忘了这条但模型必须记得。第三是记忆容量与检索效率的矛盾。就算你愿意把全部历史存下来塞进Prompt之后模型也未必能有效利用。Token一多注意力被稀释关键信息反而沉底了。所以context-mode真正的定位不是“存储上下文”而是“管理注意力”决定模型应该把注意力放在哪些信息上以什么形式呈现这些信息。2.2 我给context-mode定的三条设计原则带着上面三个矛盾我给自己定了三条硬性原则后续所有实现都围着它们转。第一条近期对话全量保留远期对话分层压缩。最近3-5轮对话原样保留因为即时上下文最影响回答的连贯性更早的对话进入压缩和摘要通道只保留“还有价值”的信息。第二条关键约束必须固化。用户明说过的偏好、约束、目标一旦识别出来就单独存进一个“长期记忆区”不随对话滚动被冲掉。这相当于给模型准备了一张便签纸上面写着“这个人说过这些话别忘”。第三条所有策略可配置、可观测。每个环节的开关、阈值、保留轮数、压缩比例全部做成配置项。这样在线上出问题的时候我能快速调整参数而不是改代码。这三条原则看起来简单实际落地的时候每条都有不少细节下面我按模块拆开讲。2.3 为什么不做“无脑全量打包”这里我要先泼一盆冷水。市面上很多简化方案是“把历史转成JSON数组全量塞进System Prompt”这种做法的坑我替你们踩过了。全量打包最直接的问题是Token爆炸。假设平均每轮对话约300个Token聊到30轮就是9000个Token加上回复内容、系统指令、工具定义一次请求可能轻松突破12000-15000 Token。对于上下文窗口只有8K的模型直接溢出对于32K甚至128K窗口的大模型虽然装得下但首Token延迟和费用都肉眼可见地增长。我做了一组粗略测试在同样的模型下上下文从3K涨到12K单次请求延迟大约翻了2.5倍尤其是长Prompt的预填充阶段慢得非常明显。更隐蔽的问题是注意力稀释。大模型对长上下文的利用能力并没有我们想的那么强你塞进去50轮历史它真正关注的可能只有最近几轮和开头部分中间一大段成了“信息孤岛”。实际表现就是用户问一个跟二十轮之前相关的问题模型答得跟失忆了一样。所以context-mode必须做“减法”而且是聪明地做减法。3. 核心细节解析context-mode的四个子模块3.1 模块一会话缓冲区缓冲区的职责很简单维护一个FIFO结构存放最近N轮完整的对话。N默认我设为6轮这不是拍脑袋定的而是根据实测结果选的——6轮以内的内容模型在回答连贯性上表现得最稳定再往上边际收益大幅下降。from collections import deque from dataclasses import dataclass, field dataclass class Turn: role: str # user / assistant content: str # 原始文本 tokens: int # 该轮token数 ts: float # 时间戳 class ChatBuffer: def __init__(self, max_turns: int 6, max_tokens: int 3000): self.turns deque(maxlenmax_turns) self.max_tokens max_tokens def add(self, turn: Turn): self.turns.append(turn) self._trim_by_tokens() def _trim_by_tokens(self): total sum(t.tokens for t in self.turns) while total self.max_tokens and len(self.turns) 1: self.turns.popleft() total sum(t.tokens for t in self.turns)这里有个细节值得说缓冲区同时受“轮数上限”和“Token上限”双重约束谁先超就触发谁。在长回复场景下比如模型一口气输出800个Token6轮可能就已经逼近3000 Token了这时候按Token裁剪比按轮数裁剪更可靠。3.2 模块二摘要压缩器当一轮对话从缓冲区被挤出去它不会直接消失而是进入摘要压缩器。这里我用了两级压缩策略立即摘要和定期重压缩。立即摘要很好理解就是每次有对话被挤出缓冲区时把被挤出的那部分拿去让模型生成一段摘要塞进一个“滚动摘要区”。但这里有个大坑如果只把新挤出的内容拿去生成摘要摘要和摘要之间是断层的。举个例子第一轮聊了“项目需要在三个月内上线”十轮之后被挤出生成摘要A第二轮聊了“技术栈选Python”二十轮之后被挤出生成摘要B。两个摘要互相独立模型到后面很可能只看到摘要B忘了“三个月内上线”这个关键约束。所以我的做法是生成新摘要的时候不是只拿新挤出的内容而是把上一版滚动摘要一并交给模型让模型输出“旧的摘要新信息合并后的新版摘要”。相当于摘要也在自我迭代。def compress(turns, prev_summary: str) - str: prompt f请将以下信息整合为一份简洁的项目背景摘要。 历史摘要{prev_summary} 新增对话{turns} 要求保留时间节点、预算、明确偏好、待办事项同一主题合并50字以内。 summary call_llm(prompt, max_tokens200) return summary这类调用的输出需要设置一个比较小的max_tokens逼模型做真正的压缩而不是复述。我一开始没限制模型经常输出几百字的“摘要”Token省了个寂寞。3.3 模块三关键约束提取器摘要负责“概括”但还不够因为摘要里信息密度分散。对于一些绝对不能丢的高优先级信息我单独加了一条提取通道用模型做结构化抽取输出JSON。extract_prompt 从对话中提取需要长期记忆的硬性约束输出JSON格式如果没有则为空数组 { deadlines: [{内容: , 日期: }], budget: {金额: , 币种: }, preferences: [], decisions: [] } 对话内容{recent_turns}这个模块的输出会直接写入一个独立的“长期记忆库”每轮对话之后异步更新。比如用户说“帮我找一个带泳池的民宿预算每晚500以内”提取器会记录偏好带泳池、预算500/晚。这个信息不进滚动摘要而是单独存一个key-value结构每次请求时强制注入System Prompt。为什么这么做因为我在实测中发现摘要压缩往往会“糊掉”具体数字。模型生成的摘要倾向于把“预算5000以内”改写成“预算有限”数字信息在压缩中大量丢失。提取器就是用来兜住这些具体数字的。3.4 模块四上下文组装器前面三个模块都是“生产内容”的组装器负责“消费”它们也就是在每次请求前决定最终往Prompt里放什么、按什么顺序放、各占多少预算。我的组装顺序固定为System Prompt含长期记忆库的强制约束 滚动摘要压缩后的历史背景 最近会话缓冲区中的近期对话 当前用户问题这个顺序不是随便排的。System Prompt在最前面让模型先建立“这个用户是谁、有什么硬约束”的认知滚动摘要提供背景近期对话提供即时上下文最后是当前问题让模型带着最新诉求去读前面的内容。我试过把摘要放最后效果明显差一截模型容易把摘要和当前问题搞混。组装器还需要一个可配置的Token预算分配表区域默认Token预算说明System Prompt800含角色设定、输出规范、长期记忆滚动摘要600压缩后的历史背景会话缓冲区3000最近6轮保留余量300防止超限给模型预留输出空间这几个数字是动态的。比如当用户的长期记忆特别多时我会压缩System Prompt里的非必要描述总预算不能超。4. 实操过程context-mode的完整落地步骤4.1 第一步搭好数据流水线我先说一下整体架构。context-mode不是一个独立的服务而是嵌入在对话服务主流程里的一个中间层所有用户消息进来先经过它处理再拼装成请求发给模型。数据流大概是这样用户消息进入对话服务服务把消息同时发给两个地方——会话缓冲区和关键约束提取器缓冲区负责更新近期对话提取器负责更新长期记忆随后组装器读取缓冲区、滚动摘要、长期记忆拼出完整Prompt模型回复后再把回复写回缓冲区同时触发摘要压缩检查。这个流程看着简单但有个顺序问题需要特别注意提取长期记忆的时机一定要在“组装Prompt之后、模型回复之前”的窗口内做异步更新还是同步执行。我一开始图省事每次请求前同步执行提取和摘要两个模型调用结果就是用户每说一句话背后要跑两三次模型延迟直接翻倍。后来改成长期记忆提取只对“有新用户消息到达”这件事触发异步执行不阻塞主流程提取结果落到缓存下次请求生效。摘要压缩只有缓冲区发生淘汰时才触发同样异步。这样主流程里的模型调用次数从“3次/轮”降回“1次/轮”体感流畅很多。代价是记忆更新有最多一个请求的延迟但实测下来用户根本感知不到上一轮的约束提取下一轮就用上了这种“轻微滞后”可以接受。4.2 第二步实现缓存层既然长期记忆要异步读写那必然需要一个存储。生产环境我建议直接用Redis带上TTL如果只是本地验证内存字典也够用。import json import redis r redis.Redis(hostlocalhost, port6379, decode_responsesTrue) MEMORY_KEY context_mode:memory:{session_id} def save_memory(session_id: str, memory: dict): r.set(MEMORY_KEY.format(session_idsession_id), json.dumps(memory, ensure_asciiFalse)) def load_memory(session_id: str) - dict: raw r.get(MEMORY_KEY.format(session_idsession_id)) return json.loads(raw) if raw else {}这里有一个业务上的坑长期记忆必须按会话隔离。千万不要做一个全局共享记忆库不同用户的数据串了那不只是技术事故是隐私事故。我见过有人为了方便把记忆存在一个全局JSON里结果用户A的预算信息出现在用户B的对话里这种错误在测试环境看不出来上线就是大事故。所以session_id一定设计好每个会话一条记录。存储结构我用的是{ session_id: sess_001, constraints: { deadlines: [], budget: null, preferences: [], decisions: [] }, summary: 用户正在筹备一个线下活动..., summary_updated_at: 1710000000, turn_count: 37 }摘要和约束分开存因为它们的更新频率不同。约束只要识别到就更新摘要只要触发压缩才更新。4.3 第三步配置化管理所有阈值我全部抽成了配置用一个简单的YAML文件管理。context_mode: buffer: max_turns: 6 max_tokens: 3000 summary: enabled: true trigger: on_evict max_tokens: 200 merge_with_previous: true extractor: enabled: true async_mode: true fields: [deadlines, budget, preferences, decisions] assembler: system_prompt_budget: 800 summary_budget: 600 buffer_budget: 3000 reserve_tokens: 300配置化带来的好处是线上出问题的时候我可以不打补丁先调参观察。比如模型频繁遗忘近期信息我就把buffer的max_turns从6调到8成本偏高我就把max_tokens压下来。这种灰度调整能力在真实线上环境里非常重要。4.4 第四步接入主流程接入代码不长核心是在原来“拼接消息列表”的地方替换成组装器。def build_messages(session_id: str, user_question: str) - list: memory load_memory(session_id) buffer load_buffer(session_id) system_parts [] system_parts.append(DEFAULT_SYSTEM_PROMPT) if memory.get(constraints): system_parts.append(【用户长期约束】 format_constraints(memory[constraints])) messages [ {role: system, content: \n.join(system_parts)} ] if memory.get(summary): messages.append({role: system, content: 【历史背景】 memory[summary]}) for turn in buffer: messages.append({role: turn[role], content: turn[content]}) messages.append({role: user, content: user_question}) return messages这段代码看起来平平无奇但它把前面所有逻辑串起来了。注意这里历史摘要也用system角色注入而不是塞进对话历史里这样可以避免模型误以为摘要内容是“上一轮说过的话”减少歧义。4.5 第五步监控与日志context-mode这种中间层最怕“黑盒”因为一旦出问题你根本不知道是模型的问题还是上下文处理的问题。所以我加了三条日志每次请求记录最终Prompt的Token分布system/summary/buffer占比。每次摘要压缩记录“压缩前Token数”和“压缩后Token数”。每次提取器记录识别出的约束内容。这三条日志帮助我在线上定位问题时省了大量时间。有次用户反馈“助手把我的要求忘了”我一看日志发现提取器在那一轮根本没输出任何约束原因是用户表达方式比较隐晦“我不想弄太贵的”提取模型没识别出来。后来我优化了提取提示词加入“识别隐含意图”的引导情况好转很多。5. 实测效果与关键参数调优5.1 一组对比实测数据我把context-mode接好之后做了一个标准化测试用一个固定虚构项目模拟40轮对话每5轮问一次“项目当前约束是什么”看模型能否答准。测试配置第10轮答对率第25轮答对率第40轮答对率平均单轮Token消耗无上下文模块只保留最近4轮100%63%41%1.2K全量历史塞入100%100%96%8.7Kcontext-mode默认参数100%100%94%2.3Kcontext-mode调优后参数100%100%98%2.1K数据说得很清楚全量塞入虽然效果最好但Token消耗是context-mode的4倍无模块方案到了第40轮基本等于失忆context-mode在保留记忆和节省成本之间取得了明显的平衡。5.2 调参经验这些参数最影响效果第一批参数是缓冲区轮数。我试过4轮、6轮、8轮、10轮四档。4轮时模型对“半小时前提过的事”开始恍惚10轮时成本上升但效果没明显变好。6轮是性价比拐点日常闲聊场景建议采用。第二批参数是摘要长度。max_tokens从100到500我都试过100太短摘要经常丢关键时间点500太长压缩没意义。200左右既能概括核心事实又保留了必要细节。你可以根据自己场景调整但50字以内的摘要基本都是废的。第三批参数是提取器的字段。一开始我提取的字段特别多包括“情绪状态”“语气偏好”这种虚的东西效果很一般。后来砍到四项硬性字段时间节点、预算、偏好、决策提取准确率从70%左右升到90%以上。提取器不要贪多少而准比多而杂强。5.3 成本测算我把成本也算了一笔账。假设每次摘要压缩约消耗500 Token输入400输出100的模型调用当一个会话每8轮触发一次压缩聊50轮大概触发6次额外成本约3000 Token/会话。相比全量塞入在50轮时单次请求就得消耗1万 Tokencontext-mode的额外开销微不足道。而且在摘要生成这类小任务上完全可以走更便宜的模型进一步压成本。6. 常见问题与排查实录6.1 问题速查表现象可能原因排查思路与解决方案模型忘了近期约束缓冲区轮数太少或Token上限太低调大buffer max_turns或max_tokens先看日志确认运行时缓冲区内确实有这几轮模型分不清历史和当前摘要注入位置不对确保摘要放在system角色中不要在user/assistant消息里塞摘要摘要像复述不像压缩生成摘要时没限制输出长度给摘要调用设置max_tokens200必要时在提示词里强制“50字以内”长期记忆迟迟不更新异步提取逻辑没触发或写缓存失败检查提取器是否真的被调用检查Redis连接和TTL设置成本无明显下降缓冲区Token上限设得过大看监控日志的Token分布重点检查buffer实际占用是否长期低于上限约束提取漏掉信息提取模型指令不够明确优化提取提示词加上“如果用户提到时间、金额、明确偏好务必提取”首次请求变慢组装器额外做了同步提取确认提取器走异步路径不要阻塞主流程6.2 我在调试中遇到的真实问题说一个印象最深的坑。当时我把context-mode接进正式环境后产品反馈“助手变笨了”。最开始我怀疑是摘要压缩丢信息仔细看日志发现一个诡异现象在某个会话里摘要显示“用户已确认采用A方案”但用户实际上一小时前已经改成了B方案。查了半天问题出在摘要的“合并时序”上旧摘要说A方案新增对话说“我们还是用B吧”合并时模型看到一句“用B吧”默认是“在我们刚才说的A方案基础上讨论”结果把B方案写成了对A方案的补充描述而不是替代关系。这个其实是语言模型的通病解决方式是在压缩提示词里明确加一条“如果新信息与旧摘要存在冲突以新信息为准并删除旧信息中的对应内容”。另一个坑是会话ID复用。我遇到过一个线上事故用户A和用户B用了同一个会话ID互相能看到对方的偏好。排查下来是前端在重建会话时没有重新生成ID复用了旧的。context-mode加强了对会话ID来源的校验凡是未知来源的ID一律拒绝创建长期记忆。这个教训分享出来希望你们别踩。6.3 关于抽象的小经验有些开发者会把context-mode这套东西抽象成“AI Memory框架”一上来就规划知识图谱、向量数据库、实体关系抽取。我的建议是先别急着上重武器。如果你的场景就是普通多轮对话摘要约束提取足以覆盖90%的需求。我曾试着给它加装了向量检索模块塞入了历史消息向量库实测对多数对话场景的提升微乎其微反而增加了一个故障点。先跑通简单方案再根据真实需求决定要不要上复杂的检索。7. 这版context-mode的扩展空间我个人在实际使用中最满意的一点是这个方案的模块边界很清晰每个模块都能独立更换。比如摘要压缩器现在用的是调用大模型生成摘要如果你嫌贵完全可以用一个开源的小模型本地跑约束提取器也可以换成基于规则的关键词抽取。context-mode的核心不是某个具体模型而是“分层管理、按需注入”这套机制。另一个值得做扩展的方向是给长期记忆加上时效性。我现在只分了“永久约束”和“滚动摘要”但有些信息的有效期是有限的。比如用户说“这周内要提交材料”过了这周这个约束就不该再往Prompt里放了。我在后面的版本里计划加一个expire字段记忆到期自动降级甚至清理避免过期信息持续占用Token。最后分享一个关于调参心态的经验不要指望一组参数适配所有场景。客服机器人需要短平快buffer轮数可以压到4轮创作助手需要风格延续摘要可以给到300 Token。把配置开放出来让不同业务线自己调比统一一套参数硬扛效果好得多。代码和配置我都放在文里了想复现的照着搭一遍就行。如果你们在落地过程中遇到别的问题欢迎交流。
RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进