最近把“context-mode”这个模块从头到尾重构了一遍,从方案调研到上线压测,前前后后折腾了快一个月,总算把多轮对话里最让人头疼的“记不住事”和“记错事”的问题压下去了。
简单说,context-mode是我在某智能客服产品里做的一个上下文管理模块,核心职责只有一句话:在用户和机器人一来一回的多轮对话中,把该记住的记牢,把不该记的丢掉,让模型每次回复时都能看到一份干净、聚焦、不超预算的上下文。
这应该是很多做对话机器人、AI客服、智能助手、RAG问答系统的朋友都会撞上的坎:模型单轮理解能力没问题,但一上多轮就露馅——用户上一句说的订单号,这一句就忘了;用户换了个话题再切回来,机器人直接断片;更气人的是,偶尔还会把A客户的上下文串到B客户头上。如果你也正在被这些问题折磨,这篇文章值得往下看。
我会把整个context-mode的拆解思路、核心参数计算、三层上下文结构、可落地的代码骨架,以及上线后真实踩过的几个坑,全部摊开来写。有基础的朋友可以直接抄作业,刚入门的朋友也能从设计逻辑里理清头绪。
1. context-mode是什么:从痛点出发的设计拆解
1.1 我遇到的真实痛点:多轮对话“失忆”三宗罪
做这个模块之前,我手上的客服机器人已经在线上跑了大半年,用户反馈里出现频率最高的三句话分别是:“刚不是说了吗”“你又忘了”“我说的是昨天那件事”。总结下来就是三个问题:
第一,短期记忆不可靠。模型本质上没有真正的“记忆”,它每一轮看到的上下文都是我们拼进提示词的文本。如果只把最近一两轮对话拼进去,用户稍微绕一点,前面的关键信息就丢了。比如用户说“我要退掉昨天买的那双42码的鞋”,下一轮问“要多久能到账”,如果模型不记得“退鞋”这个动作和“42码”这个约束,回答就会变成泛泛的“退款一般1-3个工作日”,甚至反问“您要退什么”。
第二,长期对话成本爆炸。对话轮数一多,如果把所有历史全部拼进提示词,Token消耗会直线上升。一台云服务器的账单先不谈,首字延迟也会肉眼可见地翻倍。我测过一次20轮的对话,原文全量拼进去,提示词直接干到6000多Token,响应时间从200毫秒涨到了接近2秒,用户体验完全不可接受。
第三,会话隔离存在漏洞。上下文串台是比“失忆”更可怕的问题。早期版本里因为Session Key设计得不够严谨,出现过A用户问“那件黑色的还有货吗”,系统竟然用B用户的历史上下文去理解,结果推荐了一堆完全不相关的东西。这种问题一旦出现,用户基本就流失了。
所以说,单纯“把历史都塞进去”不是方案,甚至很多时候连“多塞一点”都做不到。我们需要的是一个带管理能力的上下文模块,这就是context-mode立项的起点。
1.2 方案选型对比:为什么最终定了“槽位+缓存+摘要”三层
立项之后,团队内部对比了三种主流思路:
第一种,全量原文缓存。实现最简单,把每一轮对话按时间顺序存起来,构造提示词时全量拼接。优点是信息无损,缺点是Token爆炸快、费用高、延迟高,而且无关历史越多,模型越容易被带偏。这种方案只适合短对话场景(比如单轮问答或两三轮的轻交互),不适合客服这种动辄十几二十轮的重交互。
第二种,纯摘要压缩。每过几轮就把历史对话丢给模型生成一段摘要,提示词里只保留摘要。优点是Token可控,缺点是摘要会丢细节。我做过实测,第三轮摘要还能记住订单号,第九轮摘要就已经把“42码”和“未拆封”这种关键属性忘了,模型开始一本正经地推荐换货流程,其实用户要的是退款。纯摘要适合“话题稳定、信息密度低”的场景,但客服场景里很多关键信息恰恰是以细节形式出现的,丢了就出大事。
第三种,槽位抽取+滑动窗口+增量摘要,也就是最终采用的方案。核心思路是把上下文拆成三个层次:第一层是从对话中实时抽取的结构化槽位(订单号、商品、尺码、意图、状态等),这是“必须记住的事实”;第二层是最近N轮的原文缓存,这是“最近说了什么”,保证模型能理解最新语境和指代;第三层是历史摘要,对超过窗口的早期内容做压缩,保证“更早发生过什么”的大背景不丢。
这个方案的优势在于分层分工:事实交给槽位,细节交给原文窗口,背景交给摘要,各管一段,互不干扰。实际跑下来,上下文准确率从原来的不到70%提到了91%,Token开销比全量方案省了大概60%。
方案对比可以看下面这张表:
| 方案 | 信息保真度 | Token开销 | 延迟表现 | 适用场景 |
|---|---|---|---|---|
| 全量原文缓存 | 最高 | 最高,随轮数线性增长 | 轮数越多越慢 | 短对话、强上下文依赖极长的场景 |
| 纯摘要压缩 | 低,关键细节易丢 | 低,相对可控 | 较好 | 话题固定、信息密度低的闲聊 |
| 槽位+原文窗口+增量摘要 | 高,关键信息显式保留 | 中,可控 | 稳定 | 客服、销售、助理类重交互场景 |
1.3 整体架构与数据流
context-mode的完整调用链路可以这样理解:用户消息进来,先做会话归属判断,找到或创建对应的Session;随后进入信息抽取层,用一个小模型或者规则解析把消息里的结构化信息更新到槽位;再进入原文窗口,把这一轮内容追加到最近N轮的缓存里;每次构造提示词时,按照“系统指令—槽位快照—历史摘要—最近原文”的顺序拼接;最后,如果窗口长度或Token数触发了压缩条件,就调用一次增量摘要合成,把早期内容折叠进摘要里。
这条链路上有两个容易被忽略的设计点:一是信息抽取和对话回复解耦,抽取失败也不影响回复主流程;二是压缩动作是异步的,至少不应该阻塞用户当前这一轮的响应。我在1.0版本里犯过错误,把压缩放在同步路径里,结果每触发一次压缩,用户就要多等1秒多,后来改成异步甚至空闲时预热,体感一下子就正常了。
2. 核心细节解析与实操要点
2.1 上下文窗口与Token预算的计算方法
context-mode里最核心的参数不是模型本身,而是“这段对话到底允许多大窗口”。窗口定太大,成本和延迟兜不住;定太小,槽位和摘要又装不下必要信息。我习惯用一套固定的预算拆分公式来计算。
以我自己线上用的配置为例:底层模型上下文窗口是8000 Token,但我们并不打算真的用满。系统指令平时固定占用约400 Token,这部分包括角色设定、回复规则、安全限制;槽位快照占用约120 Token,通常包含十几个字段的JSON,比如订单号、商品名、规格、价格、意图、客户等级等;最近原文窗口保留最近5轮,每轮按150 Token估算,合计约750 Token;历史摘要预留200 Token。
这样算下来固定部分大约是1470 Token。如果希望单轮回复有足够的生成空间,再预留1500到2000 Token给模型输出,那么整个提示词的完整预算大约在3500到4000 Token之间比较合理。超过这个值,就需要触发压缩。
实际落地时还要考虑一个容易翻车的细节:Token估算不能完全信赖字符数除以3之类的粗暴算法,中英文混排时误差很大。我建议在代码里接入与线上模型一致的分词器做估算,而且估算值要留20%的冗余。原因很简单,压缩是异步的,从“触发压缩”到“压缩完成”之间,用户可能又发了一两轮消息,这期间的Token量会临时膨胀,不留冗余就会出现“压缩还没跑完,窗口已经撑爆”的尴尬局面。
Token预算计算参考表:
| 组成部分 | 估算Token | 说明 |
|---|---|---|
| 系统指令 | 400 | 角色、行为规则、安全边界 |
| 槽位快照 | 120 | 结构化业务信息,JSON序列化 |
| 最近原文窗口 | 750 | 最近5轮,每轮约150 |
| 历史摘要 | 200 | 早期对话压缩后固定上限 |
| 预留输出 | 1500-2000 | 保证生成空间 |
| 冗余缓冲 | 20% | 应对压缩期间的临时膨胀 |
| 合计 | 约3500-4000 | 底线,不超过窗口一半 |
2.2 槽位抽取与更新规则
槽位是context-mode的记忆锚点,设计得好的槽位,能让模型在对话跳来跳去时依然抓住主线。
第一步是定义槽位Schema,这一步一定要结合具体业务来,宁缺毋滥。拿客服场景举例,真正值得长期记住的不是“用户今天心情不错”这种情绪,而是类似customer_name、order_id、product_name、specification、quantity、price、user_intent、order_status、return_reason、consent_status这样的事实型字段。字段越多,抽取成功的概率越低,噪声也越多,所以我会控制核心槽位在10到15个左右。
第二步是更新规则。槽位更新我遵循三条原则:一是覆盖更新,新的值一旦确认就直接覆盖旧值,因为老订单处理完了之后,用户可能开始聊新订单,保留旧值会造成混淆;二是非空更新,抽取模型返回空值时不改动原槽位;三是否定词保护,这一步最容易踩坑。用户说“我不要黑色的了”,如果抽取模型只提取了“黑色”,槽位就会误更新成“要黑色”,整个上下文就反了。我处理的方式是在抽取层同时输出“属性值”和“否定标记”,更新槽位时如果否定标记为真,要么置为待确认状态,要么保留上一轮确认过的值,并在槽位快照里显式标注“用户已否定”。
另外还有一个加分项:槽位来源追踪。每个槽位更新时记录下来源轮次的对话序号,这样一旦后续发现上下文有误,可以回溯到具体是第几轮抽取错了,而不是对着整个Session日志大海捞针。
槽位抽取的Prompt模板我也放出来供参考,实际项目里用的是小参数模型跑这个任务,不占用主模型资源:
你是一个信息抽取助手。请从用户最新发言中抽取以下指标: order_id, product_name, specification, quantity, price, user_intent, order_status, return_reason, consent_status。 要求: 1. 只抽取本次发言中显式出现或可直接推断的信息; 2. 如果发言包含否定词,请在对应字段前增加negated标记; 3. 未提到的字段返回null,不要编造; 4. 输出格式为JSON。2.3 摘要压缩的触发与保护机制
摘要压缩是控制Token预算的关键,但也是实现得最不理想的部分。很多团队做出来的摘要就等于“越压越糊”,关键信息被平均掉了。我的做法是“增量更新”而非“全量重写”,而且摘要只看压缩,不碰槽位层。
触发条件设计成双阈值:原文缓存轮数超过N(我设置为8到10轮),或者当前Token估算超过了预算阈值的75%。不管哪个条件先到,都触发一次异步压缩。
压缩的输入是“上一版摘要+最近新产生的若干轮原文”,输出是一版新的摘要。这样摘要信息是逐步累加更新的,不会因为某一次压缩丢掉了之前的细节。压缩前先把槽位快照单独存一份,压缩完成后做一次槽位与摘要的一致性比对——重点检查订单号、规格、数量、意图这些关键字段是否还在摘要里,如果缺失则强制回补。
在做压缩的Prompt时,我特意强调“保留否定和冲突信息”。这是实测得来的教训,否则模型会把“用户不要红色”压缩成“用户考虑红色”,后果极其严重。
你是一个对话上下文压缩器。 请根据已有摘要和新增对话片段,生成一段更新后的正文摘要。 要求: 1. 保留业务实体的当前状态:订单编号、商品、规格、价格、意图、审批进度; 2. 保留否定、退换、时间冲突等关键限定,不得遗漏; 3. 丢弃寒暄、重复、与业务无关的闲谈; 4. 摘要不超过300字; 5. 直接输出摘要文本,不要加解释。3. 实操过程与核心环节实现
3.1 代码结构与ContextManager核心类
把设计落到代码,我建议把所有上下文逻辑收敛到一个名为ContextManager的类里,对外只暴露四个方法:add_message、get_prompt、reset_session、get_snapshot。这样做的好处是上下文逻辑完全隔离开,线上主服务不需要关心槽位怎么抽取、摘要什么时候压缩,只需要调用接口。
下面是一个可以在项目里直接改的骨架代码:
class ContextManager: def __init__(self, config): self.session_store = {} self.max_recent_rounds = config["max_recent_rounds"] self.max_window_tokens = config["max_window_tokens"] self.trigger_ratio = config["trigger_ratio"] self.summary_max_tokens = config["summary_max_tokens"] self.tokenizer = config["tokenizer"] self.llm_client = config["llm_client"] self.slot_schema = config["slot_schema"] self.reserved_prompt_tokens = config.get("reserved_prompt_tokens", 600) def add_message(self, session_id, role, content): session = self._get_or_create(session_id) session["recent"].append({"role": role, "content": content, "ts": time.time()}) session["token_estimate"] += self._estimate_tokens(content) self._update_slots(session, content) if self._should_trigger_compress(session): self._trigger_compress(session) return session["slots"] def _estimate_tokens(self, text): # 使用与线上模型一致的分词器,避免估算偏差 return len(self.tokenizer.encode(text)) * 1.2 def _should_trigger_compress(self, session): recent_num = len(session["recent"]) budget_used = session["token_estimate"] / self.max_window_tokens return recent_num >= self.max_recent_rounds or budget_used >= self.trigger_ratio def _update_slots(self, session, content): extracted = self.llm_client.extract(self.slot_schema, content) for key, value in extracted.items(): if value is None or value == "": continue if isinstance(value, dict) and value.get("negated"): session["slots_need_confirm"].append(key) else: session["slots"][key] = value session["slot_source"][key] = len(session["recent"]) def _trigger_compress(self, session): new_summary = self.llm_client.compress( session["summary"], session["recent"], max_tokens=self.summary_max_tokens ) if not self._validate_summary(new_summary, session["slots"]): # 摘要里丢了关键槽位信息,回退到上一版摘要并告警 return session["summary"] = new_summary session["recent"] = [] session["token_estimate"] = self._estimate_tokens(new_summary) + \ self._estimate_tokens(json.dumps(session["slots"])) def build_prompt(self, session_id, system_instruction): session = self.session_store[session_id] slots_block = self._format_slots(session["slots"]) recent_block = self._format_recent(session["recent"]) summary_block = session["summary"] prompt = f"{system_instruction}\n\n[当前业务上下文]\n{slots_block}\n\n[历史摘要]\n{summary_block}\n\n[最近对话]\n{recent_block}" return prompt def get_snapshot(self, session_id): return self.session_store[session_id]代码里几个值得注意的点:_should_trigger_compress同时看轮数和Token占比两个指标,任何一方超标都会触发;_validate_summary是压缩后的保护阀门,如果摘要与槽位关键字段不一致就回退;build_prompt按“指令—槽位—摘要—原文”顺序拼接,让模型优先看到结构化事实,再看到历史和细节,这个顺序本身就能显著提升上下文利用率。
3.2 配置化与可观测性设计
工程上的进阶要求是配置化。不要把所有阈值写死在代码里,而是放到配置文件里,让运营和算法同学可以随时调整。我会把这些参数做成一个YAML配置:
context_mode: max_recent_rounds: 5 max_window_tokens: 4000 trigger_ratio: 0.75 summary_max_tokens: 300 reserved_prompt_tokens: 600 slot_schema: [...] enable_async_compress: true log_snapshot: true可观测性是这个模块上线后能不能快速排障的关键。我在每次调用的上下文中都生成一个snapshot,记录session_id、request_id、当前槽位、最近原文、摘要、Token估算值、是否触发压缩;这些快照统一写入日志或时序数据。线上排查时,只要拿到一个request_id,就能完整回放出“模型当时看到的上下文”,而不是靠猜。
这个点起初我忽略过。1.0版本上线后,模型总在个别会话里答非所问,但没有快照日志,完全不知道模型看到的是什么,后来加上快照回放,才发现是某几轮槽位被错误更新成了空值,问题一分钟就定位了。所以这一节我要着重强调:没有可观测性的记忆系统,就是一坨无法debug的黑箱。
3.3 压测与效果验收
项目上线前,我设计了专门的压测集,覆盖客服场景的8类高频任务:订单查询、退款申请、换货咨询、投诉升级、物流催单、发票申请、会员权益、预约改期。每个场景准备6到12轮对话,总共约60条测试用例。每一条都有标准答案,重点考察三类指标:
第一,上下文准确率:关键槽位信息在后续对话中是否被正确使用。比如用户在第2轮说了“42码”,第8轮问“那我能换成43吗”,模型应当理解这是同一双鞋的尺码变更。
第二,Token消耗:对比全量拼接方案,看压缩比例是否达标。我的验收标准是长对话场景Token节省不低于50%。
第三,响应延迟:压测20轮以上长对话场景,P95首字延迟必须控制在1秒以内。
实测结果:上下文准确率91%,Token消耗比全量方案节省62%,20轮长对话P95延迟约650毫秒。这个结果说明三层结构在效果和成本之间取得了平衡。
验收指标参考表:
| 指标 | 目标值 | 说明 |
|---|---|---|
| 上下文准确率 | ≥85% | 槽位信息在后续答复中被正确引用 |
| Token节省 | ≥50% | 对比全量原文拼接方案 |
| P95首字延迟 | ≤1s | 20轮以上长对话场景 |
| 摘要回补触发率 | ≤5% | 摘要验证失败后强制回补的比例 |
4. 常见问题与排查技巧实录
4.1 上下文串台:客户的“昨天买的鞋”跑到了另一个会话里
线上问题中,最严重的是串台。现象是用户A问“那件黑色的还有货吗”,系统答了完全不相关的内容,追溯下来发现模型使用了用户B的历史上下文。
排查方法分两步。第一步查Session Key的生成逻辑。我遇到的根因就是Session Key用客户端传入的会话号直接拼接,没有加用户维度校验,两个用户在某些边缘情况下会共用同一个会话号。修复方式是改成“用户ID+会话号+时间戳”的三元组合,服务端统一生成下发。
第二步查缓存淘汰策略。即使Session管理没问题,如果集群里Session淘汰策略不当,A的上下文被错误转嫁给了B,也会出串台。我们最终在Redis里增加namespace,并按用户维度做物理隔离,A和B的数据结构上就不在同一个键空间。
4.2 Token突然溢出:长文本会话撑爆提示词
客服场景偶尔会遇到用户直接粘贴一大段文字,比如订单截图里的OCR文本、仓库物流的异常说明,一段就能顶几千Token。这时候就算有压缩机制,也来不及兜底,因为压缩是异步的,当前轮的窗口已经超了。
我的解法是加一个硬上限保护:构造提示词前检查Token总量,如果超过上限,先用保留的槽位和摘要生成回复,本轮原文窗口只截取最后两轮,多余部分标记“本段过长已截断”。虽然会牺牲部分细节,但至少模型不会直接报错或幻觉。
另外,考虑到用户粘贴的长文本经常包含大量无信息量字符,我在预处理层加了文本清洗,连续重复符号、空行、无意义换行全部合并,实测能把这类输入的体积砍掉25%左右。
4.3 指代消解失效:用户说“那个东西”系统答非所问
“那个东西”这种指代,模型自己往往能理解,但前提是提示词里确实有上一轮的内容。如果压缩触发得太激进,原文窗口被清空,留给模型的只剩摘要,指代就变得无根可循。
我调整了两处。一是压缩触发后保留最近至少2轮原文,不让窗口彻底清空;二是在槽位层增加一个“指代映射表”,当用户消息中出现代词时,先把最近几轮中出现过的实体名映射好,显式注入提示词。比如“那个东西”映射为“那双42码的运动鞋”,模型就再也不会猜错。
4.4 延迟劣化:上下文太长导致首字延迟飙升
压测时发现一个隐蔽的坑:虽然压缩能控制上下文长度,但系统指令、槽位、摘要、原文四块拼起来之后,每次请求都要重新拼接和重新编码,尤其是KV Cache无法命中时,延迟会明显恶化。
优化了三个地方:第一,构建好的Prompt按session缓存起来,只有新消息到达时才失效重建,避免同一轮内多次重复编码;第二,摘要和槽位块的序列化结果做持久化缓存,减少反复JSON序列化的开销;第三,接入KV Cache复用,因为Prompt的公共前缀在会话中是基本稳定的,只要前缀不变,这部分计算可以完全跳过。优化前20轮对话的P95延迟约1.5秒,优化后压到650毫秒。
5. 经验总结与个人体会
做context-mode这一趟,我个人最深的体会是:上下文管理不要指望模型“自觉记住”,而是要从系统层面把“记忆”拆成不同的存储介质——该结构化的用槽位、该保留细节的用原文窗口、该压缩背景的用摘要。三权分立,各司其职,才能既控成本又保效果。
另一个值得反复强调的是可观测性。给上下文模块做日志和快照,一开始觉得是额外工作量,后来成为排障的最强武器。没有快照,遇到模型答非所问只能是“重启试试”;有了快照,拿到request_id一查,“模型当时看到了什么”清清楚楚,问题根因再也不是玄学。
最后分享一个小技巧:上线前,拿一批真实对话把每一轮的快照打出来人工过一遍,重点看槽位是否有错更新、摘要是否丢了否定词。这一步非常耗时间,但也是最值得投入的环节——因为context-mode的绝大多数坑,都会在这个人工检查阶段暴露出来,而不是留给线上用户去发现。
这个上下文模式的设计思路,也不只适合客服场景。销售跟单、企业IM助手、教育辅导机器人,只要是多轮交互为主的产品,都可以复用这套分层结构。关键就是记住一句话:结构化的进槽位,近期的进窗口,久远的进摘要。