上周有个朋友跑来跟我吐槽,他做的AI客服机器人聊到第20轮就开始“装失忆”,用户在前面确认过的订单编号、收货地址,到后面全都不记得,用户气得直说“你是鱼吗,只有七秒记忆”。我瞄了一眼他的代码,发现每次请求都把完整对话历史原封不动塞进prompt,20轮下来光历史就占了6000多个token,而上下文窗口一共才8000。我没直接给答案,只问了一句:“你觉得这像是病根吗?”他沉默三秒,说:“是不是得搞个 context-mode?”
对,就是 context-mode,上下文模式。大模型应用刚火起来那阵子,这个词很少被单独拎出来讲,因为大家做的都是demo,上下文短、场景简单。可一旦到了生产环境、面对真实用户的连续对话,context-mode 就成了决定产品能上线还是只能躺PPT里的分界线。这篇文章想从工程视角把上下文模式讲透:为什么要做、怎么设计、参数怎么调、哪些坑必须绕开。不管你做的是AI客服、智能体、知识库问答,还是聊天插件,这套思维方式都直接用得上。
1. context-mode到底在解决什么问题
1.1 从一次“失忆”事故说起
先回到开头那个客服机器人。用户连续说出自己的需求,系统要记住用户的身份、订单状态、设备型号、收货地址等多个信息,并在后续对话里持续引用。如果不做任何上下文管理,直接把原始消息全部追加进prompt,会发生三件坏事。
第一是token迅速膨胀。一个订单号加上前后自然语言表述,少说20个token;一段50字的用户描述,大概是70到90个token。十轮下来,即使每轮只有两三百字,累积的历史也接近2000token。到了三四十轮,撑爆上下文窗口是早晚的事。一旦超出窗口,有的API直接报错,有的默默截断,不论哪种,对用户来说都是“系统突然失忆”。
第二是模型“中段遗忘”。这不是玄学,是Transformer注意力机制带来的现实问题。注意力矩阵是平方级复杂度,模型在处理超长文本时,对中间token的关注度天然低于开头和结尾。换句话说,你辛辛苦苦存的20轮对话,真正被模型“看见”的,可能只有前几轮和最近几轮。前面确认过的关键信息,实际上是被淹没了。
第三是延迟和成本。token越多,prefill阶段计算量越大,响应时间肉眼可见往上走;按token计费的API,每一轮都在为历史消息重复买单。我做过一次粗算:一次请求带4000token历史、一天10万次请求,按不同模型的价格差异,一个月光历史token的成本就可能够买一台开发机。这不是劝退,而是必须解决的现实问题。
1.2 别把context-mode当成一个现成开关
这里要澄清一个容易踩的误区:很多人以为context-mode是模型API自带的一个功能,打开开关就完事了。确实,现在不少服务商提供了类似“自动上下文压缩”“长对话记忆”的选项,但你上了生产环境就会明白,它通常只是在入口处做简单截断,或者把窗口撑大,真正要贴合业务逻辑,远远不够。
我更喜欢把context-mode理解成一套“上下文管理策略”:你决定哪些历史消息进prompt、哪些被压缩、哪些被丢弃、哪些被摘要替代,以及什么时候触发这些动作。这其实是一个系统工程,涉及token计算、压缩策略、摘要触发条件、检索增强的配合,甚至还要考虑多轮会话怎么分组。
所以后面提到的“context-mode”,不是某一个产品里的按钮,而是一套可以落到代码里的机制。先把这个认知建立起来,接下来的内容才有意义。
2. 为什么不能把完整历史一股脑塞给模型
2.1 上下文窗口是办公桌,不是仓库
任何大模型都有上下文窗口。即便某些模型号称128K、200K,也不代表你真能无脑把海量文本灌进去。窗口越接近上限,模型的注意力质量下滑越明显。你可以把上下文窗口理解成一张办公桌:桌面越大,能摊开的东西越多,但人眼扫来扫去,真正仔细看的部分依然有限。更麻烦的是,超过桌面边界的材料会被直接扫到地上——对应到模型就是被截断,用户前一秒说的关键信息,后一秒就被丢弃。
所以窗口再大,上下文管理也不是可选项,而是必选项。窗口大只是让你更从容,不会免除你的规划义务。我自己见过不少团队,觉得“反正模型支持200K,全塞进去就行”,结果线上效果一塌糊涂,用户多问两句就开始胡说。
2.2 信息稀释效应:越长越容易“看不见”
我用一个生活化现象来解释:你读一篇2000字的文章,能记住开头和结尾,中间很多细节其实是模糊的。大模型也一样,长prompt里中段信息的关注度会明显下降。学术界给这个现象起了个名字叫“lost in the middle”,中文可以叫“中段迷航”。
这个现象带来的直接后果是:对话历史越长,重要信息被“淹没”的概率越高。信息并没有真正丢失,而是在注意力分配中被稀释了。所以context-mode要做的不仅仅是“减少token”,还要把重要信息放到显眼的位置。实操中一个很有效的小技巧是:如果必须携带较长的前置信息,把关键事实放在最后面,也就是靠近当前问题的地方,同时用摘要压缩掉中段内容,让它不再占用太多注意力预算。
2.3 成本账单是赤裸裸的
很多开发者在初期不算这笔账。我拿一个常见的模型单价举例:假设输入每百万token收费20元,一次请求带着2000token的历史,一天10万次请求,那么光是历史token的费用就是:
2000 × 0.000001 × 20 × 100000 = 4000元/天
一个月就是12万。这还没算输出token。如果通过context-mode把历史压缩到500token,成本直接降到原来的四分之一。做C端产品的团队,这个优化有时候直接决定商业模型能不能跑通。所以context-mode看着是技术活,实际上也是算钱活。
延迟上的影响同样直观。大模型的首token延迟高度依赖输入长度,我实测过一个常规模型,输入从1000token涨到8000token,首token延迟能翻三倍还要多。在用户侧,这种感觉就是“转圈半天才开始出字”,体验非常糟糕。
3. 五种主流的context-mode设计,怎么选
3.1 滑动窗口模式:最简单,适合轻量场景
滑动窗口的思路很直白:只保留最近N轮对话,更早的一律丢弃。实现起来就是维护一个固定长度的队列,新消息进来,旧消息出队。
优点是简单、稳定、不消耗额外的token去生成摘要,线上出问题容易排查。缺点是粗暴,被丢掉的旧消息里如果有用户早先提供的关键信息(比如手机号、地址),后面就再也找不回来了。
适用场景是那些对话轮次少、关键信息会不断重提的场景。比如一些简单的问答机器人、表格填报助手,用户很少在20轮之后还在依赖第3轮的指令。如果你做的产品就是轻交互,滑动窗口完全够用,别为了炫技上复杂方案。
3.2 摘要压缩模式:给对话做“读书笔记”
摘要压缩模式的核心是:当历史消息累计到一定长度,调用模型把“旧对话”总结成一段结构化摘要,之后只保留摘要加上最近几轮完整对话。它相当于给对话做读书笔记,把长篇对话压成几个要点。
这种模式能在保留关键信息的同时大幅降低token占用,是目前生产环境里最常见、也最可靠的context-mode实现。缺点是需要额外调用一次模型来做摘要,可能引入延迟和成本。另外,摘要生成质量如果不好,关键信息照样会丢。
我做过的客服类项目里,效果最稳的组合是:保留一份滚动摘要,每次压缩时把旧摘要和新对话一起喂给模型,生成新的摘要。这样信息可以持续传递,而不是每压缩一次就把前面摘要的成果给清零。
3.3 检索增强模式:让模型“按需查资料”
检索增强模式不再保留完整历史,而是把每一轮用户消息、系统回复,甚至从外部知识库拉到的资料,都做向量化存储。每次生成回答前,先根据当前问题做相似度检索,把最相关的几段历史捞出来拼进prompt。
这个模式的优点是理论上的信息量不受上下文窗口限制,历史再久也能查到。缺点是实现复杂度高,要做向量库、要处理嵌入模型的选择、还要设计相关性的阈值。而且“漏检”是个让人头疼的问题——如果历史关键信息和当前语义不相似,检索就捞不回来,模型照样失忆。
我的体会是,检索增强适合和知识库问答结合,但它不应该完全替代摘要模式。比较好的产品实践是“摘要保底 + 检索增强”:摘要负责兜底整体脉络,检索负责补充特定历史细节。
3.4 结构化上下文模式:把prompt变成“档案柜”
结构化上下文模式是在提示词层面做文章:把对话拆分成多个固定区域,比如system区放固定的事实和用户画像,instruction区放当前指令,recent区放最近对话,memory区放长期偏好。每个区域承担不同职责,模型自然更容易“分门别类”读取信息。
这种方式往往不是单独存在的,而是跟摘要、检索配合使用。它的价值在于,给context-mode提供了一个好的“组织框架”。就算你做了摘要,把摘要塞在哪、最近对话放在哪,都会影响效果。实践中固定一个模板,比每次动态拼prompt要稳定得多。
3.5 选型对比:一张表说清楚
我把几种常见模式的优劣势和适用场景整理成一张选型表,方便对照:
| 模式 | 核心思路 | 优点 | 缺点 | 典型场景 |
|---|---|---|---|---|
| 滑动窗口 | 只留最近N轮 | 实现简单、零额外成本 | 旧信息永久丢失 | 轻交互、短对话 |
| 摘要压缩 | 旧历史变摘要 | 保留关键信息、稳定 | 需额外调用模型、有延迟 | 客服、多轮任务型对话 |
| 检索增强 | 按需向量检索 | 理论无历史上限 | 架构复杂、可能漏检 | 知识库问答、长会话翻旧账 |
| 结构化上下文 | 按职责分区组织prompt | 稳定性高、可解释性强 | 需要设计模板 | 生产级Agent、复杂业务 |
选型时不要只盯着效果,还要考虑团队维护成本。我的建议很简单:第一版先做滑动窗口,跑通后如果发现用户确实需要跨很多轮引用旧信息,再升级成摘要压缩模式。直接上来就做检索增强,大概率是给自己挖坑。
4. 实操:从0到1实现一个上下文管理器
4.1 先把接口想清楚,再动手写码
很多人在实现上下文管理时容易犯一个错误:上来就写一堆if else,把逻辑散落在各个地方。正确的做法是先定义好接口,再填实现。我会把核心能力收敛成一个类,对外暴露三个方法:add_message(写入新消息)、get_messages(取当前prompt)、以及内部的压缩触发逻辑。
在设计时,还需要支持不同模式可选。至少应该支持“关闭上下文管理”“滑动窗口”“摘要压缩”这三种模式。这样你可以随时切换对比效果,也方便做A/B实验。
我自己在项目里一般还会加一个统计入口,用来记录每次请求的token数、摘要触发次数、被截断的旧消息数量。没有这些指标,你就只能“凭感觉调参”,出了问题无从下手。
4.2 核心代码实现
我用Python写了一个简化但可运行的版本,核心逻辑都放在里面。为了便于阅读,我做了必要的精简,但关键链路是完整的:
import json import tiktoken from openai import OpenAI class ContextMode: MODE_FULL = "full" MODE_WINDOW = "window" MODE_SUMMARY = "summary" def __init__(self, mode=MODE_SUMMARY, max_tokens=6000, keep_recent_rounds=2, model="gpt-4o"): self.mode = mode self.max_tokens = max_tokens self.keep_recent_rounds = keep_recent_rounds self.model = model self.enc = tiktoken.encoding_for_model(model) self.client = OpenAI() self.history = [] self.summary = "" def _count_tokens(self, messages): total = 0 for msg in messages: total += len(self.enc.encode(msg["content"])) return total def add_message(self, role, content): self.history.append({"role": role, "content": content}) if self._should_compress(): self._compress() def _should_compress(self): if self.mode == ContextMode.MODE_WINDOW: return len(self.history) > self.keep_recent_rounds * 2 if self.mode == ContextMode.MODE_SUMMARY: return self._count_tokens(self.history) > self.max_tokens return False def _compress(self): if self.mode == ContextMode.MODE_WINDOW: keep = self.keep_recent_rounds * 2 self.history = self.history[-keep:] return if self.mode == ContextMode.MODE_SUMMARY: keep = self.keep_recent_rounds * 2 recent = self.history[-keep:] older = self.history[:-keep] self.summary = self._create_summary(older) self.history = recent def _create_summary(self, older_messages): # 把旧摘要和旧消息一起喂给模型,生成滚动摘要 content = f"下面是一段历史对话,请总结出其中的关键信息,包括用户诉求、已确认的事实、待办事项、用户偏好等:\n" if self.summary: content += f"\n之前的摘要:{self.summary}\n" for msg in older_messages: content += f"\n{msg['role']}: {msg['content']}" resp = self.client.chat.completions.create( model=self.model, messages=[{"role": "user", "content": content}], temperature=0.2, ) return resp.choices[0].message.content def get_messages(self): result = [] if self.summary: result.append({ "role": "system", "content": f"对话摘要:{self.summary}" }) result.extend(self.history) return result代码里有几个关键点需要说明。
第一,_should_compress里对token的判断用的是_count_tokens,这里我用的是tiktoken对token数做预估,比单纯按字符数判断准确得多。如果你接入的模型在tiktoken里找不到对应的编码,可以退一步用近似编码,或者直接用字符数估算然后打个折扣。
第二,摘要压缩时我并没有把所有历史都压掉,而是保留了最近keep_recent_rounds轮完整对话。这是因为模型回答当前问题时,往往最依赖最近几轮的原话,如果全压成摘要,回答的流利度和准确性都会下降。这个“保留最近完整对话”的细节,是生产环境里非常有效的trade-off。
第三,_create_summary里我把旧摘要也拼了进去,让模型基于“旧摘要 + 更新的旧消息”生成新摘要。这样信息能持续接力,不会因为每轮压缩而丢掉前面压缩的结果。
4.3 关键参数怎么定:三个核心配置项
上面代码里有三个关键参数需要重点理解:
max_tokens决定摘要触发时机。设得太小,对话没聊几句就频繁压缩,不仅浪费额外调用,还可能让摘要信息过于稀疏;设得太大,又失去了压缩的意义。一般建议把max_tokens设为模型上下文窗口的50%到70%。以8K窗口为例,6000比较合理,留出足够空间给system prompt、检索结果和模型输出。
keep_recent_rounds决定保留几轮完整对话。这个值我在不同场景试下来,2到3是比较好的平衡点。保留太少,近期对话细节丢失;保留太多,压缩效果不明显。注意“轮”是用一条user加一条assistant算一轮,所以保留2轮对应4条消息。
temperature在生成摘要时我设置成了0.2,目的是让摘要更稳定、更贴近原文事实。摘要不是创作,不需要发散。如果这里的温度太高,摘要里会出现模型自己的“脑补”,在生产环境是非常危险的事。
4.4 接入大模型调用:完整链路示例
有了ContextMode类之后,接进业务代码非常自然:
cm = ContextMode(mode=ContextMode.MODE_SUMMARY, max_tokens=6000, keep_recent_rounds=2) def chat(user_input): cm.add_message("user", user_input) resp = client.chat.completions.create( model="gpt-4o", messages=cm.get_messages(), ) reply = resp.choices[0].message.content cm.add_message("assistant", reply) return reply每来一条用户消息,先写入上下文管理器,再拿去请求模型;拿到模型回复后也写入历史。整个过程对外部调用方完全透明。测试时如果要对比效果,只要把mode改成MODE_FULL和MODE_WINDOW跑同一批用例就行。
4.5 别忘了边界情形
上下文管理器有几个必须处理的边界情况。第一是首轮对话,摘要还为空时不要调用_create_summary,否则会白白浪费一次额外请求。第二是历史消息里如果包含工具调用、函数返回结果这类非自然语言内容,摘要提示词里要明确要求“保留结构化数据”,否则模型容易把其中的JSON丢掉。第三是并发场景,如果多个会话共用同一个ContextMode实例,会产生数据串线。生产环境必须做到“一个会话一个实例”,或者把状态持久化到Redis这类外部存储。
5. 参数调优与效果验证指南
5.1 压缩阈值不是拍脑袋定的
很多人在调max_tokens时喜欢取整,比如直接设8000,理由是“反正窗口有8K”。这种做法的问题是没有给模型输出留空间。要知道,prompt里除了你的对话历史,还要有system指令、检索增强内容、示例few-shot,模型才能作答。输出也要占token。如果输入塞到8000,输出只能被截断或者被迫变得极其简短。
我一般会先统计线上真实请求的token分布,然后取P75的prompt长度作为阈值设置参考。什么意思呢?把线上样本按token数排序,取排在75%位置的那个值。比如统计下来有75%的请求prompt在5000token以内,那max_tokens就可以先设为5000,之后再观察多少比例的请求会触发压缩。目的是让压缩器“该出手时才出手”,而不是让大部分正常请求都白白挨一刀。
5.2 摘要提示词用“信息清单法”
摘要的质量好坏,直接影响context-mode的上限。我踩过最狠的坑是:提示词只说“总结这段对话”,模型就会输出一段高度概括的车轱辘话,比如“用户咨询了订单问题并得到了客服回复”。这种摘要放进后续对话,完全没用。
解决办法是给摘要规定一个“信息清单”,明确列出哪些信息必须保留。我常用的模板包含这几类:用户明确提出的诉求、双方确认过的事实(订单号、地址、价格、时间)、待办动作、用户的情绪和偏好、工具调用产生的结构化结果。可以让摘要模型按清单逐项提取,宁可信息冗余一点,也决不能把关键事实丢掉。
5.3 摘要触发时机的几个候选策略
除了简单的“超过阈值就压缩”,还可以做更精细的触发策略。比如“只在user消息到来时压缩”,可以避免在流式输出过程中反复压缩,减少延迟;或者“只在历史消息是纯assistant回复、没有正在执行的任务时压缩”,避免打断业务状态流转。实测下来,最稳的是“用户新消息到来时判断,且发生在请求模型之前”,因为这时候压缩带来的额外延迟,可以和正常请求的延迟重叠一部分,用户感知不明显。
另一个思路是做分级压缩:先触发一次轻量压缩,把最近几轮完整对话截断成更短的原文;如果还是超限,再做摘要压缩。这种渐进策略能减少模型调用次数,同时效果也更平滑。建议有精力的团队可以尝试。
5.4 怎么验证context-mode没搞坏业务
上线前一定要准备一套“回放测试”流程。具体做法是:收集一批真实多轮对话数据,用旧的未管理上下文跑一遍,记录每个回答;再用context-mode跑一遍,然后逐条对比回答质量。重点看两类样本:一类是长对话中后段涉及前文事实的问题,验证摘要有没有保住关键信息;另一类是对近期原话强依赖的问题,比如用户说“把刚才那句话里的价格改一下”,验证保留最近轮次有没有生效。
我还会额外埋点统计三个指标:摘要触发率(每百轮对话触发几次压缩)、压缩后token下降比例、以及用户侧的“上下文相关错误”发生概率。如果压缩后token降了30%,但用户相关投诉反而上升,说明摘要质量不过关,需要回去优化提示词而不是单纯调阈值。
6. 常见问题与排查技巧实录
6.1 对话被截断,关键信息当场失踪
症状是用户提到一个早前聊过的信息,模型一脸茫然。排查的第一步不是改代码,而是打开日志看一眼那一次请求的prompt到底长什么样。我见过不少“假截断”:prompt根本没被截断,只是模型生成回复过长,被API侧截断了,模型后半段内容丢失,看起来就像“不记得”。如果是这种,解决办法是限制输出max_tokens,而不是调整上下文管理。
如果确认prompt里确实没有历史信息,那就看压缩逻辑。很可能是滑动窗口的保留轮数设得太小,旧信息被清掉了。这时候要么把窗口调大,要么切换到摘要压缩模式。记住:一条铁律,先复现现场,再动手改参数。
6.2 摘要出来一段“正确的废话”
摘要提示词没有按信息清单约束,或者temperature太高,是最常见的两个原因。前者让摘要模型不知道该保什么,后者让摘要模型自由发挥过度。我的排查顺序是:先看摘要原文,如果通篇都是“用户就某个问题进行了咨询”“客服提供了帮助”,那就是提示词的问题,改用信息清单法重新生成。
如果摘要本身看起来有信息量,但模型后续没有使用,就要检查get_messages的拼接顺序。摘要放在system区只是第一步,还要在摘要前面加上“以下信息来自之前对话,如果当前问题涉及相关事实,请优先参考”之类的引导。不加引导,模型可能把摘要当成无关背景,直接忽略。
6.3 压缩之后回答质量忽好忽坏
触发条件不一致是常见病根。比如_should_compress在第一条user消息刚进来时触发了压缩,此时最近两条完整对话里可能只有一条user消息,没有assistant回复。以此生成的摘要就会缺失模型的答复内容,造成后续信息断档。
修复方式是给压缩加一个约束:只有当history里至少存在keep_recent_rounds轮完整对话(即至少包含等量的user和assistant消息对)时才允许压缩。不要小看这个边界判断,我在线上主要就靠它解决了一大批“偶尔失忆”的问题。
6.4 延迟压不下去
如果加了一层摘要逻辑,每次新消息都要额外调一次模型,延迟自然会上升。我的习惯是把摘要模型和主模型解耦:摘要用便宜的小模型,主对话用大模型。小模型虽然理解能力稍弱,但提炼信息清单这种结构化任务完全够用,成本低、速度快。
另外一个延迟优化点是异步执行。在用户发送消息的瞬间,可以先不阻塞,而是把摘要压缩任务放到后台执行,等用户连续对话的间隙再完成。不过这个方案会带来状态一致性问题,需要做好锁和补偿,我建议团队有一定基础后尝试。
6.5 我的独家排查路径
每次context-mode出问题,我都按一条固定路线排查:拿到样本后先看prompt实际内容,确认信息缺失属于“没进来”还是“进来了但模型没用上”;第二步看统计指标,是摘要触发太频繁还是触发太晚;第三步针对性地调参数或提示词,而不是一次性改多个变量。最后提醒一句:所有上下文管理策略上线前都要做回放测试,不然你根本不知道一次参数调整是在帮忙还是帮倒忙。
关于context-mode,最后想说的
做了这么多项目,我最大的感受是:上下文模式不是那种“跑起来就行”的功能,它属于典型的“平时感觉不到、出问题就致命”的后台能力。用户不会夸你“上下文管理做得好”,但一旦失忆,他们会把这个体验牢牢记住。所以做这模块时别图省事,把日志埋点、回放测试、参数文档都补齐,这才是长期主义的做法。
另外分享一个小技巧:我在每个会话的上下文里都会加一段“记忆清单”,持续记录当前对话中已经确认的关键事实。这样就算摘要压缩偶尔丢掉某个细节,模型还能从清单里捞回来。这个技巧成本极低,但对用户体验的提升非常明显,强烈建议你也试一试。