跳到正文
北京时间
原文
Hacker News 热门(buzzing.cc 中文翻译)· showmypost·· 2026-06-19精选AI 评分73

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

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

AI 导读

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

推荐理由

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

正文 · AI 翻译

在 Elasticsearch 上构建智能体记忆

三个索引、带重排序器的混合召回、取代、衰减以及 DLS。智能体持久记忆层背后的架构与数据。

Sarah 的智能灯泡只显示白光。她的智能家居助手建议重置中枢。她在三月份做过一次,上周又做了一次;两次重置都没能解决任何问题。智能体并不知道这些,也不知道她的狗咬坏了传感器线缆。那些真正重要的历史——什么有效、什么无效、Sarah 是谁——都随着每次会话结束而消失。

标准的变通做法是把先前的上下文塞进上下文窗口。这在成本、延迟以及有据可查的“中间迷失”效应上都会失效——模型会忽略那些放置在远离提示词两端位置的事实。1M token 的上下文窗口只是一块草稿纸,它不是记忆系统。

上下文窗口是短期记忆:单次推理的活跃推理空间。缺失的是长期记忆:一个能在会话结束后依然存续、可扩展到数年交互、并让你能按内容、按时间、按用户检索事实的持久存储。

本文讨论的是一个真实智能体记忆系统的架构,它构建在 Elasticsearch 之上,围绕 认知科学中的三个类别来组织,采用一条结合 RRF 与交叉编码器重排序器的混合召回查询,用取代机制处理矛盾,并通过按用户的 DLS 实现隔离。在覆盖 168 个问题的 QA 式评测中,R@10 平均为 0.89,且零跨租户泄露。

完整实现已在 GitHub 上;本文讨论的是为什么它会设计成这个样子。

智能体记忆存储必须做到什么

用户会问“上次我们试了什么修复方案?”,这是一个带精确匹配约束的时间性查询。或者问“为什么我的智能灯泡只显示白色?”,这需要把个人记忆与共享目录混合起来。记忆本身的行为并不统一:用户亲身经历的事件、关于他们的稳定事实,以及一步步的操作手册,写入频率和老化规则各不相同,因此存储必须识别类型并分别对待。而在任何多用户部署中,每个用户的记忆都必须对其他所有用户不可见。新事件积累得足够快,必须被整合进持久化的类别中,否则索引就会变成一座大海捞针的草堆。当用户与召回的事实发生矛盾时,旧版本必须被取代而不是删除,这样审计轨迹才能保留。较旧的事实不应压过较新的事实,而用户频繁触及的事实也不应沉底。整个记忆层应当能被任何会说 MCP 的客户端访问,而不是绑定在某一个智能体运行时上。

把这些拆分到向量存储、关键词引擎、审计层和一个独立的认证服务中,意味着有四个可能出故障的环节,而且每次召回都会带来额外的往返开销。需求描述的是一个搜索引擎,因此本实现就采用了一个搜索引擎。本文的其余部分将逐一讲解这些内容。

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

第一个设计决策是究竟要存储哪些类别的记忆。把所有东西都存下来只会堆出一个毫无信号的干草堆。认知心理学中情景记忆、语义记忆与程序性记忆的划分,在LLM智能体中的COALA 框架中已经被提出,它已经具备了正确的类别划分,并且能干净地映射到三个 Elasticsearch 索引上。

  • 情景记忆。带时间戳的事件:每一次用户发言在它到达时即被记录,先于任何提取或解读。其中大部分是短命的:并不总是值得保留。少数条目日后会成为持久事实的证据。
  • 语义记忆。关于用户的、经过提炼且稳定的断言。Sarah 拥有一台 Lumio Hub v2。Sarah 使用的是 iOS 17.4。Sarah 的 hub 在三月份被重置过。这些内容跨会话存续,也是智能体进行依据的基础。
  • 程序性记忆。多步骤操作手册。如何排查 Zigbee 断连问题。是流程,而非事实。每一条都带有 success_count 和 failure_count,当用户确认某个修复方案有效或无效时,由整合过程递增。当整合 LLM 考虑是否要改进或替换某份操作手册时,这些计数器会作为上下文呈现给它。

每个类别都有不同的生命周期。情景记忆不断写入并逐渐衰减。语义记忆经过整理、去重,并随着用户的变化而被取代。程序性记忆则累积结果反馈(success_count、failure_count),为整合过程提供输入。单一存储桶无法建模这一点。三个索引,每种记忆类型各一个,让各自遵循自己的写入速率、自己的老化规则和自己的更新规则,而无需相互耦合。

在这三者之外,还有第四个检索面:已经存在于 Elasticsearch 中的世界数据(目录、知识库)。它并非认知意义上的“记忆”,但智能体通过同一套混合检索流水线(将在下一节介绍)来读取它,因此它属于同一幅图景。

召回流水线:基于 RRF 与重排序器的混合检索

记忆通过两阶段混合搜索被召回:先对BM25 + Jina v5 稠密向量做 RRF,然后对合并后的候选集运行交叉编码器重排序器。每篇文档通过一次写入以两种方式建立索引:原始文本进入 BM25 倒排索引,而copy_to将同一个值路由到一个semantic_text字段,该字段自动生成 Jina v5 向量。对同一内容索引两次使存储占用保持平稳:一次真值源写入同时产生两条检索路径(index mapping)。每条路径解决不同的问题。BM25 锚定字面 token 匹配,而这些匹配会被智能体的改写所消解:版本号、错误码、像“Lumio Hub v2”这样的专有名词。稠密向量则捕捉问题的语义形态,即便答案使用了不同的措辞。任何一条路径单独使用都会漏掉另一条路径能处理的案例,而 RRF 融合它们的排名,无需将 BM25 分数与余弦相似度进行校准。

超额获取。重排序器只能对它看到的内容重新排序,因此候选池需要足够宽。混合检索器每条路径获取 80 个候选,并用rank_constant=30进行 RRF 融合(比 ES 默认的 60 更紧,因此排名靠前的条目占据更大主导权)。(_rrf_fetch)

重排序器。一个 Jina v2 交叉编码器会对合并后的候选结果与用户查询进行打分。BM25 和双编码器稠密检索都各自独立地对查询和文档打分,而交叉编码器则对二者联合打分,在查询-文档对上施加完整的注意力,从而带来更强的相关性信号,代价是每一对的成本更高。这正是两阶段流水线的动机所在:先用混合检索器廉价地多召回一些,再用更昂贵的打分器对一个小候选池进行重排序(_rerank)。

有一个微妙之处,如上图所示。智能体的工具包中包含 recall_memory(定义于 tools.py),模型会在一轮对话中调用它。单次调用会同时扇出到全部三个记忆索引以及目录:智能体并不挑选记忆类型,因为检索器的排序和每个索引各自的衰减会替它完成路由。第二个微妙之处是改写。智能体在动用该工具之前几乎总会重写用户的消息,这会在 BM25 看到查询之前就剥离掉其中的字面版本号、错误码和专有名词。因此每一轮对话开始时都会对用户的原始消息自动进行一次预召回,并像智能体自己发起了这次调用一样注入到对话中(agent.py)。

写入并整合智能体记忆。

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

写入。在 LLM 响应之前,每个用户轮次都会写入一个情景事件(ID、精确消息、时间戳等)。ID 由 Elasticsearch 在写入时分配,Sarah 的 API key 上的 DLS 查询会在后续每次召回时将该文档限定在她范围内,而时间戳正是时间衰减函数(见下文)读取用来将该事件与更新事件进行排序的依据。智能体的回复不会被存储。对话历史已经会在下一次调用时把它们带入,而且它们的长度会淹没用户所说的那些简短、富含事实的内容。热路径写入是一个刻意的选择。有两种替代方案乍听起来似乎合理。让上下文窗口把新事实向前携带,在某个打开会话的剩余时间内是可行的,但会话一旦结束或崩溃,上下文内状态就会消失;跨会话记忆才是整件事的关键。

在会话结束时批量写入可以保留跨会话状态,但它会破坏这一实现所依赖的两种同轮次模式。用户在同一条消息中提到一个新设备并询问其设备列表时,需要让这个新事实对同一轮次稍后运行的召回可见,因为工具调用查询的是索引,而不是对话历史。而取代流程会在一个工具调用批次内写入一个更正后的事实,并针对它进行召回。在延迟写入下,这两种模式都会悄无声息地出错。我们为此付出的代价是每条用户消息一次 Elasticsearch 写入,而在单次对话产生的数据量下,这不到 100ms。

哪些建议起了作用是单独捕获的,由success_count / failure_count在过程索引上记录,而不是通过存储回复文本来实现。近期包含用户确认(“谢谢,这招管用”)的对话片段会触发 success_count++;明确否定(“这没帮上忙”)则触发 failure_count++。对话本身就是反馈信号,由整合用的 LLM 充当分类器。不需要点赞按钮。出现分歧时还会浮现一个 refined_steps 字段,供 LLM 写回操作手册。

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

在一次调用(prompt)中,整合用的 LLM 会收到近期情景日志以及现有的事实和操作手册,并被要求产出三样东西:

  • 新的语义事实,附带supporting_episode_ids以标明来源。
  • 新的过程性操作手册,当某个多步解决方案不匹配任何现有触发条件时。
  • 过程性更新,根据用户是否确认了修复方案,success_count++ / failure_count++,并在用户表示不同意时附带 refined_steps。

该提示词要求每次输出都带有 supporting_episode_ids,因此稀疏的一轮会返回空列表,不写入任何内容。

去重使用的是智能体用于召回的同一种混合检索器:对于每个候选事实,针对用户的语义索引进行 top-K 混合搜索以缩小比较集,只有这些候选才会送入 LLM 进行含义判断。还有两道防护措施约束输出:低于置信度下限的候选会被丢弃,而一个被接受的事实如果其最高相似度命中超过 ≥ 0.90,则被视为重复。在此实现中,去重更简单:最近约 50 条事实会连同"不要重复"的指令一起传给整合 LLM,而 LLM 之后的置信度和相似度防护尚未接入。混合召回路径和这两道约束防护才是生产架构;此快照依赖 LLM 直接进行比较,因为语料库足够小,能够放得下。

success_count 和 failure_count 在 playbook 上闭合了一个反馈回路:在足够多的对话中,记录"这个奏效了"的同一个字段,会成为"下次优先展示这个"的信号。如今,计数已被写入,但尚未偏置到检索排序中。在少数已解决的工单上,这种加权只是统计噪声。接入生产后,一旦某次部署具备足够的密度使该信号变得有意义,才会生效。

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

只增不删的记忆最终会出错。用户说“我搬到了爱丁堡”;智能体写入一条新事实。六个月后,旧的“住在布里斯托尔”事实仍然留在索引里。每次召回时两条都会浮现,智能体要么选错,要么含糊其辞。信任很快就会崩塌。

修复方法就是在系统提示词(完整提示词)里加一条规则,不需要新工具。智能体不删除,而是取代:

一个完整示例。Sarah 的上次到访记录为id=abc,语义索引中为“Sarah lives in Bristol”。三个月后,她打开一个对话:“我们离开了布里斯托尔,现在在爱丁堡。”

1. 召回。对 Sarah 消息的预召回返回了若干命中,其中包括{id: "abc", text: "Sarah lives in Bristol", memory_type: "semantic"}。

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

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

4. 写入。智能体调用write_memory(text="Sarah lives in Edinburgh", supersedes_id="abc", contradiction="natural")。一次操作中发生两件事:

  • 一份新文档 id=xyz 以完全置信度写入(无惩罚,因为该矛盾是自然产生的)。
  • 旧文档 abc 被更新为 superseded_by=xyz, superseded_at=<now>。

5. 召回隐藏旧内容。每次召回都会应用一个 filter must_not exists field=superseded_by。abc 从智能体的视图中被隐藏。xyz 正常浮现。

6. 审计记录保留。文档 abc 仍留在索引中。对 superseded_by=xyz 的查询可重建该链条。

注意:如果 Sarah 之后问"我都在哪里住过?",智能体会调用 recall_memory(query="places sarah has lived", include_superseded=True)。DLS 作用域的召回会同时浮现 xyz(Edinburgh)和 abc(Bristol)。带有 superseded_at 设置的命中项属于归档状态;智能体的回复 会区分它们:"你现在住在 Edinburgh;你之前住在 Bristol(直到今年早些时候)。

如果 Sarah 改为说 "我从未在 Bristol 住过,那是我姐姐",第 3 步会将其归类为 harsh。写入操作相同,但新事实的置信度会被 SUPERSEDE_CONFIDENCE_PENALTY 降低。系统会略微含糊其辞,直到新状态被后续对话进一步强化。

边界情况遵循同样的形态:一个已被取代的事实可以再次被取代(abc → xyz → pqr);一个低风险的偏好(“I prefer dark mode now”)取代时会像 contradiction="natural". forget_memory 那样硬删除;仅当客户明确说“忘掉 X”时才使用它。它不是用来处理矛盾的工具。

有一个微妙之处。召回可能会浮现出若干被同一条新陈述所否定的事实。Sarah 的所在地存在于 “Sarah 住在布里斯托尔的一套维多利亚式公寓里”(语义),存在于 “Sarah 在布里斯托尔有一套公寓,她的 Hub v2 就在那里”(语义),还可能存在于之前某次对话的一个情景事件中。智能体必须取代所有这些事实,而不仅仅是它看到的第一个。扫描召回结果,找出新陈述使其变为不实的每一个事实,并为每个旧 id 发起一次 write_memory(supersedes_id=…) 调用。那些仅仅提到布里斯托尔但仍然为真的事实(“Victorian flats in Bristol have thick walls that attenuate Zigbee signal”)保持不被取代。Sarah 的搬家并不改变布里斯托尔的建筑。

被取代的文档会不断累积,但只有当智能体通过 include_superseded=True 明确请求它们时,才会在召回中浮现。在生产环境中,一次周期性的重新索引会将它们移入一个单独的归档索引,由 Elasticsearch 的索引生命周期管理(ILM)使其依次经过冷层和冻结层(可搜索快照)。审计链在冷存储上保持可查询;活跃的语义索引保持热且小。

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

Elasticsearch 的默认异步刷新间隔会在智能体于同一轮对话中写入并召回记忆时造成传播间隙。当用户在同一条消息中说“我有一个从未设置过的 Lumio Range Extender。现在我的完整设备列表是什么?”时,智能体会写入 Range Extender 这一事实,然后立即执行一次召回,这发生在同一轮内,有时甚至在同一迭代的工具调用批次内。默认的 Elasticsearch 刷新间隔加上semantic_text推理成本,会带来一个亚秒级的传播间隙风险,即刚刚写入的文档对召回尚不可见。

修复位于存储层。智能体触发的每一次write_memory都会传入refresh=True,强制分片在调用返回前完成刷新(并让内联推理处理器生成的 Jina v5 嵌入向量落地)。下一次工具调用就能看到新文档。Range Extender 之所以出现在最终回复中,是因为写入之后紧接着运行的召回看到了它。

在更高的写入量下,refresh=True会成为一种吞吐量成本。生产部署可能希望转向异步索引,外加一个智能体层的“刚刚写入”寄存器,在索引跟上之前将写入内容保留在 LLM 的上下文中。如今,更简单的选择自有其价值。

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

到目前为止,这套检索设置给每一条事实赋予相同的权重,无论它是在何时创建或最后一次使用的。这是错误的默认做法。一条在过去一周内被召回两次的事实,几乎肯定比一条两年前被提及过一次的相同事实更相关。

我们将每个结果的得分乘以两个乘数:一个主要的新近度信号和一个次要的频率细化因子。新近度信号是时间衰减:一个在 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;在两者都到位之前,仅凭新近性对于程序性检索来说是一个过于粗糙的信号。

语义记忆上的召回时更新是承重部分。它把“旧事实权重更低”变成了“智能体最近不需要的事实权重更低”。这是相关性衰减,而非真实性衰减。真实性衰减由取代机制处理(见上文)。一个 5 年前的事实如果智能体每周都召回,它就会一直排在最前面,因为last_used_at是新鲜的。

这与三桶划分所依据的认知科学脉络相同。提取练习(即回忆某事物的行为)会增强其可访问性,而弃用则使其消退。last_used_at 上的召回时更新是同一效应的工程版本。

检索时乘数

这两个因子都位于包裹每条 RRF 分支的同一个function_score块中:

在代码中,这两个函数位于同一个 Painless 脚本中,按索引分支处理(数学相同,function_score 条目更少)。

两个 _index 过滤器承担了双重职责。它们将每个函数限定到其应影响的记忆类型:时间衰减作用于情景记忆和语义记忆,使用次数加成仅作用于语义记忆。同时它们将程序性记忆和目录排除在外:过滤器不匹配的函数会返回中性值 1.0,因此包含这些索引的跨索引查询能够正确评分,而不会出现解析器问题。完整函数见 operations.py。

两个参数控制高斯曲线:

  • offset (180d):平坦区。文档年龄小于 180 天的,无论具体多老,乘数均为 1.0。若没有这个平坦区,新鲜事实之间会因不足一天的时序噪声而相互竞争。
  • scale (1825d, ~5 years):超过偏移量之后、乘数降至 decay = 0.5 的距离。实际上就是从平坦区末端开始的半衰期。

衰减是一种刻意的权衡。当语料库中的每条事实都是唯一的且随时间保持正确时,施加任何衰减都会损失一部分召回率:旧事实即使仍然正确也会被惩罚。衰减真正发挥价值的地方是现实场景:关于同一事物的多条相互竞争的事实共存,而你希望最近或最常使用的那条排名最高。默认尺度(1825d)正是出于这个原因而偏保守。对于事实快速过时的领域(产品快速迭代的客户支持),可以收紧它。对于事实多年保持相关性的个人助理记忆,可以放宽它;两者都只需对 constants.py 做一行修改。

使用 Elasticsearch DLS 实现多租户隔离

文档级安全(DLS)将隔离规则移入集群本身。每个用户获得一个 API key,其角色描述符携带一个DLS 查询,该查询只允许属于该用户的文档(以及共享目录,它没有 user_id 字段)。使用该 key 的智能体可以运行任何它想要的查询,且永远不会看到其他用户的文档。集群根本不会返回它们。这就是生产级的隔离保证,在服务端对每一个使用该 key 发起的查询强制执行

检索器还在代码中携带一个 user_id 过滤器,作为防范配置漂移的偏执性检查:新的索引模板上线时未带 DLS、角色描述符被编辑导致该子句被悄悄删除、管理员 key 被误用。DLS 是架构层面的保障;这个代码层面的检查在查询时几乎不产生任何开销。

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

记忆查找是一次 Elasticsearch 查询。Sarah 的 API key 上的 DLS 查询允许 user_id == "sarah" 的文档。目录和其他共享索引完全没有 user_id 字段;它们本就应当对所有人可见。为了将它们纳入,DLS 查询从"必须等于 sarah"放宽为"等于 sarah 或没有 user_id":一个 bool.should,允许 user_id == "sarah" 或 must_not exists: user_id。检索器、RRF 融合和衰减函数均保持不变:目录和个人记忆落在同一次召回过程中。

一个 bootstrap 脚本会生成带有已内嵌扩展查询的每用户 DLS 密钥。

现在检索 "只显示白色的智能灯泡" 会同时返回 Sarah 存储的约束条件以及目录中关于灯泡兼容性的条目,并将两者一起排序。

用户记忆和目录可能在同一主题的同一次召回中出现并相互矛盾。检索器会在处理时间衰减的同一个脚本中应用一个小的来源先验(CATALOG_SOURCE_PRIOR,0.85)(只是多一个 _index 分支,没有新机制),因此在接近平局时用户记忆胜出。这是一种软性倾斜,而不是路由规则:当目录具有明显更强的相关性匹配时(产品规格、技术查询),重排序器仍然会选择它。那些硬性情况("规格查询始终信任目录",或个人偏好则相反)位于智能体的系统提示词中,而不是检索器中。

通过 MCP 连接任何智能体

当记忆层不绑定于某一个智能体时,它才最有用。Model Context Protocol 免费提供了这一点。端点是 /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,将其粘贴到 Settings → MCP 下。重启客户端后,三个 Atlas 工具(recall_memory、write_memory 和 forget_memory)会出现在工具抽屉中,调用与 FastAPI 应用相同的 Elasticsearch 索引。同一记忆层,任意智能体,无需重写。这三个工具的契约定义在 tools.py 中。

衡量智能体记忆召回质量

关于“recall”的说明:在本文其他地方,它指的是记忆召回(智能体检索已存储的事实)。而在这里,它指的是不相关的信息检索指标 Recall@K:即正确的文档是否出现在前 K 个结果中。

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

像 LoCoMo 这样的通用记忆专用基准是存在的,能让数字在不同系统之间具备可比性。之所以选择针对特定语料的 QA 模式,有两个原因。第一,它针对每个 persona 测试实际部署的语料,因此召回数字反映的是真实对话会看到的情况。第二,它隔离出检索这一环节(源文档是否出现在 top-K 中?),而这正是 hybrid + decay + reranker 流水线正在迭代的部分;LoCoMo 的对话连贯性指标衡量的是更下游的东西。后续文章将运行完整的 LoCoMo 基准,并把检索性能与 LLM 选择、提示词工程这些混杂因素区分开来。

泄漏数是任何多租户记忆系统的发布门槛;其余部分则是质量层面的故事。该评测在 CI 中作为门禁(eval_recall.py),要求 R@10 ≥ 0.85、R@5 ≥ 0.75、泄漏数 = 0。这些数字是近似值,因为 reranker 存在服务端方差:在连续四次运行中,R@10 分别为 0.85、0.88、0.89、0.893。

语义类事实是更棘手的情况(R@10 ≈ 0.81);情节记忆平均为 0.98,程序性记忆达到 1.0。原因在于同源碰撞:一个关于 Sarah 的 hub 断开连接的问题,在语料中有若干看似都正确的事实,而检索器有时会挑错。值得指出的是:同源碰撞通常不会降低智能体的回复质量(它仍然会得到一个相关的、真实的事实),因此 R@10 对语义类而言读起来偏保守。

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

智能体记忆是若干问题的集合,每个问题对应一个动作:

  • 记忆不是单一的东西。 三个索引,每个对应一个生命周期:情景记忆(发生了什么)、语义记忆(什么是真的)、程序记忆(什么有效)。
  • 大语言模型会把关键词的精确性改写掉。 每一轮对话都以对逐字消息的召回开始;检索采用混合方式,然后重新排序。
  • 只追加的记忆会腐化。 整合把情景提升为持久事实;取代则淘汰那些被用户否定的事实。
  • 旧事实不应像新事实那样排名。 分数随时间衰减,而一次召回会把某个事实重新提上来。
  • 租户绝不能看到彼此。 隔离存在于集群中,通过 DLS 实现,而不是放在一个你可能忘记的过滤器里。

这些都不是独立的系统:目录、隔离和衰减全都组合进一个 Elasticsearch 查询中。

把这件事做对,那个一直让 Sarah 重置集线器的助手终于记住了:她在三月就试过了,狗会咬传感器线缆,而家现在在 Edinburgh。

来源:Hacker News 热门(buzzing.cc 中文翻译) · elastic.co