ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

大模型上下文管理实战:三层记忆架构与向量检索

大模型上下文管理实战:三层记忆架构与向量检索 在实际做 AI 应用的时候你有没有遇到过这种情况用户跟机器人聊到第十句它开始忘了前九句说了什么或者你往提示词里硬塞了一堆参考资料结果模型反而抓不住重点答非所问。这些问题归根到底就一个词——context上下文。我在自己的项目里专门做了一个叫 context-mode 的模块来统一解决这件事今天这篇文章就把它从头到尾拆一遍包括设计思路、具体实现、以及一路踩过来的坑。这篇文章适合正在做聊天机器人、智能客服、AI 编程助手或者任何需要长期记忆能力的 AI 产品的同学读。不管你是刚入门的技术新手还是已经跑通基础流程的开发者都可以从里面找到能直接抄作业的方案。我不会只贴代码重点会放在为什么这么设计上因为上下文这个东西方案选错一步后面返工的成本极高。1. context-mode 到底是什么以及它要解决什么问题1.1 三个让开发者头疼的上下文难题先聊一个很现实的问题现在的对话模型虽然有几十万 token 的窗口但多数实际项目为了成本和响应速度用的还是 8K 到 32K 的配置。就算你用的是大窗口模型也不代表把东西全塞进去就是对的。我在实际项目中反复撞见三类问题。第一类是短期失忆。用户在前几轮说过我在用 Python 3.10不要用新特性结果第五轮你让模型生成代码它还是给你写了个match语法。原因很简单你把历史消息截断了或者根本没有做记忆管理模型每次都以为自己在跟一个新人对话。第二类是知识过载。做问答机器人时你会把相关文档片段检索出来拼到 prompt 里。一开始我图省事一次丢进去 20 个片段结果模型开始胡言乱语把不同文档里的矛盾信息混在一起。后来才明白给模型的上下文不是越多越好超过一定量级后质量会断崖式下跌。第三类是成本失控。每次请求所有历史消息加上系统提示词都要重新计算一遍。如果用户聊到100轮你每轮都把100轮的内容全带上token 消耗就是天文数字。我见过一个项目一个月光 API 费用就烧掉几万块查下来发现全是上下文没有做压缩。context-mode 这个模块核心就是一套解决上述问题的上下文治理方案。它要在一个统一框架里管三件事记住什么、丢掉什么、从哪里找回。记住什么指历史对话和用户偏好丢掉什么指过期的、低价值的中间内容从哪里找回指通过检索把长尾知识片段拉回到 prompt 里。1.2 两种主流实现流派我为什么选了混合路线市面上处理上下文的方式大致分两派。一派是纯滑动窗口派只保留最近 N 轮对话实现极其简单但中段信息全丢用户三小时前说过的重要约束直接消失。另一派是纯检索派把历史对话和知识库全部向量化每次请求通过向量相似度找回相关内容问题在于是不是每次都能召回得准我实测过漏召回率相当高尤其是用户提到的具名实体跟历史文本表述不一致的时候。我做 context-mode 时选了混合架构短期对话用滑动窗口保底长期记忆用摘要压缩知识引用靠向量检索三层各管一段互相兜底。这套思路不是我拍脑袋想的而是参考了认知科学里的工作记忆和长期记忆的划分——人类也不是把所有事都记在脑子里短期靠记忆、长期靠整理成笔记、要用的时候去翻资料模型其实也应该这样。2. 核心设计上下文窗口的预算管理与三层记忆架构2.1 先学会给上下文做预算再谈其他我见过太多人直接把提示词拼满导致模型输出被截断。正确做法是先把上下文当作一笔预算来管理。拿 8K 窗口的模型举例我在项目里是这么划分的占位项目预算token说明系统提示词500角色设定、全局规则尽量精简输出保留1000给模型生成回答留足空间动态余量500防止超限导致请求报错对话检索上下文6000真正可用的弹性空间这里的计算逻辑是可用上下文 总窗口 - 系统提示词 - 输出保留 - 动态余量。很多人忽略输出保留结果每次生成到一半就报 context length exceeded实际上不是输入超了而是输入加输出超了。我习惯把输出保留设成最大回答长度的 1.5 倍给长回答留缓冲。算好预算之后context-mode 的调度器每次构造请求前都会执行一次体检当前消息数组预计占用多少 token如果接近 6000就触发压缩或检索替换而不是等爆了再处理。这个预检逻辑是整个模块稳定的基础。2.2 三层记忆架构工作记忆、情景记忆、语义记忆我最终落地的记忆系统分三层每一层负责不同类型的上下文。工作记忆层就是一个滑动窗口保留最近 N 轮对话的原文。N 怎么定我试过 6、8、10、12 轮最后在大多数场景下用 8 轮比较稳。太少了模型容易眼前黑太多了中间内容会稀释注意力。这层的价值是保真——对话原文不丢失短期内的细节都能直接看到。情景记忆层负责对超出窗口的旧对话做摘要。每次滑动窗口溢出时我会把最老的对话交给一个专门的摘要模型处理提炼出用户偏好、未完成任务、关键数字这三类信息压缩成一段 200 token 以内的摘要放进一个持久化的记忆列表里。需要的时候把最近一两条摘要和当前窗口拼在一起。语义记忆层是检索系统负责从知识库和全部历史记录里找回与当前问题相关的长尾信息。无论是产品规格书、用户三个月前提到的偏好还是对话里出现过的某个编号都通过向量检索拉回来。检索结果经过去重和重排序之后以参考资料的形式注入 prompt。三层之间的关系是这样的工作记忆管最近说了什么情景记忆管之前发生过什么事语义记忆管哪里有可用的背景知识。缺了任何一层都会在特定场景下表现出明显的笨。2.3 摘要压缩的触发时机与内容取舍原则摘要压缩不是每轮都做频繁压缩会破坏对话的连贯性还增加延迟。我在 context-mode 里设了两个触发条件一是工作记忆窗口满了二是某一轮对话结束后检测到关键实体或任务状态发生了变化比如用户表示方案改成方案B。压缩的时候取舍比文笔重要。我总结了一套保留和丢弃的标准。必留的信息用户的明确偏好和限制条件、当前进行中的任务或目标、涉及的具体数字和ID、尚未解决的承诺和问题。该丢的信息寒暄客套、与当前任务无关的闲聊、已经过时的临时状态、重复表达过的观点。格式上我固定用用户偏好 / 当前任务 / 待办事项 / 关键数据四段式摘要这样后续拼接时模型更容易理解和定位。3. 实操从零搭建一个可用的 context-mode 模块3.1 技术选型嵌入模型、向量库和框架怎么搭动手之前先把工具选好。嵌入模型我试过text-embedding-3-small、bge-m3等好几个结论是不要盲目追求大模型在大多数中文场景下轻量级嵌入模型的召回效果已经够用。唯一必须注意的点是嵌入模型一旦选定了后期不要随意更换因为向量库里已经存了大量旧向量换模型会导致新旧向量不在同一个语义空间里检索全部失效。这个坑我踩过换完模型之后召回准确率直接掉了三成还得全量重跑索引。向量库方面如果项目规模小直接用内存型的就好如果要上生产建议用支持过滤和持久化的方案。过滤功能特别重要因为 context-mode 的检索经常需要按时间范围、对话 ID、文档来源做条件过滤很多向量库不支持这些只能先召回再过滤既浪费 token 又影响准确率。框架层我没有用重量级的 Agent 框架只自己封装了一个类因为 context-mode 本身逻辑清晰引入太重的东西反而让调试变复杂。如果你后续要加多层工具调用再考虑迁移也不迟。3.2 核心代码实现上下文管理器与检索注入下面这段代码是我项目里 context-mode 的核心调度逻辑我按生产可用的标准写了注释你可以直接参考改造。import json import time from collections import deque from typing import List, Dict, Optional class ContextMode: def __init__(self, max_window_rounds8, budget_tokens6000): self.working_memory deque(maxlenmax_window_rounds * 2) # 工作记忆存原始消息 self.summaries: List[Dict] [] # 情景记忆存摘要按时间排序 self.budget_tokens budget_tokens self.system_prompt 你是辅助助手基于提供的上下文回答。 self._token_estimate_cache {} def add_message(self, role: str, content: str) - None: 写入一条消息并自动触发工作记忆的溢出检查。 self.working_memory.append({role: role, content: content}) if len(self.working_memory) self.working_memory.maxlen: self._summarize_overflow() def _summarize_overflow(self) - None: 把最老的一批消息交给摘要模型压缩成四段式摘要。 overflow_count len(self.working_memory) - self.working_memory.maxlen if overflow_count 0: return old_messages list(self.working_memory)[:overflow_count] # 生产环境中这里调用摘要模型我这里给出 prompt 模板 summary_prompt ( 请将下列对话压缩为结构化摘要包括用户偏好、当前任务、 待办事项、关键数据。不要遗漏数字和ID。\n json.dumps(old_messages, ensure_asciiFalse) ) # summary self.call_llm(summary_prompt) # 实际调用模型 summary self._mock_summary(old_messages) # 演示用 self.summaries.append({time: time.time(), text: summary}) # 移除已被压缩的旧消息 for _ in range(overflow_count): self.working_memory.popleft() def retrieve_context(self, query: str, top_k: int 3) - List[str]: 语义记忆层向量检索相关知识片段。 # 实际项目在这里调用嵌入模型 向量库 # 这里演示返回固定结果 return [相关文档片段1, 相关文档片段2, 相关文档片段3][:top_k] def build_messages(self, query: str) - List[Dict]: 构造最终发送给模型的完整消息序列。 messages [{role: system, content: self.system_prompt}] # 1. 先放摘要层记忆再放工作记忆保证连续性 for s in self.summaries[-2:]: messages.append({role: system, content: f[历史摘要] {s[text]}}) # 2. 工作记忆原文 for msg in self.working_memory: messages.append(msg) # 3. 检索得到的知识片段 retrieved self.retrieve_context(query) if retrieved: knowledge_block \n.join(f- {r} for r in retrieved) messages.append({ role: system, content: f[参考资料]\n{knowledge_block}, }) # 4. 当前用户问题 messages.append({role: user, content: query}) # 预算预检超限则触发压缩或丢弃低价值摘要 if self._estimate_tokens(messages) self.budget_tokens: messages self._trim_to_budget(messages) return messages def _estimate_tokens(self, messages: List[Dict]) - int: 用字符数近似估算 token 数中英文混合场景下误差可控。 total 0 for m in messages: total int(len(m[content]) * 0.7) 5 return total def _trim_to_budget(self, messages: List[Dict]) - List[Dict]: 从摘要列表最旧的开始丢弃直到预算满足。 while self._estimate_tokens(messages) self.budget_tokens and len(messages) 2: for i, m in enumerate(messages): if m.get(role) system and 历史摘要 in m.get(content, ): messages.pop(i) break else: break return messages这段代码的核心思路是构造消息时按系统提示词 → 历史摘要 → 工作记忆 → 参考资料 → 当前问题的顺序拼接。这里有个容易被忽视的细节参考资料必须放在用户问题之前而且是 system 角色这样模型会把它们当作权威背景来使用如果你把它们放在用户问题之后作为 user 消息模型很容易当成普通对话内容权重完全不同。3.3 检索注入的三个关键参数分块、召回数量、重排序检索质量决定了 context-mode 的上限。我踩过的坑很多最后沉淀出三个参数经验值分享给你参考。第一是分块大小。文档分块太小语义碎片化太大信息密度被稀释。我做了对照实验512 到 768 个字符的分块在大多数业务文档上效果最稳。分块之间加 10% 重叠避免关键句子正好卡在边界被切断。这里有个更关键的操作分块时尽量按段落边界切而不是无脑按字符切这样能保住语义完整性。第二是召回数量。在 8K 窗口里top_k 设为 3 到 5 比较合适。我在早期测试里试过 20 个片段全塞进去生成质量反而大幅下降因为不同片段之间存在事实冲突模型根本无法判断该信谁。宁可少而精不要多而杂。如果你发现需要召回大量内容才能回答说明知识库的索引结构该调整了而不是继续加数量。第三是重排序。单纯靠向量相似度召回前几个结果经常不是最匹配的。我在 context-mode 里加了一道轻量重排序用一个交叉编码器对召回结果做精排把最相关的 2 到 3 个片段放到 prompt 的最前面。这个操作最少能提升 5 到 10 个百分点的答案准确率值得做。4. 常见问题与排查技巧实录4.1 模型失忆问题排查先查消息构造顺序如果你发现模型经常忘记用户早期的设定先别急着换更大的模型。我遇到过好多次最后排查下来全是消息顺序的问题——历史摘要插在了系统提示词前面模型把摘要当成了优先级更高的指令反而覆盖了系统提示词里的角色设定。排查方法很简单把build_messages()产出的完整消息列表打出来肉眼检查顺序是否符合系统提示词在最前、且只出现一次的要求。我还做过一个工具函数给每条消息打上来源标签[system]、[summary]、[memory]、[knowledge]输出到日志里一眼就能看出拼接逻辑哪步错了。这个习惯帮我省了无数排查时间。另外要注意工作记忆窗口的 pop 顺序。用deque(maxlenN)时新消息进来会自动把最老的挤掉但如果你混用了popleft和pop有可能丢弃顺序错乱。我代码里统一只在溢出时用popleft而且必须和摘要压缩联动别的路径不允许直接操作队列。4.2 检索内容注入后回答质量反而下降怎么办这种情况我碰到过两次原因非常典型召回了一批相似度高但实际无关的信息。比如用户问退货政策向量检索召回了所有包含退货两个字的文档片段其中一半是运营手册里关于内部退货流程的内容对用户毫无价值。模型把这些无关内容也当作权威参考答案自然就偏了。解决方案有两个。一个是按来源和类型过滤在向量库里存储元数据检索时根据当前场景过滤掉不符合来源条件的片段。另一个是重排序时加入置信度阈值对得分低于阈值的片段直接丢弃宁缺毋滥。阈值怎么定我在不同业务上测过一般设置在 0.35 到 0.45 之间按相似度分数归一化到 0-1低于这个值召回回来的东西大概率是噪声。还有一种情况是lost in the middle——模型对 prompt 中间的内容记忆最差这是注意力机制的特性。所以我在build_messages()里把最重要的知识片段放在参考资料块的最前面让它们尽量靠近用户问题避开中间位置。4.3 token 成本暴涨的元凶重复前缀和失效缓存做了一段时间以后我去看 API 账单发现成本比预期高很多。查了半天罪魁祸首是两个一是对话轮次增加后历史消息越来越多每一轮都把之前的全部内容重新算一遍二是缓存命中率太低因为每条请求的 prompt 前缀都在变化插入了不同的检索片段缓存形同虚设。我的解法是给 context-mode 加了前缀稳定策略。所有检索片段统一放在消息序列的固定位置并且在片段之间用固定格式的分隔符区分。这样只要摘要和历史摘要没有变化消息前缀就是稳定的可以命中服务的 prompt 缓存大幅降低重复计算的成本。实测下来长会话场景的成本能压缩 30% 到 50%。另外_estimate_tokens里的估算方式我也优化过中英文混合场景下用字符数 × 0.7估算 token 数比单纯按字符数准确又比调用 tokenizer 快得多。不要在生产环境的每次请求上都跑一遍完整 tokenizer那本身也是一笔耗时开销尤其在高并发时。4.4 调试上下文问题的三板斧最后分享一套我一直在用的调试方法论不只是看报错而是直接看模型到底看到了什么。第一板斧是完整消息日志——把每次请求的消息数组完整落盘出问题时直接复现不要靠猜。第二板斧是检索内容回放——把检索出来的片段和它们的相似度分数、来源存储记录一起记录这样能判断是检索环节错了还是生成环节错了。第三板斧是最小化复现——出问题时把上下文一层层剥掉先只保留工作记忆跑一次再加摘要再加检索找到引发问题的变量。这套调试方式帮我定位过不少诡异的问题。有一次模型突然开始说英文查了半天发现是某次检索回来的知识片段里混进了一段英文技术资料模型被带跑了。如果没有检索回放日志这种问题根本没办法追溯。经验总结与一点个人体会context-mode 这种模块说难不难说简单也不简单。说它不难是因为核心逻辑拢共就那么几块滑动窗口、摘要压缩、向量检索、消息拼接任何一块单独拎出来都有大量现成的实现可以参考。说它不简单是因为把这几块正确地组合在一起并且让它们在长周期、高流量的真实场景里稳定运行需要大量细节上的打磨。我个人在这个项目里最大的体会是上下文管理不是加容量的问题而是做判断的问题。你不可能也无须把所有信息都塞给模型关键是想清楚每一轮对话里哪些上下文真正影响这一次的回答质量然后有取舍地去构建 prompt。判断标准只有一条——如果你是这个模型看到这段上下文能不能准确回答当前问题。拿着这个标准去逐条检查你拼出来的消息序列八成的问题都能提前发现。做应用层的 AI 开发与其追着模型的能力上限跑不如先把手头的上下文管理做扎实。
返回列表