
最近在做AI应用的时候几乎每天都会撞上同一个痛点——模型聊着聊着就把前面交代过的事情忘得一干二净。不是模型变笨了是我压根没把上下文当回事。后来我把 context-mode 这套思路真正落地到项目里多轮对话、Agent任务调度、知识库问答全都稳定了不少。这篇文章把我踩过的坑和最终沉淀下来的方案一次性讲清楚给正在被上下文管理折磨的朋友们一份可以直接抄作业的参考。context-mode 说白了就是一套围绕上下文的模式化管理策略。你去看现在主流的大模型API基本都要求你每次请求把完整对话历史都传进去模型本身没有任何记忆。可问题是对话历史越长token成本越高响应越慢而且一旦超出上下文窗口直接报错给你看。所以我们需要一个中间层负责决定哪些历史信息该保留、哪些该压缩、哪些该丢弃这就是 context-mode 存在的意义。我最初是在做一个客服私有化知识助手时被逼着做这套东西的。用户往往会连续问十几个问题每个问题都依赖前面提到的背景资料。最开始我图省事把全部消息一股脑塞给模型结果到第8轮左右就开始出现上下文溢出。后来我尝试简单的截断只保留最近5条消息结果用户一开始说的我们是做宠物医疗的客户都是兽医这种关键背景到后面全被模型忘了回答开始跑偏。折腾了大半个月才有了这套相对完整的方案。1. context-mode 到底解决什么问题1.1 没有上下文管理多轮对话就是一场灾难先聊一个最直观的场景。假设你在做一个AI理财助手用户第一轮说我的月收入是3万房贷每月1万2孩子教育支出每月3000然后你非常贴心地给出了预算建议。到第10轮用户问你之前说的那个紧急备用金比例再给我算一下如果这时候模型已经看不到第一轮的数据它就只能给出一个泛泛而谈的答案比如建议准备3-6个月的生活支出而不是基于用户真实收支给出精确数字。这还不是最麻烦的。更麻烦的是如果用户在第3轮修正过信息其实我月收入是3万5不是3万上个月刚涨薪那么到了第10轮模型可能记住的是3万因为它新款消息里已经没有3万5这个修正记录了。这属于信息一致性问题比单纯的遗忘更隐蔽也更难排查。还有一个容易被忽略的问题是成本。现在的模型基本都是按token计费你传进去的上下文越长单次调用就越贵。一个典型的客服场景如果30轮对话全都原样传进去光历史消息可能就有6000-8000个token。如果这个应用每天有几千次调用那这部分开销会非常可观。更别说响应延迟会随着token数量增长而明显上升用户体验直线下降。所以上下文管理不是一个要不要做的问题只要你打算做一个真正能用的多轮对话应用它就一定得做。context-mode 要解决的核心问题可以归纳为三个第一怎么在有限的上下文窗口内装下尽可能多的有用信息第二怎么保证关键信息不被历史消息挤掉第三怎么让token成本和响应速度保持在一个可控范围内。1.2 context-mode 的定位给模型装上工作记忆你可以把大模型的上下文窗口理解成一张白板白板面积固定你每次写新的东西前都要先擦掉一部分旧的。context-mode 负责的就是决定擦哪块、保留哪块以及怎么把擦掉的内容沉淀成一份笔记。我在实际工程里对 context-mode 的定位是它处于应用层和模型层之间是一个独立的记忆管理层。它不是你给模型的一句提示词也不是某个现成的SDK而是一套你自己设计的状态管理机制。这套机制通常要处理三件事会话状态存储、上下文内容组装、上下文预算控制。换句话说你的应用每次调用模型之前都会先经过 context-mode 这道工序。它会读取当前会话的状态根据你设定的模式决定用户消息、系统指令、历史摘要、关键知识这些内容各自占多少份额然后拼装出一个合理的 prompt 再发出去。用户是感知不到这些操作的但对模型来说每一次收到的信息都是经过筛选的、结构清晰的而不是杂乱无章的聊天记录堆砌。我在项目中给 context-mode 定过四条铁律你可以直接参考第一上下文是有限资源一切决策围绕预算展开第二关键信息必须结构化存储不能只依赖对话历史第三压缩信息时优先保留不可逆的事实比如数字、名字、约束条件第四任何上下文策略都不能影响最终回复的准确性宁可多花token也不能丢关键数据。2. 四种上下文模式的设计思路2.1 滑动窗口模式简单但不能丢关键信息第一种模式是最容易想到的只保留最近N条消息更早的直接丢掉。这个方案在实现上非常简单维护一个消息队列超过长度就从头弹出。我早期做客服助手时用的就是这个方案窗口大小设为20条。但实际用下来这个方案的优缺点非常明显。优点是简单、快、不会超上下文窗口无论对话多长prompt长度永远可控。缺点是极其容易丢失关键信息。你想一下如果用户在第2轮说我们不用考虑社保公积金公司全额承担的这句话在第8轮之后就会被挤出窗口。到了第15轮模型可能就会在计算用人成本时默认把社保公积金算进去给出一个完全错误的估算。所以我现在的建议是滑动窗口可以作为基础保底方案但一定要配合关键信息提取机制不能只单独用。比如每进来一条用户消息先跑一遍实体识别把时间、金额、名字、偏好这些结构化信息单独抽出来存好然后窗口再按条数截断。这样即使原始消息被挤掉了结构化信息还在。如果你就是要用纯滑动窗口我建议窗口大小不要只看条数要按token预估。一条消息几百token和几十token差别非常大。更合理的做法是设定一个总token阈值从最新消息开始往回加直到接近阈值就停止。2.2 摘要压缩模式把旧消息变成一句话第二种模式是把早期消息提炼成一段摘要然后每次请求都带着这段摘要进入上下文。这个方案比滑动窗口聪明因为它不是简单丢弃而是用一种无损程度更高的压缩方式来保留语义。我是在用户反馈模型老忘记我们公司的业务方向之后开始做摘要模式的。第一次尝试很粗糙就是每积累5轮对话让模型把之前的消息总结成两三句话存起来。结果发现两个问题一是每轮对话都重新总结延迟太高、成本也不低二是模型做摘要的时候会自作主张地加工内容把一些关键数字弄错。后来我调整了策略只有在上下文快要超预算的时候才触发一次摘要压缩。而且摘要的分工很明确——每轮对话结束后如果检测到新的关键信息就增量地更新摘要而不是每次整段对话重新总结。比如用户收入从3万改成3万5摘要里直接替换掉旧数字就行不用把所有历史全部重算一遍。摘要压缩模式的实现过程中我踩过的最大的坑是摘要本身会随着对话推进变得越来越长。你本来是想压缩上下文结果摘要写上几轮之后反而成了最大的token消耗者。所以一定要给摘要设置一个上限比如统一限制在500个token以内。摘要超限时再对摘要本身做一次二级压缩也就是对摘要的摘要把最核心的信息继续提炼。2.3 结构化注入模式关键信息单独存第三种模式是我个人认为最值得投入的也是在后来的项目里真正把对话质量提升一个档次的方案把关键信息预先结构化存储在每次拼装prompt时单独注入。这个方案的核心思路是不要指望模型从聊天记录里自己找信息而是你把所有重要信息整理好直接放到它面前。举个例子还是那个理财助手。用户在对话中提到的收入、房贷、教育支出、理财偏好、风险承受能力这些信息每次都应该从结构化的Profile数据里读取拼装成一个固定的用户画像块加在系统提示词的后面。这样无论对话进行了多少轮模型始终都能看到这份画像。用户在后续轮次中修正的信息直接更新到画像里模型读到的永远是最新版本。结构化注入的实战效果非常明显。我测试过同一个问题用一个纯滑动窗口的方案和结构化注入方案分别跑30轮会话到第25轮时滑动窗口方案已经对用户的初始收支情况完全没有印象而结构化注入方案依然能准确引用。严格来说这不只是context-mode的作用更是信息架构设计上的胜利把所有关键信息从流式消息变成了持久化状态。不过要注意结构化注入也有自己的适用边界。它适合信息维度有限、可枚举的场景比如用户画像、任务参数、配置规则。如果对话内容是不可预见的开放式讨论信息维度过散结构化方案就不太好用这时候还是得靠摘要模式来兜底。2.4 混合模式与选型对照实际项目里我最终采用的是混合模式也就是把上面三种策略组合使用。我会维护三个层次第一层是固定不变的系统指令和关键约束第二层是动态更新的结构化信息用户画像、任务目标、已知事实第三层是近期的原始对话记录一般是最近3到5轮。结构化信息和近期对话之间再加上一层摘要用来兜住更早但仍然有价值的信息。这套分层结构有点像写论文的框架摘要对应文章开头的内容概述结构化信息对应核心数据表近期对话对应最新引用的文献。模型每次收到的prompt都是这三层信息按预算动态拼装的结果。如果token预算宽裕就多保留几轮原始对话如果预算紧张就加大摘要和结构化信息的占比。为了方便你选型我把自己后期测试下来的对比结果整理成了一张表模式实现成本信息保留效果token控制适合场景滑动窗口低差关键信息易丢好简单闲聊、非关键对话摘要压缩中中等依赖摘要质量较好长时间多轮对话结构化注入中高最好关键信息不丢好用户画像、任务参数明确混合模式高最好可控生产级AI应用我的结论很简单如果你做的是Demo滑动窗口就够了如果你做的是生产环境的应用别犹豫直接上混合模式。前期多花一点时间建设上下文管理模块后面能给你省下大量排查模型发疯的时间。3. 从零实现一个 context-mode 管理器3.1 核心数据结构与消息生命周期接下来进入正题分享一套可以直接参考的落地实现。先说数据结构。我在项目中定义了一个 ContextManager 类核心职责是维护当前会话的消息状态并按配置模式生成最终的 prompt。消息对象我统一用字典或dataclass来表示包含 role、content、timestamp、token_count 这些字段。为了支持context-mode我额外给每条消息加了两个标记is_pinned 表示这条消息是否被固定不能淘汰is_summarized 表示这条消息已经被写入摘要即使原始内容被丢弃也不影响后续使用。from dataclasses import dataclass from datetime import datetime from typing import Optional dataclass class Message: role: str # system / user / assistant content: str timestamp: datetime token_count: int 0 is_pinned: bool False is_summarized: bool False msg_id: Optional[str] None消息的生命周期可以分成四个阶段接收、存储、压缩、注入。接收阶段不做任何处理新消息原样进入消息队列。存储阶段更新会话状态比如把消息写到数据库或Redis里用于会话恢复。压缩阶段是context-mode的核心检查当前队列总token是否超过预算如果超了就按策略压缩最靠前的旧消息。注入阶段在每次调用模型前把系统指令、摘要、结构化信息、近期消息拼装成最终prompt。我给每条消息都缓存了token_count理由非常实在每次估算token都要调用分词器开销不小。干脆在写入消息时就算好token数压缩时直接累加判断就行。中文场景下我用了一个非常粗的分词估算公式content长度乘以0.7再加10误差大概在15%以内。如果项目里对精度要求高建议直接接入官方的tokenizer。3.2 token 预算的动态分配context-mode 里最关键的环节就是预算管理。你给模型的最大上下文token数不能全用来装对话历史因为模型自己还要留一部分token来生成回复。我一般按9比1的比例切分比如模型的上下文上限是128k token那就只允许历史消息最多占用110k左右剩下的留给系统指令和模型输出。预算分配需要一个优先级排序。我在实际代码中是这样实现的先加系统指令和固定约束这部分绝对不动再加结构化信息块比如用户画像和任务参数然后是历史摘要最后才是原始对话记录。如果总预算不够优先砍原始对话记录里的早期消息而不是压缩摘要和结构化信息。class ContextManager: def __init__(self, max_tokens12000, modehybrid): self.messages [] self.summary self.structured_info {} self.max_tokens max_tokens self.mode mode def add_message(self, role: str, content: str) - None: msg Message( rolerole, contentcontent, token_countself._estimate_tokens(content), timestampdatetime.now(), ) self.messages.append(msg) self._enforce_budget() def _enforce_budget(self) - None: total self._current_token_usage() while total self.max_tokens: oldest self._find_oldest_evictable_message() if oldest is None: break self._summarize_message(oldest) self.messages.remove(oldest) total self._current_token_usage() def _build_prompt(self): parts [{role: system, content: self._build_system_prompt()}] if self.structured_info: parts.append({role: system, content: self._build_structured_prompt()}) if self.summary: parts.append({role: system, content: 历史对话摘要:\n self.summary}) recent self.messages[-6:] # 保留最近6条 parts.extend([{role: m.role, content: m.content} for m in recent]) return parts我强烈建议在 _build_prompt 里把不同来源的信息用system消息分隔开不要全部混在一条system消息里。原因有两个一是便于调试出问题时你能快速定位是哪块信息导致的二是很多模型对同一条system消息内不同段落的重要程度判断不一致分开写能让结构化信息获得更高的指令优先级。预算分配还有一个容易被忽略的点模型输出最大长度也要算进总预算。如果你设置了max_tokens为4096那么历史消息和系统指令加在一起就不能超过总上下文减去4096。有些人在调接口时只关注输入token忽略了输出预留导致模型回着回着突然被截断看起来很像是context-mode的问题其实是预算算错了。3.3 摘要生成与关键信息提取摘要模块是整个context-mode里最能体现工程水平的地方。我做了两版第一版是纯文本摘要第二版是带关键槽位的摘要强烈推荐后者。带关键槽位的摘要本质上是一份固定格式的结构化记录。比如理财助手场景槽位就包括月收入、月支出、风险偏好、已完成操作、待办事项。摘要生成时不是简单让模型概述聊天内容而是让模型把对话映射到这组槽位上输出对齐的槽位值。这样设计有个明显的好处槽位是固定的更新时非常方便用户说收入改成3万5只需要改一个字段不用重写整段摘要。SUMMARY_SYSTEM_PROMPT 你是信息提取器。请从最近这段对话中提取以下字段的最新值 - income: 用户最新月收入 - expenses: 用户各项支出明细 - risk_preference: 用户风险偏好 - completed_tasks: 用户已完成的关键操作 - pending_tasks: 用户要求但尚未完成的操作 只输出JSON不要输出其他内容。没有提到的字段保留原值。不过我必须在代码示例之外多说一句让模型做摘要本身也是一次API调用会带来延迟和成本。所以千万不要每轮对话都触发摘要。我设置的触发条件是消息总token超过预算的70%时先尝试压缩最旧的一组非固定消息如果压缩后还是超才升级为一次全量摘要重写。日常对话过程中摘要模块更多是被动获取槽位更新而不是主动触发。还有一个小技巧摘要压缩时不要把旧消息删得干干净净。保留一条标记为已浓缩的占位记录内容为空但里面记录着这部分历史已经浓缩进摘要。这样做的好处是排查问题时能清楚看到每条消息的去向。你自己复盘时会非常舒服不然时间久了根本想不起来为什么模型突然少了某个记忆。4. 实操中的常见问题与排查技巧4.1 上下文溢出与保留策略冲突先讲一个最折腾我的问题明明设了max_tokens请求还是报上下文溢出。后来查下来发现模型上下文是按输入输出一起算的而我的代码只约束了历史消息的token量没有给输出留足够空间。系统提示词写得很长加上结构化信息和高频摘要单次输入本身就占掉了90%的额度模型要输出时根本没地方放。排查思路很简单在调用模型的SDK之外加一层日志把每次请求的完整输入拼出来统计各部分的token占比。只要看一眼问题基本就水落石出了。我后来直接在日志里附带这段prompt的结构化拆分system固定指令多少、结构化信息多少、摘要多少、近期消息多少。一旦哪里长得不合理一眼就能定位。症状常见原因排查方向请求报context length exceeded输入输出超出模型窗口检查各模块token占比模型回复越来越敷衍历史消息被截断信息不足查看摘要和结构化信息是否完整同一个问题多次回答不一致早期修正信息丢失检查结构化信息是否被及时更新响应越来越慢上下文长期接近上限收紧历史消息条数限制某轮对话开始突然答非所问关键消息被当作普通消息淘汰检查is_pinned标记是否漏设4.2 摘要丢失核心指令摘要模块上线之后我遇到过一个很诡异的问题对话进行到20轮左右模型突然开始给出违反用户最初指令的回答。排查发现用户在第1轮说过不允许推荐年化收益超过8%的产品这条信息在第15轮时被普遍的消息淘汰逻辑选为压缩对象摘要模型把它草率地归入用户偏好结果生成摘要时丢了不允许这个核心否定词。这个问题让我彻底改变了摘要策略。首先所有包含关键约束条件的消息必须标记 is_pinnedTrue意思是这条消息不能被压缩淘汰。其次摘要在提取时不能输出自由文本要输出结构化槽位像不允许事项、强制约束这些字段必须单列。最后摘要生成后要做一次校验你用历史原始消息交叉核对核心数字和否定词发现不一致就重新生成。这里也给你一个排查方向的建议如果模型在长对话中突然变笨先不要去优化提示词去查它的历史摘要里到底存了什么。很多时候不是模型的问题是你压缩的时候把关键信息压丢了。我当时就是看了摘要才发现那个不字被模型在压缩时悄悄吃掉了。4.3 并发场景下的上下文隔离如果你做的是一个多用户应用并发场景下的上下文隔离问题会比单会话复杂一个数量级。我最初把所有的ContextManager实例放在一个全局变量里管理结果上线第一天就出事了用户A的对话记录串到了用户B的会话中导致B收到了A的隐私信息。这个事故等级非常高直接把我逼着把上下文模块重写了一遍。现在我的方案是以session_id为维度维护独立的ContextManager实例把实例状态存到Redis里key格式固定为 context:{app_id}:{session_id}。每次请求进来先按session_id加载对应的状态处理完再回写并且用Redis的锁机制防止并发写冲突。def get_context(session_id: str) - ContextManager: key fcontext:{APP_ID}:{session_id} data redis.get(key) if data: ctx ContextManager.deserialize(data) else: ctx ContextManager(max_tokensMAX_TOKENS, modehybrid) return ctx def save_context(session_id: str, ctx: ContextManager) - None: key fcontext:{APP_ID}:{session_id} redis.set(key, ctx.serialize(), ex3600) # 1小时过期并发隔离这里最容易踩的坑是你用了Redis但存的是同一个对象引用而不是序列化的副本。Multiple实例同时修改一个对象照样会串。必须确保每一次读写都走序列化和反序列化不能直接把对象缓存起来。另外一个比较隐蔽的问题是延迟。如果你每次请求都全量序列化整个对话历史长会话场景下Redis的往返时间会变得非常明显。我的优化做法是把ContextManager分成高频区和低频区。高频区存放最近10条消息每次都完整读低频区存放摘要和结构化信息只在会话开始和结束时读取。实测下来单次请求的上下文读取耗时从80ms降到了20ms左右。5. 进阶玩法把 context-mode 用到更多场景5.1 与RAG融合不是二选一有人会问做了RAG之后还需要context-mode吗答案是需要的两者解决的是不同类型的上下文问题。RAG解决的是外部知识怎么进来context-mode解决的是进来之后怎么在窗口里分配资源。两者根本不是替代关系而是配合关系。我在知识库问答场景里的做法是先把用户问题通过RAG检索出候选文档然后让context-mode决定这些文档在prompt里的排布。具体来说检索到的文档块按相关度排序后只取分数最高的top-k放进去k的大小由当前可用token预算决定。预算紧张就少放几个文档块但系统指令中的用户核心需求描述永远不压缩。这样能保证即使文档块被削减模型依然知道用户想干什么。有一个很常见的失误是把RAG检索到的文档块和对话摘要混在一起不加任何标记和排序规则。这会让模型分不清哪些是外部参考资料哪些是对话历史最终回答时引用混乱。context-mode的作用就是把这些不同来源的信息结构化、层次化让模型明确认知每一块的语义身份。5.2 Agent任务链中的上下文分层如果你在做Agent类的应用比如一个能调用工具完成多步任务的工作流那么上下文管理会更加复杂因为每一步都可能产生新的中间状态。我总结了一套三层上下文的分层方式实战效果不错。第一层是任务全局上下文包括用户最终目标、约束条件、已完成步骤这一层放在系统指令里全程不可压缩。第二层是当前步骤上下文包括当前正在执行的子任务输入、最近一次工具调用的输出。这一层只保留当前步骤相关的信息步骤完成就归档。第三层是工具信息也就是每个工具的调用说明和参数格式这一层按需加载只在需要调用某一工具时才注入。使用这套分层方案之后Agent的执行稳定性改善非常明显。以前模型经常在执行到第三步时忘记第一步的用户指令现在因为全局上下文全程钉在系统指令里这个问题基本消失了。而且随着步骤推移每步只保留局部信息上下文增长非常缓慢长时间任务也不会被token问题卡死。还有一个细节值得留意Agent场景里工具调用返回的结果往往很长。如果调用了一个返回JSON的接口几万个token全塞进上下文预算转眼就爆了。我现在的做法是把工具返回结果先做一个结构化截断只保留字段名和关键值数组只取前10条剩下的省略并注明共N条已省略。这一项优化单独就能把Agent应用的token消耗降低40%以上。6. 写在最后的一点体会做了这么久context-mode我最大的体会是上下文管理不是模型能力的一部分而是应用工程的一部分。很多团队把长对话效果差归咎于模型不够聪明实际上问题往往出在上游的信息组织上。模型收到一份结构清晰、重点突出的上下文和你把十几轮聊天记录像倒垃圾一样全倒给它回答质量天差地别。如果你正在做一个依赖多轮记忆的AI应用我建议你从最小可行的context-mode开始先加一个固定系统指令再加一个用户画像结构块最后再考虑摘要和滑动窗口。不要一上来就设计一套复杂的混合体系先解决信息丢失这个问题再逐步优化token成本。这套方案我前后迭代了将近两个月最终形态看着复杂但给定性和结构性目标之后每一步其实都不难。最后再分享一个小技巧每次上线新方案之前把旧方案和新方案在相同的对话集上各跑一遍然后人工对比每一轮输出的质量。不要只看最终的准确率要看它在每一轮对关键信息的保持情况。这个对比过程虽然费时间但能帮你发现很多意想不到的问题比任何单元测试都管用。