ARTICLE DETAIL

资讯详情

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

context-mode上下文管理:解决AI长对话失忆与token成本问题

context-mode上下文管理:解决AI长对话失忆与token成本问题 前两天一个做客服机器人的朋友找我排查线上事故对话进行到第40轮的时候用户问了一句“我昨天申请的那个退款批了吗”机器人居然一本正经地回答“您还没有申请过退款”。用户当场炸了他也很崩溃。聊天记录拉出来一看问题其实不出在模型也不在他的提示词而出在上下文——历史对话被暴力截断最早那几轮的记录全没了退款申请的细节当然也一起丢了。那之后我给他补了一套context-mode的上下文管理方案线上事故再没复发过。context-mode这个词最近在AI应用开发圈子里频率很高尤其在Agent、客服机器人、私有知识库问答这类场景里它决定的不是“模型聪明不聪明”而是“模型到底记不记得住”。这套方案解决的核心问题就三个在有限的上下文窗口里保住关键信息、控制token成本、别让AI在长对话里突然失忆。适合正在做对话式AI应用、Agent、RAG系统的开发者参考也能给刚入门的Prompt工程师一个可以“抄作业”的落地框架。1. context-mode到底是什么一次线上事故给我的教训1.1 事故现场40轮对话后AI彻底失忆我那朋友的客服机器人最初实现其实特别简单把用户和机器人的全部聊天记录一股脑塞进上下文超过模型窗口就截断最老的消息。这个方案在10轮、20轮以内都没什么问题直到第40轮左右开始翻车。当时具体表现是这样的用户在第10轮提过退款申请包含订单号、退款理由、期望到账时间。客服机器人当时也正确回复了“已受理预计3-5个工作日到账”。但到了第40轮用户追问退款进度时机器人已经完全不知道这回事。原因很直接第10轮的消息已经超出模型上下文窗口被代码直接丢掉了。模型能看到的只有最近二十几轮退款申请的信息恰好就在被丢掉的那一批里。这个案例典型到可以写进教科书。很多人以为上下文管理就是“把聊天记录都发过去”完全忽略了模型上下文窗口是有限的物理资源。一旦窗口满了旧消息必然被挤出问题只是“被挤掉的是哪部分”——如果搞不清这里面的门道AI就会以最糟糕的方式“失忆”。1.2 context-mode的定义与核心目标从这次事故里我总结出一个结论上下文不能靠“无脑全塞”必须为它设计一套明确的运行模式。所谓context-mode就是把“历史消息如何组织、压缩、保留、丢弃”这件事制度化的方案。具体来说一套完整的context-mode需要回答四个问题哪些消息必须无条件保留系统提示词、当前用户输入、关键工具执行结果这些属于高优先级。哪些消息可以被压缩比如10轮前的寒暄、冗余的中间确认、大段工具返回原文。压缩成什么形式一段摘要、几个关键字段还是结构化的事件记录。窗口满时先丢什么最老的客套话、中间轮次的重复追问还是某次失败的函数调用输出。把这个思路落到代码里就变成了一个上下文管理器。它不应该只是简单拼字符串而要像一个内存管理器一样知道每一块内容的大小、优先级和生命周期。1.3 四种主流上下文模式对比我在实际项目中用过、也见过别人用过的上下文模式主要有四种。它们各有各的适用场景没有谁绝对好只有谁更匹配你的业务。模式名称核心策略优点缺点适合场景FullContext全部历史消息都保留信息最完整实现最简单token成本高容易超窗口短对话、窗口很大的模型SlidingWindow只保留最近N轮消息稳定可控不会爆窗口中间关键信息可能丢闲聊型机器人、简单问答SummaryMode历史消息压缩成一到多段摘要成本低能记住全局脉络细节损失摘要本身会漂移超长会话、新闻摘要类HybridContext近期全量 远期摘要兼顾细节与全局效果均衡实现复杂度高客服、Agent、销售助手我最推荐新手直接上HybridContext也就是混合模式。它把最近N轮对话完整保留保证当前话题的连续性把N轮之前的旧消息压缩成摘要保住全局记忆。这样既不会让模型“失忆”也不会让token成本失控。后面我会给出具体实现。2. 上下文模式的设计要点从token预算到压缩策略2.1 先算清楚token预算做context-mode第一件事不是写代码而是算账。模型上下文窗口是固定的比如gpt-4o有128K tokenDeepSeek有64K或128K。但你不能把128K全用来装历史对话因为输出也要占空间。我先给一个通用的token预算公式模型窗口总量 T系统提示词占用 S当前用户输入占用 U工具返回或检索结果占用 R预留模型输出空间 O建议至少1024到2048可用历史预算 H T - S - U - R - O举个例子。假设用128K窗口的模型系统提示词 1500 token当前用户输入 2000 token工具返回结果 3000 token预留输出 2048 token那么历史预算 H 128000 - 1500 - 2000 - 3000 - 2048 119452 token。如果平均每轮对话消耗500 token理论上能装约239轮。但这里有个容易被忽略的坑工具调用返回的JSON可能非常大某个接口返回一份5000 token的数据几轮下来窗口就被吃掉了。所以算预算时必须把工具调用单独列出来别跟对话历史混在一起。我常用的办法是直接用tiktoken或模型自带的tokenizer统计每条消息的真实token数import tiktoken def count_tokens(messages: list[dict], model: str gpt-4o) - int: enc tiktoken.encoding_for_model(model) total 0 for msg in messages: total len(enc.encode(msg.get(content, ))) return total每一条进入上下文的消息都要先打上token_count标签。否则你的上下文管理器就是个瞎子根本不知道窗口还剩多少余量。2.2 摘要压缩策略怎么选SummaryMode和HybridContext都依赖摘要。但摘要写不好一样翻车。先说最常见的滚动摘要也叫rolling summary。做法是每聊到第N轮让模型把“旧摘要新对话”合并成一份新摘要。优点是实现简单token占用小缺点是摘要会层层传递产生“摘要漂移”。第一轮用户说“我要退单号A10086的货”第二轮摘要变成“用户发起退款”第五轮摘要可能变成“用户有退款需求”第N轮摘要彻底把关键信息洗没了。我的经验是别把摘要当流水账要按结构化字段来提取。比如客服机器人历史摘要至少应该包含用户核心诉求退款、换货、投诉、咨询某个具体功能涉及的关键实体订单号、商品名、金额、联系方式已经完成的操作已提交申请、已发货、已立案用户情绪变化是否升级到投诉、是否有过激语言未解决的问题待确认事项、需要回访的点这样做的好处是模型即使在远期摘要里也能快速回答“我昨天申请的那个退款批了吗”这类需要检索具体事实的问题。另一个更稳的方案是分层摘要。把对话按时间或话题切成若干段每段生成独立摘要并打上时间戳和话题标签。查询时先定位相关摘要再只加载这一段。这个方案适合长会话中需要经常回溯旧细节的场景但实现成本也高。对大部分业务来说结构化滚动摘要已经够用了。摘要触发频率也很关键。我踩过坑每两轮就做一次摘要结果摘要本身调用模型的token花销比省下的还多。建议按阈值触发比如历史token超过窗口预算的50%时才对最老的30%做一次摘要。2.3 消息结构与优先级架构整洁的前提是消息结构清晰。我建议每条历史消息至少包含这些字段from dataclasses import dataclass from typing import Optional import time dataclass class Message: role: str # system / user / assistant / tool content: str # 消息内容 created_at: float time.time() # 时间戳用于确定顺序 token_count: int 0 # 真实token数 priority: int 0 # 优先级越高越不可丢弃 msg_id: str # 全局唯一ID便于追踪优先级的设计要贴合业务。比如系统提示词优先级最高永远不能丢。用户当前输入优先级最高必须保留。工具返回结果可以适当压缩如果只需要提取结果就不要保留原始JSON。最近的10轮对话优先级高保留全文。10轮之前的普通对话优先级低优先送去摘要。窗口溢出时就按优先级从低到高逐条淘汰。这里有一个很多人忽略的细节不要只按“时间最老”来淘汰而是要按“业务重要性”来淘汰。一条3轮前的工具调用错误信息重要性可能远远低于20轮前用户确认过的收货地址。context-mode的价值就在于它给了内容一个“议价权”。3. 实操我自己实现的一套轻量context-mode3.1 架构设计策略模式加上下文管理器我实现context-mode时没有直接套现成的LangChain Memory而是自己维护了一个轻量上下文管理器。原因有三一是LangChain的memory组件在复杂业务下不太透明出了问题不好排查二是客服场景有大量工具调用需要精细控制工具返回内容的去留三是自己实现一套策略模式以后换模型、换压缩策略都容易。整体架构分三层第一层是ContextManager负责整体调度对外只暴露build_prompt和add_message两个干净接口。第二层是各种模式策略类比如FullContextMode、HybridContextMode、SummaryMode实现同一个接口。第三层是消息存储用数据库或内存队列保存所有原始消息策略类只负责决定“哪些原始消息要进入最终的prompt”。这样设计之后切换上下文模式只需要改一个配置字段业务代码完全不用动。3.2 核心代码实现先看ContextManager主体。这里省略了数据库读写用内存列表演示from __future__ import annotations from typing import List, Dict, Optional from enum import Enum class Mode(str, Enum): FULL full SLIDING sliding SUMMARY summary HYBRID hybrid class ContextManager: def __init__( self, context_window: int 128000, mode: Mode Mode.HYBRID, system_prompt: str , reserved_output: int 2048, ): self.context_window context_window self.mode mode self.system_prompt system_prompt self.reserved_output reserved_output self.history: List[Message] [] def add_message(self, role: str, content: str, priority: int 0) - Message: msg Message( rolerole, contentcontent, token_countcount_tokens([{content: content}]), prioritypriority, ) self.history.append(msg) return msg def build_prompt(self, user_input: str, tool_results: Optional[List[Dict]] None) - List[Dict]: # 1. 计算总预算 budget self.context_window - self.reserved_output # 2. 系统提示词固定占用 messages: List[Dict] [{role: system, content: self.system_prompt}] budget - count_tokens(messages) # 3. 工具结果按需注入但要做好截断 if tool_results: for item in tool_results: raw {role: tool, content: item[content]} cost count_tokens([raw]) if cost budget: # 工具结果过大时只保留关键字段 continue messages.append(raw) budget - cost # 4. 按模式打包历史消息 if self.mode Mode.FULL: history_msgs self._pack_full(budget) elif self.mode Mode.SLIDING: history_msgs self._pack_sliding(budget) elif self.mode Mode.SUMMARY: history_msgs self._pack_summary(budget) else: history_msgs self._pack_hybrid(budget) messages.extend(history_msgs) messages.append({role: user, content: user_input}) return messages再看HybridContextMode的核心实现也就是我最推荐的那套def _pack_hybrid(self, budget: int) - List[Dict]: recent_num 10 # 最近10轮完整保留 if len(self.history) recent_num: return self._pack_full(budget) recent_msgs self.history[-recent_num:] older_msgs self.history[:-recent_num] # 远期消息需要先摘要这里用结构化摘要替代原文 older_summary self._summarize(older_msgs) selected: List[Dict] [] used older_summary[token_count] if older_summary else 0 if used budget: if older_summary: selected.append({ role: system, content: f[历史摘要] {older_summary[content]} }) for m in recent_msgs: if used m.token_count budget: break selected.append({role: m.role, content: m.content}) used m.token_count return selected摘要是整个环节里最需要调优的地方。我用的是一个_summarize方法它会把旧的原始消息拆成若干段逐段让模型提取结构化槽位最后合并成summary对象。这样比直接让模型自由发挥稳定得多。核心是摘要对象不要用纯字符串最好用JSON字段明确。3.3 落地场景客服机器人的实测效果我给朋友的客服机器人部署这套方案后专门记录了一组对比数据。测试场景完全一致同一个机器人回答100轮售后咨询包括查订单、申请退款、催发货、改地址四种需求平均每轮消耗700 token。模式历史区token占用100轮输入成本估算能否记住第10轮的退款申请首token延迟感受原方案暴力截断约70K一直在爆高且效果差不能随窗口增大变慢FullContext70K最高能较慢SummaryMode8K低部分能细节不全快HybridContext12K较低能明显改善这里成本估算按gpt-4o每百万输入token大约几美元的量级算。100轮对话原始历史约70K tokenFullContext的总输入成本是最高的。HybridContext把远期历史压成摘要后历史区只用12K token近10轮细节完整保留。退款申请这种关键信息在哪一期只要结构化摘要里有订单号和“已申请退款”标记模型就能准确回答。那几天我还顺手在他的页面上做了一个小开关可以在后台切换三种模式肉眼对比回答质量。HybridContext明显在“记得住”和“不超预算”之间达到了最好的平衡。3.4 和LangChain这类框架怎么结合如果你已经用了LangChain不建议彻底推倒重来。LangChain提供了一些现成组件但它们本质上是把context-mode的几种策略固定成了类比如ConversationBufferWindowMemory对应SlidingWindowConversationSummaryMemory对应SummaryModeConversationSummaryBufferMemory接近HybridContext。我的建议是在LangChain里跑原型验证可以用这些内置记忆类但上生产之前替换成自己的ContextManager。原因有两个。第一内置记忆类往往帮你做了太多隐式处理一旦出现上下文丢失问题定位链路很长第二业务侧的优先级规则没法完全塞进一个通用的memory类。自定义实现之后接入LangChain并不难只需要让ContextManager每次输出最终messages再把这个messages数组直接传给chain的prompt template即可。4. 常见问题与排查技巧实录4.1 症状AI突然失忆、答非所问这几乎是我被问得最多的问题。排查顺序我建议按下面三步来第一步查系统提示词是否在打包时被挤掉。很多上下文管理器写得太粗暴窗口快满时连system都被截断。解决方法是给系统提示词打上最高优先级在build_prompt里先固定占用预算。第二步查用户是否在很靠前的轮次留下了关键信息。如果关键信息确实在10轮之前FullContext又不现实就需要给摘要加上结构化字段。比如退款申请、订单号、收货地址这类实体即使不做长篇摘要也一定要单独提取并持续追加到摘要的高优先级字段里。第三步查中间是否有工具返回结果混入对话。客服机器人里查订单接口返回的JSON经常占几百甚至几千token。模型很容易被这些大段技术字段干扰反而忽略用户真实问题。我处理的办法是工具返回内容只提取结果原始JSON一律不塞进上下文。4.2 症状成本不降反升这是用SummaryMode翻车最多的地方。有些人很兴奋地把历史全部改成摘要结果账单反而比全量模式还高。原因往往是摘要触发太频繁了。假设你每3轮就调用一次模型总结每次摘要消耗800 token。用户聊了30轮光是摘要就调用了10次消耗8000 token。而如果什么都不做30轮的原始对话可能也就15000 token省下的有限但额外调用的开销实打实变多了。我的经验是摘要触发频率要设置双阈值历史token总量超过窗口的50%才触发且每次只摘要最老的30%内容。另外要尽量用小参数模型做摘要比如用一些轻量模型别每次都把最强的模型拉来做历史归档。摘要请求和正常用户请求应该走不同的模型路由。4.3 症状检索增强后上下文爆炸在RAG场景里context-mode还要面对一个难题检索回来的相关资料太多。很多应用把top-5甚至top-10的文档片段全部塞进去结果上下文被文档内容占满留给对话历史的预算所剩无几。我的做法是给检索结果也设置预算。比如总预算128K系统提示词1.5K输出预留2K历史对话预留20K那么检索结果最多只能占104.5K。但通常我会强行限制检索结果不超过10K甚至5K剩下大部分预算留给历史对话。检索回来的片段必须经过rerank只保留和当前用户问题最相关的前2到3条每条再按最大字数截断。这里有个很实用的技巧把检索片段放在历史对话之前并加上明确的标签“以下是从知识库检索到的参考资料仅作参考”。这样模型就知道知识库内容是辅助用户与助手的连续对话才是主线不会被大段参考资料带偏。4.4 问题速查表症状可能原因排查与解决AI不记得旧关键信息关键信息在窗口外被截断使用HybridContext结构化摘要有实体槽位系统提示词被挤掉窗口满时没保护systembuild_prompt最先分配预算给system摘要后细节丢失摘要太泛化按字段摘要订单号、操作状态、待办事项token成本异常高摘要触发太频繁双阈值触发用小模型做摘要工具结果污染对话原始JSON进入上下文只保留工具提取结果原始数据落库不塞promptRAG结果占了全部预算检索片段过多过长强制检索预算rerank后只留top2-3条同一问题反复答错上下文里新旧信息冲突保留“变更记录”让最新状态覆盖旧状态我在实际使用中还有一个体会context-mode做得好不好光看单轮问答是看不出来的必须做长会话回归测试。建议准备一组固定剧本包含跨20轮以上的信息勾连问题每次改动上下文策略后都跑一遍。这套回归测试能帮你提前拦截大多数“AI失忆”问题。如果你也在做对话式AI应用我最后想补一句别迷信某一种模式也别把所有希望寄托在窗口变大上——模型窗口越大无脑塞的成本越高最终还是要靠上下文管理模式来控预算、保有效信息。先想清楚你的用户真正需要记住什么再去决定怎么压缩和保留context-mode这个工程做扎实了AI的稳定性会立刻上一个台阶。
返回列表