ARTICLE DETAIL

资讯详情

深耕编程入门与网站建设的一线实战洞察。

大模型上下文管理实战:五段式模式解决对话失忆与Token浪费

大模型上下文管理实战:五段式模式解决对话失忆与Token浪费 做AI应用最怕什么不是模型不够强是对话一长模型就开始“失忆”。我前阵子做的一个内部工具名字就叫context-mode核心解决的就是这件事在大模型应用里怎样把上下文窗口用得更聪明。它不是给模型提能力的而是给模型“省着点用”的。你可以把它理解成一个精打细算的管家负责把有限的内存上下文窗口合理分配给每一轮对话该留的留、该扔的扔、该压缩的压缩。这个项目做完之后原本经常出现的“聊着聊着忘了前面说了什么”的问题基本消失每次调用的token成本也降了四成左右。这篇文章我就把整个思路、代码落地、踩坑过程完整拆出来给同样在做AI应用、Agent、私有知识库对话这类项目的朋友做个参考。1. 项目背景为什么我决定自己写一个 context-mode 管理器在动手之前先把问题想清楚上下文模式管理到底在解决什么问题如果不把这个想明白后面所有优化都是瞎忙。1.1 从一次线上事故说起起因是一个客服问答机器人。一开始效果挺好用户问什么答什么准确率高。但聊了十几轮之后效果断崖式下跌——用户说“刚才那个方案还有没有别的选择”机器人完全不知道“那个方案”指的是什么。我查了日志发现每次请求发送给模型的prompt已经攒了6000多token里面全是前面十几轮的原始对话。更重要的是这些对话里包含大量无效信息用户重复的表述、中间试探性提问、机器人的部分错误修正……真正关键的意图被淹没在噪音里。传统做法是“无脑截断”只保留最近5轮。但这样又带来新问题——用户在第3轮提供了关键信息比如“我预算只有5000块”第20轮问“那我之前说的预算范围内的方案是哪个”模型早就把第3轮丢了只能答非所问。所以缺的不是“截断”这个动作而是一套有策略的上下文管理模式——知道什么时候该截、什么时候该留、什么时候该把旧信息做摘要、什么时候该从历史里检索关键片段。这就是context-mode项目最初的出发点。1.2 上下文管理到底在管理什么先厘清概念。这里说的上下文不只是“把对话历史拼在一起发给模型”而是三种东西会话级上下文就是多轮对话的用户消息和助手回复有明确的先后顺序和依赖关系。指令级上下文系统提示词、用户设置的固定要求、当前任务的约束条件比如“你是客服助手回答要简洁”“遇到投诉先道歉”。记忆级上下文跨会话的、需要长期存储的信息比如用户的历史偏好、之前谈成的订单号、熟悉程度等。一个合格的上下文管理模式要同时管好这三类信息让它们在有限窗口里各得其所。指令级上下文优先级最高永远不能丢会话级上下文要动态裁剪记忆级上下文要有专门的存取机制不占普通对话窗口。这个认知让我放弃了所有“直接调库就完事”的方案决定自己动手。市面上的现成框架大多只解决某一层问题——有的只处理对话截断有的只做向量存储但把它们拼起来中间总有缝隙而缝隙就是信息丢失的地方。2. 五段式模式设计透明、滑动、摘要、混合、持久化context-mode的核心是一张模式表。不同对话阶段、不同任务类型切换不同的上下文处理策略。2.1 透明直通模式小对话别折腾透明直通模式transparent是最简单的一种不裁剪、不压缩、不摘要把当前所有符合条件的消息直接发给模型。适用场景很清晰对话轮次少比如少于5轮、总token量远低于窗口上限、任务本身要求看到完整历史。典型场景是单轮问答、代码生成、翻译以及一些需要全局理解的任务——比如要求模型总结整段对话时截断反而是错的。我设计模式的第一步就是明确“不要过度加工”。很多上下文管理工具的问题不是管得不够是管得太多——对话才3轮就急着压缩摘要结果模型既看不到原文又得依赖摘要的转述反而丢失了细节。实现上透明模式只需要做总量校验def should_use_transparent(messages, ctx_budget): total sum(m.token_count for m in messages) return total ctx_budget * 0.8注意这里的0.8不是用满100%预算。因为模型回答本身还要占输出token而且预留一点缓冲可以避免刚好卡在窗口边缘导致的意外截断。这个0.8的系数是我调出来的后续会细说。2.2 滑动窗口模式最简单也最粗暴滑动窗口模式sliding是大家最熟悉的只保留最近N轮对话更早的全部丢弃。实现起来一行代码但难在N怎么定。定太大窗口很快堆满还是得裁定太小旧信息丢失严重。我试过固定轮数的方式比如固定保留最近8轮效果很差——不同场景下对话长度差异极大。用户一个问题带了好几百字的背景描述8轮就满了反过来全是“嗯”“然后呢”这种短回复20轮也占不了多少空间。所以我的实现改成按token预算反推轮数而不是按轮数正推。def sliding_window(messages, token_budget): kept [] current 0 for msg in reversed(messages): cost msg.token_count if current cost token_budget: break kept.append(msg) current cost return list(reversed(kept))这个模式适合对历史连贯性要求不高、但需要最近对话细节的任务比如轻量客服、闲聊机器人、临时问答。但你要清楚它的硬伤任何超出窗口的旧信息都会硬丢失没有任何恢复手段。所以我只把它当兜底方案或者配合下面的摘要模式使用——先摘要摘要还放不下再滑动。2.3 摘要压缩模式把废话浓缩成要点摘要压缩模式summarize是重头戏。它的思路是超出预算的旧对话不是直接扔掉而是先用模型或规则生成一段结构化摘要塞进上下文的固定位置。一开始我做了个很朴素的设计每满5轮就触发一次摘要把前5轮压成一段话然后拼接上最近的对话。实测效果很差原因是无差别压缩——模型连“用户昨天说想要蓝色款”这种关键偏好也会一并压没因为生成摘要时模型不知道后面哪句话会被再次引用。后来我把摘要改成了“结构化摘要”用模板约束摘要必须包含五类信息用户已经明确表达的偏好颜色、价位、风格、时间约束等已经确认的事实订单号、人名、日期、地址等尚未解决的待办事项需要模型后续跟进的问题双方已经达成的结论关键时刻的原始引语特别重要的句子原文保留summary_template 请将以上对话压缩为结构化摘要必须包含 1. USER_PREFERENCES: 用户偏好与限制条件 2. CONFIRMED_FACTS: 已确认的事实信息 3. PENDING_TASKS: 待办或悬而未决的事项 4. AGREEMENTS: 已达成的结论 5. KEY_QUOTES: 2-3条关键原话 不要保留寒暄、重复表达、试探性提问。 这样改造之后摘要的可用性大幅提升。因为模型知道后面可能用到哪些信息压缩时就会刻意保留。摘要的存储位置也讲究。我把它放在系统提示词后面、最近对话前面形成一个“摘要区→最近消息区”的结构。这样模型先读到全局概况再看具体对话细节理解效率最高。2.4 混合检索模式旧信息按需召回滑动窗口丢失信息摘要模式丢失细节那有没有一种方案既能控制总量又能按需找回被压缩掉的信息这就是混合检索模式hybrid。它的做法是默认情况下历史对话只保留一份摘要。但每当新消息进来系统会做一次检索——把当前用户的问题和历史所有消息做语义匹配如果检索到高度相关的旧片段就把它临时注入到上下文里。举个例子用户在第3轮说“我公司在上海要租200平办公室”第25轮问“之前说的200平那个能不能看看浦东的”。滑动窗口可能已经丢了第3轮但混合模式会在第25轮触发检索把“200平”“上海”相关的旧消息重新拉回来模型就能准确回答。核心代码逻辑是这样的def hybrid_context(query, current_window, history_store, top_k3): # 1. 先取当前窗口内消息 messages current_window.messages() # 2. 向量检索历史找相关片段 hits history_store.search(query, top_ktop_k) # 3. 把检索到的片段包装成只读消息注入上下文 retrieved [to_context_messages(h) for h in hits if h.score 0.75] return recovered_messages messages注意两个细节不是所有检索结果都值得注入。我会设一个相关度阈值embedding相似度0.75以上低于阈值的旧信息宁可不要——因为不相关的旧信息注入进去不但占窗口还会误导模型生成方向。注入的旧消息用只读标记包裹比如[相关历史片段]模型能读但明确知道这是背景资料不是最新的实际对话。这个包装能有效降低模型混淆时间线的概率。混合模式适合对信息完整性要求高的场景企业知识库问答、长文档分析、深度客服系统。代价是每次请求都要多一次向量检索延迟增加20-50ms在可接受范围内。2.5 持久化记忆模式跨会话的长期上下文如果说前面四种模式管的是“一场对话内”的上下文那持久化记忆模式persistent管的是“跨对话”的记忆。我遇到过的情况是用户昨天刚咨询了产品今天又来了说“还是上次那事”。如果系统对用户没有任何记忆模型根本不知道“上次那事”是什么。持久化记忆做两层一层是用户画像记忆存放从多轮对话中提取的结构化偏好另一层是事实记忆存放具体的事务性信息。memory_schema { user_id: , preferences: [], facts: [], interaction_history: [ {date: , topic: , outcome: } ], last_context_summary: }在每次对话结束我会跑一个轻量提取任务把本轮新出现的偏好和事实更新到memory。下次对话开始时把memory内容作为系统提示词的一部分注入。这条设计我最大的收获是持久化记忆不要存原始对话只存结构化事实。原始对话又长又杂检索成本高反而结构化后的记忆即使过了一个月也能准确回答用户偏好的问题。3. 从零落地窗口预算、压缩调度与核心代码设计方案定完之后就是落地。这块我把每一步的关键参数、计算公式、实测经验全部列出来。3.1 预算分配以8000窗口为例怎么分才够用假设你用的是8K上下文窗口的模型比如早期GPT-3.5级别整个窗口不是全给对话历史而是要先给其他部分切蛋糕。我最终采用的分配方案是组成部分预算比例8000窗口下的预留量系统提示词15%1200 token摘要区12%960 token检索注入区8%640 token最近对话区45%3600 token模型输出20%1600 token这个比例不是拍脑袋定的说一下推导逻辑模型输出留20%是因为输出token和输入token在很多模型里是分开计费的但共享同一个计算窗口。测试时发现一旦输出超过窗口总量的20%回答质量会明显下降因为模型在生成末尾时几乎是在“裸奔”。系统提示词15%是底线。很多项目不重视系统提示词随便写几百字但功能复杂的Agent系统提示词动辄上千token是很正常的。如果系统提示词本身就超预算需要先压缩的是它不是对话。摘要区12%对应960 token大概能容纳2-3段结构化摘要。超过这个量就要考虑把旧的摘要再合并。最近对话区45%是重点。这个区域的大小直接决定了模式切换的触发点。我开了个调试函数实时打印各类别的token占比这样每次请求都能看到预算有没有超def log_budget_usage(parts): for name, used, limit in parts: pct used / limit * 100 if pct 100: logger.error(%s OVER BUDGET: %d/%d, name, used, limit) else: logger.info(%s: %d/%d (%d%%), name, used, limit, pct)3.2 压缩触发条件不能等满了再动手压缩调度最忌讳的一件事等窗口快满了才触发压缩。为什么因为很多压缩手段比如摘要本身需要调用模型调用模型要占用时间。如果到了窗口边缘才开始摘要这个请求的响应时间就会翻倍而且摘要期间如果新消息进来还得等。我把触发条件设计成三级预警预算使用率达到60%只预警不做操作。达到75%启动“对最老的一段对话做摘要”的异步任务。达到90%强制阻塞把摘要结果合入后再继续。这样做的效果是大多数请求在75%预警线时就完成了压缩90%的强制阻塞几乎不会触发。这里还有一个细节摘要任务可以异步执行。因为摘要其实不需要等用户的新消息它处理的只是已有的旧对话。我开了一个后台队列一旦检测到使用率越过75%就把最老的一段压成摘要更新到摘要区。到真正需要时摘要早就就绪了。class CompressionScheduler: def __init__(self, thresholds(0.6, 0.75, 0.9)): self.warn, self.compact, self.block thresholds def on_new_message(self, ctx_manager, new_msg): usage ctx_manager.usage_ratio() if usage self.warn: return # 不需要干预 if usage self.compact: ctx_manager.pre_warn() # 只打日志 elif usage self.block: ctx_manager.submit_compress_async() else: ctx_manager.compress_blocking()这套调度策略的收益是长尾的——使用率在75%到90%之间的请求如果走异步压缩耗时几乎不增加如果等满了再压每次都要多出2到3秒的延迟。3.3 核心实现消息管线与模式切换逻辑整体消息管线我用一个类来管理入口对外暴露统一的build_context()接口。调用方不用关心内部用的是什么模式把消息列表丢进来拿到的就是整理好的、可发给模型的上下文。class ContextManager: def __init__(self, max_tokens8000, mode_rulesNone): self.window ContextWindow(max_tokens) self.summarizer Summarizer() self.history_store VectorStore() self.rules mode_rules or default_rules() def build_context(self, new_message, memoryNone): # 1. 先判断使用哪种模式 mode self.select_mode(new_message) # 2. 按模式处理消息列表 if mode transparent: context self.window.all_messages() elif mode sliding: context sliding_window(self.window.messages(), self.window.budget(recent)) elif mode summarize: context self.summarizer.compress(self.window.messages(), budgetself.window.budget(summary)) elif mode hybrid: context hybrid_context(new_message, self.window, self.history_store) elif mode persistent: context self.inject_memory(new_message, memory) # 3. 更新使用率触发调度 self.scheduler.on_new_message(self, new_message) return context def select_mode(self, message): usage self.window.usage_ratio() if self.history_store.has_relevant(message): return hybrid if usage 0.75: return summarize if len(self.window.messages()) 6: return transparent return slidingselect_mode是模式选择的核心逻辑。我把规则写成优先级如果历史库里有当前问题的高度相关片段优先走混合模式为了召回准确性牺牲一点压缩。如果窗口使用率超过75%走摘要模式此时压缩优先。如果消息不足6轮且总量不大透明模式不做任何处理。其余情况滑动窗口兜底。这个优先级我在真实项目里跑了两周才确定下来。最初的版本是“按照使用率一票否决”结果导致大量本可以精确检索的需求走了滑动窗口旧信息全丢了。后来加上“相关片段优先”之后用户问题中涉及历史细节的准确率明显回升。3.4 摘要合并策略与向量索引写入除了主流程还有两个后台任务不能忽略。第一个是摘要合并。当摘要区快满时需要把旧的摘要和新摘要再压一轮形成“二级摘要”。比如第一轮对话压出300 token的摘要第二轮对话又压出400 token的摘要二者加起来超过预算就要合并成一份500 token的二级摘要。这个流程可以递归但我限制最大层级为3层超过就直接丢弃最早的信息——再复杂的摘要体系也不能无限膨胀。第二个是向量索引写入。每次对话结束我不会把整段对话塞进向量库而是先做切分按语义完整度拆成250-500 token的片段再写入。切分时尽量避免切断不完整的句子否则检索到的片段语义不完整召回效果大打折扣。def index_dialogue(history_store, messages, chunk_token400): chunks split_by_semantics(messages, chunk_token) # 尽量在自然句处断开 for chunk in chunks: vec embed(chunk.text) history_store.upsert( vectorvec, metadata{ session_id: chunk.session_id, timestamp: chunk.timestamp, role: chunk.role, text: chunk.text } )切分器要避免的典型错误是“按字数硬切”——假设400 token一刀切下去切出来的片段很可能没有开头也没有结尾检索出来后模型读着莫名其妙。我最后写了个简单规则优先在句号、问号、换行处断开没有标点再退到空格再不行才硬切。4. 实战避坑我踩过的5个坑与排查方法理论说得再好落地总是要流血的。这一节把我在跑context-mode时踩到的实在坑分享出来每个都附带了排查思路和解决方案。4.1 第一个坑token统计口径不一致导致溢出一开始我统计token用的是第三方库的方法没跟模型自带的tokenizer对齐。结果就是库统计说一套模型真实收到一套。某次线上请求库里显示用了7000 token安全实际发过去模型一算8100,直接拒绝。排查过程很简单就是对日志——把发送前的prompt存下来用模型官方tokenizer再数一遍两边数字对不上差了两三百。解决方案没有捷径直接用你用的那个模型对应的tokenizer做统计。如果用OpenAI系列就用tiktoken如果模型是开源的就用模型仓库自带的tokenizer.json。这个不能省也不能用“近似方法”。那段\n的计数差异看似只有几十个token但在窗口边缘就是压垮骆驼的最后一根稻草。核心代码用对应的tokenizer替换统计逻辑import tiktoken enc tiktoken.encoding_for_model(gpt-4o) def count_tokens(text): return len(enc.encode(text)) class Message: def __init__(self, role, content): self.role role self.content content self.token_count count_tokens(content) count_tokens(role) # role也要计入这里有个细节统计时不要只算content角色标识符user/assistant/system也会占token虽然每个只有几个但消息多了积少成多误差就是这么来的。另一个细节是系统提示词和消息格式本身的token比如|im_start|这种特殊标记。不同模型的计数方式不同但只要你全程用同一个口径压缩调度时保持一致就不会有大问题。4.2 第二个坑摘要丢关键细节尤其数字和姓名摘要模式上线后我遇到一个很典型的问题——用户第2轮说“我电话号码是138xxxx”第15轮问“号码再发我一遍”模型答不上来。看摘要果然只写了“用户提供了联系方式”具体号码没保留。问题出在摘要生成的自由度太高。模型在压缩时会自动把“看起来不重要”的细节去掉——但问题是它不知道后面哪句话会引用这些细节。电话号码、地址、日期、订单号、人名这些恰恰是最容易被引用的。我的解决方法是给摘要加“强制保留清单”。在摘要模板中明确要求凡是包含手机号、地址、邮箱、金额、日期、订单号、身份证号、车牌号的消息不得压缩必须原文保留到位。IMPORTANT_PATTERN r(1[3-9]\d{9})|(\d{6,})|(订单[#号]?\d)|(\d{4}[-年]\d{1,2}[-月]\d{1,2}日?) def pre_summary_filter(messages): keep_these [] for m in messages: if re.search(IMPORTANT_PATTERN, m.content): keep_these.append(m) return keep_these保留的原文会附加到摘要末尾所以我叫“关键引语区”。这个区块的数据不受摘要压缩影响直到token预算不足才考虑淘汰。4.3 第三个坑频繁压缩导致上下文断层摘要模式的另一个罕见问题是压缩太频繁导致上下文逻辑不连贯。用户前一句问“那个方案到底可不可行”紧接着系统把包含“那个方案”的几轮对话压成摘要模型看到的是“用户问方案可不可行”但“方案”的内容在哪在摘要里而摘要只写了“讨论了方案A和方案B”。这种断层在长对话中会反复出现。原因在于压缩的动作和当前问题之间存在时间差——压缩任务处理的是旧消息但新消息的引用关系没被考虑进去。我的补丁是在摘要触发后强制保留当前窗口最后一条完整消息不参与压缩。这样即使之前的消息都变成了摘要模型至少有一条“连接消息”锚定当前对话状态不会完全断片。def compress_with_bridge(messages, budget): if not messages: return [] bridge messages[-1] # 最后一条完整消息永远保留 compressible messages[:-1] summary summarize(compressible, budget) return summary [bridge]这个改动的效果立竿见影context里的逻辑断裂问题少了很多。虽然理论上最后一条消息也可能被后面引用但至少保证了一个连续的边界。4.4 第四个坑检索注入的噪音太多混合检索刚开始的时候检索结果的召回率是提上去了但准确率很拉胯。经常检索出来一堆相关性一般的内容硬塞进上下文模型反而被带偏。我做了三件事来控制噪音相关度阈值从0.7提高到0.75。低于阈值的直接丢弃宁可漏召回也不注入噪音。对检索结果做重排把和当前问题关键词重叠度更高的片段排到前面只取前3名。给注入片段加明显的标记让模型知道这是背景资料而非实时上下文。加标记的示例如下system_prompt 以下是用户问题相关的历史片段仅供背景参考不代表最新事实 [片段1] ... [片段2] ... 请基于这些背景回答当前问题。 这个标记很重要。不加的时候模型会把检索到的历史消息和当前消息一视同仁可能出现“把已经推翻的结论当现在结论”的情况。加上标记之后模型会主动判断背景资料和当前事实的区别回答准确率又提了一个台阶。4.5 第五个坑压缩阻塞了主流程压缩调度早期是同步执行的一旦某次请求触发压缩用户请求整体响应时间从800ms暴增到3秒。体验非常差。虽然是常识但很多人还是会掉进这个坑——把压缩操作放在请求主链路里同步跑。压缩摘要需要调用一次模型在最长3秒的响应上雪上加霜。我的解决方法是分两步走在一条消息处理完、下一条还没来之前跑异步摘要。这是空闲时间怎么压都不影响体验。如果空闲时间不足比如连续快速发消息就把摘要推迟到“下下轮”再做不暂停当前请求。这套异步机制上线后P95延迟从2700ms降回1100ms压缩反而变成提升体验的工具——因为它降低了往后的token量。异步的实现不复杂关键是调度策略要对压缩的必要性判断必须在消息进入时做需要知道当前使用率但压缩的执行可以延后。async def schedule_compression(ctx_manager): while True: if ctx_manager.need_compression() and ctx_manager.is_idle(): await ctx_manager.compress_async() await asyncio.sleep(1.0)再补充一个细节异步压缩时要保护共享状态加锁或者用原子引用防止压缩过程中有新消息进入导致窗口状态不一致。我用了一个小技巧压缩期间先把window.messages()加锁压缩完成再释放避免读写竞争。5. 模式选择速查表与效果复盘项目跑了一个多月之后我整理出了上面五种种模式的使用建议和实测数据给有类似需求的人参考模式适用场景优点缺点实测效果透明直通短对话、单轮问答信息无损、零延迟只适合短对话5轮内对话使用率35%滑动窗口闲聊、轻量客服实现简单、速度快硬丢失历史信息适合不依赖旧信息的任务摘要压缩长对话、需要全局理解大幅压缩token摘要可能丢细节成本降42%但需模板约束混合检索知识库问答、深度客服兼顾压缩与召回多一次向量检索延迟召回准确率提升31%持久化记忆跨会话、用户画像真正的长期记忆需要额外存储和提取用户复访满意度提升明显整体来看线上业务跑稳定之后我统计到的核心收益是平均每次请求的输入token从6400降到了3900左右降幅接近四成而关键信息召回率按人工评测反而提升了20多个点。这证明上下文管理不是“牺牲质量换成本”而是通过更聪明的取舍把token花在真正该花的地方。从部署复杂度来说最轻量的是滑动窗口一天可以上线收益最大的是摘要和混合但调试周期需要两周左右。如果你刚开始做这块我建议从摘要加滑动窗口的组合起步跑稳了再加混合检索逐步迭代。
返回列表