前一阵我在调一个客服问答机器人,初期图省事,把用户从第一天开始的对话历史一股脑全塞给模型。结果用户聊到第200轮时,prompt直接撑爆了上下文窗口上限——模型开始答非所问,甚至把前面已经确认过的订单信息又重复问了一遍。后来我花了两周时间把上下文管理整个重写了一遍,顺手把"Context Mode"做成了可插拔的模块。这篇博文就是把这套方案的完整设计思路、代码实现和实测数据复盘一遍,适合正在做对话机器人、RAG问答应用,或者任何需要和大模型保持长会话的开发者参考。
我见过太多团队在模型能力和提示词上死磕,却忽视了"上下文模式"这个真正的性能瓶颈。模型还是那个模型,API还是那个API,可一旦你把上下文管理好了,对话质量、响应速度、账单金额这三件事会同时发生肉眼可见的变化。下面就把我从原理到实战的完整路径拆开讲。
1. 为什么Context Mode决定了一个对话应用的生死
1.1 上下文"三连问":传什么、传多少、怎么传
很多新手做LLM应用时踩的第一个坑,就是把"调用模型接口"当成了全部工作。跑通一个单轮问答demo之后就开始做产品,结果一进入多轮对话场景就崩。真正拉开差距的,恰恰是demo里不怎么起眼的"上下文"这三个字。
先拆解一下这里说的上下文到底指什么。它通常包含五类内容:
- 系统提示词(system prompt),用来设定角色、行为边界和输出格式。
- 用户最近几轮的真实输入,这是模型理解当前需求的核心依据。
- 模型之前产出的回答,对话的连续性主要靠它维持。
- 外部检索到的知识片段,RAG场景下尤其重要。
- 工具调用的结果,比如查数据库返回的订单状态、天气接口返回的数据。
从数据层面看,上下文就是上面这些东西的某种组合。但从工程层面看,真正麻烦的是"传多少"和"怎么传"。传少了,模型缺少关键信息,答得驴唇不对马嘴;传多了,Token成本线性飙升,响应延迟也跟着涨;传乱了,模型区分不了"历史事实"和"当前问题",被无关信息带偏。
所谓Context Mode,就是一套解决"传什么、传多少、怎么传"的规则策略。它在上下文窗口这个"池子"里做取舍,决定哪些信息必须保留、哪些可以扔、哪些需要压缩后保留。这个决策直接影响三个指标:响应质量、Token成本、单轮延迟,而且这三者往往是互相拉扯的。
1.2 窗口溢出、成本失控、上下文污染:三个真实翻车案例
用三个我实际遇到过的案例来说明,这三个问题是怎么把应用搞死的。
第一个是窗口溢出。客服机器人早期方案里,我把所有对话历史原样拼接进prompt。用户聊了200轮之后,单轮prompt已经超过了当时模型的窗口上限,API直接报错。即便把窗口上限放宽,超长prompt也会让模型在生成时"忘掉"中段内容——这是注意力机制本身的局限性,跟模型聪明不聪明没关系。
第二个是成本失控。做一个数据分析助手的时候,我图省事把一张80列的表格整个塞进上下文。每轮对话模型都要重新读一遍这张表,单轮Token消耗直接飙到一万多。用户多问几个问题,账单哗哗地涨,财务看到后台数据直接来问怎么回事。这就是典型的"上下文没有任何管理"的后果——你每问一句,都在为过去的全部信息付费。
第三个是上下文污染。RAG场景里,检索器召回了5个片段,我一股脑全塞进去。结果其中3个片段跟用户问题毫无关系,模型被这堆噪声干扰,编造出了来源里根本没有的数据。这就是典型的"传了不该传的",信息过载比信息不足更危险。
这三个案例分别对应了上下文的三个维度:容量、成本、质量。一个合格的Context Mode方案,必须同时解决这三件事,而不是只堵住其中一个窟窿。
2. 三种主流Context Mode的原理与取舍
2.1 Full Context Mode:全量直传,简单但贵
第一种模式就是我最开始用的那种,全量直传。每次请求都把完整的对话历史拼接好丢给模型,不做任何裁剪或压缩。结构上就是:
[系统提示词] + [第1轮用户/助手] + [第2轮用户/助手] + ... + [第N轮用户/助手] + [当前问题]这个模式的优点非常明显:信息零损失。模型能看到从第一句到当前的每一个字,理论上对早期信息的理解是最完整的。实现也极其简单,一个数组拿来拼接就行。
但它的缺陷同样致命。Token消耗随对话轮数线性增长,如果每轮对话平均2000 Token,第100轮时仅历史就逼近20万Token,先不说成本,很多模型窗口根本装不下。就算窗口装得下,注意力机制也会让模型对中间部分的信息"视而不见",俗称lost in the middle——信息是传进去了,但模型读不到。
所以Full Context Mode只适合两类场景:一是一次性问答或极短会话(比如单轮知识问答),二是对历史完整性有硬性要求的离线分析(比如把整段对话丢给模型做总结)。在长会话产品里直接用,基本等于给自己埋雷。
2.2 Sliding Window Mode:滑动窗口,省Token但有代价
第二种是滑动窗口模式,这是实际工程里用得最多的方案。思路很简单:只保留最近N轮对话原文,更早的直接丢弃。
[系统提示词] + [最近N轮的对话原文] + [当前问题]N的取值看实际场景,我常用的范围是6到20轮。这个方案的好处是Token消耗被严格锁死,不会随着对话轮数增长,单轮成本可控,响应延迟也稳定。实现上只需要在消息列表里做一个切片操作。
代价也很明显——模型会"失忆"。用户在第3轮告诉过你收货地址改了,第20轮问"我现在的地址是什么",滑动窗口里如果只剩最近10轮,这个信息已经没了,模型只能瞎猜或者让你重复提供。
我用一段极简Python演示一下这个模式的切片逻辑:
def build_sliding_context(all_messages, window_size=10): """ 滑动窗口模式:只保留最近 window_size 条消息 all_messages: 完整的历史消息列表,按时间正序排列 """ recent_messages = all_messages[-window_size:] return recent_messages就这么几行,没有多余的逻辑。但问题也随之而来——"最近"真的等于"重要"吗?用户在第3轮提到的一个订单号,可能比第18轮的闲聊重要得多。所以单纯的滑动窗口更适合那种历史信息本身权重不高的场景,比如问答辅助、通用助理。
2.3 Summary Mode:摘要压缩,保留核心但加重延迟
第三种是摘要压缩模式,它试图鱼和熊掌兼得。核心思路是:超早期对话不再用原文保留,而是先用模型把它压缩成一段摘要,最近N轮仍保留原文。
[系统提示词] + [早期对话摘要] + [最近N轮的对话原文] + [当前问题]这样做的好处是,既避免了全量直传的Token爆炸,又比纯滑动窗口多保留了一层"长期记忆"。对客服类场景尤其有意义——用户三小时前提过的问题、表态过的偏好,都被浓缩进了摘要,模型在后续回答时依然能参考。
代价是,每次请求多了一步"摘要压缩"的额外调用。虽然可以在后台异步做,或者在对话轮数达到阈值时才触发,但只要有压缩动作,就会有额外的延迟和Token支出。而且摘要本质上是"有损压缩"——模型在总结时可能会丢掉细节,甚至理解偏了,这个坑我后文会详说。
三种模式各有利弊,我用一张表做了对比:
| 维度 | Full Context Mode | Sliding Window Mode | Summary Mode |
|---|---|---|---|
| 实现复杂度 | 低 | 极低 | 中 |
| Token成本 | 随轮数线性增长 | 恒定 | 略高于滑窗 |
| 长期记忆 | 完整但易被忽略 | 丢失 | 保留核心要点 |
| 响应延迟 | 随轮数增长 | 稳定 | 有压缩开销 |
| 适合场景 | 短问答 | 高频短会话 | 长对话、客服 |
2.4 三种模式的本质:在记住与忘记之间做技术取舍
说到底,Context Mode就是"记忆的管理策略"。大脑记不住所有事情,所以人会做三件事:忘掉不重要的、记住重要的、把复杂的信息提炼成要点。这三种模式恰好对应这三种策略:全量直传是什么都记,滑动窗口是什么都不记,摘要压缩是记重点。
理解了这个本质,你就能明白为什么不存在"绝对最优模式"——不同产品对记忆的需求天差地别。一个股票查询助手,用户关心的是最新行情,滑动窗口够了;一个长期健康管理助手,用户三周前说过"我对青霉素过敏",这种信息就绝不能丢,得上摘要模式甚至更激进的"关键信息持久化"方案。
这也是我把Context Mode设计成可插拔模块的原因——不是选一个模式用到底,而是在不同场景、不同对话阶段动态切换。
3. 实战:把Context Mode做成可插拔模块
3.1 上下文管理器的基础数据结构
要支撑多种Context Mode,第一步是定义一套干净的数据结构。我把核心抽象成三个部分:消息对象、模式枚举、上下文管理器。
消息对象负责承载单条对话记录,模式枚举负责标识当前策略,上下文管理器负责组装最终发给模型的prompt。先看消息和模式的实现:
from enum import Enum from dataclasses import dataclass from typing import List, Optional import time class ContextMode(str, Enum): FULL = "full" # 全量直传 SLIDING = "sliding" # 滑动窗口 SUMMARY = "summary" # 摘要压缩 @dataclass class ChatMessage: role: str # system / user / assistant / tool content: str # 消息正文 timestamp: float = time.time() # 写入时间,用于排序 metadata: dict = None # 附加信息,比如token数、来源等这里有个细节值得注意:我把system角色的消息也放进了同一套结构里。很多初学设计会在列表外单独存一份system prompt,但封装进消息列表后,做模式切换时系统提示词会跟随消息一起处理,逻辑反而更统一。比如滑动窗口模式下,系统提示词永远在窗口切片之后重新附加到开头,这样就不会出现窗口滚动时系统指令被挤掉的问题。
3.2 策略注入:让模式可以随时热切换
数据结构定了之后,就是核心的组装逻辑。我的做法是定义一个上下文管理器,它持有当前模式配置,并通过一个build_context方法把消息列表转化为最终发送给模型的内容。
这里的关键设计是"策略注入":不同模式组装逻辑不同,但对外暴露的接口一致。调用方只需要调用build_context,内部自动根据模式分发到不同策略。
class ContextManager: def __init__(self, mode: ContextMode = ContextMode.SLIDING, window_size: int = 10, summary_threshold: int = 20): self.mode = mode self.window_size = window_size self.summary_threshold = summary_threshold self.summary: Optional[str] = None # 初始摘要为空 def build_context(self, messages: List[ChatMessage]) -> List[ChatMessage]: """对外统一入口:根据模式返回最终上下文消息列表""" if self.mode == ContextMode.FULL: return self._full_context(messages) elif self.mode == ContextMode.SLIDING: return self._sliding_context(messages) elif self.mode == ContextMode.SUMMARY: return self._summary_context(messages) def _full_context(self, messages: List[ChatMessage]) -> List[ChatMessage]: return messages def _sliding_context(self, messages: List[ChatMessage]) -> List[ChatMessage]: # 分离系统提示词与历史消息 system_msgs = [m for m in messages if m.role == "system"] history_msgs = [m for m in messages if m.role != "system"] # 取最近 window_size 条历史 recent = history_msgs[-self.window_size:] return system_msgs + recent def _summary_context(self, messages: List[ChatMessage]) -> List[ChatMessage]: history_msgs = [m for m in messages if m.role != "system"] system_msgs = [m for m in messages if m.role == "system"] # 如果历史条数超过阈值,触发一轮摘要更新 if len(history_msgs) > self.summary_threshold: # 早期消息(阈值之前的部分)进摘要 early_msgs = history_msgs[:-self.window_size] recent_msgs = history_msgs[-self.window_size:] # 调用摘要引擎 self.summary = self._update_summary(self.summary, early_msgs) # 返回结构:系统 + 摘要(以system角色承载) + 最近原文 summary_msg = ChatMessage( role="system", content=f"以下是与用户此前的对话摘要,请作为背景参考:\n{self.summary}" ) return system_msgs + [summary_msg] + recent_msgs else: return system_msgs + history_msgs这个实现里有一个值得展开的设计:摘要是以"system角色消息"的形式拼进上下文的。原因是摘要代表的是"背景信息",而不是"用户的直接发言",用system角色承载可以让模型把摘要当作指令性的背景知识来参考,而不是当成对话的一部分去续写。实测下来,这种角色划分比简单地拼接一段"历史记录"更不容易让模型混淆。
3.3 摘要引擎:压缩质量和Token成本的平衡
_update_summary是Summary Mode的核心,它负责把旧摘要和新一批早期消息合并压缩。它的实现直接决定你这个摘要模式是好用的长期记忆,还是单纯的Token消耗器。
我的做法是分两步。第一步,如果还没有旧摘要,就对当前这批早期消息做一次完整总结。第二步,如果已有旧摘要,就执行"旧摘要+新消息→新摘要"的合并压缩。
def _update_summary(self, old_summary: Optional[str], new_messages: List[ChatMessage]) -> str: """合并旧摘要与新消息,生成新摘要""" conversation_text = "\n".join( f"{m.role}: {m.content}" for m in new_messages ) if old_summary: prompt = f"""请将下面的【旧摘要】和【新增对话】合并为一份更完整的摘要。 要求: 1. 保留所有旧摘要中已记录的关键事实,不要丢失。 2. 合并新增对话中的重要信息。 3. 按主题归类,控制在500字以内。 【旧摘要】 {old_summary} 【新增对话】 {conversation_text} 请直接输出更新后的摘要。""" else: prompt = f"""请将下面的对话压缩为简洁的摘要。 要求: 1. 保留用户的明确需求、偏好、已确认的关键事实。 2. 去除寒暄和与任务无关的闲聊。 3. 按主题归类,控制在500字以内。 【对话内容】 {conversation_text} 请直接输出摘要。""" response = self._call_llm(prompt) # 内部封装模型调用 return response这里控制摘要长度的上限是关键。我试过不限制长度让模型自由发挥,结果摘要越滚越长,最后比原文还占空间。你需要在"保留足够信息"和"控制Token成本"之间找一个平衡点,500字是我针对客服场景调出来的比较舒服的值,如果你的场景更复杂,可以适当放宽,但一定要设个上限。
3.4 在对话流程中接入ContextManager
最后一步是把管理器挂进整个对话循环里。这个循环负责接收用户消息、追加历史、构建上下文、调用模型、拿回答再写回历史。示例代码如下:
def chat_loop(manager: ContextManager, user_input: str, history: List[ChatMessage]): # 1. 把用户新消息追加到历史 history.append(ChatMessage(role="user", content=user_input)) # 2. 根据当前模式构建上下文 context_messages = manager.build_context(history) # 3. 调用模型 response = call_llm(context_messages) # 4. 将模型回复写回完整历史(注意:写回原始列表,不受滑动窗口影响) history.append(ChatMessage(role="assistant", content=response)) return response这里有一个新手特别容易犯的错:把构建出来的context_messages当成历史存回去。一旦你用了滑动窗口模式,context_messages里只保留了最近N轮,把它存回历史意味着早期消息被永久丢弃,摘要模式也就无法回头挖掘早期信息了。正确做法是:历史列表永远存全量,只在构建上下文时做裁剪。我管这个叫"全量存储、按需裁剪"原则,后面所有调优都建立在这条原则之上。
4. 实测:三种模式在真实对话中的表现对比
4.1 成本、延迟、记忆质量的三维实测
理论说了一堆,最终还得看数据。我拿一个真实客服对话数据集做了对比实验:同一个50轮的客服会话,分别用三种模式跑完整流程,记录每轮的平均Token消耗、平均响应延迟,以及在第30轮时询问第5轮提过的关键信息的回答正确率。
先看Token消耗和延迟:
| 指标 | Full Context | Sliding Window(10轮) | Summary(阈值20轮) |
|---|---|---|---|
| 平均单轮Token | 约18500 | 约3200 | 约4600 |
| 第5轮时单轮Token | 约1800 | 约2900 | 约2800 |
| 第30轮时单轮Token | 约29000 | 约3300 | 约4700 |
| 平均响应延迟 | 约6.8秒 | 约2.1秒 | 约2.6秒 |
数据非常直观。Full Context模式的Token在第30轮已经到了近3万,延迟也水涨船高;Sliding Window的Token几乎恒定;Summary模式比滑动窗口高出约40%的Token,但换来了长期记忆能力。
再看记忆质量,也就是在第30轮问"用户在第5轮提出过什么修改要求"这个问题:
- Full Context:正确率约70%。字段完整但模型"读不进去",因为信息被淹没在大量先前的对话里。
- Sliding Window:正确率约20%。第5轮的消息早已滚出窗口,模型只能碰运气。
- Summary Mode:正确率约85%。关键事实被摘要捕获,模型在回答时能直接读到。
这个实验结果很有说服力:Full Context正确率居然只比Sliding高一点,因为信息虽然传进去了,但模型根本不会去注意超长上下文中靠前的内容。这也解释了为什么单纯堆上下文长度并不能解决记忆问题。
4.2 什么场景该用哪种模式
结合实测数据和实际产品经验,我给出一份选型建议,直接照着用就行:
- 单轮问答、搜索摘要、文档问答:用Full Context。对话历史很短,不存在成本问题,信息完整度最重要。
- 代码助手、通用聊天、高频短会话:用Sliding Window,窗口设在8到20轮之间。这类对话历史信息的半衰期短,用户关心的是当下。
- 客服、医疗咨询、私人助理等需要"了解用户"的场景:用Summary Mode。用户的偏好、经历、长期状态比闲聊更有价值。
- 多角色复杂对话(如审讯模拟、教研助手):也不要在纯技术模式里纠结。真正需要考虑的也许是"哪些用户信息值得永久保存"这一层。
另外要提醒一点:模式切换不用等到整个产品阶段。我的经验是,在一次会话的开始阶段用Sliding Window,当检测到对话轮数超过阈值时自动切换为Summary Mode,这样能把前期省下的Token用在后期更关键的记忆保留上。这个动态切换思路在下一节的混合方案里会具体展开。
5. 踩坑记录与最终调优方案
5.1 Summary Mode的递归压缩失真问题
把Summary Mode投到线上之后,我最先遇到的坑是"摘要的摘要"失真。早期实现里,摘要更新逻辑是每次都拿着旧摘要+新消息去让模型"合并生成新摘要"。一次两次没什么问题,可当会话超过一百轮、摘要被压缩了七八次之后,问题就来了——模型在合并摘要时,会不自觉丢掉一些旧摘要里的信息,尤其是一些看起来"不那么重要"的细节,比如用户提过的时间节点、某个反悔过的意向。
一个客户场景里,用户在第12轮说过"星期五之前必须发货",但这个时间约束在摘要被压缩两轮之后就消失了,导致模型在第80轮建议了一个下周的排期,完全答非所问。
我的解决办法是:从"递归压缩"改为"分层归档"。具体说,不再用旧摘要反复压缩,而是维护一个持久化关键事实库,摘要只负责保留对话脉络,关键事实(用户的硬性要求、身份信息、订单号、时间节点)单独抽取成结构化字段存起来,每次组装条件时,特殊字段放在显眼的位置单独注入。
# 关键事实库示例 key_facts = { "shipping_deadline": "星期五必须发货", "address_updated": "收货地址已改为浦东新区XX路", "user_preference": "偏好文字回复,少用表格" } # 组装时:关键事实作为最高优先级拼在摘要前面 facts_prompt = "\n".join(f"- {k}: {v}" for k, v in key_facts.items())这个改造之后,摘要信息不断被压缩但关键事实几乎不丢。核心思路是:摘要压缩负责"讲个故事",关键事实库负责"钉住硬信息",各管一摊。
5.2 滑动窗口切割对话的"语境断裂"
滑动窗口模式在长对话里的另一个坑是语境断裂。窗口只保留了最近10轮,可用户在第11轮问"那刚才说的第三个方案呢"——这里的"刚才"指第2轮提到的三个方案,而窗口里早就没有了。
这其实是所有截断式上下文方案的通病:模型在回答指代性问题时,眼前的上下文无法提供被指代的对象。
我的解决方法是"指代触发回溯"。当用户消息中出现"刚才""之前""上次说的""那个方案"这类指代词时,不直接走滑动窗口,而是临时触发一次全量召回——在完整历史里检索最相关的若干个片段,把检索结果拼进当轮上下文。这个逻辑可以复用RAG的向量检索能力,不需要模型额外判断,只是增加了一个触发条件。注意,这里的全量检索,并不等于把全文重新塞给模型,而是只取topK,成本影响并不大。
实现上,在build_context入口处增加一个needs_lookup判断:
def build_context(self, messages, user_input=""): if self._has_reference(user_input): # 触发指代回溯:从完整历史中检索相关片段 references = self.lookup_history(messages, user_input, top_k=5) recent = [m for m in messages if m.role != "system"][-self.window_size:] return system_msgs + references + recent # 正常按模式分发 ...这个逻辑也不复杂:先在完整历史里用关键词或向量检索出相关片段,拼在最近窗口之前。模型既能理解"刚才说的第三个方案"指代什么,又不至于重新吃一遍全部历史。
5.3 我的最终方案:混合Context Mode
踩完这些坑之后,我最终的线上方案是三者混合,严格按信息层级组织:
- 系统提示词,永远是第一层。设定行为边界。
- 关键事实库,紧跟其后。单独抽取硬信息,优先级最高。
- 滚动摘要,第三层。保留对话的长期脉络,内容可以动态更新。
- 最近N轮原文,第四层。提供具体对话上下文,N通常取8到15。
- 指代巡检的结果,第五层。当用户提到过去的具体对象时,临时插入相关片段。
这其实就是在Summary Mode的基础上叠加了关键事实抽取和指代回溯。整套方案的组装成本比纯滑窗高一点,但换来的是长会话场景下稳定可靠的表现。我的客服机器人上线这个混合方案后,单轮Token稳定在5000以内,用户问早期信息时模型基本能准确回答,答非所问的情况显著下降。
5.4 工程上值得注意的三个小细节
最后分享三个工程细节,都属于"不踩不知道"的类型。
第一,历史全量存在Redis或内存里的原始列表,不要因为构建上下文做了裁剪,就顺手把历史也改了。前文提到的"全量存储、按需裁剪"原则,在分布式会话场景里依然是铁律。第二,摘要更新应该异步做。对话进行中,界面等待模型回复时,后台可以先把新的摘要算好存起来,等下一次构建上下文时直接用旧摘要,避免每次请求都多一次模型调用的延迟。第三,Token统计别只盯着prompt,也要看输出。我在滑动窗口模式下遇到过一个诡异现象:输入Token一直稳定,但账单还是涨了,查了半天发现是模型把常见问题的超长答案完整输出,累计下来也吞掉不少预算。控制输出长度(max_tokens参数)和控制输入长度同等重要。
说到底,Context Mode不是一个"选个模式跑起来"的配置项,而是一套需要根据场景不断打磨的上下文策略。我的这套混合方案是我在客服场景里踩了一圈坑之后的产物,换到你的场景里,未必需要完全照搬——但"分层组织信息、全量存储历史、按需裁剪、关键信息单独保护"这套思路,我认为是通用的。
你在做长会话应用的时候,如果也被"模型失忆""Token超支"这些问题折腾过,不妨按这个框架重新审视一下自己的上下文方案。先画出你的上下文里有哪几类信息,再决定每类的优先级和保留策略,多半能发现不少可以优化的点。