我们构建了基于Elasticsearch的持久化智能体记忆层,实现0.89召回率

Hacker News Top 新闻

摘要

Elasticsearch博客文章描述了构建一个持久化智能体记忆层,包含三种记忆类型(情景记忆、语义记忆、程序记忆),在QA评估中实现0.89召回率,并利用混合召回和DLS隔离实现了零租户泄漏。

暂无内容
查看原文
查看缓存全文

缓存时间: 2026/06/18 14:49

# 我们如何在 Elasticsearch 上构建持久化 Agent 记忆层:召回率 0.89,零租户泄漏 来源:https://www.elastic.co/search-labs/blog/agent-memory-elasticsearch ## 在 Elasticsearch 上构建 Agent 记忆 *三个索引、带重排器的混合召回、取代机制、衰减和 DLS。支撑 Agent 持久化记忆层的架构与数据。* Sarah 的智能灯泡只显示白光。她的智能家居助手建议重置集线器。她在三月份做过一次,上周又做了一次;两次重置都没解决问题。Agent 不知道这些历史,也不知道狗咬坏了她的传感器线缆。那些重要的历史——什么有效、什么无效、Sarah 是谁——都随着每次会话而终结。 常见的变通方法是把之前的上下文塞进上下文窗口。但这在成本、延迟以及众所周知的“中间迷失”(https://arxiv.org/pdf/2307.03172)效应面前都会失效——该效应中模型会忽略离提示边缘较远的事实。一个 100 万 token 的上下文窗口只是一个草稿本,不是记忆系统。 上下文窗口是短期记忆:单次推理(https://www.elastic.co/docs/explore-analyze/elastic-inference)的活动推理空间。缺少的是长期记忆:一种持久化存储,能在会话结束后存活、扩展到多年的交互,并能按内容、按时间和按用户检索事实。 这篇文章介绍一个真实的 Agent 记忆系统架构,它构建于 Elasticsearch(https://elastic.co/elasticsearch)之上,并按照认知科学(https://alicekim.ca/17.CanPsy85.pdf)中的三个类别进行组织,采用一次混合召回查询,结合 RRF(https://www.elastic.co/search-labs/blog/weighted-reciprocal-rank-fusion-rrf)和跨编码器重排器,支持矛盾事实的取代机制,以及基于 DLS 的单用户隔离。在一个包含 168 个问题的 QA 风格评估中,R@10 平均值为 0.89,并且零跨租户泄漏。完整实现代码在 GitHub(https://github.com/noamschwartz/atlas-memory-demo)上;本文重点解释**为什么**这样设计。 ## Agent 记忆存储需要做什么 用户问“我们上次尝试过什么修复方法?”,这是一个带有精确匹配约束的时间查询。或者“为什么我的智能灯泡只显示白光?”,这需要将个人记忆与共享知识库融合。记忆本身并不是均匀的:用户亲身经历的事件、关于用户的稳定事实、以及逐步操作手册,都有不同的写入频率和老化规则,因此存储必须识别类型并分别处理。 在任何多用户部署中,每个用户的记忆必须对其他用户完全不可见。新事件累积得很快,必须合并到持久类型中,否则索引就会变成大海捞针。当用户反驳一个已召回的事实时,旧版本必须被取代而非删除,以便保留审计轨迹。旧事实不应比新事实排名更高,用户经常触及的事实也不应沉没。 整个记忆层应能被任何支持 MCP(https://www.elastic.co/search-labs/blog/mcp-current-state#what-is-model-context-protocol-(mcp))的客户端访问,而不是绑定到某个特定的 Agent 运行时。将这些需求分散到向量存储、关键词引擎、审计层和独立认证服务中,意味着四个可能出问题的组件以及每次召回时的额外网络往返。 这些需求描述的就是一个搜索引擎,因此本实现使用了一个。下文将逐一介绍。 ## Agent 记忆的三种类型:情景记忆、语义记忆、程序记忆 第一个设计决策是存储哪些记忆类别。全部保存只会构建一个毫无信号的大海捞针。认知心理学中将记忆分为情景、语义和程序三类(https://alicekim.ca/17.CanPsy85.pdf),在 COALA 框架(https://arxiv.org/pdf/2309.02427)中为 LLM(https://www.elastic.co/what-is/large-language-models) Agent 引入,已经提供了正确的分类,并且可以清晰地映射到三个 Elasticsearch 索引上。 - **情景记忆**。带时间戳的事件:每个用户轮次按其发生原样存储,没有任何提取或解释。大多数是短命的,不一定值得保留。少数条目会成为后续持久事实的证据。 - **语义记忆**。关于用户的、经过提炼的稳定断言。“Sarah 有一个 Lumio Hub v2。Sarah 使用 iOS 17.4。Sarah 的集线器在三月份被重置过。”这些跨会话存活,是 Agent 进行推理的基础。 - **程序记忆**。多步骤操作手册。“如何排查 Zigbee 断开连接的问题。”是流程,而非事实。每个条目带有 `success_count` 和 `failure_count`,当用户确认某个修复有效或无效时,通过合并操作递增。这些计数作为上下文提供给合并 LLM,供其考虑是否修改或替换操作手册。 每个类别有不同的**生命周期**。情景记忆不断写入并衰减。语义记忆经过整理、去重,并在用户变化时被取代。程序记忆积累结果反馈(`success_count`、`failure_count`)用于合并。一个桶无法建模这一切。每个记忆类型一个索引,共三个,让每个索引遵循自己的写入频率、自己的老化规则以及自己的更新规则,互不耦合。 在这三个索引之外,还有第四个检索面:已经存在于 Elasticsearch 中的世界数据(目录、知识库)。它虽然不是认知意义上的“记忆”,但 Agent 通过相同的混合检索管道(将在下一节介绍)读取它,因此它属于同一张图。 ## 召回管道:带 RRF 和重排器的混合检索 记忆的召回采用两阶段混合搜索(https://www.elastic.co/search-labs/blog/hybrid-search-elasticsearch):**对 BM25(https://www.elastic.co/search-labs/blog/lexical-and-semantic-search-with-elasticsearch)和 Jina v5 稠密向量进行 RRF,然后对合并后的候选结果使用跨编码器重排器**。 每个文档在一次写入中被索引两次:原始文本进入 BM25 倒排索引,同时 `copy_to` 将相同的值路由到 `semantic_text` 字段,该字段自动生成 Jina v5 向量。对相同内容进行两次索引保持了存储空间平坦:一次源真相写入产生两条检索分支(`索引映射`(https://github.com/noamschwartz/atlas-memory-demo/tree/main/backend/app/atlas/memory/mappings))。 每条分支解决不同问题。BM25 锚定字面 token 匹配——Agent 的释义会模糊这些:版本号、错误码、专有名词如“Lumio Hub v2”。稠密向量捕捉问题的语义形状,即使答案使用不同的词。任一条分支单独都会遗漏另一条分支能处理的情况,而 RRF 融合它们的排名,无需校准 BM25 分数与余弦相似度。 **过度获取**。重排器只能重排它看到的结果,因此候选池必须足够宽。混合检索器从每条分支获取 80 个候选,并使用 `rank_constant=30`(比 ES 默认的 60 更紧,这样排名靠前的条目占更大比重)进行 RRF 融合。(`_rrf_fetch`(https://github.com/noamschwartz/atlas-memory-demo/blob/main/backend/app/atlas/memory/operations.py#L497)) **重排器**。Jina v2 跨编码器根据用户查询对合并后的候选进行评分。BM25 和双编码器稠密模型独立地对查询和文档评分,而跨编码器联合评分,对这对文本进行完全注意力计算,这是更强的相关性信号,但每对计算成本更高。这就是两阶段管道的动机:用混合检索器廉价地过度获取,然后用更昂贵的评分器对少量候选池进行重排(`_rerank`(https://github.com/noamschwartz/atlas-memory-demo/blob/main/backend/app/atlas/memory/operations.py#L381))。 上图中显示了一个微妙之处。Agent 的工具包包括 `recall_memory`(定义在 `tools.py`(https://github.com/noamschwartz/atlas-memory-demo/blob/main/backend/app/atlas/tools.py#L25)),由模型在一次轮次中调用。一次调用同时向所有三个记忆索引和目录发送请求:Agent 不会选择某个记忆类型,因为检索器的排名和按索引的衰减替它处理路由。 第二个微妙之处是释义。Agent 在调用该工具之前,几乎总是重写用户的消息,这会在 BM25 看到之前就剥离字面版本号、错误码和专有名词。因此,每个轮次都以对原始用户消息的自动预召回开始,如同 Agent 自己调用一样注入到对话中(`agent.py`(https://github.com/noam schwartz/atlas-memory-demo/blob/main/backend/app/atlas/agent.py#L128))。 ## 写入和合并 Agent 记忆 两个操作将记忆从“刚刚发生了什么”转移到“关于这个用户什么是持久的”。 **写入**。每个用户轮次在 LLM 响应之前写入一条情景事件(ID、确切消息、时间戳等)。ID 由 Elasticsearch 在写入时分配,Sarah API 密钥的 DLS 查询将该文档限定为她所有,在后续每次召回中都如此,时间戳则供时间衰减函数(见下文)读取,以将事件与较新的事件进行排名。Agent 的回复不存储。对话历史已经将它们带入下一次调用,并且它们的长度会淹没用户所说的简短、富含事实的内容。 热点路径写入是经过深思熟虑的选择。一开始听起来可行的两种替代方案分别是:让上下文窗口将新事实带向前,这只在未结束的会话中有效,但一旦会话结束或崩溃,上下文中的状态就消失了;跨会话记忆才是重点。在会话结束时批量写入可以保留跨会话状态,但它会破坏本实现依赖的两个同轮次模式。一个用户在一句话中提到新设备并询问设备列表,需要新事实在同一轮次后续的召回中可见,因为工具调用查询的是索引,而不是对话历史。同时,取代流程会在一次工具调用批次中写入一个更正后的事实并与自身进行比较。这两种模式在延迟写入下都会悄然出问题。我们付出的代价是每个用户消息一次 Elasticsearch 写入,在单一对话产生的规模下,这不到 100 毫秒。 *哪些建议有效*是通过程序索引中的 `success_count`/`failure_count` 单独捕获的,而不是存储回复文本。包含用户确认(“谢谢,那个有效”)的近期情景触发 `success_count++`;明确拒绝(“那个没用”)触发 `failure_count++`。对话本身就是反馈信号,合并 LLM 作为分类器。不需要点赞按钮。当用户提出异议时,还会生成 `refined_steps` 字段,供 LLM 写回到操作手册中。 **合并**。情景日志累积很快。合并将它们提升为语义事实和程序操作手册,在对话历史消失后仍然存在。本实现在每个轮次都运行合并,因此你可以实时观察检查器更新;在生产环境中,正确的节奏是后台作业:每 24 小时,或当用户的某个情景索引跨越 N 个新事件时。每轮次合并会使每个消息的 LLM 调用翻倍。在一次调用(提示词(https://github.com/noamschwartz/atlas-memory-demo/blob/main/backend/app/atlas/consolidate.py#L24))中,合并 LLM 接收最近的情景以及现有的事实和操作手册,并要求输出三件事: - **新的语义事实**,带有 `supporting_episode_ids` 用于溯源。 - **新的程序操作手册**,当某个多步骤解决方案与任何现有触发条件都不匹配时。 - **程序更新**:根据用户是否确认修复,`success_count++`/`failure_count++`,以及当用户提出异议时的 `refined_steps`。 提示词要求每个输出都带有 `supporting_episode_ids`,因此稀疏的轮次只返回空列表,不写入任何内容。去重使用与 Agent 召回相同的混合检索器:对于每个候选事实,在用户的语义索引中进行 top-K 混合搜索以缩小比较范围,然后只有这些候选才交给 LLM 进行意义判断。另外两个守卫包围输出:低于置信度底线的候选被丢弃,而一个被接受的事实若其最高相似度命中达到 `≥ 0.90`,则视为重复。 *在本实现中,去重更简单:最近大约 50 个事实被传递给合并 LLM,并附带“不要重复”指令,而后 LLM 的置信度和相似度守卫尚未连接。混合召回路径和包围守卫是生产架构;此快照依赖 LLM 直接进行比较,因为语料足够小,可以容纳。* `success_count` 和 `failure_count` 在操作手册上形成了一个反馈循环:经过足够多的对话,记录“这个有效”的同一个字段变成了“下次优先显示这个”的信号。 *目前,这些计数已被写入,但尚未偏向检索排名。在少数已解决的工单上,这种提升只是统计噪声。将其接入生产环境后,一旦部署具有足够密度使该信号有意义。* ## Agent 记忆如何处理矛盾与取代 只增不删的记忆最终会出错。用户说“我搬到了爱丁堡”,Agent 写入一个新事实。六个月后,旧的“住在布里斯托尔”事实仍在索引中。每次召回两者都会出现,Agent 要么选错,要么含糊其辞。信任很快就会丧失。 解决办法是系统提示词(完整提示词(https://github.com/noamschwartz/atlas-memory-demo/blob/main/backend/app/atlas/agent.py#L32))中的一条规则,无需新工具。Agent 不是删除,而是**取代**: **一个工作示例**。Sarah 上次访问记录了 `id=abc`,“Sarah 住在布里斯托尔”在语义索引中。三个月后,她打开一个聊天窗口:“我们离开了布里斯托尔,现在在爱丁堡。” 1. **召回**。对 Sarah 消息的预召回返回结果,包括 `{id: "abc", text: "Sarah lives in Bristol", memory_type: "semantic"}`。 2. **检测**。Agent 看到召回事实与新消息之间的冲突。 3. **分类**。“我们离开了布里斯托尔,现在在爱丁堡”是自然的更新,而不是否认。Agent 选择 `contradiction="natural"`。 4. **写入**。Agent 调用 `write_memory(text="Sarah lives in Edinburgh", supersedes_id="abc", contradiction="natural")`。一步完成两件事: - 写入一个新文档 `id=xyz`,置信度完全(无惩罚,因为矛盾是自然的)。 - 旧文档 `abc` 更新为 `superseded_by=xyz, superseded_at=<时间>`。 5. **召回隐藏旧文档**。每次召回应用 `filter must_not exists field=superseded_by`(https://github.com/noamschwartz/atlas-memory-demo/blob/main/backend/app/atlas/memory/operations.py#L313)。`abc` 从 Agent 的视图中隐藏。`xyz` 正常出现。 6. **审计保留**。文档 `abc` 保留在索引中。针对 `superseded_by=xyz` 的查询可以重建链。 注意:如果 Sarah 后来问“我住过哪些地方?”,Agent 调用 `recall_memory(query="places sarah has lived", include_superseded=True)`。DLS 限定的召回会同时显示 `xyz`(爱丁堡)和 `abc`(布里斯托尔)。带有 `superseded_at` 的命中是已归档状态;Agent 的回复会区分它们(https://github.com/noamschwartz/atlas-memory-demo/blob/main/backend/app/atlas/agent.py#L49):“你现在住在爱丁堡;你以前住在布里斯托尔(直到今年早些时候)。” 如果 Sarah 反而说“我从未住在布里斯托尔,那是我姐姐”,步骤 3 会将其分类为“harsh”。相同的写入发生,但新事实的置信度会降低 `SUPERSEDE_CONFIDENCE_PENALTY`。系统会稍微保守,直到后续对话强化新状态。 边缘情况遵循相同模式:一个已经被取代的事实可以再次被取代(`abc → xyz → ...)。

相似文章

在 LongMemEval-S 上对智能体记忆检索进行基准测试 — Recall@5 达 98%,R@23 实现 100% 召回,仅依赖本地嵌入模型 (all-MiniLM-L6-v2),无需 LLM 与 API Key

Reddit r/AI_Agents

作者分享了用于智能体记忆的 Python 库 memweave 的基准测试结果,该库仅使用本地嵌入且无需调用 LLM,便在 LongMemEval-S 上实现了 98% 的 Recall@5。本文详细介绍了实现方法,并与 mempalace 进行了性能对比,突出了其在不同问题类型上稳定的检索表现。

ElasticMem:作为LLM智能体可学习资源的潜在记忆

arXiv cs.CL

ElasticMem 为 LLM 智能体引入了一种可学习的潜在记忆机制,该机制能够自适应地为检索到的记忆分配可变预算,从而在减少 token 成本的同时,提升内存密集型问答和具身智能体任务的性能。