news 2026/9/11 11:03:46

大模型上下文管理实战:滑动窗口+摘要检索解决记忆难题

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
大模型上下文管理实战:滑动窗口+摘要检索解决记忆难题

去年我做了一个内部AI客服项目,上线第一周就翻车了——用户多问几个来回,聊天机器人就开始胡言乱语,要么把前面聊过的车架号忘了,要么把之前改好的订单地址又改回去。我翻了半天代码,发现原因很朴素:上下文窗口就这么大,历史消息一长,前面的内容就被暴力截断了。后来我专门抽了两周时间,把一个叫 context-mode 的上下文管理模块搭了起来,才算把这个问题按住。这套东西说白了就是一套给大模型做“记忆调度”的中间层,核心是解决一件事:在上下文窗口有限的前提下,怎么让模型同时记得住、找得到、用得上。下面我把我当时的思路、代码和踩坑记录整理出来,适合正在做聊天机器人、RAG知识库或者AI Agent的开发者参考。

1. 为什么需要一套“上下文模式”

1.1 一个让我重构的翻车现场

先说翻车现场。当时我们接的是32K上下文窗口的模型,最开始的做法就是所有对话历史一把梭地拼进Prompt里,逻辑简单,代码也简单。但实际跑起来,问题一个接一个:一是成本肉眼可见地涨,用户聊满十分钟,每次请求都要把全部历史重发一遍,token开销成倍翻;二是模型表现极其不稳定,当历史超过十个来回,它开始“选择性失聪”,偶尔把最新需求忽略,偶尔又拿两个月前的一句玩笑当指令执行。

这个现象其实不奇怪,业界叫它 lost in the middle,模型对长文本中间部分的关注度明显低于开头和结尾。你把用户最新的一句话放在Prompt最后,模型当然看得到,但中间夹着几十轮历史,它很可能抓错重点。我当时的第一个念头不是优化代码,而是重新想清楚:我们要的到底是一个“能记住所有话的聊天窗口”,还是一个“知道该记什么该忘什么”的记忆系统?后一个,就是我说的 context-mode。

1.2 上下文模式的定位:不是单次请求,而是链路

想清楚定位之后,整个设计就变了。context-mode 不是一个Prompt模板,也不只是一段截断逻辑,而是一条完整的数据链路。用大白话描述就是:用户的话进来之后,先做意图判断和敏感信息过滤,然后去记忆系统里检索相关的历史知识,再把检索结果、最近对话摘要、系统指令和当前问题按优先级拼装成一个精简但信息完整的Prompt,等模型输出完之后,异步地把新对话沉淀回记忆库,该更新的更新,该压缩的压缩。

它分三层:第一层是即时对话上下文,也就是最近几轮原始消息,保证模型对当前话题不跑偏;第二层是工作记忆,就是自动生成的摘要和关键信息条目,负责承接中短期记忆;第三层是长期知识库,用向量检索承载,负责把用户以前问过的相似问题、项目背景资料捞回来。三层各干各的活,互不干扰,这是整套方案能稳定工作的基础。

2. 关键设计:如何分配有限的上下文窗口

2.1 先给上下文算一笔账

设计 context-mode 之前,我干了一件很笨但很有效的事:把token预算当成钱一样记账。以32K窗口为例,输出必须要留出空间,一般按8K预留,不然模型生成到一半被截断,用户体验极其糟糕;系统指令和套话固定占掉3-4K,这部分雷打不动;剩下大概20K才是真正能塞动态内容的地方。20K看着不小,但中文token消耗本来就快,一段2000字的纪要可能就吃掉3000 token。如果不做管理,聊十分钟就爆。

所以我在代码里写死了比例:动态内容里,最近对话最多占40%,摘要占20%,检索结果占15%,剩余留白给容错。这个比例不是拍脑袋拍出来的,而是跑了大量离线测试之后收敛出来的,后面会有参数说明。你完全可以根据自己的模型和场景去调,但建议先按这个框架把每一块的上限钉死,而不是让消息一路堆到Prompt末尾。

2.2 三种上下文管理模式

调到最实用的层面,我把 context-mode 拆成了三种运行模式,对应不同使用场景。第一种是全量模式,适合代码评审、长文档分析这类一次性任务,要求模型看全所有材料,不设摘要和截断,但只在明确触发时启用;第二种是滑动窗口模式,适合普通客服和闲聊,只保留最近N轮原始消息,更早的内容被自动摘要替代,窗口始终保持收支平衡;第三种是摘要加检索模式,适合需要长期记忆的个人助理或Agent,滑动窗口保留近期,向量库负责捞中长期信息,两条线并行。

三种模式各有各的代价,我用一个表格对比过:

模式适合场景内存与费用实现难度典型上下文占用
全量模式长文档、法律文本、代码库分析简单20K-28K
滑动窗口客服、闲聊、任务型对话中等6K-10K
摘要+检索Agent、个人助理、知识库问答中高较高8K-14K

2.3 选择策略背后的“为什么”

为什么我不推荐一直使用全量模式?这里面有三个很现实的原因。一是成本,重复发送全量历史等于每次调用都按最贵的价格付费,聊天类场景一天几万次请求,费用差异是量级性的。二是效果,我刚才提到过 lost in the middle,当内容一多,模型抓重点的能力显著下降,你费力把历史塞满,结果它反而漏掉关键指令,这属于花钱买差评。三是速度,Prompt越长首字延迟越高,用户等不起。

那滑动窗口是不是最优解?也不是。它牺牲了长期记忆,用户三天前交代过的事情,如果没人提,模型就真的忘了。所以对于真正需要记忆的产品,我建议直接上摘要加检索模式,虽然实现复杂度最高,但它是唯一能同时兼顾短期连贯性和长期记忆的方案。

3. 实操:从零写一个上下文管理器

3.1 项目结构与基础模型封装

下面这部分是代码实操。我用Python写了一个最小可用的 ContextManager,结构非常简单,但骨架是完整的,你可以直接抄回去扩展。先看项目结构:

context_mode/ ├── __init__.py ├── manager.py # 核心上下文管理器 ├── bucket.py # token预算控制 ├── memory.py # 向量记忆检索 ├── summarizer.py # 摘要生成 └── config.py # 参数配置

核心思路是:manager对外只暴露 add_user_message 和 build_prompt 两个方法,其余逻辑全部内聚在内部。这样UI层不用关心上下文细节,只管把用户的话丢进来,拿组装好的Prompt去调用模型。这里我建议所有模型调用都走统一的OpenAI兼容接口,方便以后换模型厂商。基础封装的复杂度不在于接口,而在于token计算必须准确,所以memory和bucket都依赖 tiktoken 来做tokenize。

3.2 实现滑动窗口与自动摘要

滑动窗口是 context-mode 里最基础也最关键的一环。我实现的方式是:维护一个双端队列保存最近消息,每次新增消息后检查总token数,超过阈值就触发摘要任务,把窗口内最旧的一段消息压成摘要,塞进memory模块的短时区,然后从队列里清除。这个设计不需要临时去截字符串,也不会把用户一句话从中间切断,因为摘要的粒度是按整条消息来的。

import tiktoken class SlidingWindow: def __init__(self, max_tokens=8000, summary_ratio=0.2): self.encoder = tiktoken.get_encoding("cl100k_base") self.max_tokens = max_tokens self.summary_ratio = summary_ratio self.history = [] # [(role, content)] self.summary = "" # 自动摘要 def _count(self, text: str) -> int: return len(self.encoder.encode(text)) def _total(self) -> int: return sum(self._count(msg[1]) for msg in self.history) + self._count(self.summary) def add(self, role: str, content: str): self.history.append((role, content)) self._maybe_compact() def _maybe_compact(self): if self._total() <= self.max_tokens: return budget = int(self.max_tokens * self.summary_ratio) removed = [] used = 0 while self.history and used < budget: msg = self.history.pop(0) removed.append(msg) used += self._count(msg[1]) if removed: text = "\n".join(f"{r}: {c}" for r, c in removed) self.summary = summarize(text)

注意几个细节。第一个是摘要触发时机,我建议在新增消息后检查,而不是在请求前检查,这样可以避免模型调用前临时压缩造成的延迟。第二个是budget的计算,不是简单地把超出的部分全砍掉,而是按固定比例留出压缩空间,剩下的交由摘要器决定保留什么。第三个是 summarize 函数,你可以接任意LLM,我会在后面单独讲摘要提示词的写法。

3.3 加入向量检索,让“模式”有长期记忆

滑动窗口解决的是短时记忆,向量检索解决的是长期记忆。这里我用最轻量级的方案演示:embedding用OpenAI接口,向量存储先用一个简单的numpy数组加json元数据,够了;生产环境再升级到FAISS或pgvector。检索的流程是:用户消息进来后,先生成query向量,然后在记忆库里做相似度搜索,返回top-k相关记录,这些记录会拼进Prompt的历史区。

import numpy as np class VectorMemory: def __init__(self, embed_fn): self.embed_fn = embed_fn self.items = [] # [{text, vec}] def add(self, text: str): vec = self.embed_fn(text) self.items.append({"text": text, "vec": np.array(vec)}) def search(self, query: str, top_k: int = 5): q = np.array(self.embed_fn(query)) scored = [] for item in self.items: score = self._cosine(q, item["vec"]) scored.append((score, item["text"])) scored.sort(reverse=True) return [text for _, text in scored[:top_k]] @staticmethod def _cosine(a, b): return float(a @ b / (np.linalg.norm(a) * np.linalg.norm(b)))

这个Demo看起来简单,但要注意两点:一是embedding的文本需要和检索时的query保持同一粒度,用户问题短,记忆条目也应该短,我通常在写入时会把长对话切成小块再embed,而不是把一大段丢进去;二是向量记忆不是越多越好,成千上万条不相关的记忆会让检索噪声变大,必须配合定期清理或按时间衰减。我实际项目里,会把超过一定时限且从未被命中的记忆标记为可清理。

3.4 把上下文模式接进Prompt模板

最后一步是把所有模块组装成Prompt。模板我用的是经典的四个区块:系统指令区、长期记忆区、近期对话区、当前问题区。顺序很重要,不可乱排。系统指令固定在最前,用来设定角色和输出规则;长期记忆区放检索结果和摘要,让模型拿到背景;近期对话区放最近的原始消息,让模型保持语气和话题连贯;当前问题放在最后,也是注意力最强的地方,保证它优先响应你要它做的事。

def build_prompt(self, user_input: str): memory_hits = self.memory.search(user_input, top_k=self.top_k) blocks = [] blocks.append("system: " + self.system_prompt) blocks.append("memory:") blocks.extend(["- " + item for item in memory_hits]) blocks.append("recent:") blocks.extend([f"{r}: {c}" for r, c in self.history]) blocks.append("question:") blocks.append(f"user: {user_input}") return "\n".join(blocks)

组装出来的效果大概长这样:

system: 你是XX业务的AI助手,请基于提供的历史和业务规则回答用户问题。 memory: - 用户偏好:习惯用简体中文,喜欢结论先行的回答方式 - 3天前用户咨询过退款流程,结果已标记为处理中 recent: user: 之前说的退款现在到哪一步了? assistant: 我帮你查一下,请稍等。 question: user: 好的,麻烦快点。

这里要提醒一个很多人会踩的坑:检索出的记忆如果和最近对话自相矛盾,以哪一个为准?我的策略是,在memory区块加一行生成时间标签,比如3天前、刚刚,并在提示词里明确写“如果记忆信息与近期对话冲突,以近期对话为准”。否则模型可能会拿着过期记忆一本正经地瞎解释。

3.5 摘要提示词与记忆写入策略

摘要提示词是整个 context-mode 里最容易偷懒也最不能偷懒的部分。我一开始用的是通用提示词,比如“请总结这段对话”,结果就是模型把对话变成了一段枯燥的流水账,丢失了大量有用的实体信息。后来我改成结构化模板,明确要求输出三个部分:人物和关键实体、用户诉求、当前状态。这相当于让摘要器按信息类型分类归档,后续检索的时候也非常容易命中。

summarize_prompt = """ 请对以下对话进行结构化摘要,输出三部分: 1. 关键实体:包括用户ID、订单号、手机号、地址等(没有的写无) 2. 用户诉求:用户想要解决的问题或目标 3. 当前状态:该问题目前处理到哪一步,有什么结果 对话内容: {text} """

在写回记忆库之前,我会把这三部分拼接成一段自然语言,同时把原始的对话引用ID带上。这样即使摘要写错,也能顺藤摸瓜找回原文。另外要提醒的是,摘要生成时的模型温度一定要调低,我固定在0.1,避免模型在压缩事实类信息时自己脑补。

4. 性能、成本与调优记录

4.1 延迟和费用的平衡点

模块写完以后,我用同一组测试数据把三种模式跑了一遍,结果还挺有意思。

模式平均上下文token单次调用费用(相对值)p50首字延迟
全量模式21000100%580ms
滑动窗口650042%340ms
摘要+检索820050%380ms

滑动窗口模式直接省了一半多的token,延迟也掉了40%。摘要加检索模式只比滑动窗口略贵,但保留了长期的记忆能力。如果你的产品没有强记忆需求,直接上滑动窗口就够了;如果有,摘要加检索带来的额外成本是值得花的。

4.2 三个值得调的参数

第一个参数是窗口大小,它决定模式的个性。窗口越大,短期记忆越强,但成本和延迟也跟着涨;我建议以3到5轮对话为下限,低于这个值聊天气氛会显得很笨。第二个是摘要触发的比例,之前代码里是0.2,意思是压缩后留下的摘要最多占窗口的20%。这个值太大会丢细节,太小又会频繁压缩导致性能下降。第三个是向量检索的top_k,我测试下来5到8是甜点区,超过10之后,模型容易被多条相似但无关的记忆带偏。

4.3 实测对比:同样的问题,三种模式的效果差异

用同一个场景举例:用户周一问过退货政策,周五又问“我之前那个退货申请现在卡在哪”。全量模式如果历史没超限,表现稳定但成本高;滑动窗口模式到了周五一早就把周一的对话挤出窗口,只能回答“我没有找到相关记录”,体验翻车;摘要加检索模式在检索区捞回了周一的对话摘要,能正确识别用户说的是哪件事,并且续上状态。效果差异一目了然。性能调优不是玄学,核心就一句话:在满足业务效果的前提下,尽量少给模型塞无关信息。

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

5.1 问题速查表

下面是我在开发和上线期间遇到过得比较多的五类问题,整理成速查表。

现象问题原因解决办法
模型漏掉最新指令历史塞太满或模板顺序问题调整模板顺序,把当前问题放最后;收紧窗口
记忆里出现自相矛盾没有给记忆加时间标签写入时记录时间,提示词里声明近期优先
摘要之后重要信息丢失摘要温度太高或摘要范围过大降低摘要LLM温度,限制单次摘要条数
向量检索捞回一堆无关内容存储粒度太粗切块存储,清理过期记忆,调低top_k
多用户共用同一记忆库导致串话没有按会话隔离记忆条目标记用户ID,查询时强制过滤

5.2 排查思路:先看日志,再看数据

context-mode 这类模块最难查的问题是“偶发性失忆”,因为它不像报错那样有明确堆栈。我自己的排查习惯是两步走。第一步看日志,每次调用模型的Prompt全文必须落盘,光记录token数没用,要把组装后的完整Prompt存下来,出问题当场复盘就能定位是哪一块没给到位。第二步看记忆库,把当次检索命中的记忆条目dump出来,对比实际输入,大概率能发现到底是没有写入、没有检索到,还是检索到了但被截断了。这套思路救了我很多次。

另外,强烈建议在管理后台做一个Prompt预览功能,哪怕是自己内部用,能看到每次请求实际喂给模型的是什么,排查效率直接翻倍。很多时候你以为模型“变笨了”,其实是上一轮摘要把关键字段丢了,或者检索时把别的用户的订单信息拉了过来。

5.3 我踩过的三个坑

第一个坑是没区分全局记忆和局部记忆。一开始我把用户ID和订单号都塞进向量库,结果不同用户的信息在检索时互相干扰,追问了一圈才发现是embedding时把用户标识一起嵌进去了。解决办法是加一层元数据过滤,检索之前先按用户ID过滤向量集合。

第二个坑是摘要生成时温度设太高,摘要结果开始“创作”,把订单状态从处理中写成了已退款,差点造成客诉。后来摘要生成直接固定 temperature=0.1,并且把摘要历史也纳入核对。

第三个坑是流式输出时的token估算错误,前端拿到一半内容就断流,排查半天是因为我用的是 encode 的长度估算,但有些特殊字符在流式拼接时被重复计数。流式场景下我建议用模型的 usage 回传数据校准,而不是自己估。

6. 后续可以怎么扩展

这个模块做到能稳定上线之后,我还有几个方向上想继续做,先说给同样在搞 context-mode 的同行参考。一是把记忆库从JSON文件换成真正的向量数据库,比如 pgvector 或者 Milvus,这样能在数据量变大之后保持检索性能。二是加一个“重要信息自动标注”模块,让系统在对话中主动识别需要长期记住的信息,比如用户新换的手机号、常去的地点,而不是只靠全文embedding。三是给摘要加版本管理和回滚,每次摘要都记录原始消息的引用ID,一旦发现问题能定位到源头。

这些扩展有没有价值,取决于你的业务场景,但 context-mode 最核心的价值不是某一项技术,而是让你在设计对话系统时,从一开始就把记忆当成一等公民去考虑。

最后分享一个小技巧:不管用哪种模式,都要在Prompt里用自然语言给模型说明这套记忆规则,而不是只靠字段顺序硬压。比如在系统指令里加一句“你可以参考记忆区的内容,但必须以用户最新的问题为核心”,模型的配合度会高很多。我做 context-mode 最大的体会是,给大模型做上下文管理,本质上是在做信息的取舍和重构,而不是一味地往回拼文字。先算清预算,再设计分层,最后把细节抠清楚,这个系统就能稳定陪你上线很久。

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

万卡集群的隐形老板:GPU调度器如何决定训练效率

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

作者头像 李华
网站建设 2026/9/11 11:02:22

行车记录仪前后双录选购指南:分辨率、夜视与停车监控全解析

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

作者头像 李华
网站建设 2026/9/11 11:00:31

django+vue构建在线继续教育系统:从模型设计到部署全解析

1. 继续教育系统的核心业务与功能拆解在线继续教育系统这个题目&#xff0c;乍一看只是个普通的管理系统&#xff0c;但真上手做的时候你会发现&#xff0c;它比一般的电商后台或资讯站要复杂得多。继续教育本身有一套完整的业务闭环&#xff1a;学员注册、选课报名、在线学习、…

作者头像 李华
网站建设 2026/9/11 11:00:05

Simulink建模:多能源协同调频系统设计与优化

1. 多能源调频系统概述在电力系统频率调节领域&#xff0c;多能源协同调频已成为现代电网稳定运行的关键技术。传统电力系统主要依赖火电机组进行频率调节&#xff0c;但随着新能源渗透率的不断提高&#xff0c;风电、光伏等波动性电源的大规模并网给系统频率稳定带来了新的挑战…

作者头像 李华