
最近好几个做 AI 应用的朋友都跟我聊到同一个问题Agent 聊着聊着就“失忆”了用户明明是第 3 次问同一个订单它却像第一次听见一样。表面上像是模型能力不行实际上几乎可以断定是项目里压根没有设计好 context-mode也就是上下文模式。这个词最近在 LLM 应用开发圈子里高频出现但它不是什么玄学它决定了一件很具体的事每次调用模型时你往 Prompt 里塞哪些内容、不塞哪些内容、按什么规律更新这些内容。模型是无状态的上一轮说过什么它根本记不住是你在外面帮它维护一套“记忆系统”而 context-mode 就是这套系统的运行策略。这篇文章会把 context-mode 的几种典型实现、背后原理、可复现代码和踩坑记录完整拆一遍适合正在做对话应用、Agent、客服机器人、RAG 系统的开发者也适合想搞清楚大模型应用底层逻辑的产品和技术决策者。1. context-mode 到底解决什么问题先搞懂上下文管理的本质1.1 从一次“失忆”事故说起先还原一个最常见的场景。用户在客服对话框里说“我要退一件 3 天前到的蓝色毛衣订单号是 ABC123因为尺码小了。” 接着又说“那退货要填什么单子” 如果你的后端只是把这两句话各自调一次 API第二句话没有任何订单号信息模型只能回答“请提供您的订单号”。这不是模型笨是调用方没有把上一轮的关键信息传进去。理解这个大模型 API 的特质很重要它不保存任何跨调用的状态。你把消息列表原原本本发过去它就知道上下文你只发一句新的它就跟得了失忆症一样。所以应用层必须做一件事——把用户的历史对话、系统规则、检索到的资料、工具返回的结果按照某种规则组合成新的消息列表再交给模型。context-mode 说的就是这套“组合规则”。1.2 context-mode 的核心输入决定输出我在刚接触大模型开发时以为 context 管理就是“把历史消息全拼上去”后来才知道事情远没那么简单。核心矛盾在于对话历史可以无限增长上下文窗口却是有限的。就算窗口再大无限堆历史也会带来四个连锁反应。第一是 token 成本。现在按量计费的模型输入侧 token 也计费上下文越长单次调用越贵。假设一轮对话折算 500 token100 轮就是 5 万 token高频接口跑一天账很快就会让你肉疼。第二是响应延迟。模型的 prefill 阶段要处理全部输入输入越长首 token 来得越慢用户那边体感就是转圈时间变长。第三是信息密度下降。真实对话里大部分是寒暄、重复、确认等低价值内容全堆进去后有效指令反而被淹没模型更容易答非所问。第四是长上下文下的注意力问题。研究表明模型对超长上下文中间部分的信息记忆往往较弱这被称为“迷失在中间”现象你辛辛苦苦塞进去的中段关键信息模型可能根本就没看进去。所以 context-mode 的本质不是“把什么传进去”而是“在成本、响应速度和关键信息完整性之间做取舍”。理解这个含义后面所有模式就都说得通了。2. 四种主流 context-mode 选型全量、滑动窗口、摘要、RAG 怎么挑2.1 全量模式最简单也最容易爆全量模式就是字面意思把 system prompt 和所有历史消息一锅端发给模型什么都不丢。这个方案在 demo 阶段没问题几轮对话内效果也最好因为信息完整。但它是最不适合上生产的模式。我见过一个团队把全量模式跑在客服机器人上用户聊了 20 分钟历史消息快 3 万 token每次都完整发过去结果单次成本飙升接口频繁报错。更尴尬的是用户在第 15 轮又问了一遍“你们几点下班”模型看到前面 10 轮都是无关闲聊反而无法准确回答。全量模式只适合两类场景一是对话轮次很少且你不在意成本二是内部工具里上下文窗口远大于实际对话量。否则别用。2.2 滑动窗口模式用最少的 token 留住最近的记忆滑动窗口是生产环境里最常见的模式之一。思路很朴素历史消息按先进先出方式淘汰只保留最近 N 轮或最近 M 个 token。内存里放一个按时间排序的队列新消息进入旧消息出队窗口稳步向前滑动。这个模式的优点是成本低、延迟稳定、实现简单。缺点也很明显超过窗口的远期信息会彻底丢失。适合的场景是闲聊机器人、客服分流、即时问答这类“每轮之间依赖不强”的对话。而如果你正在做的是多轮数据分析和长任务编排窗口一滑动用户两轮前指定的“按季度环比口径统计”就被丢掉了就会踩到我在后面第 4 节里讲的那个大坑。实现滑动窗口时有个容易忽略的细节system prompt 的工作机制和普通消息不一样它是全局指令无论窗口怎么滑动system 都必须是固定常驻的不能作为淘汰对象。工具定义的 function schema 也同理。很多新手把 system 也当历史消息一起裁掉结果模型很快变得“行为失控”。2.3 摘要压缩模式让模型自己当记性管家摘要模式是为解决滑动窗口丢信息而生的思路是让模型把旧对话“压”成一段摘要然后每次请求时把摘要作为长期记忆放在前面后面再接上最近几轮完整对话。这样既保留了大体上的历史信息又控制住了 token 消耗。这里有个关键设计——摘要不是一次生成的。长对话里的信息层次很丰富如果等对话结束再总结中间那些关键约定早就丢了。更合理的做法是“滚动摘要”当历史消息超过预设上限就把最老的对话拿去做摘要生成一段 compact summary历史列表里被摘要的消息清出去下次再超限时把已有摘要和新积累的消息合并再压缩一次。如此滚动摘要会越来越精炼。我用一个生活化类比解释这就像你每隔几天给旅行手账做一次“精华版”整理把每天流水账删掉只留重要的人和事过两周再整理时是基于上一次的精华版补充新内容而不是把整个手账重新抄一遍。摘要模式能保留长期记忆但它是“有损压缩”压缩过程可能丢掉关键字段比如订单号、日期、用户的具体偏好。这一点后面第 4 节我会详细展开对应的补救措施。2.4 RAG 模式把上下文搬进外部数据库当对话历史和知识库都非常大时摘要模式也开始力不从心。RAG检索增强生成是另一种思路不把所有历史堆在 Prompt 里而是把历史消息、知识文档切分成块存进向量数据库每次用户发起新请求时先用语义检索找出最相关的片段再把命中的片段拼进 Prompt。RAG 模式把上下文管理变成了“按需检索”理论上可以支撑无限规模的记忆。它的引入成本也最高需要选型向量库、处理文本切分、维护 embedding 流程、管理检索结果的相关性并处理检索错误。值得注意的是纯 RAG 并不适合做会话记忆因为对话里的时序信息和语气很难靠向量召回完整还原。我见到的成熟项目通常会把 RAG 用在知识库问答同时再用滑动窗口或摘要处理会话历史两者互补。这里把四种模式放在一起对比方便选型模式核心思路token 消耗长期记忆实现难度典型场景全量模式全部历史直接拼接高完整极低demo、极短对话滑动窗口只保留最近 N 轮低差低闲聊、客服即时问答摘要模式旧对话压缩成摘要中中等中长会话、任务型 AgentRAG 模式相关片段按需检索动态可控高依赖检索质量高知识库、海量文档问答3. 从零实现 context-modetoken 预算与核心代码实战3.1 技术选型与整体结构先说明一点我不打算用 LangChain 或 LlamaIndex 这类重量级框架来演示因为 context 管理的核心逻辑其实不复杂手写一个轻量模块反而更容易让你看清里面的决策过程方便你迁移到自己的工程里。用 Python 实现依赖只需要 tiktoken这是 OpenAI 官方的 tokenizer 库用来精确统计 token 数量。整体结构上ContextManager 负责三件事维护对话历史、按模式组装 Prompt、做 token 预算控制。对外暴露 add_user_message、add_assistant_message 和 build 三个方法调用方不需要关心内部用的是哪种模式。3.2 token 预算怎么算一个可复用的分配公式很多人写代码时直接用一个常量“把 token 上限设为 4000”这不是预算这是碰运气。预算必须基于三个值算出模型上下文窗口大小、为生成结果预留的 max_tokens、以及安全余量。预算公式就是prompt 预算 模型上下文窗口 - max_tokens - 安全余量。假设你用 gpt-4o-mini上下文窗口是 128000每轮生成预留 1024 token安全余量再留 1024那么 prompt 可用量就是 125952。这里的安全余量是给你自己的缓冲因为实际 tokenizer 版本和 API 内部计算可能存在细微差异也防止你拼接消息时的结构化开销突破预估。有了总预算再按比例分配。我的习惯是system prompt 预留固定值比如 2000 token最近对话占可用预算的 50% 到 60%历史摘要占 20% 左右RAG 检索片段占 10% 到 20%工具定义占剩余部分。预算紧张时的降级顺序是先砍 RAG 片段、再压缩摘要、最后才缩减最近对话轮数因为“新鲜度优先”是会话场景的基本原则。用生活化类比就是收拾行李箱先把不能丢的必需品system 和工具定义放好再按优先级塞东西放不下时先把非必需品的包装拆掉压摘要而不是扔掉刚买必需品。我会把预算检查逻辑放进 build 方法里每次构建都查一遍超了就自动触发降级策略。3.3 核心代码一个最小可运行的 ContextManager下面是一个可直接复制的最小实现支持 full、sliding、summary 三种模式。如果你要用真实版本只需要把调用模型的函数替换成自己的 client 即可。import tiktoken from typing import List, Dict, Optional class ContextManager: def __init__( self, model: str gpt-4o-mini, max_tokens: int 1024, safety_margin: int 1024, system_prompt: str 你是一个智能助手。, mode: str sliding, recent_rounds: int 5, ): self.model model self.max_tokens max_tokens self.safety_margin safety_margin self.system_prompt system_prompt self.mode mode self.recent_rounds recent_rounds self.history: List[Dict[str, str]] [] self.summary: str self._enc tiktoken.encoding_for_model(model) def add_user_message(self, content: str) - None: self.history.append({role: user, content: content}) def add_assistant_message(self, content: str) - None: self.history.append({role: assistant, content: content}) def _estimate_budget(self) - int: # 以 gpt-4o-mini 的 128k 窗口为例其他模型换成对应的 context_window context_window 128000 return context_window - self.max_tokens - self.safety_margin def _num_tokens(self, messages: List[Dict[str, str]]) - int: # 参考 OpenAI Cookbook 的统计方式每个消息体额外占 4 个 token num_tokens 0 for message in messages: num_tokens 4 for key, value in message.items(): num_tokens len(self._enc.encode(value)) if key name: num_tokens - 1 num_tokens 2 # 整个 messages 列表额外开销 return num_tokens def build(self) - List[Dict[str, str]]: if self.mode full: return self._build_full() if self.mode sliding: return self._build_sliding() if self.mode summary: return self._build_summary() raise ValueError(funknown mode: {self.mode}) def _build_full(self) - List[Dict[str, str]]: return [{role: system, content: self.system_prompt}] self.history def _build_sliding(self) - List[Dict[str, str]]: messages [{role: system, content: self.system_prompt}] recent self.history[-(self.recent_rounds * 2):] messages.extend(recent) # 预算不够时从最旧消息逐步剔除 budget self._estimate_budget() while self._num_tokens(messages) budget and len(recent) 2: recent.pop(0) messages [{role: system, content: self.system_prompt}] recent return messages def _build_summary(self) - List[Dict[str, str]]: # 历史太长时先触发一次摘要 if len(self.history) self.recent_rounds * 2 2: self._roll_summary() messages [{role: system, content: self.system_prompt}] if self.summary: messages.append({ role: system, content: f以下是之前的对话摘要请据此理解更早的背景\n{self.summary}, }) messages.extend(self.history[-(self.recent_rounds * 2):]) return messages def _roll_summary(self) - None: # 把最近 N 轮之外的老消息提取出来调用模型生成摘要 summarizable self.history[: -(self.recent_rounds * 2)] if not summarizable: return text \n.join(f{m[role]}: {m[content]} for m in summarizable) new_summary self._call_summarize(text) if new_summary: self.summary new_summary # 关键已经摘要的消息要从历史移除防止下次重复处理 self.history self.history[-(self.recent_rounds * 2):] def _call_summarize(self, text: str) - str: # 这里替换成你自己的 LLM 调用即可 # 我用 OpenAI SDK 时大概长这样 # from openai import OpenAI # client OpenAI() # resp client.chat.completions.create( # modelself.model, # messages[ # {role: system, content: 你负责为对话生成简洁摘要保留事实细节。}, # {role: user, content: f请总结以下对话\n{text}}, # ], # max_tokens512, # ) # return resp.choices[0].message.content raise NotImplementedError(请接入你自己的模型调用) # 使用示例 cm ContextManager(modesummary, recent_rounds3) cm.add_user_message(我要退一件 3 天前到的蓝色毛衣订单号是 ABC123) cm.add_assistant_message(好的我来帮您处理退货。) cm.add_user_message(那退货要填什么单子) cm.add_assistant_message(您需要填一张退货申请单。) prompt cm.build() print(prompt)这段代码刻意保持了最小化但有几个细节值得单独强调。第一summary 模式里_roll_summary的触发条件并不是每次调 build 都会执行而是 history 长度超过阈值才触发。这避免了每次请求都做一次摘要模型调用成本和延迟都会失控。第二_roll_summary执行后要把被摘要的消息从 history 里真正移除。如果不移除下一次 build 时又会把同样的消息再摘一次摘要会重复累计成“摘要的摘要”内容质量迅速下降。第三_num_tokens里每条消息额外加 4 个 token这是 OpenAI 消息格式的结构化开销很多人用简单len()估算时忽略它导致本地估算 9000 token发出去却提示超过 10000 限制。细节决定成败这条我踩过不止一次。3.4 模式切换怎么设计统一接口与策略build() 方法本质上是个策略分发器。对上层调用方来说它不需要关心底层是滑动窗口还是摘要模式只要调用同一个接口传参一致拿到的就是组装好的 messages 列表。这个设计的价值在于你可以把模式作为配置项开放给产品和运维某些用户走 summary 模式某些走 sliding 模式某些场景直接全量。不用改代码改配置即可。这对 Agent 类项目特别有用。比如一个数据分析 Agent长流程场景用 summary 模式能记住用户的目标和口径短问答场景用 sliding 模式省成本。如果后面业务下沉要接自己的向量库我建议把 RAG 检索也封装成类似 build_rag_context 的独立方法在 ContextManager 的组装流程里预留一个 rag_fragments 参数而不是在业务代码里到处乱拼 Prompt。4. context-mode 高频坑位排查我踩过的 4 个典型问题4.1 摘要模式把关键信息“压没了”第一次上摘要模式时我以为只要让模型“总结对话”就行结果客户在一长段对话里留下的订单号、地址、售后偏好摘要模型统统没提炼出来。最尴尬的是用户事后追问“我之前说不要发短信通知我”系统根本答不上来因为摘要里根本没留这条关键信息。问题出在摘要产出缺少结构。解决方案是我在_call_summarize的 prompt 里强制指定输出格式。我可以明确要求模型按“重要事实订单号、地址、偏好、承诺、约定口径”和“事件进展”分开列每条尽量保留原始措辞不要二次创作。最终摘要效果比自由发挥强得多。我给摘要 prompt 一个可以直接抄的模板请从对话中提炼以下内容 1. 重要事实订单号、编号、地址、时间节点、用户明确表达的需求和偏好逐条列出不要概括成一句。 2. 当前进展事情已经推进到哪一步下一位接手者需要知道什么。 3. 未解决的问题明确没做完的事带等价原话引用。 不要输出对话记录不要添加你推断的动机。4.2 滑动窗口切断了正在进行的长任务另一个坑来自滑动窗口的长度设置。用户在用 AI 做数据分析时第 1 轮说“请把口径切换成季度环比剔除 0 销量商品”后面连续讨论 20 多轮第 30 轮问“那第二个图表为什么少了一个品牌”模型一脸茫然。因为窗口只保留最近 10 轮关键口径早已被滑掉。这种问题不是加长窗口就能解决的窗口加一倍成本也跟着涨而且超长窗口一样可能“迷失在中间”。我更推荐两种补救方式。一是任务指令固化把用户在第 1 轮提出的任务约束提取出来放进 system prompt作为常驻上下文即使最近的对话窗口滑掉了任务意图仍然在。二是必要时混合摘要模式给分析类会话直接用 summary 模式而不是裸的 sliding 模式用上一节的滚动摘要持续保留“用户的目标和口径”。4.3 token 统计不一致导致超限报错在本地估算 token 时用了len(text)然后上线后报context_length_exceeded这种事在真实项目里频繁发生。中文字符尤其明显len()按字符数统计而 tokenizer 会把汉字编码成多个 token两者差距可以到好几倍。正确做法是始终用模型对应的官方 tokenizer 库来统计并且统计时把每条消息的 role、name 等字段开销也估算进去。除了统计方法我建议在请求外层加一个兜底重试机制捕获超限异常后把最老的消息移除一条重新统计再发循环直到成功。我给这类降级逻辑取了个名字叫“挤牙膏策略”通常循环两三次就能把一个合法请求发出去同时也能帮你定位是预算配得过于激进还是历史消息增长异常。def _shrink_until_fits(messages, budget, counter): while counter(messages) budget and len(messages) 1: # 保留 system 消息剔除最早的非系统消息 for i, m in enumerate(messages): if m[role] ! system: messages.pop(i) break return messages4.4 工具调用结果污染上下文Agent 场景里另一个隐蔽问题工具调用返回的长 JSON 被当成普通消息存进 history。一个搜索工具可能返回 3000 token 的列表Agent 调用 5 次后下一轮请求的上下文里塞了 1.5 万 token 的中间数据而用户真正的问题可能只占几百 token。处理思路是把工具返回值分成两类最终结果和中间过程。中间过程默认不进历史只保留摘要或完全丢弃最终结果作为 assistant 消息引用。实现上可以在 history 里给工具消息加一个discardable: True的标识当 context 预算紧张时优先丢弃这些 discardable 消息。这样既能掌控完整工具调用链也不会让 Agent 被中间噪音带偏。5. 进阶经验我给 context-mode 做的三个增强5.1 给每次请求做“token 体检”主动降级不要等接口报错再处理超限我会在 build 完成后统计各分区的 token 占比每条请求记录三个数system 占多少、历史摘要占多少、最近对话占多少。当某一部分异常增长比如历史摘要突然超过预算的 40%就说明摘要压缩强度不够需要调低摘要模型的 max_tokens 或增加压缩频率。这种体检逻辑让 context-mode 从一个被动方案变成一个能自我调节的组件。5.2 关键信息“钉板”对抗有损压缩针对摘要丢信息我在 ContextManager 里增加了一个important_facts列表由一段独立的“关键信息抽取”流程在每次对话后更新。它就像一块钉板把订单号、用户偏好、时间节点这些不能丢的内容钉在显眼处build 时优先拼进 system prompt。摘要无论怎么压缩关键事实都会原样出现在消息的最前面模型永远能读到。这是我在客服场景里最推荐的一个增强。5.3 异步预压缩别让用户等太久最初的摘要逻辑是在 build 时同步调模型用户请求因此多等一次模型调用延迟明显上升。后来我改成异步预压缩对话空闲时让后台去生成摘要build 时如果发现摘要还在生成中就先用旧摘要加最近几轮完整对话兜底。这个方案的收益是用户体验几乎无感知代价只是后台多占一点算力。我在实际项目里最常用的组合是“摘要 最近 N 轮 关键信息钉板”对多数长会话 Agent 够用成本也可控。如果哪天真遇到千万级知识的问答场景再把 RAG 架到这套结构旁边让检索片段成为最后一次 build 里的动态补充。context-mode 不是某个固定的代码模板而是一套根据业务诉求持续调优的策略上面这些方法是起步真正的优化空间藏在你的具体场景里。