做Agent开发这一年多,我踩过最深的一个坑,就是“上下文”这件事。一开始我以为把历史对话一股脑塞给模型就行,结果token账单飞涨,模型还老是答非所问。直到我把上下文管理拆成独立的“context-mode”设计,才真正把这个问题理顺。所谓的context-mode,就是在不同业务场景下,对“给模型看什么、怎么看、看多少”做显式地策略化管理。这篇文章我就把整套设计思路、落地参数、踩坑实录都摊开讲,给正在跟上下文较劲的同行一个能直接抄作业的参考。
1. 内容整体设计与思路拆解
先别急着写代码,把“为什么要做context-mode”想透,后面所有技术决策都会顺很多。我最早的项目是做一个文档问答机器人,最开始就是无脑拼接用户的历史消息和检索到的文档片段,一次性把可能接近一万多token的内容全抛给大模型。结果模型确实能回答,但经常被很久以前的、跟当前问题无关的闲聊干扰,而且每次请求的延迟和成本都高得离谱。
真正让我下决心重构的,是一次用户问“刚才说的那个方案第三点是什么”,模型完全理解错了,它在历史里找到了一个更早的“第三点”。这暴露了一个核心问题:上下文不是越长越好,而是越“对”越好。context-mode这个设计的本质,就是要把“上下文”从一种无意识的资源消耗,变成一种有意识的策略管理。
我在设计初始就把上下文分成了三个维度来思考,这也是整个模式的核心骨架:
- 时间维度:是只关心最近几轮对话,还是需要拉取很久之前的关键结论?
- 内容维度:是需要原始细节(比如具体数字、表格),还是只需要概括性的结论?
- 功能维度:是辅助理解用户意图(比如系统指令),还是提供事实依据(比如检索文档)?
基于这三个维度,我把context-mode拆分成四种基础模式:连续窗口模式、摘要压缩模式、关键检索模式、混合加权模式。每一种模式回答的问题不同,占用的资源也不同。这个设计思路的来源其实特别朴素:我回忆了一下自己平时跟人协作的方式。如果我是团队负责人,别人问我“上次会议结果是什么”,我不会把会议三小时的录音全放一遍,我只会说结论;但如果是问我“某个报价数字是多少”,我会直接去翻具体邮件。人脑天然会做上下文压缩和检索,context-mode就是要让大模型也具备这种能力。
这个设计还有一层隐含价值:它让调试变得可控。以前上下文是隐性的,出了问题很难复现。现在每种模式都是显式的、可观测的,出问题我能直接看到是哪一段策略导致的,排查效率提升了不止一个量级。
1.1 为什么不能只用一种模式
很多团队的做法是“统一走滑动窗口,只保留最近二十轮”。这个方案实现简单,但问题也很明显。比如在客服系统里,用户可能五轮之前提到过自己的会员等级,之后又聊了十个无关问题,再回来问“那我这个等级能打几折”。如果只用滑动窗口,会员等级这个关键信息早被冲掉了,模型只能靠猜。
反过来,如果所有场景都用“摘要压缩”模式,把历史全部概括成一段话,那像合同编号、订单号这种需要精确匹配的信息,就很容易在压缩过程中被模糊化甚至丢失。我自己就遇到过模型把“订单号SD20240001”压缩成了“一个订单”的情况,这种错误在业务上是致命的。
所以context-mode必须是一个组合拳,而不是一根筋。我的做法是在入口处加一个“意图分类器”,先用一个小模型判断当前用户问题的类型,再决定走哪种模式。比如判断为“连续追问细节”就走窗口模式,判断为“总结性质”就走摘要模式,判断为“引用历史论据”就走检索模式。这个分类器一开始可以用规则写,后面再慢慢迭代成模型分类,效果会更准。
1.2 设计目标:效率、质量、成本的三角平衡
任何技术方案都是取舍,context-mode也不例外。我给自己定下了三个可量化的目标:响应质量不降低、单次请求成本下降至少百分之四十、长会话(超过五十轮)的连贯性显著提升。这三个目标其实是互相拉扯的,比如提升连贯性最简单的办法是拼命加历史,但这会推高成本。所以设计的核心是“结构化”,而不是“堆量”。
过程中我用了一个很笨但很有效的方法:人工对比测试集。我整理了一百个真实用户的长会话咨询记录,给每条都标注了“正确答案”和“需要的最小上下文”,然后用这套数据集来评估不同模式组合的效果。哪怕只是把质量分从六十分提升到八十分,也是巨大的进步。这套评估集我强烈建议每个团队都做一个,模型能力再强,没有评估就是盲人摸象。
2. 核心细节解析与实操要点
搞清楚了为什么,接下来就是落地。这里我不讲废话,直接讲我在实现过程中总结出来的核心细节。先给一个总览表,再逐条展开。因为细节比较多,我建议你收藏起来对照着做,每一行都代表一个我实测过才确定的策略。
| 设计点 | 实现方案 | 核心收益 | 关键风险 |
|---|---|---|---|
| 意图分流器 | 规则+小模型双路拼接 | 模式选择精准 | 分类错误会放大错误 |
| 窗口长度设定 | 动态按轮数与业务度量双重控制 | 响应速度稳定 | 过短会丢线索 |
| 摘要压缩触发 | 超过阈值后自动重写历史 | 控制长期token成本 | 事实性内容被模糊 |
| 检索召回条数 | top-k动态,默认6条 | 信息密度最高 | k值过大引入噪音 |
| 指令固定驻留 | 系统提示词永远不参与压缩 | 行为稳定可预期 | 占用固定token |
| 用户目标建模 | 每五轮生成一次目标快照 | 主线稳定不跑偏 | 快照出错难追溯 |
2.1 意图分流器的设计细节
这是context-mode的入口,也是我最开始忽略、后来发现最影响整体效果的一环。如果分流错了,后续的模式再正确也白搭。我的第一版分流器是纯规则,用正则匹配关键词,比如“刚才”“上面”“你之前说”会偏向连续窗口模式,“总结”“概括”“提炼”会偏向摘要模式。
但规则很快就遇到瓶颈,用户表达太丰富了,“那个东西”“第二点”这种指代,规则完全处理不了。所以我又加了一个轻量级的意图分类模型,用之前标注的两千条对话做了微调。实际运行时是“规则找线索,模型做兜底”的双轨策略:规则能明确命中就立刻走对应模式,规则模糊就让模型判断。这个方案的准确率从最初的六成提升到了九成以上,而推理成本几乎可以忽略不计。
2.2 各类模式的定义与转换条件
四种模式之间不是静止的,它们会随着对话推进动态转换。我用状态机来管理这种转换关系:初始进入连续窗口模式,当对话轮数超过十二轮,或者总token数超过六千时,触发摘要压缩。如果用户提出一个全新的、跟之前话题无关的问题,意图分流器会把它导回窗口模式,而不是继续沿用旧摘要。
关键检索模式的触发条件比较特殊:当用户问题里出现了精确实体词(订单号、人名、日期)时,系统会从全量历史里做向量检索,把这些实体附近的内容召回,拼接到当前窗口中。这里有个坑,必须把“检索到的内容”和“当前对话”在prompt里分隔清楚,我通常用特殊标记包起来,让模型能区分哪些是引用材料、哪些是当前问题。
混合加权模式的实现比较复杂:摘要作为常驻背景信息,检索片段作为即时论据,最近两轮窗口作为“当前思维线”,三者拼接。我经验上建议的配比是摘要占三成、检索占四成、窗口占三成,但这个比例要拿自己的数据去测,不同业务差异非常大。
3. 实操过程与核心环节实现
下面进入最关键的实操环节,我不光说做了什么,也把参数怎么定、取舍怎么做的过程说清楚。
3.1 基于LangChain搭建context-mode框架
我的整体框架是基于LangChain的,因为它的模块化设计非常适合做context-mode这种策略管理。第一版架构大致是这样的:
from langchain.memory import ConversationBufferWindowMemory, ConversationSummaryMemory from langchain.retrievers import TimeWeightedVectorStoreRetriever from langchain.schema import SystemMessage, HumanMessage class ContextModeRouter: def __init__(self): self.window_memory = ConversationBufferWindowMemory(k=4) self.summary_memory = ConversationSummaryMemory(llm=llm) self.retriever = TimeWeightedVectorStoreRetriever(vectorstore=vectorstore, k=6) self.history_store = deque(maxlen=100) def route(self, user_query, intent): if intent == "precise_fact": return self._build_retrieval_context(user_query) elif intent == "general_follow_up": return self._build_window_context() elif intent == "long_summary": return self._build_summary_context() else: return self._build_hybrid_context(user_query)这段代码里最核心的是route函数,它完全就是状态机的转移逻辑。_build_retrieval_context会先从向量库召回历史片段,_build_hybrid_context则会同时拿摘要和最近窗口。值得留意的是,LangChain自带的ConversationSummaryMemory在长会话场景下摘要质量不稳定,建议至少循环两次摘要,让内容更稳定。
3.2 自定义检索器:解决时效性问题
大模型本身的参数量决定了它只认到某个时间点,但业务数据的时效性完全靠自己搭的检索层来保障。我用TimeWeightedVectorStoreRetriever自定义了一个检索器,在计算相似度分数时加上了时间衰减因子:越新的内容权重越高,但为了防止新内容淹没所有历史,衰减系数不能太激进。
class TimeWeightedRetriever(BaseRetriever): def _get_relevant_documents(self, query, run_manager=None): docs = self.vectorstore.similarity_search_with_score(query, k=self.k) weighted_docs = [] for doc, score in docs: time_decay = 0.9 ** max(0, (now - doc.metadata['timestamp']).days) weighted_docs.append((doc, score * time_decay)) weighted_docs.sort(key=lambda x: x[1], reverse=True) return [doc for doc, _ in weighted_docs[:self.k]]这个写法的核心是时间衰减不是重排的唯一标准,它只是对语义相似度做一个微调。如果衰减太激进,一周前的核心答案就会彻底消失;太温和,三个月前的旧结论会被不断翻出来。我试着把衰减系数调到0.9,即每天降10%权重,配合k=6来取回结果,效果比较理想。
3.3 摘要压缩的实现细节与成本计算
摘要压缩是所有模式里最体现“成本控制”的一项,它虽然没有检索的成本,但每次调用模型生成摘要本身要花钱。另一个关键是它不能频繁触发,我用了一个计数器,当对话轮数超过阈值且总token超过6000时才触发一次摘要生成。
成本计算我直接举个例子:假设你的模型输入价格是每千token约0.03元,原始历史有8000个token,每轮都全部发送会产生0.24元成本;每天一万次请求就是2400元。而摘要压缩后每次只发送约1500个token,成本降到0.045元,就算把摘要生成额外的模型调用算进去,总成本大概能降到原来的四分之一。这笔账算完,团队都会愿意在这个模块上多花开发时间。
def maybe_summarize(self, force=False): if force or (self.cur_turns > self.turn_threshold and self.cur_tokens > self.token_threshold): summary = self.summary_memory.predict_new_summary(self.messages) self.conversation_context = summary self.cur_turns = 0 self.cur_tokens = len(summary)实际运行中还有个隐藏收益:因为历史被压缩,模型生成时的“注意力分散”问题也减少了,回答质量反而有提升,具体表现在模型不再被旧的无关信息带偏。这一点在长会话场景里尤其明显。
4. 常见问题与排查技巧实录
这部分是实战中最容易“翻车”的地方,我把踩过的坑整理成速查表,再挑两个典型的展开讲。每个问题后面都是真实的修复过程,不是预设答案。
| 常见问题 | 现象描述 | 我排查后找到的根因 | 我的修复方案 |
|---|---|---|---|
| 模型遗忘历史关键信息 | 问“我刚才说什么”模型答错 | 滑动窗口过短,关键实体被挤出 | 增加“动态锚点”机制,保留关键实体 |
| 回答越来越散、越扯越远 | 长会话后半程逻辑混乱 | 摘要压缩时丢失了用户目标 | 每五轮生成一次用户目标快照 |
| 检索命中不相关文档 | 召回内容答非所问 | top-k取值偏大,噪音过多 | k值降到6,并加实体词过滤 |
| 并发高时接口响应卡顿 | 请求排队严重 | 每次请求都触发向量检索 | 给检索结果加了5分钟LRU缓存 |
| 摘要生成反而导致事实错误 | 重要数字被压缩错 | 摘要模型幻觉 | 摘要不覆盖精确数字,数字走原始引用 |
4.1 关键实体丢失:动锚点机制
这个坑的典型场景是用户聊了三十轮,期间提到了“黄金会员”,但那是二十轮之前的事了。到了第三十轮,用户问“我这个会员能领赠品吗”,模型已经完全不记得会员等级。第一版用普通滑动窗口,前二十轮的信息被冲得一干二净。
我排查的时候先把memory里的内容打印出来,发现确实干干净净,于是加了一个“动态锚点”机制:每一轮对话结束后,用一个实体抽取模型把当前会话里的关键实体(人名、会员等级、订单号、产品名)提取出来,存进一个固定的锚点槽位。构造上下文时,锚点槽位的内容永远跟着当前窗口走,不受窗口滑动影响。这相当于在记忆橡皮擦下面放了一张贴纸,关键信息永远擦不掉。
4.2 长会话目标漂移:快照小抄
长会话最容易出现的问题是聊着聊着,模型忘了用户最初想干嘛。比如用户一开始想“对比三款相机”,中途聊到了配件、价格、售后,三十轮后用户说“那你觉得我应该买哪个”,模型开始从配件聊起,完全偏了。
我的修复办法是引入“用户目标快照”:每五轮对话结束,用一个小模型把“目前用户的核心诉求”提炼成一句话,单独存储。每次构造上下文时,把这句话放到系统提示词里,相当于给模型一张“小抄”,让它随时记得主线目标。效果非常明显,模型在长会话中的“跑题率”大幅下降,而且这个小模型每次调用成本极低。
这里有一个自查小技巧:如果你发现模型经常“很固执”地按照最近几轮的话题走,而不是按用户最初的需求走,多半就是缺少了目标快照。加上之后,不仅主线稳定了,用户在对话后期对“这个模型居然还记得我要什么”的体验提升非常明显。
5. 模式选型与调优经验
既然做了一套context-mode,那平时调优就是在“选模式”和“调参数”之间反复横跳。这里分享几个我认为最值得反复试验的参数和对应的选型思路。
5.1 不同场景的最佳实践配置参考
我总结了一个选型表,可以直接作为初版配置参考。但务必用你的真实数据再调一轮,因为不同行业的用户说话习惯差异很大,参数必须适配自己的数据。
| 业务场景 | 推荐默认模式 | 关键参数建议 | 业务理由 |
|---|---|---|---|
| 客服售后 | 混合加权模式 | k=6,时间衰减0.9,摘要阈值6000 | 既要事实检索又要保证连续性 |
| 内容创作辅助 | 摘要压缩模式 | 摘要每5轮刷新一次 | 创作者需要整体连贯的思路 |
| 流程任务执行 | 窗口模式+目标快照 | 窗口k=8,快照每3轮一次 | 任务连续性大于历史广度 |
| 知识库问答 | 关键检索模式 | 检索召回10条,但只取Top3拼接 | 精准度优先,少量关键词即可定位 |
这个表里体现了选型的第一原则:模式匹配业务的核心诉求。客服场景既要查历史订单又要保持多点连续问答,所以混合模式最合适。而知识库问答核心是“找证据”,窗口模式反而无用,检索模式最合适。
5.2 动态窗口与Token预算动态调整
固定参数永远是不够的,因为用户的对话长度和复杂程度完全不可预知。我给窗口长度和token预算都加了动态调节机制:先设定一个“硬上限”,比如最大token总量不超过5000;在这个上限内,根据当前对话轮数和最近两条消息的长度动态调整窗口轮数。如果最近聊得很密,就少放几轮;如果用户隔了很久才回复,就多放几轮。
实现上我用了一个简单的贪心算法,从最新消息开始往前加,直到超越预算。这个方案最大的好处是“不浪费每一分token”:用户短问就快回复,用户长问就足够覆盖前文。我之前用固定窗口每天大约上传500万token,用了动态预算后降到约200万token,成本下降六成,质量还没下降。
5.3 高阶用法:多条记忆“并行”
最后的进阶内容是给“复杂业务”准备的,比如同时负责多个用户或多个任务时,单条上下文流已经不够用了。我实现了一个支持多条context-mode并行管理的类:每个场景独立维护自己的状态、摘要和检索索引,所有数据用did做隔离。
class ContextManagerHub: def __init__(self): self.modes = {} def get_mode(self, task_id): if task_id not in self.modes: self.modes[task_id] = ContextModeRouter() return self.modes[task_id] def reset_task(self, task_id): self.modes.pop(task_id, None)这套并行架构让我的后端服务可以同时服务上千个独立的会话,而互相不干扰。唯一要注意的是,task_id的归属一定要严格规范,最好用用户ID加业务域名的组合来生成,避免串号。一旦串号,A用户的数据出现在B用户的上下文里,这是致命的。排查出来的原因往往就是task_id设置不当,所以第一道防线就是权限校验。
6. 结语:context-mode的未来演进
context-mode不只是为了省token而存在,它更大的价值,是把“上下文”从系统黑盒变成了一个可设计、可控制、可演进的基础组件。当前我实现的是一个多模式分路调度系统,可以应对大部分客服问答、内容生成和任务执行场景。但我知道它还有很长的路可以走。
未来的一个大方向是“自我演进模式”:让模型根据当前会话的复杂度实时打分,自己决定切换到哪种上下文策略,而不是靠外部分流器预先指定。我最终的形态设想是:模型不仅能使用上下文中被动的信息,还能主动记忆那些“值得永久保留”的内容,同时自动遗忘那些对当前任务无关的信息。这种动态、自适应的上下文管理,才是构建真正可用Agent的底座。
另一个方向是“多模态上下文”。现在的context-mode还停留在文本领域,但最新的模型已经支持图片、语音等多模态输入。如何在不同模态之间做上下文切换,比如用户发了一张表格截图、然后问“这个季度营收多少”,系统需要把截图内容结合历史数据一起组织成混合上下文,这中间的检索与融合逻辑,大概率会在下个版本的context-mode里实现。
所以,不管你是刚接触大模型开发的新手,还是已经在生产环境里跑了很久的老手,花一点时间把上下文管理这件事单独拎出来思考设计,绝对是值得的。它不一定是最有光环的技术,但往往是决定产品智能上限的关键。如果这篇文章对你有一些参考价值,你可以直接从最简单的窗口模式开始改造,先跑通,再对比,再迭代,上下文会给你答案。