作者:来自 Elastic Noam Schwartz
发现基于 Elasticsearch 构建的持久化、多租户 agent 记忆层架构:三个索引、结合 RRF 和重排序器的混合检索、supersession、衰减机制以及按用户的 DLS(Document-Level Security) 隔离。在 168 个问题上 R@10 达到 0.89。包含完整开源实现。
Agent Builder 现已正式发布。通过 Elastic Cloud 试用 开始使用,并查看 Agent Builder 的文档 这里。
在 Elasticsearch 上构建 agent 记忆
_三个索引、结合 reranker 的混合召回、_替代机制(supersession)、衰减机制以及 DLS。这是为 agent 构建持久记忆层的架构与背后指标说明。
Sarah 的智能灯泡只显示白光。她的智能家居助手建议重置 hub(集线器)。她在三月做过一次重置,上周又做了一次;两次重置都没有解决问题。agent 并不知道这些,也不知道她的狗咬断了传感器电缆这件事。那些重要的历史信息 —— 什么有效、什么无效、以及 Sarah 是谁 —— 都随着每个 session 结束而消失了。
常见的变通方法是把之前的上下文塞进 context window。但这在成本、延迟(latency)以及众所周知的 “lost in the middle(中间遗失)” 效应上都会失效,在该效应中,模型会忽略那些位于提示词两端之外的事实。1M token 的 context window 也只是一个便签板(scratchpad),而不是一个记忆系统。
context window 是短期记忆:用于单次 inference 的活跃推理空间。而缺失的是长期记忆:一个持久化存储,可以跨 session 存在、支持多年交互,并且能够按内容、时间和用户检索事实。
这篇文章讲的是一个真实 agent 记忆系统的架构,构建在 Elasticsearch 之上,并围绕来自认知科学(cognitive science) 的三类结构设计,一个结合 RRF 和 cross-encoder reranker 的混合 recall query,用于矛盾处理的覆盖替代,以及按用户的 DLS 隔离。在一个包含 168 个问题的 QA 评测中,R@10 平均达到 0.89,且没有跨租户泄漏。
完整实现已开源在 GitHub;本文重点在于解释它为什么会被设计成这样。
Agent 记忆存储需要做什么
当用户问:“我们上次尝试了什么修复?” 这是一个带时间约束的精确匹配查询。或者问:“为什么我的智能灯泡只显示白色?” 这需要把个人记忆与共享目录信息结合起来。记忆本身并不是统一行为:用户经历过的事件、关于用户的稳定事实,以及一步一步的操作流程,它们各自有不同的写入频率和衰减规则,因此存储系统必须识别类型并分别处理。
在任何多用户部署中,每个用户的记忆都必须对其他用户完全不可见。新鲜事件会快速累积,如果不进行归并,就会把索引变成一堆无序信息。用户一旦和已检索事实产生矛盾,旧版本必须通过 覆盖替代(supersession)而不是删除来处理,以便保留审计轨迹。旧事实不应压过新事实,用户频繁触达的事实也不应下沉。整个 memory 层还应该可以被任何支持 MCP 的 client 访问,而不是绑定在某一个 agent runtime 上。
如果把这些能力拆分到一个 vector store、一个关键词引擎、一个审计层以及一个独立认证服务中,就会变成四个可能出错的系统,并且每次 recall 都要额外增加往返开销。这些需求本质上描述的是一个 search engine,因此这个实现直接使用一个系统完成。后文将逐一展开。
Agent 记忆的三种类型:情景记忆、语义记忆、程序记忆
第一个设计决策是:到底要存储哪些类型的记忆。如果只是把所有东西都存起来,就会变成没有信号的干草堆。来自认知心理学(cognitive psychology)的 情景记忆(episodic)、语义记忆(semantic)、程序记忆(procedural)划分,在 COALA 框架中已经被用于 LLM agent,这一分类本身就非常合适,并且可以直接映射到三个 Elasticsearch 索引。
-
情景记忆(episodic memory):带时间戳的事件。每一轮用户输入在进入系统时就被记录下来,尚未经过抽取或解释。其中大部分是短期的,不一定需要长期保存,只有少部分会成为后续稳定事实的证据。
-
语义记忆(semantic memory):被提炼后的稳定用户断言。例如:Sarah 拥有 Lumio Hub v2,Sarah 使用 iOS 17.4,Sarah 的 hub 在三月被重置。这些信息跨 session 存在,是 agent 进行 grounding 的基础。
-
程序记忆(procedural memory):多步骤操作流程。例如 Zigbee 断连的排查步骤。它不是事实,而是过程。每条流程都会携带 success_count 和 failure_count,并在 consolidation 时根据用户是否确认修复成功来更新。这些计数会作为上下文提供给 LLM,用于判断是否需要优化或替换该流程。
每一种 memory 都有不同的生命周期。情景记忆持续写入并衰减;语义记忆会被整理、去重并在用户信息变化时被覆盖替代;程序记忆则通过 success_count 和 failure_count 累积反馈,用于指导 consolidation。单一结构无法表达这些差异,因此采用三个索引,每个 memory 类型拥有独立的写入频率、衰减规则和更新机制。
在这三类之外,还有第四种检索(retrieval)数据源:已经存在于 Elasticsearch 中的世界数据(目录、知识库)。它在认知意义上不属于 “记忆”,但 agent 通过同样的 hybrid retrieval pipeline 访问它(下一节会讲),因此在整体架构中被统一纳入同一视图。
检索管道:基于 RRF 和 reranker 的混合检索
Memory 的召回通过一个两阶段的 hybrid search 实现:在 BM25 + Jina v5 dense上做 RRF 融合,然后对合并后的候选集用 cross-encoder reranker 重新排序。每一条 document 在一次写入中被双重索引:原始文本进入 BM25 的 inverted index,同时通过 copy_to 把同样的值路由到 semantic_text 字段,由此自动生成 Jina v5 向量。Indexing 同一份内容两次并不会增加存储负担:一次 source-of-truth 写入同时产生两条检索路径(index mapping)。每条路径解决不同问题。BM25 捕捉字面 token 匹配,这些信息在 agent 改写问题时会丢失:版本号、错误码、像 “Lumio Hub v2” 这样的专有名词。dense 向量捕捉语义结构,即使表达不同也能匹配问题与答案。单独任何一条路径都会遗漏另一条能覆盖的情况,而 RRF 在无需对 BM25 分数和 cosine 相似度做标定的情况下融合排序结果。
Over-fetch。 reranker 只能对已有候选集重排序,因此候选集必须足够大。混合检索器每条路径取 80 个候选,并用 RRF 进行融合,rank_constant=30(比 ES 默认 60 更紧,使得高排名条目权重更集中)([](https://github.com/noamschwartz/atlas-memory-demo/blob/main/backend/app/atlas/memory/operations.py#L497 " ")rrf_fetch[](https://github.com/noamschwartz/atlas-memory-demo/blob/main/backend/app/atlas/memory/operations.py#L497 " "))。
Reranker。 使用 Jina v2 cross-encoder 对合并后的候选与用户查询进行打分。BM25 和 bi-encoder dense 是分别独立对 query 和 document 编码,而 cross-encoder 会对二者联合建模,在完整 attention 下计算相关性,效果更强但计算成本更高。这正是两阶段 pipeline 的原因:先用便宜的 hybrid retriever 做 over-fetch,再用更昂贵的 scorer 对小候选集 rerank([](https://github.com/noamschwartz/atlas-memory-demo/blob/main/backend/app/atlas/memory/operations.py#L381 " ")rerank[](https://github.com/noamschwartz/atlas-memory-demo/blob/main/backend/app/atlas/memory/operations.py#L381 " "))。
有一个细节,如上图所示。agent 的工具集包含 recall_memory(定义在 tools.py),模型在一次对话中会调用它。一次调用会同时横向检索所有三个 memory index 和 catalog:agent 不需要选择 memory 类型,因为 retriever 的 ranking 和各 index 的时间衰减已经完成路由。第二个细节是改写(paraphrasing)。agent 几乎总是会先改写用户消息再调用工具,这会在 BM25 看到 query 之前剥离版本号、错误码和专有名词。因此每一轮都会先对用户原始消息做一次自动 pre-recall,把结果注入对话中,仿佛 agent 自己已经调用过该工具([](https://github.com/noamschwartz/atlas-memory-demo/blob/main/backend/app/atlas/agent.py#L128 " "))。
写入与合并 agent memory
有两个操作把 memory 从“刚发生的事件 ” 变成 “长期保留的信息”。
Write。 每一轮用户输入都会先写入一条 episodic event(ID、原始消息、时间戳等),然后 LLM 才开始响应。ID 由 Elasticsearch 写入时生成,DLS(Document-Level Security) 查询通过 Sarah 的 API key 在后续每次 recall 中限制数据范围,时间戳用于下面的 time-decay 排序函数。agent 的回复不会被存储,因为对话历史已经会在下一轮输入中携带这些信息,而且回复内容通常冗长,会稀释用户提供的短但高价值事实。选择在 hot-path 写入是刻意的。两种看似合理的替代方案都存在问题。把新事实留在 context window 中确实能覆盖当前 session,但一旦 session 结束或崩溃,in-context state 就会消失,而跨 session memory 正是目标。
在 session 结束时 batch 写入可以保留跨 session 状态,但会破坏同一轮中的两类关键流程:用户在同一条消息中提到新设备并查询设备列表时,新事实必须在同一 turn 后续 recall 中可见,因为 tool call 查询的是 index 而不是 conversation history;supersession 流程也会在同一 tool-call batch 中写入修正事实并立即 recall。如果延迟写入,这些模式会静默出错。因此选择每条用户消息一次 Elasticsearch 写入,成本是可控的(单次通常低于 100ms)。
“哪些建议有效” 被单独记录在 procedural index 中的 success_count / failure_count,而不是保存完整回答文本。最近的带用户确认的事件(“谢谢,这有效”)会触发 success_count++;明确否定(“没用”)触发 failure_count++。对话本身作为反馈信号,由 consolidation LLM 做分类,不需要点赞组件。分歧还会生成 refined_steps 字段回写到流程中。
Consolidate。 episodic logs 增长很快,因此需要把它们提升为 semantic facts 和 procedural playbooks,使其在对话历史消失后仍然存在。该实现每轮都会运行一次(用于观察 inspector 实时更新),但在生产中更合理的节奏是后台任务:每 24 小时一次,或当用户 episodic index 超过 N 条事件时执行。逐轮执行会让 LLM 调用次数翻倍。
在一次调用中(prompt),consolidation LLM 会接收最近事件 + 已有 facts 和 playbooks,并生成三类输出:
-
新的 semantic facts,包含
supporting_episode_ids用于溯源 -
新的 procedural playbooks,当多步骤解决方案不匹配已有 trigger 时生成
-
procedural updates,基于用户反馈更新
success_count++/failure_count++,以及在用户否定时生成 refined_steps
Prompt 要求每条输出都必须包含 supporting_episode_ids,因此没有信息支撑的 turn 会返回空列表,不会写入任何内容。
Dedup 使用与 recall 相同的 hybrid retriever:对每个候选 fact 先做 top-K hybrid search 缩小比较范围,再交给 LLM 判断语义是否重复。还有两个额外保护条件:低于 confidence 阈值的候选会被丢弃;如果最相似匹配 ≥ 0.90,则视为重复。在这个实现中,dedup 更简单:只把最近约 50 条 facts 交给 consolidation LLM,并提示 “不要重复”,而 post-LLM 的 confidence / similarity guard 尚未接入。hybrid-recall 和 guard 是生产架构,这个版本依赖 LLM 直接比较,因为 corpus 足够小。
success_count 和 failure_count 形成 playbook 的反馈闭环:随着对话增多,同一字段逐渐变成“下次优先展示哪个方案”的信号。目前这些计数已经写入,但还没有影响 retrieval ranking。在少量已解决 ticket 上,这个信号还只是统计噪声;在正式生产中,随着数据量增加,它会变得有意义。
Agent 如何处理矛盾与 supersession 的 memory
只会不断追加、不会删除的 memory,最终一定会出错。用户说 “我搬到 Edinburgh”; agent 写入一个新事实。六个月后,旧的 “住在 Bristol” 的事实仍然留在 index 里。两者都会在每次 recall 时被检索出来, agent 要么选错,要么只能含糊处理。信任会很快崩塌。
解决方法是在 system prompt 里加一条规则(full prompt),不引入新工具。不是删除,而是让 agent 进行 supersession:
`
1. If the customer contradicts a recalled fact, call
2. write_memory(text=<new>, supersedes_id=<old id>, contradiction="harsh"|"natural").
4. Use contradiction="harsh" when the customer explicitly denies or
5. corrects the prior fact ("no, that's wrong", "I never X"). The
6. new fact carries a small confidence penalty.
8. Use contradiction="natural" for routine updates (moved, upgraded,
9. preference change). The new fact is written at full confidence.
11. Never ask the customer to confirm before superseding.
12. The contradiction itself is the signal.
13. forget_memory is only for explicit "forget this" requests.
`AI写代码
一个完整示例
Sarah 的上一次访问记录了 id=abc,“Sarah lives in Bristol”,写入 semantic index。三个月后,她打开对话:“we left Bristol, in Edinburgh now.”
1. 召回(Recall)。 对 Sarah 的消息进行 pre-recall,返回命中结果,包括 {id: "abc", text: "Sarah lives in Bristol", memory_type: "semantic"}。
2. 检测(Detect)。 agent 发现被召回的旧事实与新消息之间存在冲突。
3. 分类(Classify)。 “we left Bristol, in Edinburgh now” 被判断为自然更新,而不是否认,因此选择 contradiction="natural"。
4. 写入(Write)。 agent 调用
[](https://github.com/noamschwartz/atlas-memory-demo/blob/main/backend/app/atlas/memory/operations.py#L55 " ")write_memory[](https://github.com/noamschwartz/atlas-memory-demo/blob/main/backend/app/atlas/memory/operations.py#L55 " ")(text="Sarah lives in Edinburgh", supersedes_id="abc", contradiction="natural")。这一操作会同时发生两件事:
-
写入一条新文档
id=xyz,并保持完整置信度(因为是自然变化,不做惩罚) -
旧文档
abc被更新为superseded_by=xyz, superseded_at=<now>
5. Recall 隐藏旧数据(Recall hides the old)。 每次 recall 都会应用一个过滤规则(filter must_not exists field=superseded_by)。因此 abc 会从 agent 视野中隐藏,xyz 正常返回。
6. 审计保留(Audit kept)。 文档 abc 仍然保留在 index 中。通过查询 superseded_by=xyz 可以重建完整链路。
注意:如果 Sarah 后续问“我住过哪些地方?”,agent 会调用
recall_memory(query="places sarah has lived", include_superseded=True)。由于 DLS 作用域限制,这次查询会同时返回 xyz(Edinburgh) 和 abc(Bristol)。带有 superseded_at 的记录表示历史状态,agent 在回答时会区分它们(实现位置):
“你现在住在 Edinburgh;你之前住在 Bristol(直到今年早些时候)。”
如果 Sarah 说的是:“I never lived in Bristol, that was my sister”,第 3 步会被分类为 harsh。写入流程相同,但新事实的置信度会被 SUPERSEDE_CONFIDENCE_PENALTY 降低,使系统在新状态未被充分确认前保持一定谨慎。
边界情况遵循同样结构:一个已被 supersede 的事实可以继续被 supersede(abc → xyz → pqr);低风险偏好(例如 “我现在更喜欢 dark mode”)同样按 contradiction="natural" 处理。forget_memory 才是硬删除机制,只在用户明确要求 “忘掉 X” 时使用,不用于普通冲突更新。
还有一个关键细节:一次 recall 可能命中多个与新陈述冲突的事实。例如 Sarah 的位置可能同时存在:
-
“Sarah lives in a Victorian flat in Bristol”(semantic)
-
“Sarah has a flat in Bristol where her Hub v2 is”(semantic)
-
以及某次 episodic event
agent 必须对所有冲突事实执行 supersede,而不是只处理第一个命中的结果。对每个被新陈述否定的 id,都要分别调用 write_memory(supersedes_id=…)。但那些只是 “提到 Bristol、但仍然成立” 的信息不会被覆盖,例如:
“Bristol 的维多利亚式公寓墙体很厚,会削弱 Zigbee 信号”
这类知识仍然有效,因此不应被 supersede,因为它不依赖“是否住在 Bristol”。
已 supersede 的文档默认不会出现在普通 recall 中,只有在 include_superseded=True 时才会返回。在生产环境中,这类数据通常通过 Elasticsearch 的 ILM(Index Lifecycle Management)迁移到冷/冻结层(searchable snapshots)。审计链仍然可查询,但活跃 semantic index 保持小而高效。
确保 Elasticsearch agent memory 的同一 turn 写入可见性
Elasticsearch 默认的异步 refresh 间隔会在 agent 写入 memory 并在同一 conversation turn 立即进行 recall 时产生传播延迟。当用户在同一条消息中说:“I have a Lumio Range Extender I never set up. Now what's my complete device list?” 时,agent 会先写入 Range Extender 这一事实,然后立刻执行 recall,这一过程发生在同一个 turn,甚至可能在同一个 tool-call batch 内。默认的 Elasticsearch refresh 间隔,加上 semantic_text 的 inference 计算成本,可能导致亚秒级传播延迟,使刚写入的文档在 recall 时尚不可见。
解决方案在存储层。每一次 agent 触发的 write_memory 都会传入 [](https://github.com/noamschwartz/atlas-memory-demo/blob/main/backend/app/atlas/tools.py#L233 " ") refresh=True[](https://github.com/noamschwartz/atlas-memory-demo/blob/main/backend/app/atlas/tools.py#L233 " "),强制 shard 在返回前 refresh(同时 inline inference processor 生成的 Jina v5 embedding 也会完成落盘)。这样下一次 tool call 就能看到新写入的文档。Range Extender 会出现在最终回答中,因为紧随写入之后的 recall 已经能检索到它。
在更高写入量下,refresh=True 会带来 throughput(吞吐) 成本。生产环境可能会倾向于切换为异步 indexing,同时在 agent 层维护一个 “刚写入记录表”,把新写入内容暂存在 LLM context 中,直到 index 追上为止。目前这种更简单的方案在系统中仍然占据合理位置。
Agent 记忆检索中的时间衰减与使用次数评分
目前为止的检索方案对所有事实赋予相同权重,而不考虑它们的创建时间或最近使用时间。这并不是一个合理的默认策略。一个在过去一周被召回两次的事实,几乎肯定比一个两年前只提过一次的事实更相关。
因此,我们在每个结果的评分上乘以两个权重因子:一个主导的时间衰减(time-decay)信号,以及一个辅助的使用频率优化信号。时间衰减是一个高斯形状的乘子,在 Painless 中基于每个 index 的日期字段计算(见下文)。使用频率优化是一个use-count boost(1 + log10(1 + use_count) * weight),大致效果是:被召回 10 次的事实约提升 1.2 倍,被召回 100 次的事实约提升 1.4 倍。
这两个机制回答的是不同问题:时间衰减回答 “这个事实最近是否被使用”,使用次数回答 “这个事实被使用了多少次”。当多个事实共享相同的 last_used_at 时,两者就会产生分化:衰减无法区分 “只用过一次” 和 “用过四十次”,但 use-count 可以。时间衰减是基础信号;use-count 是在召回规模足够大后才变得有效的增强信号。
每种 memory 类型的时间字段
episodic 和 semantic 使用不同的时间字段。episodic 使用 timestamp(事件发生时间),semantic 使用 last_used_at(写入时设置,并在每次 recall 时更新)。Elasticsearch 原生的 gauss 无法跨字段工作,因为该函数要求所有 index 必须存在同一个字段名。因此时间衰减必须在 Painless 脚本 中实现:根据 index 类型选择不同字段,并在运行时计算高斯衰减值。
procedural memory 被刻意排除在时间衰减之外。因为 last_used_at 会在每次 recall 时更新(无论成功与否),如果直接使用衰减函数,会奖励“最近被尝试过”,而不是“最近有效”。更合理的设计应该是引入 last_success_at,并结合 success_count / failure_count 来参与排序。在这些字段尚未完整接入之前,仅靠时间衰减会过于粗糙。
semantic 上的 recall-time bump 是整个机制的关键。它将“旧事实权重下降”转化为“长期未被使用的事实权重下降”。这是相关性衰减,而不是事实衰减。事实是否仍然真实由 supersession 机制处理;而一个五年前的事实如果仍然被频繁召回,它依然会排在前列,因为 last_used_at 是最新的。
这也对应认知科学中的同一机制:retrieval practice(检索练习)会增强记忆可用性,而长期不使用会导致衰减。对 last_used_at 的召回时更新,本质上是这一机制的工程实现。
检索时的乘子(retrieval-time multiplier)
两个因子最终都会汇入同一个 function_score block,并包裹在每一条 RRF 分支之上:
`
1. {
2. "function_score": {
3. "query": "<bool query: text/semantic match + filters>",
4. "functions": [
5. {
6. "filter": {"terms": {"_index": ["atlas_memory_episodic", "atlas_memory_semantic"]}},
7. "gauss": {"last_used_at": {"origin": "now", "scale": "1825d", "offset": "180d", "decay": 0.5}}
8. },
9. {
10. "filter": {"term": {"_index": "atlas_memory_semantic"}},
11. "script_score": {"script": "1 + log10(1 + use_count) * 0.2"}
12. }
13. ],
14. "score_mode": "multiply",
15. "boost_mode": "multiply"
16. }
17. }
`AI写代码
在代码层面,这两个函数都写在同一个 Painless 脚本里,只是按 index 做分支(同一套数学逻辑,更少的 function_score 条目)。
两个 _index 过滤器起到双重作用:它们用于限定每个函数应该影响的 memory 类型范围——时间衰减作用于 episodic 和 semantic,而 use-count boost 只作用于 semantic。同时它们也把 procedural 和 catalog 排除在外:如果某个 function 的 filter 不匹配,就返回中性值 1.0,因此即使 cross-index 查询包含这些 index,也不会出现评分或解析问题。完整函数在 [](https://github.com/noamschwartz/atlas-memory-demo/blob/main/backend/app/atlas/memory/operations.py#L168 " ")operations.py[](https://github.com/noamschwartz/atlas-memory-demo/blob/main/backend/app/atlas/memory/operations.py#L168 " ")。
两个参数控制 gauss 曲线:
-
offset(180天):一个平坦区间(flat zone)。小于180天的文档统一乘以1.0的系数,不论具体新旧。否则新事实之间会因为“亚天级别”的时间噪声而互相竞争。 -
scale(1825天,约5年):距离 offset 之后的时间点,在该位置衰减系数达到decay = 0.5。可以理解为“从平坦区结束开始计算的半衰期”。
衰减本身是一个刻意的权衡。当 corpus 中每个事实都是唯一且长期有效时,任何衰减都会带来 recall 损失:旧事实仍然正确,但被人为降权。衰减真正发挥价值的场景是现实情况:多个关于同一对象的事实同时存在,而你希望最新或最常被使用的那个排在最前。
默认 scale(1825天)是保守设置,因为它更不容易误伤长期有效信息。在产品支持这种“事实快速过期”的场景(例如产品频繁迭代的客服系统)可以调小;在个人助手 memory 这种“事实长期稳定”的场景可以调大。这两个参数都只是对 constants.py 的一行修改。
基于 Elasticsearch DLS 的多租户隔离
Document-Level Security(DLS) 将隔离规则直接下沉到 cluster 层。每个用户都有一个 API key,其 role descriptor 中包含一个 DLS 查询,只允许访问属于该用户的文档(以及共享 catalog,因为它没有 user_id 字段)。使用该 key 的 agent 无论执行什么查询,都无法看到其他用户的数据 —— cluster 在返回结果前已经过滤掉了。这是生产级别的隔离保证,由 server 端在每次查询时强制执行。
retriever 还额外在代码层加了一层 user_id filter,作为防御性兜底:防止 index template 配置漂移、role descriptor 被修改但 DLS 条件丢失、或 admin key 被误用等情况。DLS 是架构级保障,这一层只是低成本的 paranoia check。
将共享 catalog 数据接入 agent memory 检索
memory lookup 本质上是一条 Elasticsearch 查询。Sarah 的 API key 的 DLS 规则允许访问 user_id == "sarah" 的文档;而 catalog 和共享索引没有 user_id 字段,因此默认对所有人可见。为了把它们纳入同一次检索,DLS 查询从“必须等于 sarah”扩展为:“等于 sarah 或不存在 user_id”:即一个 bool.should 条件,包含 user_id == "sarah" 或 must_not exists: user_id。这样 catalog 与个人 memory 可以在同一次 recall 中一起返回。RRF 融合和 time decay 都不需要改变。
用户初始化 key 的 bootstrap 脚本在 bootstrap script 中生成,并内置了这个扩展后的 DLS 条件。
因此当查询 “smart bulb showing only white” 时,系统会同时返回 Sarah 的历史约束 + catalog 中关于灯泡兼容性的条目,并在同一个 ranking 里排序。
user memory 和 catalog 可能在同一 recall 中出现并相互冲突。retriever 在同一个 script 内加入一个轻量 source prior(CATALOG_SOURCE_PRIOR,0.85),作为一个额外 _index 分支实现,不引入新机制,用于在近似相关度相同时偏向 user memory。
这是一个 soft bias,而不是 routing rule:如果 catalog 的 relevance 明显更高(例如产品规格或技术查询),reranker 仍然会选择 catalog。那些“必须始终信任 catalog”或“必须优先用户偏好”的硬规则,则放在 agent system prompt 中,而不是 retriever 层。
通过 MCP 连接任意 agent
memory 层的价值在于不绑定单一 agent。Model Context Protocol 提供了这一能力。endpoint 为 /api/atlas/mcp/{user_id},任何支持 MCP 的客户端(Claude Desktop、Cursor 或自定义 agent)都可以通过在配置中粘贴 mcp.py 提供的 JSON 片段接入。
在 Claude Desktop 中,配置文件位于 ~/Library/Application Support/Claude/claude_desktop_config.json(macOS)或 %APPDATA%\Claude\claude_desktop_config.json(Windows)。在 Cursor 中则位于 Settings → MCP。重启后,三个 Atlas 工具(recall_memory、write_memory、forget_memory)会出现在 tool 面板中,调用的是同一套 Elasticsearch indices,与 FastAPI 服务共享同一 memory 层。三种 tool contract 定义在 tools.py 中。
衡量 agent-memory 的召回质量
关于 “recall” 的说明:在本文其他部分,它指的是 memory recall(agent 从存储中检索事实)。而在这里,它指的是信息检索指标中的 Recall@K:正确文档是否出现在 top-K 结果中——两者含义不同。
memory 架构很难验证。这里的评估采用 QA 风格的 passage retrieval,也就是标准的 RAG 基准测试。对于每一条采样的 document,LLM 会生成两个用户可能提出的问题,这些问题的答案都指向该文档。例如:“my baby's sleep is fragile, anything I should remember when setting up automations?” 会指向 Sarah 关于 nursery quiet-hours 的事实。然后 retriever 必须在 top-K 中检索到对应的源文档。
类似 memory 的通用 benchmark(例如 LoCoMo)已经存在,并且可以用于跨系统对比。但这里选择 corpus-specific QA 模式有两个原因。第一,它针对的是每个 persona 的真实部署语料,因此 recall 数字更贴近真实对话场景。第二,它专注于检索这一阶段(源文档是否进入 top-K),而本文中的 hybrid + decay + reranker pipeline 正是在优化这一部分;而 LoCoMo 的 dialogue coherence 指标则衡量更下游的整体生成质量。
后续文章会运行完整的 LoCoMo benchmark,并进一步拆分 retrieval performance 与 LLM 选择、prompt engineering 等因素的影响。
对于任何多租户记忆系统而言,“泄漏数”(leaks number)是决定能否通过的关键指标,而其余指标则反映了质量水平。在 CI 流程中,评估环节设有硬性门槛(由 eval_recall.py 执行):要求 R@10 ≥ 0.85、R@5 ≥ 0.75 且泄漏数为 0。上述数值均为近似值,因为重排序器(reranker)在服务侧存在波动:在连续四次运行中,R@10 的结果分别为 0.85、0.88、0.89 和 0.893。
Semantic facts 是更困难的情况(R@10 ≈ 0.81);episodic 平均 0.98,而 procedural 命中率 1.0。原因是 sibling collision:一个关于 Sarah 的 hub disconnects 的问题,在 corpus 中可能存在多个“看起来都合理”的事实,retriever 有时会选错那个。
值得注意的是:sibling collision 通常不会降低 agent 的最终回答质量(因为它仍然会拿到一个相关且真实的事实),所以 R@10 对 semantic 来说本质上是保守指标。
Agent memory architecture:关键设计决策
Agent memory 是一组问题,每个问题对应一个关键解法:
-
memory 不是单一事物。 三个 index,对应三个生命周期:episodic(发生了什么)、semantic(什么是真的)、procedural(什么有效)。
-
LLM 会 paraphrase,削弱关键词精度。 每一轮都会先对原始消息做 recall,因此 retrieval 使用 hybrid,再经过 rerank。
-
append-only memory 会退化。 consolidation 会把 episodes 提升为持久 facts;supersession 用于淘汰用户已否认的事实。
-
旧事实不应该与新事实同等排序。 score 会随时间 decay,同时 recall 会将常用事实重新抬升。
-
多租户必须完全隔离。 isolation 由 cluster 内的 DLS 实现,而不是容易遗漏的应用层 filter。
这些并不是独立系统:catalog、isolation 和 decay 最终都组合成一条 Elasticsearch query。
只要这些机制正确组合,那个曾经不断让 Sarah 重置 hub 的 assistant,就会记住:她三月已经试过了,狗会咬传感器线,而家现在在 Edinburgh。
原文:Agent memory on Elasticsearch: hybrid retrieval and DLS - Elasticsearch Labs