☰
AI-Memory:为智能体打造持久记忆层,突破上下文窗口瓶颈
2026/9/26 13:13:04 网站建设 项目流程

当我把一个装配了优秀基座模型的运营助手接进真实的客户服务工单时,被问到最频繁的不是“你怎么还不懂这道题”,而是“你上次答应过我的事呢”。这是一个典型的记忆问题——模型能力很强,但它在对话窗口之外什么都不剩。有同行认为“把更多历史拼进上下文”就是记忆,我试过,结果只是把下次请求的延迟和账单一起拉高。真正改变项目走向的,是一套面向智能体应用的持久记忆层,我把它叫作ai-memory:将对话中产生的关键信息提炼成画像、事实、偏好与约束规则,用向量索引存储起来,在每次对话前按需检索注入。这篇文章完整记录我从零搭建这套系统的过程,以及生产环境里踩过的坑和走通后的取舍。

适合谁看:被上下文窗口瓶颈困扰的智能体开发者、想把RAG升级为“会记住你”的记忆组件的同学、以及打算自己搭一套离线记忆服务的技术负责人。

1. 为什么智能体越做越像“金鱼”,以及记忆系统到底在记什么

我最初接手的是一个内部知识库问答助手,初期效果不错——回答准确、引用规范。但客户换了一种用法:把它当成长期项目助理,今天讨论需求,明天补充数据口径,一周后要求它“按上次说好的逻辑出报表”。这个时候问题集中爆发了:助手完全不记得之前确认过什么,每次都重新问一遍,甚至给出和上周结论相反的方案。用户容忍度迅速下降,而研发团队还在不断加长上下文窗口。

1.1 “把全部历史塞进提示词”为什么是一条死路

很多团队的第一反应是:把聊天记录全部转成文本,拼到 system prompt 里。这个方案在数据量小的时候勉强能用,但稍微放大就会崩。我算过一笔账:一次跨两周的复杂项目协作,聊天记录累计超过 200k token。就算模型支持 128k 或 200k 的窗口,实际生产环境出于成本和延迟考虑,通常会限定在 40k 到 60k 以内。全量塞入意味着:

  • 每次请求都要重新编码一遍完整历史,首字延迟从 1 秒变成 5 秒以上;
  • token 费用成倍上涨,一个每天 5000 次请求的助手,仅上下文费用就接近全部算力成本的一半;
  • 关键是长文本里真正有用的信息密度极低,模型注意力会被大量寒暄和噪音稀释,反而丢失关键结论。

此外还有工程层面的隐患:上下文达到上限后,要么截断早期信息,要么触发滑动窗口策略。后者听起来合理,但实际是——如果业务逻辑依赖一条“上周确认的口径”,它被滑出去之后,模型完全不知道,不会主动去查询,也不会承认自己忘了。这就是“金鱼现象”的根源:模型没有记忆,只有一段正在滑动播放的录像带。

1.2 记忆系统应该记什么,不记什么

所以 ai-memory 的第一版设计,我先做了记什么和不记什么的边界定义。不是所有对话都值得沉淀,也不该把摘要做得又长又全。经过多轮验证,我把记忆内容划分为四类:

类型示例存储形式
用户画像用户是市场部同事,负责投放预算属性键值,可覆盖更新
事实结论上周确认使用“按点击量分摊成本”口径带时间戳的命题文本
偏好与约束报告里不要出现英文缩写,图表用柱状图约束规则,带优先级
事件轨迹已跟进过的工单编号、曾给出的建议结构化索引,可追溯

这四类信息有一个共同特征:它们不依赖对话上下文也能独立被理解,且对后续决策有稳定影响。相反,随口寒暄、临时数据明细、情绪表达,都不进入长期记忆。一开始我也舍不得丢,后来发现这些噪音会直接污染检索质量,让向量检索召回一些看似相似但毫无价值的片段。所以清理是必要的,记忆系统不是黑匣子录音机,它更像人的长期记忆——只留下对行为有持续影响的部分。

1.3 对“遗忘”也要做设计

明确了记什么之后,我突然意识到一个反直觉的点:遗忘机制和记忆机制同等重要。真实场景里,用户的偏好会变,项目口径会调整,曾经正确的结论在新背景下可能不再成立。如果记忆系统只写不改,就会变成一本过时的档案册,AI 引用的都是错误前提。

因此 ai-memory 从架构层面设计了“可修改、可失效、可淘汰”的机制。每条记忆都不作为不可变事实直接落库,而是保留一个生命周期状态;当新的对话信息与旧记忆冲突时,不是简单覆盖旧记录,而是触发一次“冲突裁决”。这个细节给后期生产稳定性帮了大忙,后面第 5 部分会完整展开。

2. 最小可用的记忆回路:从 SQLite 和嵌入模型搭出第一个版本

不要一开始就上重型向量数据库。第一版我刻意控制依赖数量,用两个库就打通了完整回路:sqlite-vec做向量索引,OpenAI 的text-embedding-3-small做文本向量化。运行了大概三周,足够验证流程本身是否合理。

2.1 核心数据结构设计

我用四张表组织记忆系统,另加一张关联表维护记忆与原始对话片段的溯源关系:

CREATE TABLE memories ( id TEXT PRIMARY KEY, user_id TEXT NOT NULL, type TEXT NOT NULL, -- profile / fact / preference / event content TEXT NOT NULL, status TEXT DEFAULT 'active', -- active / pending / deprecated priority REAL DEFAULT 1.0, source_turn_id TEXT, created_at INTEGER, last_access_at INTEGER, access_count INTEGER DEFAULT 0, last_verified_at INTEGER ); CREATE TABLE memory_embeddings ( memory_id TEXT PRIMARY KEY, embedding BLOB NOT NULL, model_name TEXT NOT NULL, updated_at INTEGER ); CREATE TABLE turns ( id TEXT PRIMARY KEY, conversation_id TEXT NOT NULL, role TEXT NOT NULL, content TEXT NOT NULL, created_at INTEGER );

关键设计在于source_turn_id——每条记忆都能追溯到具体产生它的对话轮次。这给后续做“信息漂移检测”留了后路,没有这层溯源,后面遇到多轮摘要导致的信息失真时基本无从排查。last_verified_at字段当时还被组内同事认为是多余的,结果后来成了清理陈旧记忆的关键开关。

2.2 记忆回路的四个步骤

整个回路分四步:裁剪、向量化、存储、检索。这里给核心代码骨架,这是第一版就跑通的最小实现:

class MemoryService: def __init__(self, embed_model="text-embedding-3-small"): self.db = sqlite3.connect("memory.db") self.db.enable_load_extension(True) self.db.load_extension("vec0") self.embed_model = embed_model def remember(self, user_id: str, turn_batch: list[dict]): """写回:把一批对话轮次沉淀为记忆条目""" candidates = self._extract_memories(turn_batch) for item in candidates: embedding = self._embed(item["content"]) mem_id = uuid.uuid4().hex self.db.execute( "INSERT INTO memories VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", (mem_id, user_id, item["type"], item["content"], "active", 1.0, item["source_turn_id"], int(time.time()), 0, 0, int(time.time())) ) self.db.execute( "INSERT INTO memory_embeddings VALUES (?, ?, ?, ?)", (mem_id, embedding, self.embed_model, int(time.time())) ) def recall(self, user_id: str, query: str, top_k: int = 10): """检索:向量化查询,从索引里取回候选集""" q_emb = self._embed(query) rows = self.db.execute( """ SELECT m.id, m.content, m.type, m.priority, vec_distance_L2(e.embedding, ?) AS distance FROM memories m JOIN memory_embeddings e ON m.id = e.memory_id WHERE m.user_id = ? AND m.status = 'active' ORDER BY distance ASC LIMIT ? """, (q_emb, user_id, top_k) ).fetchall() return [{"id": r[0], "content": r[1], "type": r[2], "score": 1.0 / (1.0 + r[4])} for r in rows]

_extract_memories是核心,它本质上是一个“记忆提炼器”:输入一批对话轮次,输出结构化记忆候选。第一版我用模型来实现,提示词很简单,核心约束是——只抽取可独立理解、可被检索、对后续对话有帮助的信息。不要抽取感叹句、不要抽取临时性数据、避免主观评价。

2.3 为什么第一版就用 SQLite 而不是 Milvus

当时团队有人建议一步到位上 Milvus,我坚持先用sqlite-vec,因为验证阶段最大的风险不是检索性能,而是记忆流程本身是否成立。几十万条以下的数据规模,SQLite 加向量扩展完全够用;而引入重型分布式数据库意味着运维成本、部署复杂度、网络调优负担同时上来,反而干扰对核心逻辑的判断。

而且用 SQLite 跑通有利于排查:所有表都是普通数据库表,可以直接配合 SQL 做数据审计,一条记忆被检索多少次、有没有被写回、状态对不对,全部透明可见。这在调试阶段完全对冲掉了它的性能上限。实践证明,第一版的瓶颈从来不在查询速度,而在“写回的内容质量差”和“检索排序不合理”,这是用任何重型数据库都无法解决的。

3. 真正决定记忆质量的是写回策略,而不是向量库

很多参考资料一上来就讲向量相似度、HNSW 参数、ANN 召回率,但我的切身体会是:记忆系统的上限,八成由写回策略决定。所谓写回,就是把对话过程中散落的信息凝练成可检索的记忆条目的过程。写得太碎,存储迅速膨胀且检索噪音大增;写得过粗,单条记忆过于泛化,召回时根本不解决具体问题。

3.1 时序钩子与主题钩子

第一版我使用“每 10 轮对话写回一次”的固定策略,效果很差。高频场景下大量中间状态被写入,低频场景下关键结论可能要拖到很晚才落库。后来我改成了双钩子触发:

  • 时间钩子:距离上次写回超过 30 分钟,强制进行一次写回;
  • 主题钩子:检测到对话主题发生明显切换时立即写回,例如用户从“讨论部署方案”跳到“写周报”,中间的关键结论必须先固化下来,避免后续上下文被新主题覆盖。

主题钩子的实现并不复杂:不需要专业的主题分类模型,只需要对连续几轮对话的向量做一次平均,再计算新出现内容与当前主题质心的余弦距离,超过阈值就视为主题切换。这个方法实测下来既便宜又稳定。

3.2 摘要长度和记忆条目粒度的平衡

写回时另一个常见误区是追求全面。一次写回恨不得把 20 轮对话所有信息浓缩成一整段几百字的摘要,结果产生了一条“既像什么,又不像什么”的记忆。检索时即使命中了它,它也无法直接回答具体问题。我最终采用的策略是:

  • 把一次写回的对话窗口拆成若干个“语义块”,每块独立判断是否值得沉淀;
  • 每条记忆控制在 30 到 60 个字之间,表达一个完整且单一的事实;
  • 如果同一事实出现在多个语义块中,合并为一条,并更新最后确认时间。

举个例子。某次对话用户先后提到“我预算上限是 10 万”“优先投放搜索引擎渠道”“不要太依赖信息流广告”,系统会产出三条独立记忆,而不是一条 300 字的会议纪要。这样后续用户问“渠道偏好”和问“预算上限”时,检索到的都是高度相关的单点记忆,回答精确度明显提升。

3.3 原始片段溯源:防止多轮写回造成的“信息熵增”

多轮写回有一个隐藏杀手:信息漂移。每一轮摘要都可能在压缩过程中丢掉细节、改变措辞,经过三轮写回后,最终保存的内容可能和最初的事实大相径庭。如果没有原始溯源,这个问题永远发现不了。

我在表结构里加上source_turn_id之后,就开始对每条记忆维护一条溯源链:从最初的对话轮次,到第一次提炼出的记忆文本,再到后续每次更新的中间版本。一旦发现某条记忆在多次更新后语义偏移明显,就用原始片断重新做一次提炼,回滚错误。这个机制在长期运行中非常值钱,尤其是处理偏好类记忆,因为用户偏好本身就容易变,并不是所有变化都是漂移,需要一个可对比的基线。

4. 从相似度到可用:检索排序必须叠加时间衰减与冲突裁决

向量检索只解决“找出语义相近的候选”,不解决“哪些记忆此刻真正有用”。我踩过最典型的坑:用户一周前说过“预算 10 万”,昨天改口“预算提高到 20 万”,但向量检索时旧事实和新事实语义都很接近,旧记忆因为历史访问次数更多、排序更靠前,被注入到了上下文中,助手因此给出了完全过时的信息。

4.1 排序公式:不只是语义分数

为此我基于语义相似度、时间衰减、访问频率和置信度四项指标,重新设计了排序分数:

final_score = semantic_score * 0.55 + recency_bonus * 0.25 + frequency_bonus * 0.10 + confidence_bonus * 0.10

其中recency_bonus基于last_access_at和last_verified_at计算。具体规则是:距离当前时间 24 小时以内,保持满分;超过 24 小时,每多 7 天衰减 20%;超过 90 天继续衰减到下限 0.1。frequency_bonus则避免冷门记忆永不出现——访问次数高的记忆说明是常用事实,可以给予排序加成。

这套公式直接替代了原先“只按向量距离排序”的逻辑。它不追求极致的相关性,而是追求“在特定时刻最有用的记忆”。比如用户只在一个月前提过一次的“差旅报销上限”,平时基本不需要,但如果今天对话刚好涉及出差安排,向量相似度已经把这条召回上来了,这时候时间衰减会让它顺利排进前三。

4.2 对待冲突记忆:不直接覆盖,而是裁决

记忆冲突是生产环境最棘手的问题。用户昨天说“我不太用微博”,今天说“微博还是得发”。如果系统简单覆盖旧条目,失去的是“原本偏好”的历史信息;如果保留两条,检索时又会同时返回互相矛盾的内容。

我的方案是这个:写回阶段检测到与现有记忆语义冲突时,不直接覆盖,而是进入一个“冲突裁决”流程。先由提取模型做一次判断,规则如下:

  • 如果新信息明确表示取代旧信息,如“不用微博,改成用小红书”,保留新条目,旧条目标记为 deprecated;
  • 如果新旧是互补关系,如“日常不刷微博,但工作需要运营微博”,合并为一条更精确的记忆;
  • 如果新信息只是一次性特例,如“这周不推送微博内容”,不改变长期记忆,只更新短期会话状态。

裁决结果都会保留在一条memory_changes审计日志里,方便复盘系统为什么做出这样的调整。这个机制运行第一周就避免了至少三次明显的错误回复。

4.3 召回后的二次过滤

即使排序公式已经生效,我仍然建议对 topK 召回结果做一次 LLM 二次过滤。这一步的本质是“让模型判断这些记忆对当前问题是否真的有用”,因为向量相似度判不了“有用性”。比如检索“项目进度”时,召回了一段关于“项目命名规则”的讨论,语义相似但不构成有效上下文。

做法很简单:把当前问题、候选记忆列表、以及一段系统指令一起送入模型,让它剔除与当前任务无关的记忆,保留最相关的 3 到 5 条。代价是一次额外的模型调用,增加约 50 到 120 毫秒延迟,但对最终回答质量的提升非常明显。如果对延迟敏感,可以只在候选数大于 8 时启用该步骤。

5. 生产环境里最值得复盘的三类记忆问题

5.1 新鲜记忆反复“复活”

现象:某条记忆明明被新信息取代了,比如用户把预算从 10 万改成 20 万,但之后几天的对话里,系统仍然经常检索到 10 万这条旧记忆。我一度以为是冲突裁决逻辑的问题,查了发现裁决确实已经把旧条目标记为 deprecated。

真正的根因在检索链路:召回时带了status = 'active'条件做过滤,但内部排序时“访问频次加权”对旧记忆做了补偿。旧记忆历史上访问次数多,frequency_bonus偏高,即使被标记为 deprecated,前一轮如果没把过滤条件执行干净,它还是能混进候选集。修复方案是在 SQL 层把状态过滤前置,并且在deprecated状态下,即使向量完全匹配,也直接排除。这个坑提醒我——记忆系统的数据质量不仅取决于写入逻辑,也取决于查询链路里每一个过滤条件是否足够严格。

5.2 陈旧记忆占用上下文空间

另一个高频问题是:大量长期未被访问的记忆在检索时因为时间衰减系数已经很低,但仍可能进入 topK,占用了宝贵的上下文空间。与其让它们挤占候选取,不如让它们进入“冷宫”。我的做法是定期扫描access_count和last_access_at,超过 90 天从未被验证过的记忆会暂时从检索范围移除,只保留在归档表里。这里关键一点:不是删除,而是离线存放。谁知道某个季度性业务会突然需要一条半年前的事实呢?

5.3 用户画像的“标签膨胀”

用户画像型记忆如果按属性键值存储,时间一长会出现严重膨胀。一个活跃用户经过三个月使用,可能积累了上百个标签,其中一半已经不再适用。如果不做清理,检索时这些旧标签会被当作画像的一部分注入 prompt,导致助手认为自己面对的客户和真实情况完全不匹配。

我的解法是:画像记忆单独建表,每个属性有confidence与confirmed_at字段;一套规则自动设置属性的有效期限——身份类属性最长两年,偏好类属性最长三个月,临时场景类属性最长一周。接近过期时,系统会发起一次主动询问或等待用户提及新信息完成续期。这比无限期保存所有画像属性可靠得多。

6. 评测与选型:如何判断记忆系统真的“记住”了用户

记忆系统的好坏不能靠感觉评估。没有量化指标,你根本不知道调的是代码还是玄学。我推进了三套评测方案,由易到难,都可以直接复用。

6.1 离线事实注入测试:最低成本的基线评测

这是我最先采用的方案:人工构造 100 条“事实”,每条都具备可查询性,例如“用户所在城市是杭州”“用户偏好周报用柱状图”。把这些事实写入系统,再用 50 条改写后的查询去召回,看 topK 中能否命中对应记忆。评价指标用 Hit@5 和 Hit@10,目标分别是 0.75 和 0.9。

这套方案的优点是快,周期不到一天,适合任何模型和向量库选型的初步判断。缺点是它测不出真实对话的复杂上下文干扰。所以我把这关当作“准入线”,过不了线的系统直接淘汰。

6.2 多轮对抗测试:向记忆系统“发起矛盾”

第二步我设计了一组对抗测试。做法是在一轮对话中先后输入互相矛盾的信息,例如先输入“预算上限 10 万”,隔几条再输入“预算上限 20 万”,然后查询系统给出的记忆版本。正确结果应该是系统最终保留新信息,并能合理说明旧信息的废弃原因。

这个测试暴露了很多问题。最初版本直接覆盖时,模型在后续对话中引用旧预算的次数仍然偏多。加入冲突裁决后,测试通过率从 62% 提升到 94%。剩下 6% 的失败主要发生在信息表达不够明确的场景,比如用户没有说“改成”,只是暗示性地提到新数字,提取器没有触发冲突裁决。这个测试我强烈建议任何做记忆系统的团队都跑一跑,你会发现“记忆一致性”远比想象中难做到。

6.3 生产日志回归测试:以真实数据持续监控

维护期最关键是建立回归集。每周从生产日志中抽取 200 条真实对话,人工标注其中应该被记住的关键信息,然后在新版本代码上跑一遍召回,对比版本间指标变化。这里的指标不只包括召回率,还包括“错误记忆注入率”——即检索结果中与当前对话无关或被判定为过时的记忆占比。这个比例高于 15% 时,系统体验就会出现明显退化。

实话说,这套回归机制在初期被团队忽视,直到一次升级为了优化 20% 的召回率,结果引入了一批低质量记忆,错误注入率从 9% 飙升到 23%,线上回复风格变得非常怪异。没有回归测试,这类问题很难被快速定位。

6.4 向量库与嵌入模型的替换路线参考

如果你看完前面内容决定自己动手,选型时可以参考我实测后的总结:

阶段存储方案嵌入模型适用规模
原型验证SQLite + sqlite-vectext-embedding-3-small十万级以下
生产初版Qdrant 单机BGE-m3 / bge-large-zh百万级以下
规模化部署Milvus 或 Qdrant 集群Cohere embed v3 / 自训练模型千万级以上

之所以最后建议考虑专用集群,一个重要原因是当记忆条目接近百万时,索引构建、增量更新和向量检索的资源竞争会开始影响业务主链路的稳定性,拆分部署能有效隔离风险。嵌入模型方面,BGE-m3 在中文场景效果出色,而且能本地部署,数据不出内网,对很多团队是刚需。

7. 我在实际运行中的一些体会

做完这套系统再回头看,最大的感悟是——记忆系统不是一个检索模块,它是一个需要持续维护的数据产品。写回策略决定数据质量,冲突裁决决定数据一致性,淘汰机制决定数据新鲜度,这三者比向量库选型重要得多。很多文章把 RAG 和记忆混为一谈,但从实践看,记忆系统比通用 RAG 复杂在“写”和“改”这两个环节:RAG 的文档基本是静态的,而记忆每时每刻都在产生、冲突、更新、过期。你在设计时就要把这些动态都考虑进去。

如果你准备做类似的项目,我建议第一周只做一件事:把写回流程和冲突裁决跑通,甚至不用接真正的向量库,用枚举扫描就能验证逻辑。等到写回质量稳定了,再考虑大规模索引和检索优化。顺序反了,后面大概率要推翻重来。我自己就是从 SQLite 起步,一步步走到集群部署,每一步都集中在验证核心逻辑上,而不是提前优化性能。这条路走下来,系统复杂度可控,问题排查也快。如果你的智能体也面临“金鱼化”的问题,ai-memory 这套思路值得一试。

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

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

立即咨询