news 2026/9/11 11:38:15

多轮对话上下文不丢?context-mode实践:滚动摘要+滑动窗口+轻量检索

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
多轮对话上下文不丢?context-mode实践:滚动摘要+滑动窗口+轻量检索

前阵子我在打磨一个基于大模型的对话应用,最头疼的还真不是模型选型,而是上下文这块。聊到第8轮,用户前面明明说过的偏好就“蒸发”了;聊到第20轮,模型甚至开始一本正经地编造前面根本没出现过的东西。后来我干脆自己动手写了一个可插拔的context-mode模块,专门接管多轮对话里的“记忆”和“取用”逻辑。这篇文章就是把这个模块的设计思路、实现细节和踩坑过程完整复盘一遍。如果你也在做聊天机器人、RAG问答或者Agent任务编排,遇到“上下文总是丢”“历史太长成本爆炸”这类问题,这篇应该能给你一套直接能落地的参考方案。

1. context-mode到底解决什么问题

1.1 三个每天都在踩的坑

先说说我为什么要单独做一个context-mode,而不是直接无脑把历史消息全塞给模型。第一个坑是上下文溢出。现在很多开源模型还是8K上下文窗口,你做个客服机器人,用户一天内反复进来问问题,累积几十轮对话,硬塞全量历史根本不现实,token直接爆掉。有人第一反应是把消息从前往后截断,但往往截掉的恰好是最重要的早期用户需求。

第二个坑是无关上下文干扰。多轮对话里有大量寒暄、确认、重复信息,这些跟当前问题完全无关。如果全部塞给模型,等于让它在噪音里找信号。模型再强也会被无关指令带偏,轻则回答变得啰嗦,重则干脆忽略真实意图。第三个坑是成本不可控。每多转发一轮历史,都是实打实的token开销,长会话场景下成本随轮次线性增长,产品还没上线,API账单先扛不住了。

这就像开会的时候,你不可能每场会都把过去三个月的所有会议纪要一字不差念一遍。有经验的会议主持人是先提炼决议、明确待办,遇到争议时再翻出与该议题相关的旧记录。context-mode就是给模型配了这样一个“会议助理”,按需取用,而不是全量搬运。

1.2 三种模式对应三种场景

我最初想的很简单,给context-mode做两种模式:一种是只带短期记忆,一种是全量加摘要。但实际用下来发现,不同业务场景对上下文的需求差异实在太大了,所以最后定成了三种内部工作模式。

模式携带内容优势劣势适用场景
short-term只带最近N轮原文延迟低、成本低、实现简单无法处理跨轮长期任务闲聊、快问快答、一次性问答
long-term全量历史+滚动摘要信息完整,很少丢细节token占用高,无关信息多需要严格审计的长对话、人工复盘
hybrid最近N轮原文+窗口前摘要+按需检索兼顾近期细节与长期信息实现复杂度最高,需要调参客服、助理、Agent等绝大多数场景

我最后的生产环境全部跑的是hybrid模式。它的核心思路是:最近的对话保留原文,因为模型回答需要“临场感”,用户上一句说了什么,机器人必须逐字看到;更早的历史压成摘要,因为那部分只需要“大概发生过什么”;当摘要里信息不够时,再从原始消息里检索相关片段补进去。这套组合下来,既不会丢关键信息,也不会让历史无限膨胀。

1.3 这套方案的适用范围与扩展边界

context-mode这个名字听起来像只解决聊天机器人的问题,实际上它的适用范围要比这宽得多。RAG问答系统里,用户的“对话历史”本身就是一个上下文源,query加历史再走检索,效果比单查一次好很多,而历史部分完全可以用同样的模式管理。Agent任务编排里,一次任务可能拆成多步工具调用,每一步的中间结果其实就是“上下文”,如果不做压缩,一个复杂任务的上下文能冲到几万token。

我目前只在纯文本对话里做了验证,但数据结构上特意留了扩展位,后续可以塞图片描述、工具结果、结构化表单。唯一要注意的是,跨会话长期记忆这个方向,单靠context-mode还不够,需要把摘要向量化之后存到向量数据库,等检索的时候再召回。我会在文章结尾再提一下这个演进思路。

2. 核心细节与实现设计

2.1 先把数据模型定清楚

写代码之前我纠结了很久,要不要直接复用现成的LangChain memory组件?后来放弃了,因为那些组件的抽象层次太高,出问题不好排查。我决定自己定义一套简单的数据结构,所有上下文逻辑都挂在这个结构上。

{ "session_id": "sess_abc123", "mode": "hybrid", "global_summary": "用户是电商运营,正在策划618大促,偏好简洁回复,已确认使用A方案", "messages": [ { "role": "user", "content": "帮我看看这个活动的转化率", "ts": 1717000000000 }, { "role": "assistant", "content": "当前转化率是3.2%,相比上周提升0.5个百分点。", "ts": 1717000001000 } ], "token_budget": 8000 }

session_id是一切上下文操作的锚点,绝对不能省。mode字段记录当前会话用哪种策略,方便在同一个产品里对不同用户群体做实验。global_summary就是滚动摘要,messages只保留窗口内的近期消息,token_budget是这次请求允许占用的上限。

为什么不把摘要和消息塞在一个数组里?因为它们是两种完全不同的东西:摘要是“压缩产物”,不允许修改和删除消息内容;消息是“原始证据”,需要保持原样。混在一起会导致后续逻辑判断非常麻烦。

2.2 摘要压缩是怎么做的

摘要的核心不是让模型“总结一下”,而是让模型“提取关键事实”。我踩过的最大一个坑是,最初用了一个很泛的提示词:请总结这段对话。结果模型把用户随口说的“我今天心情不好”也当成重要信息保留,把真正关键的业务数据漏掉了。后来我把摘要模板改得非常具体。

你是对话摘要器。请将以下对话压缩成要点式摘要,只保留四类信息: 1. 用户的明确目标或需求 2. 关键约束条件(时间、预算、偏好) 3. 已经确认的决策或结论 4. 尚未完成的待办事项 不要添加对话中不存在的内容,不要猜测用户意图,数字和专有名词必须原样保留。

摘要生成也不能每次都从头开始。假设窗口是8轮,第9轮来了,如果每次都对前8轮重新摘要,摘要还好说;如果是第50轮,历史累积了42轮,你不可能让模型每次看42轮原文。所以必须做滚动摘要:把上一轮生成的旧摘要和新进入窗口的消息拼在一起,再让模型生成新的摘要。

滚动摘要有个衍生问题:摘要会越滚越短,因为旧摘要已经是压缩过的,再压缩一次细节就没了。我的处理方式是给滚动摘要加一个“分层”策略:每次滚动时,保留最近两次的旧摘要片段,而不是只保留最新一个。这样虽然多占一点token,但能保证关键数据不会在一次滚动中被彻底抹掉。

2.3 滑动窗口的大小怎么定

窗口大小是context-mode里最关键的参数,调不好整个系统就废了。很多人直接拍脑袋定个“最近10轮”,这在生产环境是不行的,因为每轮消息长度差异很大,有时用户一句话只有几个字,有时粘贴了一大段日志。

我的做法是先做token估算,再反推窗口大小。比如我用的模型上下文是8K,系统提示占500 token,工具定义占800 token,本次用户输入预计占1000 token,回答预留1200 token,那么历史部分最多可以占4500 token。如果一条消息平均250 token,窗口理论可以放18轮,但如果还要放一份1500 token的摘要,窗口就只能压到12轮:12乘以250等于3000,加摘要1500正好4500。

实际代码里我不会写死窗口轮数,而是一个动态计算函数:每次请求前先数一下现有消息总token,如果超过预算,就从最老的消息开始弹出,弹到预算以内;如果窗口已经很小还是超,就触发一次摘要压缩。这个“先算预算再定窗口”的思路,比固定一个数字稳健很多。

2.4 关键信息检索:让“旧记忆”在需要时出现

摘要最大的问题是信息粒度太粗。用户10轮前提过一个报价,摘要里可能只剩一句“用户提到过报价相关事宜”,但当用户现在问“之前那个报价单上的含税价是多少”时,这句摘要完全不够用。解决办法是把窗口之前的历史消息切成小块,做一层轻量检索。

我第一版用的是向量检索,接了个embedding接口,效果确实不错,但延迟和成本都上来了。后来发现一个性价比更高的方案:用关键词重叠度做粗筛。先把当前用户问题分词,提取出名词和数字,然后跟历史消息做倒排匹配,挑出重叠度最高的Top-K条原文,注入到prompt里。对大多数业务场景,这种轻量级检索的效果已经够用,而且延迟可以忽略不计。

如果对话量特别大,我还是建议上向量库,但要注意给每条历史消息打时间戳,检索结果按时间倒序排列,避免模型把旧消息当成新消息理解。

3. 实操过程与核心环节实现

3.1 先算清楚上下文预算

动手写核心逻辑前,我强烈建议先把预算公式列出来,不然很容易写出一个“在本地测试没问题、一上线就爆token”的代码。我的计算公式是这样的:

历史可用预算 = 模型上下文窗口 - 系统提示词token - 工具定义token - 用户当前输入token - 回答预留token

模型上下文窗口是硬上限,系统提示词和工具定义基本固定,用户当前输入每次不同,需要单独估算,回答预留token是给模型生成答案留的空间,不能省,否则输出会被截断。举例来说,一个8K窗口的模型,系统提示500、工具定义800、用户输入1000、回答预留1200,历史最多只能分到4500 token。这个4500就是context-mode所有压缩和窗口动作的基准线。

这里有三个容易忽略的地方。第一,回答预留要按业务场景调,如果是代码生成类任务,输出经常很长,预留就得从1200提到2000以上。第二,系统提示词可能在运行时动态变化,比如用户选择了不同的角色设定,这时不能用缓存,必须实时计算。第三,一些模型的tokenizer不是按字符数线性换算的,特别是中文夹杂英文和数字时,最稳妥的方法是直接调用模型的tokenizer离线统计,而不是用简单的“字符数乘系数”估算。

3.2 核心代码实现

我把context-mode的核心逻辑封装成了一个类,主要暴露两个方法:一个是put,用来写入一条新消息并触发压缩策略;一个是build_context,用来生成最终拼进prompt的上下文内容。为了演示方便,下面是我的简化版实现。

import json from typing import List, Dict, Optional class ContextManager: def __init__(self, model_ctx=8192, window=8, summary_limit=1200, reserved_reply=1200, system_tokens=500, tool_tokens=800): self.model_ctx = model_ctx self.window = window self.summary_limit = summary_limit self.reserved_reply = reserved_reply self.system_tokens = system_tokens self.tool_tokens = tool_tokens def estimate_tokens(self, text: str) -> int: # 简化估算:中文场景约1.5 token/字,英文约1 token/4字符 # 生产环境建议直接用模型tokenizer离线统计 return int(len(text) * 1.5) if any('\u4e00' <= c <= '\u9fff' for c in text[:20]) \ else max(1, len(text) // 4) def historical_budget(self, user_input: str) -> int: input_tokens = self.estimate_tokens(user_input) return self.model_ctx - self.system_tokens - self.tool_tokens - input_tokens - self.reserved_reply def build_context(self, session: Dict) -> Dict: messages = session.get("messages", []) summary = session.get("global_summary", "") budget = self.historical_budget(session.get("current_input", "")) # 第一步:优先保留最近window轮原文 recent = messages[-self.window:] if len(messages) > self.window else messages[:] older = messages[:-self.window] if len(messages) > self.window else [] # 第二步:老消息进入滚动摘要 if older: summary = self._roll_summary(summary, older, self.summary_limit) # 第三步:检查是否超预算,超了就继续压缩 while (self.estimate_tokens(json.dumps(recent, ensure_ascii=False)) + self.estimate_tokens(summary)) > budget: if len(recent) <= 2: break moved = recent.pop(0) summary = self._roll_summary(summary, [moved], self.summary_limit) return { "summary": summary, "recent_messages": recent, "token_usage": { "summary": self.estimate_tokens(summary), "recent": self.estimate_tokens(json.dumps(recent, ensure_ascii=False)), "budget": budget } } def _roll_summary(self, old_summary: str, messages: List[Dict], limit: int) -> str: # 实际场景这里调用LLM,配合前面提到的摘要提示词 # 简化示例:直接拼接,生产环境请替换为真实LLM调用 raw = old_summary + "\n" + json.dumps(messages, ensure_ascii=False) if self.estimate_tokens(raw) > limit: raw = raw[-limit * 4:] # 粗略截断,仅作示例 return raw

这段代码有几个设计要点。第一,build_context返回的是结构化的summary和recent_messages,而不是直接拼好的prompt字符串,这样后续还可以决定是加进system还是加进user消息。第二,超预算时优先从recent的最前面弹出,这保证了离当前问题越近的消息越完整,深层逻辑还是“近期比远期重要”。第三,_roll_summary里给LLM调用留了位置,实际项目里这就是摘要生成函数,上面那段截断只是占位。

3.3 接入聊天循环和工具调用

单独封装一个类之后,接入聊天主循环就很简单了。每来一条新消息,先把它存进session的messages数组,再调用build_context拿到摘要和近期消息,拼出最终请求发给模型。

def chat(session, user_input): session["messages"].append({"role": "user", "content": user_input}) ctx = ctx_manager.build_context(session) prompt = [] if ctx["summary"]: prompt.append({"role": "system", "content": f"以下为更早对话的摘要,仅作背景参考:\n{ctx['summary']}"}) prompt.extend(ctx["recent_messages"]) prompt.append({"role": "user", "content": user_input}) # 调用模型,省略具体请求代码 reply = call_llm(prompt) session["messages"].append({"role": "assistant", "content": reply}) return reply

工具调用场景比普通聊天要复杂一些,因为工具的返回结果也可能是“上文”的一部分。比如一个查询天气的工具返回了一大段JSON,如果不做处理,这些JSON会全部留在messages里,很快就把窗口占满。我的做法是:每次工具返回后,不把它作为完整消息存进历史,而是先压缩成一行摘要,比如“get_weather工具返回:北京晴,25度,湿度40%”。这样既保留了关键信息,又不会让原始JSON污染上下文。

3.4 测试效果对比

我把同一个测试集分别跑在无context-mode、short-term和hybrid三种配置下,用50轮超长对话做对比,结果差别非常明显。

配置记住用户早期偏好回答上下文准确率平均token消耗/轮
无context-mode(只保留最近5轮)基本遗忘62%1.2K
short-term(最近8轮原文)7轮后开始遗忘71%2.5K
hybrid(窗口8轮+摘要+轻量检索)50轮后仍能准确引用88%3.1K

token消耗方面,hybrid虽然没有short-term省,但考虑到准确率提升的幅度,这个成本完全是值得的。如果接入轻量检索后,用户主动问“我之前说的那件事”时,准确率还能再往上拉几个点。对于追求极致成本的项目,可以直接用short-term模式,但前提是用户对“机器人记不住事”的容忍度要高。

4. 常见问题与排查技巧实录

4.1 摘要越压越“失真”

我第一次跑通context-mode后,发现模型偶尔会说出用户根本没提过的细节,排查半天,根因在滚动摘要。旧摘要第一次生成是对的,第二次滚动压缩时,模型为了“连贯”,自己脑补了一些可能的内容,第三次再压,脑补的内容就被当成了事实。解决方法是两管齐下:一是严格限制摘要提示词,明确写“这是已有事实的压缩,不是创作”;二是给摘要体增加元信息,比如在摘要末尾附上一行“本摘要生成于{时间戳},基于前{数量}轮消息”。一旦发现模型引用了可疑细节,就能快速定位是哪一次滚动产生的问题。

另外一个技巧是:对金额、日期、人名、订单号这类硬性数据,在摘要里必须单独列出来,不要混在长句子里。因为长句子压缩时,模型很可能把次要细节丢掉。单独列一行“关键数据:报价含税价1500元/件”之后,即使其他文字被压缩,核心数据仍然能保留。

4.2 多会话上下文串扰

这个问题在我的多租户产品里出现过一次:A用户问了一句“我上次说的那个报价”,结果B用户的消息被拼了进来。查了半天发现是session_id没有严格从请求头里取,而是用了服务端默认值。修复方式很简单,所有context-mode操作都显式传入session_id,不能依赖默认参数。生产环境我把session信息存在Redis里,key模式是context:{session_id},并且给每条消息都带ts时间戳,即使读出来也能按时间重新排序,避免并发写入导致顺序错乱。

排查串扰时的经验是:先看请求日志里的session_id和消息条数是否匹配,再对比上下文拼装后的最终prompt。建议开发环境里把build_context的输出直接打印出来,一出现串扰,从prompt内容一眼就能看出是哪条消息混进去了。

4.3 token超限导致请求报错

即使有了预算公式和窗口动态压缩,还是会出现超限的情况。最常见的原因有两个:一是系统提示词或工具定义在运行时被动态修改了,实际token比预估的多;二是用户当前输入特别长,比如粘贴了一整段代码或长文档,直接吃掉了大部分预算。我的处理方式是在build_context末尾加一道“安全阀”:如果计算出recent和summary的总token仍然大于剩余预算,就忽略窗口轮数限制,直接从最老的消息开始截断。宁可这次对话少一点历史,也不能让整个请求失败。

还有一个细节:有些模型对超限是返回错误,有些是静默截断,后者更隐蔽,因为不会报错,但模型会漏掉中间一段内容。所以我在build_context返回的token_usage里加了日志,每次请求都会记录summary和recent各自的token数。上线观察几天后,基本就能摸清业务里用户输入长度的分布,再针对性地调整系统提示词的精简程度。

4.4 性能与成本优化

滚动摘要每次都要调用一次LLM,如果用户消息频繁,成本会明显上升。我的优化办法是把摘要触发改成“攒批”模式:不是每新增一条消息就立刻摘要,而是消息数超过窗口之后,每满5条新消息才触发一次滚动摘要。这样在长对话中,摘要调用次数能减少70%左右,而近期消息窗口本身已经覆盖了那几条尚未摘要的消息,所以效果几乎不受影响。

如果项目对延迟更敏感,还可以把摘要生成做成异步任务:build_context同时返回“可用上下文”和“待更新摘要标记”,模型正常回复,后台线程再去更新摘要。这样用户完全感知不到摘要等待时间,代价是代码复杂度稍微高了一点。

4.5 问题排查速查表

我把实际运行中比较容易遇到的问题整理成了一个速查表,方便你直接对标排查。

现象可能原因解决方案
模型引用了用户未提过的信息滚动摘要过程中模型进行了推断严格摘要提示词,禁止添加对话外信息
关键数字被漏掉摘要压缩粒度太粗,数据混在长句中单独保留关键数据列表,不参与压缩
A用户消息出现在B用户对话中session_id未隔离或使用了默认值所有操作显式传入session_id,Redis按用户隔离
请求偶尔超限报错用户输入长度波动大,预算计算滞后增加安全阀,强制截断最老消息
摘要生成延迟高每轮消息都触发了滚动摘要改为攒批触发或异步更新
模型把摘要当成了当前原文摘要和近期消息没有区分标识在摘要前加“以下为更早对话的摘要”提示
检索到的历史消息太旧,与当前问题矛盾检索结果未按时间排序对检索结果按ts倒序排列,必要时加时间标签

我个人实际操作下来的体会是:上下文管理没有银弹,hybrid模式只是“工程上最稳”的起点。你完全可以根据自己的业务去调窗口大小、摘要频率和检索策略,但有两个原则一定要守住:一是把当前对话的细节和长期记忆分开对待,二是给token预算留够安全余量。最后分享一个小技巧:在把摘要注入prompt时,我习惯把它放在最近窗口消息之前,并且在摘要前加一行“以下为更早对话的摘要”,模型对“摘要”与“原文”的区分非常敏感,这个细节实测下来能明显减少张冠李戴的情况。如果后面有时间,我打算再把摘要向量化接入长期记忆,让context-mode支持跨会话联想,到时再写一篇续集。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/9/11 11:37:59

云翼企服:扎根北京的本地企业注册服务机构

创业第一步往往是注册公司。对很多第一次开公司的朋友来说&#xff0c;核名、经营范围、注册地址、银行开户这些流程看着简单&#xff0c;真办起来处处是细节。今天这篇把「云翼企服」这个本地服务机构一次说清楚——我们是谁、能帮你办什么、怎么收费、在哪能找到我们。 一、我…

作者头像 李华
网站建设 2026/9/11 11:37:35

重建人与钱的关系:可落地的商业关系操作系统

1. 这不是“学营销”&#xff0c;而是重建你和钱的关系“marketingskills”这个词最近在招聘平台、自由职业接单站、甚至小红书知识博主的标题里高频闪现&#xff0c;但它绝不是教你怎么写朋友圈文案、怎么投信息流广告的速成课。我带过37个从零起步转行做私域运营的学员&#…

作者头像 李华
网站建设 2026/9/11 11:37:23

企业如何用继续预训练(CPT)打造行业大模型?实战指南

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华