第一次看到“context-mode”这个词,很多人会下意识觉得它是某个编辑器的快捷键,或者是某个框架里的配置项。其实它更像你在做大模型应用时绕不开的一个工程命题:当上下文窗口塞不下所有信息时,到底用什么策略来决定哪些内容进、哪些内容出、哪些内容先压缩一遍再进。我在几个实战项目里做过完整的上下文管理改造,今天把被叫做 context-mode 的这一整套设计思路、实现代码、参数配置和踩坑记录都整理出来,给正在做对话机器人、知识库问答或 Agent 编排的开发者一个能直接抄作业的参考。
这篇文章不会讲太多玄乎的概念,重点放在三件事:为什么需要给上下文分层管理、五种常见模式怎么选型、以及一套可落地的实现长什么样。适合的人群很明确——你的 AI 应用开始频繁出现“答非所问”“对话第 8 轮开始失忆”“token 账单涨得离谱”这类症状,那多半就是上下文模式没设计好。读完你至少能对自己的系统做一个诊断,并在一天之内搭出第一版可用的 context-mode。
1. 为什么需要“上下文模式”:窗口有限,信息无限
1.1 上下文窗口的“不可能三角”
大模型再强,一次能“看”的内容也是有限的。这个限制叫上下文窗口,从几千 token 到几十万 token 不等,听起来很大,但真放到业务里很快就会捉襟见肘。你可以把上下文窗口理解成一张办公桌:桌面面积固定,而你要在上面摊开的资料却越来越多——用户的历史订单、商品详情、售后政策、之前 20 轮的对话记录、工具返回的查询结果——全堆上去,桌面瞬间就爆了。
这里存在一个“不可能三角”:窗口容量有限,业务信息无限,token 成本随长度增长。三者你不可能同时兼顾。如果只追求信息完整,把所有东西一股脑塞进去,窗口溢出不说,费用也会让人肉疼;如果只追求省钱,粗暴截断历史,模型就会表现出严重的“短期失忆”,连用户刚才说想要黑色款还是白色款都记不住;如果只追求效果,每次都手工挑选重要信息,人力和时效又撑不住。
所以我一直觉得,上下文管理不仅仅是“怎么塞得下”的问题,它本质上是一道工程决策题。context-mode 就是为这道题设计的一套可复用解决方案:把不同性质的信息分门别类,再用一组策略决定在什么场景、什么轮次、什么费用预算下,以什么样的形态组装进最终请求。
1.2 从业务场景倒推模式需求
没有一种上下文策略是万能的。不同业务对信息的需求完全不一样,强行套同一个方案只会顾此失彼。我习惯用四个典型场景来说明这一点:
客服多轮对话是信息连续性要求最高的场景。用户可能在第 6 轮才透露自己的收货地址,在第 9 轮问“那我之前说的破损件怎么处理”。系统必须保留足够多的原始对话切片,同时还能快速提取订单号、商品编号这类关键槽位,缺一个都会让体验崩掉。
代码库问答则相反,它不需要记住用户昨天问过什么,但需要精准定位到某个函数定义、某段报错上下文。这种场景下,历史会话的重要性远低于检索回来的代码片段,核心矛盾是“召回准不准”而不是“记忆长不长”。
长文档分析又是另一个极端,比如让模型读一份 50 页的行业报告然后输出摘要。这里根本不存在多轮对话,唯一的问题是文档本身太长,远超过单次窗口,必须用分层压缩、逐步蒸馏的方式把几十页内容浓缩成模型能消化的信息块。
工具调用类 Agent 则有它的特殊性。模型需要感知工具返回的结构化结果,还需要把这些结果和最初的用户意图绑定在一起,否则会出现“调了工具但回答完全没用上结果”的弱智表现。
把这四个场景摆在一起你就会发现,所谓 context-mode,本质上是一套可插拔的策略集合。不同场景挂载不同策略,同一个场景在不同轮次也可以切换策略。想用一个固定逻辑通吃所有需求,那是最典型的初学误区。
2. 五种核心模式的设计思路与选型方法
2.1 分层、可插拔、可观测的三条设计原则
在开始写代码之前,建议先想清楚三个原则,它们是后面所有实现的地基。
第一是分层。把上下文拆成几个独立区块:系统提示词、历史对话、检索资料、用户当前输入、工具返回结果。每块信息单独管理,互不污染。这样做的好处是显而易见的——你要压缩的时候,只对“历史对话”这块动手;要增强的时候,只往“检索资料”里补内容;系统提示词永远保持稳定,不会因为拼接顺序错乱导致模型人设漂移。
第二是可插拔。每一个上下文策略都实现同一个接口,对外暴露相同的调用方式,内部各自成体系。这样业务方切换策略就像换插头一样简单,不需要把调用链路的代码重写一遍。
第三是可观测。每一轮请求结束后,都应该记录“这轮用了哪些信息块、每块多少 token、走了什么策略、命中了几条检索结果”。没有观测的上下文管理系统,出了问题只能瞎猜,这对线上故障排查来说是灾难。
2.2 五种模式的对比与适用场景
完整模式(Full)是最简单的策略:把从第一轮到当前轮的所有消息全部带入。它的优势是信息无损,模型上下文的连贯性最好;缺点也很直白,窗口消耗快、费用高、响应时间随历史增长而拉长。它只适合短对话任务,或者你需要模型“通读全部材料再做判断”的一次性分析场景。
滑动窗口模式(Sliding Window)只保留最近 N 轮对话,更早的内容直接丢弃。这相当于给桌面定期清理,保证永远只看最近的消息。它的优点是实现简单、token 消耗稳定;缺点是突然丢掉了关键早期信息,比如用户在第 3 轮报过订单号,第 10 轮再问时模型已经不知道了。
摘要压缩模式(Summary Compression)是滑动窗口的改良版。它不会直接丢弃久远内容,而是把它们总结成一段简洁的“记忆摘要”,再连同最近几轮原始消息一起送入模型。用一个简单的比喻:你把旧资料归纳成几页笔记放进抽屉,新资料仍摆在桌面上,需要用的时候先翻笔记。这个模式在客服场景里非常实用,能保留关键信息又控制 token 成本。
语义检索模式(Retrieval/RAG)把检索结果作为上下文的主要来源。它不是从对话历史里找信息,而是从预先建好的知识库中找出与当前问题最相关的片段,再拼进提示词。这个模式适合知识库问答、代码问答、商品咨询等需要外部知识的场景,它的上限取决于检索质量,如果向量召回不准,后面什么都白搭。
分层总结模式(Map-Reduce)专门对付超长文档。先把长文档切成多段,分别让模型提炼摘要,再把摘要合并、再提炼,逐层向上汇总,最终得到一份全局概括。它的优点是能处理任意长度的输入;缺点是中间过程会消耗较多 token,而且每层压缩都可能损失细节,需要靠分段设计和摘要校验来控制质量。
从选型角度来看,没有绝对最好的模式,只有“在当前场景最合适”的模式。我通常先问自己三个问题:信息主要来自对话历史还是外部知识库?允许的 token 预算是多少?用户对响应时间的敏感程度高不高?把这三个答案落到表格里,基本就能锁定一个大方向。
| 模式 | 保留的信息 | Token 成本 | 延迟 | 主要风险 | 典型场景 |
|---|---|---|---|---|---|
| 完整模式 | 全部历史 | 持续增长 | 高 | 窗口溢出、费用失控 | 短对话、一次性长分析 |
| 滑动窗口 | 最近 N 轮 | 稳定 | 低 | 关键早期信息丢失 | 闲聊类、非强依赖的问答 |
| 摘要压缩 | 摘要 + 最近轮次 | 中等 | 中 | 摘要失真、关键槽位遗漏 | 客服、多轮任务型对话 |
| 语义检索 | 检索到的知识片段 | 低 | 中 | 召回不准、时效性弱 | 知识库、商品咨询 |
| 分层总结 | 逐层合并摘要 | 高 | 非常高 | 多层信息损耗 | 长文档分析 |
3. 核心实现:从零搭一套 context-mode 模块
3.1 定义统一的上下文数据结构
写代码第一步不是急着调模型接口,而是先把数据结构定死。我习惯定义一组通用的消息和上下文打包结构,让所有策略都产出同一种格式。
from dataclasses import dataclass, field from typing import Optional @dataclass class Message: role: str # system / user / assistant / tool content: str name: Optional[str] = None # 工具名或参与方标识,可选 meta: dict = field(default_factory=dict) # 时间戳、token数、是否压缩过等 @dataclass class ContextBundle: system_prompt: str # 系统提示词,保持不变 history: list[Message] # 经过模式处理后的历史消息 knowledge: list[str] # 检索或外部知识片段,可能为空 current_input: str # 用户本轮输入 tool_results: list[str] # 工具返回结果,可能为空为什么要这么设计?因为它把上下文分成了五块互不干扰的缓冲区。后续无论你切换哪种模式,最终产出的都是一个标准 ContextBundle,模型调用层只需要负责把它拼接成最终的 messages 数组即可,这层逻辑永远不会变。
拼接逻辑可以单独抽成一个函数,保证格式一致:
def build_messages(bundle: ContextBundle) -> list[dict]: messages = [] if bundle.system_prompt: messages.append({"role": "system", "content": bundle.system_prompt}) for item in bundle.knowledge: messages.append({"role": "system", "content": f"[知识片段]\n{item}"}) for msg in bundle.history: messages.append({"role": msg.role, "content": msg.content}) for res in bundle.tool_results: messages.append({"role": "tool", "content": res}) messages.append({"role": "user", "content": bundle.current_input}) return messages3.2 模式编排器:决定每一轮走哪条路
有了统一数据结构之后,核心工作就是实现一个“模式编排器”。它接收用户消息和当前会话状态,根据预设规则选择对应的模式策略,最后返回 ContextBundle。
我用一个典型的策略调度逻辑来演示:
class ContextMode: FULL = "full" SLIDING_WINDOW = "sliding_window" SUMMARY = "summary" RETRIEVAL = "retrieval" MAP_REDUCE = "map_reduce" class ContextManager: def __init__(self, config: dict): self.config = config self.strategies = { ContextMode.FULL: FullStrategy(config), ContextMode.SLIDING_WINDOW: SlidingWindowStrategy(config), ContextMode.SUMMARY: SummaryStrategy(config), ContextMode.RETRIEVAL: RetrievalStrategy(config), ContextMode.MAP_REDUCE: MapReduceStrategy(config), } def get_bundle(self, session, user_input: str) -> ContextBundle: mode = self._decide_mode(session, user_input) strategy = self.strategies[mode] bundle = strategy.build(session, user_input) bundle.current_input = user_input self._log(session.session_id, mode, bundle) return bundle def _decide_mode(self, session, user_input: str) -> str: # 用户消息命中知识库关键词:优先走检索 if self.config["retrieval_trigger"].search(user_input): return ContextMode.RETRIEVAL # 历史轮数超过阈值:切换到摘要压缩 if len(session.history) > self.config["max_full_turns"]: return ContextMode.SUMMARY return ContextMode.FULL这里的关键参数有几个。max_full_turns 表示多少轮以内走完整模式,超出之后进入摘要压缩;retrieval_trigger 是检测用户是否在询问知识性问题的规则,可以是关键词列表,也可以是一个轻量分类器;summary_token_threshold 表示历史消息总 token 数达到多少时触发强制压缩。
_config 结构大致如下:
config = { "max_full_turns": 6, # 6轮以内用完整模式 "window_size": 8, # 滑动窗口保留最近8轮 "summary_token_threshold": 3000, # 历史超过3000 token触发压缩 "summary_max_tokens": 500, # 压缩摘要的目标长度 "retrieval_top_k": 5, # 检索返回的片段数量 "retrieval_trigger": ... , # 检索触发规则 }之所以把模式决策单独放到 _decide_mode 里,是为了让规则可以随时调整。你上线后发现某个阈值不合理,直接改配置就行,完全不用碰策略内部实现。这也是可插拔原则的实际落地。
3.3 摘要压缩模式的核心实现与参数计算
摘要压缩是最容易被写砸的模式,很多人就是简单地把旧消息丢给模型来一句“请总结”,结果摘要质量差不说,关键信息还大量丢失。下面是我验证过比较稳的做法。
先看滚动摘要的存储结构:
class SummaryStrategy: def __init__(self, config): self.config = config self.tokenizer = config["tokenizer"] # 开源tokenizer工具包 def build(self, session, user_input): history = list(session.history) current_tokens = self._count_tokens(history) # 超过阈值,先把旧消息浓缩,保留最近N轮原始消息 if current_tokens > self.config["summary_token_threshold"]: old_msgs = history[:-self.config["window_size"]] recent_msgs = history[-self.config["window_size"]:] new_summary = self._summarize(session.summary, old_msgs) session.summary = new_summary history = [Message("system", f"[历史摘要] {new_summary}")] + recent_msgs return ContextBundle( system_prompt=session.system_prompt, history=history, knowledge=[], current_input="", tool_results=[], ) def _summarize(self, old_summary, old_msgs): # 将已有摘要与旧消息拼接,请求模型生成新的合并摘要 content = "已有摘要:\n" + old_summary + "\n\n新增对话:\n" content += "\n".join(f"{m.role}: {m.content}" for m in old_msgs) content += "\n\n请综合以上内容,压缩成不超过500字的摘要。必须保留:订单号、商品名、数量、地址、用户诉求、未解决问题。" resp = call_model([{"role": "user", "content": content}], max_tokens=500) return resp这里有一个很重要的设计:我不在每次请求时都重新压缩全部历史,而是维护一个“滚动摘要”。每一轮旧消息被压缩后,只把新产生的摘要存回 session.summary,下次再压缩时只需要处理本次新增的旧消息和已有摘要的合并。这样每次压缩的开销是可控的,不会随着对话轮数无限上涨。
token 阈值的计算也有一套实际经验。假设你用的模型窗口是 8k token,你需要留出三块预算:模型输出大约占 2k,检索/工具结果最多占 2k,那么历史对话和系统提示词合计最多占 4k。系统提示词一般固定占用 500 到 800,历史对话可用空间大概在 3.2k 左右。如果平均每轮对话约消耗 250 token,那么 3.2k 除以 250 大约等于 13 轮。这就是你应该设置 summary_token_threshold 为 3000 左右、并在 10 轮左右开始监控压缩的由来。
建议在开发环境加一个中间层,打印每轮 token 消耗明细,用真实的业务对话数据去校准这些阈值,而不是照搬别人的参数。
3.4 语义检索模式的实现要点
检索模式的本质是“从外面找答案”,而不是从历史里翻答案。它有几个容易踩坑的环节,我一个个说。
首先是数据分块。知识库内容不能整块丢进去向量化,太长会稀释语义。常见做法是根据标题和段落边界切成每块 200 到 500 字左右,并给每块记录来源、更新时间等元数据,方便后续过滤。
其次是查询改写。用户真实输入往往是口语化的,和知识库的书面表达差距很大。比如用户问“这东西能不能退”,如果把原句直接拿去向量检索,召回效果通常很一般。比较实用的做法是用模型先把口语问句改写成一个描述清晰的问题,比如“退货政策是什么,退货条件有哪些?”,再做向量检索。这一步虽然多一次模型调用,但收益非常明显。
再看召回排序。单纯依赖向量相似度排序,前几条结果经常不够精准。我的做法是先用向量召回 top 20,再用一个轻量级 rerank(可以是模型打分,也可以是规则加权)重新排序,取前 5 条。规则加权可以很简单:包含用户提到的商品名、关键词的片段加 2 分,业务线匹配加 1 分,按总分重排。
伪代码大致如下:
class RetrievalStrategy: def build(self, session, user_input): rewritten_query = self.rewrite_query(user_input) candidates = self.vector_store.search(rewritten_query, top_k=20) ranked = self.rerank(candidates, user_input, top_k=5) knowledge = [item.content for item in ranked] history = self._recent_history(session.history, window=4) return ContextBundle( system_prompt=session.system_prompt, history=history, knowledge=knowledge, current_input=user_input, tool_results=[], )这种组合方式在实践中非常稳定。如果你发现检索模式的效果不好,先别急着调向量模型,大概率是分块粒度不对,或者查询改写没做好。
4. 实操过程:某客服机器人接入 context-mode 的完整案例
4.1 项目背景与改造目标
我参与过的一个模拟项目 X,是做电商售后客服机器人。接入大模型之后线上出现两个典型问题:一是对话超过 8 轮后模型经常遗忘用户前面的诉求,比如用户在第 5 轮说明了要退货,第 9 轮追问退款时间时,模型竟然反问“您是要退货吗”;二是 token 消耗增长非常快,因为每一轮都把全部历史塞进去,账单很快就超了预算。
改造目标定得很明确:让机器人在至少 20 轮以内保持关键信息不丢,同时把单次请求的平均 token 消耗压缩 40% 以上。我们决定引入 context-mode 做统一管理。
4.2 接入步骤与关键配置
第一步,梳理会话状态。我们把每个会话拆成三个文件:system_prompt 负责固定人设和回复规范;session.history 存原始消息;session.summary 存滚动摘要;另外单独划出一个 slot 区域,用来记录订单号、商品编号、用户意图、处理进度这些关键字段。这个 slot 区域是后面压缩不丢失信息的核心保障。
第二步,接入 ContextManager。把原来的“直接拼 messages”逻辑替换为 manager.get_bundle(),再调用 build_messages 做组装。这个改动涉及到的代码量不大,但在此之前必须先保证数据结构统一。
第三步,配置模式切换规则。我们的规则是:前 4 轮使用完整模式,保证开场信息完整;从第 5 轮起,如果历史 token 超过 2500,就切换到摘要压缩;一旦用户消息命中“退货、退款、发票、配送、售后”这些关键词,触发语义检索,从售后知识库找答案。
配置大致如下:
config = { "max_full_turns": 4, "window_size": 6, "summary_token_threshold": 2500, "summary_max_tokens": 400, "retrieval_top_k": 5, "retrieval_trigger": keyword_rule(["退货", "退款", "发票", "配送", "售后"]), "slot_extractor": slot_extractor, # 抽取订单号/商品名/用户意图 }第四步是回填关键字段。每次用户输入进来,先跑一遍 slot_extractor,把抽到的订单号、商品名、地址等关键槽位同步写入 session.slot 区。压缩摘要时,即使模型输出的摘要漏了订单号,我们也会在发送前强制补回:“用户订单号:{slot[order_id]},商品:{slot[product]}”。这一步解决了摘要模式最让人头痛的“关键信息失忆”问题。
4.3 线上实测数据与指标对比
改造后我们在测试环境用 200 条真实客服对话做了对比。测试逻辑很简单:同一批对话分别用“旧版全量拼接”和“新版 context-mode”跑一遍,统计平均 token 数、平均响应延迟和关键信息保留率。
| 指标 | 接入前(全量拼接) | 接入后(context-mode) | 变化 |
|---|---|---|---|
| 平均单次请求 token | 4200 | 2300 | 下降约 45% |
| 平均响应延迟 | 1.8 秒 | 1.2 秒 | 下降约 33% |
| 10 轮后关键信息保留率 | 61% | 93% | 提升 32 个百分点 |
| 20 轮以上会话占比 | 10% | 27% | 提升 17 个百分点 |
| 知识库问题命中率 | 48% | 82% | 提升 34 个百分点 |
这个结果挺能说明问题的。token 下降是因为压缩了大量早期对话,延迟下降则有两方面原因:一是输入变短,模型处理更快;二是检索模式能快速定位答案,模型不需要从海量历史里“猜”用户意图。关键信息保留率的大幅提升主要归功于强制 slot 回填,这比单纯指望摘要模型“自觉记住”可靠得多。
有一点要提醒:线上灰度时别一次性切全量流量。建议先放 5% 的流量跑一两天,对比各轮次的流失率和升级投诉率,确认新模式没有引发风格突变后再逐步放量。模式切换有时会造成模型语言风格前后不一致,这在客服场景里很容易被用户感知到。
5. 常见问题与排查技巧实录
5.1 摘要压缩后关键信息丢失
这是摘要模式最常被骂的问题。模型在压缩时,为了保证语句通顺,经常把订单号、日期、具体地址这类“看起来不重要”的信息给省略掉。我们早期的测试里,这种丢失率能达到 20% 到 30%。
解决办法是双保险。第一,像上面案例里那样维护一个独立 slot 区,用规则或小模型抽取出结构化关键字段,压缩后强制回填;第二,在摘要生成的内容尾部附加校验指令,要求模型输出关键字段完整性自检,例如让模型判断摘要里是否包含订单号,没有则归为失败,重新生成。这个自检步骤只会在压缩瞬间做一次,不会拖慢正常请求。
5.2 模式切换导致回答风格割裂
上下文模式切换后,模型看到的背景信息结构变了,回复风格经常跟着变。比如前几轮还是完整对话,语气亲切详细;切到摘要模式后,回复突然变得干巴巴的,用户会觉得对面“换了一个人”。
一个很有效的技巧是在摘要模式下,给系统提示词注入一句话:“你正在处理一段压缩后的历史对话,但请保持和之前一致的语气和回复风格,不要因为信息形式变化而改变表达方式。”这条提示能在一定程度上稳定模型的口吻。另外,摘要压缩时保留最近 4 到 6 轮原始消息,也能让风格过渡更自然。
5.3 语义检索召回结果不准
如果你发现检索模式经常答非所问,先按三个顺序排查。第一步检查分块粒度,如果知识库一块内容超过 800 字,冗余信息会稀释向量语义,改成 200 到 400 字的小块通常会有改善。第二步看查询改写,把用户口语原句直接拿去检索是最常见的召回差原因。第三步看排序策略,加一层 rerank,把和用户输入中实体词重合度更高的结果提到前面,往往比换向量模型见效更快。
还有一个容易忽略的点:知识库的时效性。如果知识库内容更新不频繁,而线上政策变动很快,检索回来的片段可能是过期信息。这时候需要给知识片段打上生效时间戳,在组装上下文时过滤掉已经失效的内容。
5.4 压缩过程本身造成的 token 开销
很多人误以为压缩一定省钱,实际上压缩是一次模型调用,它本身要消耗 token。如果压缩触发得太频繁,比如每两轮就压缩一次,那么省下的调用 token 可能还抵不上压缩花的钱。
建议把压缩触发阈值设置得保守一点。比如窗口是 8k,历史到 3500 token 再触发压缩,而不是一到 2500 就着急处理。另外,压缩时不要每次都把完整历史交给模型重新总结,使用滚动摘要的方式,只合并增量部分,能够显著压低压缩成本。上线前最好用一段长对话离线跑一遍,统计一下压缩动作平均每天发生多少次,每次消耗多少 token,算清总账再定阈值。
5.5 可观测性的实战建议
context-mode 这类中间层一旦出问题,靠肉眼是看不出来的。我强烈建议在每一轮请求的日志里,至少记录这几个字段:当前模式名、history 条数、history token 数、knowledge 条数、knowledge token 数、摘要是否触发、slot 区内容、最终总 token 数。有了这些日志,排查“为什么这轮回答这么差”就变成了一个简单的数据回溯操作。
我还会在本地做一轮回放工具。把线上真实会话日志按时间顺序重放到测试环境,每次重放时可以手动切换不同模式配置,对比同样输入在不同模式下的输出差异。这个工具帮我在一次项目里快速定位到“第 9 轮检索结果与用户问题完全无关”的问题,最后发现是查询改写模型在某些商品名上产生了错误改写。
最后分享一点实在的
这套 context-mode 的方案我前后做了几个版本,最大的感受是:不要一开始就追求复杂的自适应决策。先写死规则,比如几轮内走完整、超过阈值走摘要、命中关键词走检索,跑通之后再慢慢加入更聪明的判断逻辑。简单可靠的规则永远比花哨的模型调度靠得住,尤其是在线上流量还不小的时候。
还有一点想单独强调:上下文管理不是模型能力的替代品,而是模型能力的放大器。你把它做好了,模型才能在有限的窗口里把事办明白;你不做,就算换一个更大的模型,也只是把问题推后几轮而已。如果你的项目也正卡在“对话一长就崩”的坎上,不妨从今天讲的最小实现开始,搭一个 context-mode,用真实数据测一轮。它可能不会让你的模型变聪明,但一定会让你的系统变得更可控。