☰
AI多轮对话中的上下文管理:三种context-mode实现与踩坑实践
2026/10/16 7:30:13 网站建设 项目流程

最近在做一个智能客服项目,多轮对话里模型一次次“失忆”:前几轮刚确认的订单号,后面就答非所问;用户临时换个话题,系统却还在维护上一个目标。排查到最后,问题根本不在模型本身,而在于我没有一套管理上下文的模式。后来我把上下文处理抽成几个可切换的模式——也就是标题里说的 context-mode,问题才真正解决。这篇没有高深理论,全是解决实际问题时的实现思路和踩坑记录。如果你也在做对话机器人、代码生成工具,或者任何需要“记住前面内容”的AI应用,那这篇文章的参考价值最大。思路可以平移,代码也能直接抄过去改。

1. 从一次“模型失忆”事故说起:context-mode 到底在解决什么问题

1.1 一次对话翻车现场

我先还原一下当时的场景。一个用户进来说:“我在你们的App上买了个东西,订单号是A307,但是一直显示待发货。”客服机器人第一轮正常回复:“您查询的订单A307目前处于待发货状态,预计今日发货。”用户又问:“那这个订单能改地址吗?”机器人答:“可以,您需要提供新的收货地址。”到这里一切正常。然后用户说:“好的,地址我稍后补充,你先帮我看看另一个订单B921的物流。”机器人突然回了一句:“请问您要修改A307的收货地址到哪个新的地址?”这时候整个对话就崩了——用户问的是B921,机器人还在处理A307。

这不是模型能力的问题,是我没有把历史记录的状态切换处理好。模型收到的上下文里,最近几个回合全部是关于A307改地址的内容,新问题B921的物流查询被淹没在旧信息里。模型只能根据当前对话历史去推断意图,而历史里占主导的还是A307。这个现象在业内有个通俗叫法:上下文漂移。当时我第一反应是加大窗口,把更多历史塞给模型。结果更糟糕:不仅没解决问题,反而因为历史太长,模型被更多无关信息干扰,回复延迟也变高了。也就是从那一刻开始,我意识到对话系统的核心不是“记住全部”,而是“聪明地选择记住什么”。这个选择机制,就是 context-mode 要管的事。

1.2 上下文管理的本质:不是记忆,而是选择

如果只用一个类比来理解 context-mode,我会选“人的工作记忆”。一个人不可能把每次聊天的每个字都记住,工作记忆只能保留对当前任务最关键的信息,而把次要信息放到长期记忆里,需要时再调取。AI对话系统面对的是同样的约束:模型的上下文窗口在令牌数量上是有限的。假设模型单次最多能接收8000个令牌,系统提示词已经占了800,用户当前输入占1200,那留给历史记录的空间只有6000左右。如果之前的对话总共积累了10000个令牌,就必须做出取舍:哪些轮次留下,哪些轮次删掉,或者更高级一点——把旧内容压缩成一段摘要,再把摘要留在上下文里。

所以说到底,context-mode 是一种“上下文选择策略”。它要回答三个问题:保留哪些上下文、以什么形态保留(原始对话还是摘要)、保留多久(固定轮次还是动态时间)。这三个问题不同答案的组合,就形成了不同的模式。我在项目里实践下来,最常用的是三类:固定窗口模式、滑动窗口模式、语义压缩模式。下面一个个说清楚,包括它们各自的实现方式和适用边界。

2. 三种 context-mode 实现思路:固定窗口、滑动窗口与语义压缩

2.1 固定窗口模式:规则简单,效果最稳

固定窗口是最朴素的做法:每次构造请求时,只取最近 N 轮对话嵌入上下文,N 轮之前的一律丢弃。这个 N 可以是10、20、50,看你的业务需要。我最早实现的就是这个,代码简单到不需要额外依赖:

from collections import deque class FixedWindowContext: def __init__(self, max_rounds=10): self.max_rounds = max_rounds self.history = deque(maxlen=max_rounds) def add(self, user_message, assistant_message): self.history.append({ "user": user_message, "assistant": assistant_message }) def build_context(self): return list(self.history)

这里的deque(maxlen=N)就是天然的固定窗口:新消息进来时,如果队列满了,自动把最老的一条弹出。用上这个类之后,之前订单号失忆的问题有没有解决?部分解决了。因为它保证最近 N 轮一定在上下文里,模型不会再因为“太多历史”而被淹没。但很快暴露出新问题:如果一个用户在第1轮报了订单号,然后连续问了10个关于其他商品的问题,等到第11轮问“那我刚才说的订单改地址了吗”,订单号已经被弹出窗口,模型照样不知道。所以说固定窗口保住了“最近性”,丢了“关键性”。

2.2 滑动窗口模式:按时间或轮次动态伸缩

滑动窗口是对固定窗口的改良:不再简单按轮次截断,而是按“距离当前时间”或“距离开话题的间隔”来决定保留哪些轮次。比如一个窗口大小按时间计算,用户在第5秒说的话,比第15秒前的话更重要;又比如按轮次累计令牌数,超过阈值就从头截断。两者本质都是维护一个可动态伸缩的窗口。

我实践时用的滑动窗口设计是:以“轮次”为基本单位,但窗口大小不是固定的,而是由“最近触发的话题数”决定。举个例子:用户问订单A,接着讨论改地址,聊了5轮;然后突然问订单B,这时系统会开启一个新话题,旧话题的轮次会被压缩成一个摘要,新话题开始积累完整轮次。实现重点在于“话题切换检测”——如果检测到用户输入包含新的订单号、新的人名、新的商品ID,就认为需要切换上下文焦点。代码上,我会给每个对话添加一个topic_id字段:

class SlidingWindowContext: def __init__(self, min_rounds=5, max_rounds=30, compression_threshold=15): self.min_rounds = min_rounds self.max_rounds = max_rounds self.compression_threshold = compression_threshold self.rounds = [] def add(self, user_message, assistant_message, topic_id): self.rounds.append({ "topic_id": topic_id, "user": user_message, "assistant": assistant_message }) self._enforce_window() def _enforce_window(self): # 统计每个topic的轮次 topic_counts = {} for r in self.rounds: topic_counts[r["topic_id"]] = topic_counts.get(r["topic_id"], 0) + 1 # 如果某个话题数量超过阈值,压缩旧话题为摘要(实际工程中调用压缩函数) # 这里简化:把最早超过阈值的话题的轮次标记为“可压缩” current = 0 while len(self.rounds) > self.max_rounds: self.rounds.pop(0)

这个实现相对粗糙,但它表达了核心思路:不是无脑保留最近N轮,而是根据话题分布,把老话题的信息“降级”处理。滑动窗口的好处是,在长对话中它不会丢失关键实体,因为压缩前那段会话已经被代入新的状态。坏处是话题检测本身容易出错,如果检测不准,反而会把重要上下文误判成旧话题。所以滑动窗口适合那种业务场景里“实体特征非常明显”的对话,比如订单号、工单号、案件编号。

2.3 语义压缩模式:让模型自己总结历史

第三种模式是我目前用下来效果最好、但也最费心的一种:语义压缩。思路很简单——当历史轮次接近窗口上限时,不直接丢掉,而是调用模型把旧轮次压缩成一段摘要,再用摘要替代原始内容驻留在上下文里。这样既能保留核心信息,又不占用太多令牌预算。

实现这一段时,我写了一个简单的压缩函数:

def compress_history(history, llm_client): raw_text = "" for turn in history: raw_text += f"用户:{turn['user']}\n助手:{turn['assistant']}\n" prompt = f"""请压缩下面的对话,保留所有关键实体(订单号、人名、时间、金额)、用户明确的目标、以及尚未解决的待办事项。不要加入任何解释,直接输出压缩后的摘要。 对话内容: {raw_text} 压缩摘要:""" summary = llm_client.complete(prompt) return summary

这个摘要生成后,我会把它作为一条“虚拟轮次”放在历史最前面,再配合最近轮次的原始对话。这样模型既能看到概括的整体脉络,也能看到当前回合的精确表达。但注意,压缩摘要有个很大的坑:模型可能漏掉关键实体,尤其是不常见的订单号或长尾名词。我后面会单列一节专门讲怎么处理。

语义压缩模式是“用一次模型调用的成本换取长期记忆”。对于动辄几十上百轮的复杂对话,这个成本非常值得;但如果你的每次对话只有三五轮,完全没必要用它,直接上固定窗口就好。

3. context-mode 的工程落地:数据结构、令牌预算与配置调优

3.1 数据结构设计:deque 够用,但别只用 deque

很多朋友看了上面的代码,可能觉得“就一个 deque 就完事了”。但真实工程落地时,数据结构要复杂得多,因为你还要存储每条消息的时间戳、轮次ID、令牌数、业务实体。我最终维护的数据结构是自定义类,底层用collections.deque做滚动队列,但每个元素都是一个字典:

@dataclass class Turn: sequence: int timestamp: float role: str # "user" 或 "assistant" content: str token_count: int topic_id: str entities: Optional[dict] = None

deque只负责“回收最老的项”这个行为,真正的复杂逻辑都在entity提取和topic_id分配上。我建议所有需要持久化的会话,都单独用数据库或Redis存储完整原始记录,context-mode 只负责构建每次请求的“有效上下文”。千万注意:不要把原始记录和有效上下文混在一个数组里。原始记录是用来追溯和审计的,有效上下文是喂给模型的。一旦混在一起,为了截断而删除原始记录,后续排查问题就没有依据了。

3.2 令牌预算分配:系统提示、历史、当前输入的占比

不管哪种 context-mode,最终都要落到“令牌数不能超过模型上限”。我见过不少新手直接把所有历史拼接后塞给模型,然后收到“token too long”的报错。正确做法是在构建上下文时,先计算各部分令牌占用,再动态裁剪。

我通常用这个分配比例:系统提示词占10%,历史记录+摘要占60%,当前输入占30%。举个例子,模型上限8000个令牌,系统提示词分配800,当前输入2400,历史部分就是4800。历史部分再按“摘要+最近轮次”分配。如果摘要已有800令牌,那最近轮次最多4000令牌。根据这个比例动态决定窗口大小,而不是拍脑袋写死一个轮数。

令牌计算可以自己用本地分词器估算,也可以直接调用模型服务返回的 token usage。为了在发送前就拦截超限,我用一个近似估算函数:

def estimate_tokens(text): # 中文粗略估算:1个中文字符约0.6~1 token;英文按4字符1 token估算 # 这里简化为:len(text) 作为近似值,真实使用应换成分词器 return len(text) // 2 + 1

实际项目中我用官方分词器做了精确计算,避免误差。如果你不想引入额外依赖,也可以用len(text) / 2这个经验值做保守估算。反正原则是“宁少勿多”,一定要留出30%的余量,因为模型回复本身也要消耗令牌,很多接口把“回复的最大令牌数”也算在输入上限里。

3.3 调参建议:先把这些参数固化成配置

把上下文模式的参数抽到配置文件里,是我在第3次重构时才醒悟的事。一开始我把窗口大小写死在代码里,每次调参都要改代码、打包、重启。后来我改成 YAML 配置,所有参数一目了然,还能按场景分开配置:

参数名默认值说明
modefixed_window可选:fixed_window / sliding_window / semantic_compress
max_rounds20固定窗口或滑动窗口的轮次上限
min_rounds5滑动窗口保留的最小轮次
compression_threshold15000历史令牌数达到该值时,触发语义压缩
summary_tokens_limit600压缩摘要的令牌目标上限
topic_switch_keywords[]触发话题切换的实体类型关键词

我强烈建议你把模式做成可运行时切换的,比如从固定窗口切到语义压缩,只需要改配置,不用改代码。一个面向外部的业务系统,可能在上午的客服咨询和下午的数据分析中需要不同模式,动态切换能省掉很多上线成本。

4. 我踩过的坑:上下文漂移、令牌溢出与语义断裂

4.1 上下文漂移:用户换话题,系统还抱着旧状态不放

开头那个订单翻车案例,本质就是上下文漂移。它在窗口模式下依然会出现,因为滑动窗口只按轮次保留,不识别意图转变。用户从“改地址”聊到“查物流”,如果窗口足够大,这两个话题的原始内容会同时出现在上下文里,模型很可能被旧话题带偏。我在真实项目里如何解决?我采用了一个很朴素的“意图边界”机制:在用户消息进入 context-mode 之前,先用一个轻量分类器判断这次输入是否开启了新任务。如果开启了新任务,就把旧任务的轮次降级为摘要,只保留最近两三轮原话,确保模型把注意力集中在新任务上。

举个实际做法。我会在构建上下文前,对用户当前输入做一次规则扫描:如果出现了订单号/工单号/案件编号等实体,并且该实体没有被任何上下文中的实体匹配到,就判定为“新话题开启”。这时调用一次压缩,把旧话题变成摘要。这个方案我用了很久,准确率能达到95%以上。如果你做的业务没有这么强的实体信号,也可以换成语义向量相似度——但成本更高,延迟更大,先用规则是更划算的。

4.2 令牌溢出:一次完整排查链路

有一个周末,客服机器人在下午高峰期突然大量报错,错误全是“request too large”。我刚开始以为是把窗口调大了,排查链路是这样的:

第一步,看监控日志,发现报错集中在13:00~14:00,且每个报错都发生在长会话的后续轮次。第二步,导出一条报错请求的上下文构造日志,发现历史记录居然累计了87轮,总令牌数超过了24000,远超当时模型上限20000。第三步,定位到代码:因为我当时只在“新消息进入时”添加轮次,没有在构建上下文时做令牌裁剪。固定窗口虽然设置了“最多20轮”,但那行deque(maxlen=20)只在add的时候触发淘汰,而历史记录是从数据库读回来的,每次重载时直接全部塞进上下文,根本没有经过窗口队列。修复方法是:在build_context里重新执行窗口裁剪逻辑,确保从任何来源恢复对话历史后,都会先经过 context-mode 的过滤。

这个坑让我总结出一个铁律:窗口裁剪必须发生在“构建上下文的最后一步”,而不是“写入历史的时刻”。因为历史可以从任何地方恢复,只有最后一步是必经之路。

4.3 语义断裂:压缩摘要丢了关键信息

语义压缩模式上线跑了一天,就被用户投诉了。原因是有个用户在第3轮报了一个8位订单号,然后连续聊了20多轮售后,等到第30轮问“那个订单最后换货处理好了吗”,模型回答“请提供订单号”。为什么?因为第20轮时触发了语义压缩,压缩模型生成的摘要里写的是“用户有一笔订单需要换货”,但订单号的原文被漏掉了。这其实不是模型压缩能力不行,是我的压缩提示词没有强调“必须保留订单号”。修复也简单,在压缩提示词里加上强制要求:

必须保留所有8位或以上长度的数字组合、手机号、邮箱、订单号、日期。 如果无法确认某信息的完整性,宁可逐字保留,也不要改写。

同时,我在压缩后加入一步校验:把原始文本中的关键实体提取出来,如果摘要里没出现,就自动把摘要和实体列表拼接在一起,而不是让摘要单独存在。这种方式相当于给摘要加了个“保底索引”,效果比单纯信任模型的总结靠谱得多。

5. 如何为业务场景选择合适的 context-mode

5.1 场景匹配表

不是所有项目都需要语义压缩,也不是任何情况都适合固定窗口。我根据自己的实践,把常见业务场景和推荐模式整理成了下面这张表。

业务场景推荐模式为什么
客服问答(订单/售后)语义压缩 + 引擎校验对话轮次长,实体关键,必须长期保留订单号
代码问答/代码生成滑动窗口需要近期代码片段,但旧代码可以摘要化,聚焦当前文件
公文写作/长文润色固定窗口(较大)上下文集中在当前段落,历史影响小,简单高效
教育辅导/知识问答固定窗口(中等)每轮独立性高,需要保留少量最近轮次即可
多角色扮演场景语义压缩需要长期记住角色设定、关系、重要事件
数据分析/报表咨询滑动窗口 + 会话重置信号用户频繁切换数据集,需要话题检测切换

这个表不是教条,但能帮你少走弯路。核心原则是看两个维度:实体敏感性有多高?对话轮次有多长?如果实体敏感且轮次长,就别用压缩会丢实体的模式;如果轮次很短,直接用固定窗口,省成本。

5.2 渐进式落地路线:先跑通、再优化

我见过太多团队一上来就上语义压缩+向量检索,结果一周都调不通,最后回退到固定窗口。我的建议是三步走。第一步,先用固定窗口模式跑通全流程,窗口大小设为10轮左右,确保没有令牌溢出问题。这个阶段的目标是验证业务逻辑本身。第二步,在用户反馈和日志里找到“因为上下文丢失导致回答错误”的典型案例,针对这些案例把模式切换成滑动窗口。等滑动窗口稳定后,再对比错误率和成本。第三步,只有当你确认那些长对话里需要长期记忆,才引入语义压缩,并且一定要加上前面说的关键实体校验。

我在这个项目里花了三周才走到语义压缩阶段。前两周固定窗口已经解决了80%的对话质量问题,剩下20%的长尾场景用摘要补齐。如果一开始就追求复杂方案,很可能连模型调用的稳定性都没把握住,更别提效果了。做工程,有时候最慢的路才是最快的路。

最后,回到 context-mode 这个词本身。它不是什么新鲜算法,而是把“上下文管理”从“加班调prompt”升级成了“工程化配置”。给上下文一条清晰的模式边界,比让模型自己猜哪句话重要要靠谱得多。我的建议很简单:把这个模式做成配置项,先跑通,再优化,永远别在最开始就追求完美。如果你也想在自己项目里试,建议从固定窗口开始,把数据结构先搭好,后面换模式都是顺手的事。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询