# 我们在 Elasticsearch 上构建了一个持久化代理内存层，其召回率为0.89

- 来源：Hacker News 热门（buzzing.cc 中文翻译）
- 作者：showmypost
- 发布时间：2026-06-19 13:01
- AIHOT 分数：73
- AIHOT 标记：精选
- AIHOT 链接：https://aihot.virxact.com/items/cmqkhoy7906goslhiji2e8rc4
- 原文链接：https://www.elastic.co/search-labs/blog/agent-memory-elasticsearch

## 精选理由

Elastic 把这套代理记忆架构连同评估数据一次性放出来，三种记忆类型、混合召回、衰减和隔离全挤在一个查询里，做 Agent 持久记忆的开发者可以直接抄，召回 0.89 的工程决策讲得清楚。

## AI 摘要

Agent Builder 正式上市（GA）。基于 Elasticsearch 的持久化内存层将记忆分为情景、语义、程序三类，分别存入独立索引，各设不同写速率与过期规则。召回采用 BM25 与 Jina v5 稠密向量的 RRF 融合，再经交叉编码器重排序。在 168 道 QA 题评估中，R@10 平均 0.89，零跨租户泄漏。该层可通过支持 MCP 协议的客户端访问，不绑定特定运行时，已开源至 GitHub。

## 正文

Agent Builder 现已正式发布（GA）。您可以通过 Elastic Cloud 试用版立即开始使用，并在此处查阅 Agent Builder 的文档。

在 Elasticsearch 上构建智能体记忆

三个索引、结合重排序器的混合召回、替代机制、衰减机制以及 DLS。支撑智能体持久化记忆层的架构与数据。

莎拉的智能灯泡只显示白色。她的智能家居助手建议重置网关。她在三月份做过一次，上周又做了一次；两次重置都没解决任何问题。智能体对此一无所知，它也不知道那条狗咬断了她的传感器线缆。那些重要的历史记录——什么有效、什么无效、以及莎拉是谁——都随着每次会话的结束而消失了。

标准的变通方法是将先前的上下文塞入上下文窗口。但这在成本、延迟以及被充分记录的“中间迷失”效应上都会出问题——模型会忽略远离提示词边缘的事实。一个 100 万 token 的上下文窗口只是一个草稿本。它不是一套记忆系统。

上下文窗口是短期记忆：单次推理的主动推理空间。真正缺失的是长期记忆：一个持久化的存储，能在会话结束后继续存在，可扩展至多年的交互，并允许你按内容、按时间、按用户检索事实。

本文介绍的是一个真实智能体记忆系统的架构，该系统构建于 Elasticsearch 之上，并围绕认知科学中的三个类别进行组织，采用结合 RRF 和交叉编码器重排序器的混合召回查询、用于矛盾信息的替代机制，以及基于用户的 DLS 隔离。在一个包含 168 个问题的 QA 风格评估中，R@10 平均值为 0.89，且零跨租户数据泄露。

完整实现已在 GitHub 上开源；本文旨在解释其设计成这样的原因。

一个智能体记忆存储需要做到什么

有用户问“我们上次尝试了什么修复方法？”，这是一个带有精确匹配约束的时间查询。或者“为什么我的智能灯泡只显示白色？”，这需要将个人记忆与共享目录融合。记忆本身并非统一运作：用户亲身经历的事件、关于他们的稳定事实，以及逐步操作指南，都有不同的写入速率和老化规则，因此存储系统必须识别类型并分别处理。在任何多用户部署中，每个用户的记忆必须对其他用户完全不可见。新事件积累得足够快，必须整合到持久类型中，否则索引就会变成一团乱麻。当用户反驳某个已回忆的事实，旧版本必须被取代而非删除，以便保留审计轨迹。旧事实不应优先于新事实，用户经常触及的事实也不应沉没。整个记忆层应能被任何支持 MCP 的客户端访问，而不局限于某个智能体运行时。

将这些需求分散到向量存储、关键词引擎、审计层和独立的认证服务中，意味着四个可能出故障的环节，以及每次回忆时的额外往返。这些需求描述的是一个搜索引擎，因此本实现使用了一个搜索引擎。本文其余部分将逐一讲解每个环节。

三种智能体记忆类型：情景记忆、语义记忆、程序记忆

第一个设计决策是究竟要存储哪些类别的记忆。仅仅保存所有内容会构建一个毫无信号的乱麻堆。认知心理学中情景记忆、语义记忆和程序记忆的划分，在 COALA 框架中被引入用于大语言模型智能体，已经具备了正确的分类，并且它们能清晰地映射到三个 Elasticsearch 索引上。

情景记忆。带时间戳的事件：每次用户交互的原始记录，未经任何提取或解释。其中大部分是短暂的：并不总是值得保留。少数条目会成为后续持久事实的证据。

语义记忆。关于用户的提炼、稳定断言。Sarah 拥有一台 Lumio Hub v2。Sarah 使用的是 iOS 17.4。Sarah 的集线器在三月份被重置过。这些信息跨会话保留，是智能体所依据的基础。

程序性记忆。多步骤操作手册。如何排查 Zigbee 断连问题。是流程，而非事实。每条流程都带有 success_count 和 failure_count，当用户确认某个修复方案有效或无效时，通过 consolidation 机制递增这些计数。这些计数器会作为上下文提供给 consolidation 大语言模型，供其在考虑是否优化或替换某条操作手册时参考。

每个类别都有不同的生命周期。情景记忆被持续写入并逐渐衰减。语义记忆会随着用户变化而被整理、去重和取代。程序性记忆则累积结果反馈（success_count, failure_count），用于支撑 consolidation。单一存储桶无法建模这种差异。三种索引，每种记忆类型对应一个，让各自遵循自己的写入频率、自己的老化规则以及自己的更新规则，彼此互不耦合。

在这三种记忆之外，还有第四种检索来源：Elasticsearch 中已有的世界数据（目录、知识库）。从认知意义上说，它并非“记忆”，但智能体通过同样的混合检索管道（下一节介绍）读取它，因此它属于同一幅图景。

回忆管道：基于 RRF 和重排序器的混合检索

记忆的回忆采用两阶段混合搜索：先对 BM25 + Jina v5 稠密向量进行 RRF 融合，再对合并后的候选结果使用交叉编码器重排序器。每个文档在写入时以两种方式建立索引：原始文本进入 BM25 倒排索引，同时 copy_to 将同一值路由到自动生成 Jina v5 向量的 semantic_text 字段。对同一内容建立两次索引保持了存储占用不变：一次真实来源写入即可产生两条检索分支（索引映射）。每条分支解决不同问题。BM25 锚定智能体改写会丢失的字面 token 匹配：版本号、错误码、专有名词如“Lumio Hub v2”。稠密向量则捕捉问题语义形态，即使答案使用不同词汇。任何一条分支单独使用都会遗漏另一条能处理的场景，而 RRF 融合它们的排序结果，无需校准 BM25 分数与余弦相似度。

过度获取。重排序器只能对其所见内容进行重新排序，因此候选池需要足够宽泛。混合检索器每路获取 80 个候选结果，并使用 rank_constant=30（比 ES 默认的 60 更严格，因此排名靠前的项目占据更大优势）进行 RRF 融合。（_rrf_fetch）

重排序器。一个 Jina v2 交叉编码器将合并后的候选结果与用户查询进行评分。BM25 和双编码器稠密模型都是独立对查询和文档进行评分，而交叉编码器则对它们进行联合评分，在配对间进行完全注意力计算，这能提供更强的相关性信号，但每对的计算成本也更高。这正是采用两阶段流程的动机：先用混合检索器进行低成本的过度获取，再用成本更高的评分器对较小的候选池进行重排序。（_rerank）

如上图所示，有一个细微之处。智能体的工具包中包含 recall_memory（定义在 tools.py 中），模型会在一次交互轮次中调用它。单次调用会同时遍历所有三个记忆索引和目录：智能体不会选择记忆类型，因为检索器的排序和按索引衰减机制会代为处理路由。第二个细微之处是改写。智能体在调用该工具之前，几乎总是会先重写用户的消息，这会在 BM25 处理查询之前，从中剥离掉字面意义上的版本号、错误代码和专有名词。因此，每一轮交互都以对用户原始消息的自动预召回开始，并将结果注入对话，就像智能体自己进行了调用一样。（agent.py）

写入并整合智能体记忆。

两个操作将记忆从“刚刚发生了什么”转变为“关于这个用户，什么是持久有效的”。

写入。在 LLM 响应之前，用户的每一轮交互都会写入一个片段事件（包含 ID、确切消息、时间戳等）。ID 由 Elasticsearch 在写入时分配，Sarah 的 API 密钥上的 DLS 查询确保该文档在后续每次召回时都限定在她的范围内，而时间戳则供时间衰减函数（见下文）读取，用于将该事件与较新的事件进行排序。智能体的回复不会被存储。对话历史已经将它们带入下一次调用，而它们的长度会淹没用户所说的简短、富含事实的内容。热路径写入是一个刻意的选择。两种替代方案乍看之下似乎合理。让上下文窗口将新事实带入后续调用，在同一个开放会话的剩余时间内是可行的，但一旦会话结束或崩溃，上下文中的状态就会消失；而跨会话记忆才是整个目标。

在会话结束时批量写入可以保留跨会话状态，但它破坏了这个实现所依赖的两种同轮次模式。用户在同一消息中提及新设备并询问其设备列表时，需要新事实对同一轮次稍后运行的召回可见，因为工具调用查询的是索引，而非对话历史。而替换流程会在一个工具调用批次内写入一个修正后的事实，并针对该事实进行召回。这两种模式在延迟写入的情况下都会静默地出现错误行为。我们为此付出的代价是每条用户消息进行一次 Elasticsearch 写入，在单个对话产生的数据量下，这耗时低于 100 毫秒。

哪种建议有效，是通过过程索引上的 success_count / failure_count 分别捕获的，而不是通过存储回复的文本。最近的片段事件中包含用户确认（“谢谢，这有效”）会触发 success_count++；明确的拒绝（“这没有帮助”）会触发 failure_count++。对话本身即是反馈信号，由整合 LLM 充当分类器。无需点赞控件。当出现分歧时，还会暴露一个 refined_steps 字段，供 LLM 将内容写回行动手册。

整合。片段日志积累得很快。整合将它们提升为语义事实和程序性行动手册，这些内容在对话历史消失后依然存在。此实现在每个轮次都运行整合，因此你可以实时观察检查器更新；在生产环境中，合适的节奏是作为后台任务运行：每 24 小时一次，或者当用户的片段索引新增事件超过 N 个时。按轮次整合会使每条消息的 LLM 调用次数翻倍。

在一次调用（提示词）中，整合 LLM 被输入最近的片段以及现有事实和行动手册，并要求输出三项内容：

新的语义事实，附带用于溯源的支持性片段 ID。

新的程序性行动手册，当某个多步骤解决方案与任何现有触发条件都不匹配时生成。

程序性更新，根据用户是否确认修复来增加 success_count++ / failure_count++，并在用户不同意时更新 refined_steps。

提示词要求每个输出都附带支持性片段 ID，因此稀疏的轮次会返回一个空列表，不写入任何内容。

去重使用与智能体用于回忆相同的混合检索器：对于每个候选事实，针对用户的语义索引进行一次 top-K 混合搜索，以缩小比较范围，只有这些候选事实才会被送入 LLM 进行含义判断。还有两道防护措施对输出进行限制：低于置信度阈值的候选事实被丢弃，并且一个被接受的事实如果其最高相似度命中值达到 ≥ 0.90，则被视为重复项。在此实现中，去重更简单：最近的大约 50 个事实被传递给整合 LLM，并附带一条“不要重复”的指令，而 LLM 后的置信度和相似度防护尚未接入。混合检索路径和防护措施是生产架构；此快照依赖于 LLM 直接进行比较，因为语料库足够小，可以容纳。

success_count 和 failure_count 为行动手册形成了一个反馈循环：在足够多的对话中，记录“此方法有效”的同一个字段，会成为“下次优先展示此方法”的信号。目前，计数已被写入，但尚未用于影响检索排序。在少数已解决的工单上，这种提升只是统计噪声。一旦接入生产环境，当部署达到足够密度使该信号具有意义时，便会启用。

智能体记忆如何处理矛盾与替代

只增不减的记忆最终会出错。用户说"我搬到爱丁堡了"，智能体写入一条新事实。六个月后，旧的"住在布里斯托尔"这条事实仍然在索引中。每次回忆时两者都会出现，智能体要么选错，要么含糊其辞。信任感很快消失。

解决方案是在系统提示词（完整提示词）中加入一条规则，无需新增工具。智能体不删除，而是进行替代：

一个实际示例。Sarah 上次访问时在语义索引中记录了 id=abc，"Sarah 住在布里斯托尔"。三个月后，她打开聊天窗口说："我们离开布里斯托尔了，现在在爱丁堡。"

1. 回忆。对 Sarah 消息的预回忆返回匹配结果，包括 {id: "abc", text: "Sarah 住在布里斯托尔", memory_type: "semantic"}。

2. 检测。智能体发现回忆到的事实与新消息之间存在冲突。

3. 分类。"我们离开布里斯托尔了，现在在爱丁堡"是一个自然的更新，而非否认。智能体选择 contradiction="natural"。

4. 写入。智能体调用 write_memory(text="Sarah 住在爱丁堡", supersedes_id="abc", contradiction="natural")。一次操作完成两件事：

写入一条新文档 id=xyz，置信度设为满（无惩罚，因为矛盾是自然的）。

旧文档 abc 被更新，添加 superseded_by=xyz, superseded_at=<现在>。

5. 回忆时隐藏旧记录。每次回忆都会应用一个过滤器：must_not exists field=superseded_by。abc 从智能体的视野中隐藏。xyz 正常显示。

6. 审计保留。文档 abc 仍保留在索引中。查询 superseded_by=xyz 可以重建整个链条。

注意：如果 Sarah 后来问"我住过哪些地方？"，智能体调用 recall_memory(query="sarah 住过的地方", include_superseded=True)。DLS 作用域内的回忆会同时返回 xyz（爱丁堡）和 abc（布里斯托尔）。带有 superseded_at 设置的命中结果是已归档状态；智能体的回复会区分它们："你现在住在爱丁堡；你之前住在布里斯托尔（直到今年早些时候）。"

如果 Sarah 说的是“我从未在布里斯托尔住过，那是我姐姐”，那么步骤 3 会将其归类为“严厉”。同样的写入操作会发生，但新事实的置信度会因 SUPERSEDE_CONFIDENCE_PENALTY 而降低。系统会略微保持谨慎，直到新状态通过后续对话得到强化。

边界情况遵循同样的模式：一个已被取代的事实可以再次被取代（abc → xyz → pqr）；一个低风险偏好（“我现在更喜欢深色模式”）会以 contradiction="natural" 的方式取代。forget_memory 是硬删除；仅当客户明确说“忘记 X”时才使用它。它不是矛盾处理工具。

有一个微妙之处。召回可能会浮现出多个被同一新陈述所矛盾的事实。Sarah 的位置信息存在于“Sarah 住在布里斯托尔一栋维多利亚式公寓里”（语义层面）、“Sarah 在布里斯托尔有一套公寓，里面放着她的 Hub v2”（语义层面），也可能存在于之前某次对话的一个情景事件中。智能体必须取代所有这些事实，而不仅仅是它看到的第一个。扫描召回结果，找出新陈述使其不成立的每一个事实，并为每个旧 ID 发起一次 write_memory(supersedes_id=…) 调用。那些仅提及布里斯托尔但依然成立的事实（“布里斯托尔的维多利亚式公寓有厚墙，会衰减 Zigbee 信号”）则不会被取代。Sarah 搬家并不会改变布里斯托尔的建筑结构。

被取代的文档会累积，但只有当智能体通过 include_superseded=True 明确请求时，才会在召回中浮现。在生产环境中，定期重建索引会将它们移入一个单独的归档索引，Elasticsearch 的索引生命周期管理（ILM）会将其依次过渡到冷层和冻结层（可搜索快照）。审计链在冷存储上仍可查询；活跃的语义索引则保持热状态且体积小巧。

确保 Elasticsearch 智能体记忆中同轮写入的可见性

Elasticsearch 的默认异步刷新间隔会导致一个问题：当智能体在同一轮对话中写入并召回记忆时，会产生传播延迟。当用户在一句话里说“我有一个 Lumio 信号扩展器，一直没设置过。现在我的完整设备列表是什么？”时，智能体会先写入该扩展器的事实，然后立即在同一轮对话中执行召回，有时甚至是在同一轮迭代的工具调用批次中。默认的 Elasticsearch 刷新间隔加上 semantic_text 的推理开销，可能导致亚秒级的传播延迟，使得刚写入的文档在召回时还不可见。

修复方案位于存储层。智能体触发的每次 write_memory 都传递了 refresh=True 参数，强制分片在调用返回前完成刷新（并使内联推理处理器生成的 Jina v5 嵌入向量落地）。下一次工具调用就能看到新文档。最终回复中出现了该扩展器，因为写入后立即执行的召回看到了它。

在写入量较高的情况下，refresh=True 会带来吞吐量开销。生产环境部署可能需要转向异步索引，再加上一个智能体层的“刚写入”注册表，将写入内容保留在大语言模型的上下文中，直到索引追上进度。目前，更简单的方案仍有其用武之地。

智能体记忆检索的时间衰减与使用次数评分

目前的检索设置对所有事实赋予相同权重，无论其创建时间或最后使用时间。这是错误的默认行为。一个在过去一周内被召回两次的事实，几乎肯定比一个两年前被提及一次、内容相同的事实更相关。

我们将每个结果的分数乘以两个乘数：一个主要的新近度信号和一个次要的频率修正。新近度信号是时间衰减：一个在 Painless 中基于每个索引的日期字段计算的高斯形乘数（详见下文）。频率修正是使用次数增益（1 + log10(1 + use_count) * weight），因此一个被召回十次的事实大约提升 1.2 倍，被召回一百次的事实大约提升 1.4 倍。

这两个指标回答的是不同的问题：时间衰减衡量的是某个事实最近被触及的时间；使用次数衡量的则是被触及的频率。当多个事实共享同一个 last_used_at 时间戳时，两者就会出现差异：衰减无法区分"被回忆过一次"和"被回忆过四十次"；而使用次数可以。时间衰减承担主要负载；使用次数则是一种优化手段，只有当每个事实的回忆量足够高、能够承载有效信号时，它才能发挥价值。

每种记忆类型对应的日期字段

情景记忆和语义记忆使用不同的日期字段。情景记忆使用 timestamp（事件发生时间）；语义记忆使用 last_used_at（写入时设置，回忆时更新）。ES 原生的 gauss 函数无法同时覆盖这两个字段，因为该函数只接受一个字段名，且该字段必须存在于搜索涉及的每个索引中。因此，时间衰减通过 Painless 脚本实现，该脚本根据索引选择正确的字段，并就地计算出一个高斯形状的乘数：

程序性记忆被有意排除在时间衰减之外。last_used_at 在每次回忆时都会更新，无论回忆成功与否，因此纯粹的衰减乘数会奖励"最近尝试过"而非"最近有效过"。正确的做法是将 last_success_at 字段与 success_count / failure_count 结合起来，融入排序逻辑；在这两个字段都就位之前，仅凭时效性对于程序性记忆的检索来说过于粗糙。

语义记忆中的回忆时间更新是承担主要负载的部分。它将"旧事实权重更低"转变为"智能体最近不需要的事实权重更低"。这是相关性的衰减，而非真实性的衰减。真实性的衰减由替代机制（见上文）处理。一个五年前的事实，如果智能体每周都会回忆它，那么由于 last_used_at 始终是新的，它就会保持在最顶部。

这与三个桶分类法所依据的认知科学脉络相同。检索练习（回忆某件事的行为）会增强其可访问性，而长期不用则会让它逐渐消退。对 last_used_at 的回忆时间更新，正是同一效果在工程层面的体现。

检索时的乘数

这两个因子都位于一个 function_score 块中，该块包裹着 RRF 的每个分支：

在代码中，这两个函数都位于一个 Painless 脚本中，该脚本按索引进行分支（数学计算相同，但减少了 function_score 条目数量）。

两个索引过滤器承担双重职责。它们将每个函数限定到其应影响的内存类型：时间衰减适用于情景记忆和语义记忆，使用次数提升仅适用于语义记忆。同时，它们将程序性记忆和目录排除在外：过滤器不匹配的函数返回中性值 1.0，因此包含这些索引的跨索引查询能够正确评分，无需解析器处理。完整函数位于 operations.py 中。

两个参数控制高斯曲线：

offset（180天）：一个平坦区域。距今不足 180 天的文档无论具体天数如何，均获得乘数 1.0。如果没有该参数，新事实会因亚日时间噪声而相互竞争。

scale（1825天，约 5 年）：超出平坦区域后，乘数衰减至 0.5 的距离。实际上是从平坦区域结束算起的半衰期。

衰减是一种刻意的权衡。当语料库中的每个事实都是唯一的且随时间保持正确时，应用任何衰减都会损失部分召回率：旧事实即使仍然正确也会受到惩罚。衰减发挥作用的地方在于现实场景：关于同一事物的多个相互竞争的事实共存，而你希望最新或最常用的那个排名最高。正因如此，默认 scale（1825天）是保守的。对于事实快速过时的领域（如产品迭代迅速的客户支持），可收紧该参数。对于事实多年保持相关性的个人助手记忆，可放宽该参数；两者都只需在 constants.py 中修改一行代码。

基于 Elasticsearch DLS 的多租户隔离

文档级安全（DLS）将隔离规则移至集群本身。每个用户获得一个 API 密钥，其角色描述符携带一个 DLS 查询，该查询允许访问属于该用户的文档（以及没有 user_id 字段的共享目录）。使用该密钥的智能体可以运行任何查询，但永远不会看到其他用户的文档。集群根本不会返回这些文档。这就是生产级隔离保障，在每次使用该密钥发起的查询中由服务端强制执行。

检索器还在代码中携带了一个 user_id 过滤器，作为防止配置漂移的额外安全措施：新索引模板上线时可能没有 DLS，角色描述被编辑后相关子句被静默丢弃，管理员密钥被误复用。DLS 是架构层面的设计；这个代码级别的检查在查询时几乎不消耗任何成本。

将共享目录数据集成到智能体记忆检索中

记忆查询就是一次 Elasticsearch 搜索。针对 Sarah API 密钥的 DLS 查询只允许 user_id 为 "sarah" 的文档。目录和其他共享索引根本没有 user_id 字段；它们本应对所有用户可见。为了包含这些索引，DLS 查询从"必须等于 sarah"扩展为"等于 sarah 或没有 user_id"：一个 bool.should 条件，允许 user_id == "sarah" 或 must_not exists: user_id。检索器、RRF 融合和衰减函数保持不变：目录和个人记忆进入同一轮召回。

一个启动脚本会生成每个用户的 DLS 密钥，并将扩展后的查询条件内置其中。

现在，搜索"只显示白色的智能灯泡"时，会同时返回 Sarah 存储的约束条件和关于灯泡兼容性的目录条目，并按相关性排序。

用户记忆和目录可能在同一主题的同一轮召回中出现并相互矛盾。检索器在处理时间衰减的同一个脚本中（多一个 _index 分支，没有新机制）应用了一个小的来源先验权重（CATALOG_SOURCE_PRIOR，0.85），因此在相关性接近时用户记忆胜出。这是一种软性倾斜，而非路由规则：当目录具有明显更强的相关性匹配时（如产品规格、技术查询），重排序器仍会选择目录。硬性规则（如"规格查询永远信任目录"或相反地"个人偏好永远信任用户记忆"）由智能体的系统提示词处理，而非检索器。

通过 MCP 连接任意智能体

当记忆层不绑定于单个智能体时，它最为有用。模型上下文协议（MCP）天然实现了这一点。端点地址是 /api/atlas/mcp/{user_id}，因此任何支持 MCP 的客户端（Claude Desktop、Cursor、你自己的智能体）都可以通过将 mcp.py 中的 JSON 片段粘贴到其配置中来接入。

对于 Claude Desktop，配置文件位于 `~/Library/Application Support/Claude/claude_desktop_config.json`（macOS）或 `%APPDATA%\Claude\claude_desktop_config.json`（Windows）。对于 Cursor，将其粘贴到“设置 → MCP”下。重启客户端后，三个 Atlas 工具（`recall_memory`、`write_memory` 和 `forget_memory`）会出现在工具抽屉中，它们调用的是 FastAPI 应用所使用的同一套 Elasticsearch 索引。同一套记忆层，任意智能体，无需重写。这三个工具的接口定义在 `tools.py` 中。

衡量智能体记忆召回质量

关于“召回”的说明：本文其他部分中，它指记忆召回（智能体检索已存储的事实）。而此处，它指的是不相关的信息检索指标 Recall@K：即正确的文档是否出现在前 K 个结果中。

记忆架构很难验证。这里的评估采用问答式段落检索，即标准的 RAG 基准测试。对于每个采样的文档，大语言模型会编写两个用户可能合理提出的问题，其答案均指向该文档。例如，“我宝宝的睡眠很脆弱，设置自动化时有什么需要记住的吗？”指向的是 Sarah 的育儿室安静时段这一事实。然后，检索器必须在前 k 个结果中呈现源文档。

存在像 LoCoMo 这样的通用记忆专用基准测试，它们能使数据在系统间具有可比性。选择基于语料库的问答模式有两个原因。首先，它测试了每个角色实际部署的语料库，因此召回率能反映真实对话中的表现。其次，它隔离了混合+衰减+重排序流水线正在迭代的检索环节（源文档是否出现在前 K 个结果中？）；LoCoMo 的对话连贯性指标衡量的是更下游的内容。后续文章将运行完整的 LoCoMo 基准测试，并将检索性能与 LLM 选择及提示词工程带来的干扰因素分离开来。

泄露数量是任何多租户记忆系统的准入门槛；其余部分则是质量表现。该评估在 CI（`eval_recall.py`）中设置了门控条件：R@10 ≥ 0.85，R@5 ≥ 0.75，泄露数 = 0。这些数字是近似值，因为重排序器存在服务端方差：在连续四次运行中，R@10 分别达到了 0.85、0.88、0.89 和 0.893。

语义事实是更困难的情况（R@10 ≈ 0.81）；情景事实平均为 0.98，程序事实则达到 1.0。原因是兄弟冲突：关于 Sarah 的集线器断连问题，语料库中存在多个看似正确的事实，检索器有时会选错。值得注意的是：兄弟冲突通常不会降低智能体的回复质量（它仍然会得到一个相关且真实的事实），因此 R@10 对于语义事实而言是偏保守的指标。

智能体记忆架构：关键决策

智能体记忆由几个问题组成，每个问题都有唯一的解决方案：

记忆并非单一事物。共有三个索引，分别对应一个生命周期：情景记忆（发生了什么）、语义记忆（什么是真的）、程序记忆（什么方法有效）。

大语言模型会通过改写消除关键词的精确性。每一轮对话都以检索原始消息开始；检索采用混合方式，然后进行重排序。

仅追加的记忆会腐化。整合过程将情景提升为持久事实；取代机制则淘汰用户反驳的事实。

旧事实不应与新事实拥有相同的排名。分数会随时间衰减，而一次召回会将事实的排名重新提升。

不同租户绝不能互相可见。隔离通过 DLS 在集群层面实现，而非通过一个可能被遗忘的过滤器。

这些都不是独立的系统：目录、隔离和衰减全部整合到一次 Elasticsearch 查询中。

只要把这个做对了，那个一直告诉 Sarah 重置集线器的助手终于能记住：她在三月份已经试过了，狗会咬传感器线缆，而且家现在在爱丁堡。
