Elasticsearch持久化 Agent 记忆系统(一个开源工具)
概述
AI 编程助手(如 Claude Code)本质上是无状态的。
虽然你可以通过文件系统让代理读取历史记录,但读取文件≠回忆相关上下文。这种“会话即忘”的模式在实际工作中会带来明显的成本:
重复推导结论
代理无法记住之前的推理过程,每次都会重新读文件、重新审视权衡,甚至得出不同答案。这导致开发者在不同会话中面对同一问题时,代理行为不一致,你却很难定位原因。多设备摩擦
如果你在多台机器之间切换,每换一台设备,就得从头加载代理的上下文:git pull、grep搜索相关文件、手动粘贴项目状态……每一次切换都是一笔额外的“摩擦税”。跨会话上下文丢失
代理产出的最有价值的东西——架构决策、阻塞任务的 ID、方案变更的理由、解释“为什么选择这条路”的上下文锚点——统统只活在当前会话的工作内存中,会话结束便消失。代码提交能保存产出物,却无法保留背后的推理过程。
业界常见的解决方案是专为记忆打造的服务层,例如单独的记忆编排服务或独立的向量数据库。这些方案确实有效,但同时也意味着:又一个服务、又一个 API、又一套需要构建和维护的东西。
如果 Elasticsearch 已经是你技术栈的一部分,那么你可能已经拥有了所需的一切。
Elasticsearch:你已有的搜索引擎,也能成为记忆系统
先别急着讨论“Agent 记忆”这个特定场景,我们先看看 Elasticsearch 本身能提供哪些开箱即用的能力:
混合检索开箱即用
Elasticsearch 天然融合了 BM25 词法匹配与稠密向量检索。通过semantic_text字段类型,你只需将字段映射为该类型并指向推理端点,嵌入生成就会自动完成——无需自己管理嵌入流水线。强大的查询语言 ES|QL
在 Elasticsearch 中,记忆就是文档,而文档可以用完整的查询语言进行检索。ES|QL 支持按类型、日期范围、Agent ID、访问范围等进行过滤,还支持聚合、时间函数和多路召回融合(FUSE)。相比大多数向量存储仅提供的轻量级元数据过滤,ES|QL 的查询表达力显著更强。语义打分前的元数据过滤
跨 Agent 的作用域隔离、时间窗口、类型过滤——这些都是 Elasticsearch 的标准查询能力,你无需编写额外逻辑,只需组合已有原语。时间衰减(Temporal Decay)
通过BRIDGE_MEMORY_DECAY_WINDOW变量,系统会对记忆按时间加权,让近期的记忆比旧记忆排名更高。底层使用了 ES|QL 的DECAY函数(需要 Elasticsearch 9.3+ 或 Serverless 版本)。如果版本不支持,也有等效的回退方案。现成的运维生态
监控、告警、索引生命周期管理、备份——这些你已经在现有 Elasticsearch 部署中拥有了。增加新的索引并不会带来新的运维负担。
本质上,Elasticsearch 提供了一个可查询、可融合、可衰减的文档存储层,而 Agent 的记忆数据正是这种存储层的理想负载。
架构概览
为了让 Elasticsearch 成为 Claude Code 的专用记忆层,构建一个名为bridge的 CLI 工具。整个架构的核心关系如下:
图中的关键点:
- Claude Code 与 Elasticsearch 不直接通信,所有交互都通过
bridgeCLI。 - 三个 Hook将
bridge自动挂载到 Agent 的生命周期中,无需代理显式调用。 - 离线队列:当 Elasticsearch 不可达时,写操作不会失败,而是落入本地的
fallback/{agent}/outbox/目录,待连接恢复后由bridge sync批量刷入。 - 七个索引各自承载不同的数据维度,其中
{agent}-entities和{agent}-entity-history支撑了知识图谱功能。
索引映射与存储结构
下面以agent-memory索引的映射为例,展示数据的存储方式:
{"mappings":{"properties":{"memory_id":{"type":"keyword"},"agent":{"type":"keyword"},"type":{"type":"keyword"},"category":{"type":"keyword"},"title":{"type":"text","fields":{"keyword":{"type":"keyword"}}},"title_semantic":{"type":"semantic_text","inference_id":"jina-v5-embeddings"},"content":{"type":"text"},"content_semantic":{"type":"semantic_text","inference_id":"jina-v5-embeddings"},"tags":{"type":"keyword"},"source":{"type":"keyword"},"created_at":{"type":"date"},"updated_at":{"type":"date"},"access_scope":{"type":"keyword"}}}}关键设计点:
title_semantic和content_semantic使用semantic_text类型,并指定 Jina v5 嵌入推理端点。写入文档时,Elasticsearch 自动计算嵌入向量,无需额外编码。- 所有字段都支持精确匹配(
keyword)和全文检索(text),为混合召回提供了数据基础。 access_scope用于控制记忆的可见范围(如shared或特定 Agent)。
Elasticsearch 在这里的两个本质差异化优势:
- 查询表达力:ES|QL 是完整的查询语言,而不是单纯的过滤 API。你可以在一个查询中组合向量检索、精确匹配、时间衰减和聚合。
- 运维整合:如果 Elasticsearch 已在你的栈中,这只是一个新索引,而不是一项新服务。其他向量数据库也能定义模式,但区别在于你定义完模式后能做什么,以及是否增加新的运维依赖。
钩子集成与离线队列
自动化的 Hook 集成
Claude Code 的 Hook 机制让bridge能够无缝嵌入 Agent 的日常行为中,无需显式调用:
| Hook 时机 | 触发动作 | 作用 |
|---|---|---|
SessionStart | bridge sync-memories+bridge heartbeat | 将本地记忆文件同步到 Elasticsearch,并注册 Agent 为活跃状态 |
PostToolUse(Write/Edit/MultiEdit) | bridge entity index-file | 每次写入.md文件时,自动索引该文件作为知识图谱实体 |
Stop | 记录会话结束事件到agent-sessions | 保留会话日志供后续查询 |
这样,Agent 不需要“记得”去更新记忆——一切都是自动的。第一次会话同步会哈希所有本地文件,仅重新索引变更的部分,因此首次同步可能较慢,后续则很快。
离线队列保障
当 Elasticsearch 不可达时,写入操作不会失败,而是写入fallback/{agent}/outbox/目录下的 JSON 文件。bridge sync(由SessionStart自动调用)会在连接恢复后通过 bulk API 将队列刷入 Elasticsearch。
- 离线期间,数据不会丢失。
- 读操作(如
bridge recall)会在 ES 不可达时以非零退出码优雅失败,Agent 会话仍可继续,只是记忆功能降级。 - 离线队列没有自动大小限制。长时间离线后,建议使用
bridge sync --batch-size 100分批发送,避免 bulk 请求过大。
混合召回:记忆检索的核心机制
许多 Agent 记忆实现仅依赖单一向量检索:查询向量与存储向量做余弦相似度,返回最近邻。这在语义召回上有效,但在精确术语匹配和时间衰减两个场景下明显不足。
两种检索分支并行融合
我们的混合召回使用 ES|QL 的FUSE(Reciprocal Rank Fusion)将两条检索路径的结果合并:
下面的 ES|QL 查询展示了这一过程(来自lib/memory.sh):
FROMagent-memory METADATA _id,_score,_index|FORK(WHERE(access_scope=="shared"ORaccess_scope=="kk-only"ORagent=="kk")AND(content:"vector search"ORtitle:"vector search"ORtags:"vector search")|SORT _scoreDESC|LIMIT50)(WHERE(access_scope=="shared"ORaccess_scope=="kk-only"ORagent=="kk")ANDcontent_semantic:"vector search"|SORT _scoreDESC|LIMIT50)|FUSE|EVAL final_score=_score*DECAY(created_at,NOW(),45days)|EVAL display=COALESCE(title,SUBSTRING(content,1,80))|SORT final_scoreDESC|LIMIT5|KEEP memory_id,type,display,access_scope,agent混合优于纯语义
- 精确匹配场景:如果记忆存储了“任务 ID: kk-task-20260428-deploy-blocker”,而查询是“deploy blocker”,语义检索可能找到概念相近的内容,但只有 BM25 分支能精确匹配到该 ID。没有 BM25,你得到的是概念而非你需要的确切引用。
- 语义泛化场景:如果记忆标题为“将默认分块策略改为句子级以提高短查询召回率”,查询“分块配置”虽然字面不重叠,但语义分支能准确找到它,纯关键词则找不到。
时间衰减(Temporal Decay)
BRIDGE_MEMORY_DECAY_WINDOW默认值为 45 天,作为时间衰减的半衰期。同等条件下,今天的记忆得分远高于 90 天前的记忆。这符合 Agent 记忆的实际规律:近期的上下文几乎总是比旧的上下文更相关,即使旧记忆在语义上更贴近查询。
版本提示:
DECAY函数需要 Elasticsearch 9.3+ 或 Serverless。若版本不支持,系统会回退到等价公式:final_score = _score / (1 + DATE_DIFF("day", created_at, NOW()) / 45.0)。
图谱搜索的权重调优
在知识图谱实体搜索中(lib/graph.sh),我们使用了FUSE LINEAR并显式设置权重——BM25 占 0.3,语义占 0.7——因为实体搜索更依赖语义匹配。你可根据需求调整权重,偏向关键词精度或语义泛化。
|FUSE LINEARWITH{"weights": {"fork1":0.3,"fork2":0.7},"normalizer":"minmax"}知识图谱层
实体索引与关系提取
知识图谱建立在 Elasticsearch 文档之上,但需要强调:这不是一个图数据库——遍历深度限制为 2,没有 Cypher 查询语言,也没有属性图模型。它的价值在于为 Agent 自身的工作记录提供实体化查询能力,而不是构建外部世界知识的百科全书。
每个 Markdown 文件通过PostToolUseHook 被索引为一个实体,实体 ID 格式为{agent}-{type}-{slug},保证幂等性(重写文件即更新实体)。关系边从前置元数据字段(initiative、blocked_by、depends_on)中提取。
图谱命令示例
| 命令 | 用途 |
|---|---|
bridge graph search "infrastructure blockers" | 跨所有实体进行混合语义搜索 |
bridge graph related kk-initiative-platform --depth 2 | 从特定实体出发遍历关系(深度1或2) |
bridge graph check-blockers --stale-days 3 | 找出过期的阻塞或等待实体 |
bridge graph semantic-diff 2026-04-01 2026-04-20 | 对比两个日期之间的实体状态变化 |
bridge graph gen-handoff --hours 8 | 生成跨设备交接的结构化上下文负载 |
bridge graph reconcile | 移除源文件已不存在的 ES 实体 |
语义差异与交接
semantic-diff命令在周会前运行,能生成结构化的变更日志:新增实体、状态变化、新阻塞项、已完成项,无需翻阅每个会话日志。
gen-handoff命令生成包含实体更新、活跃阻塞和最近会话日志的 JSON 负载。加上--synthesize标志,还可通过 Agent Builder 生成一段叙述性文字,新会话加载此负载即可重建上下文,而无需逐文件阅读。
实践数据:一个运行数月的实例,跨两台笔记本,累积了 533 条记忆、50,802 个会话、207 个实体,以及约 1,364 条关系边(从
initiative、blocked_by、depends_on中解析而来)。
跨设备记忆实践
设想如下场景:你在一台工作站的台式机上工作了两天,然后带着旅行笔记本出差。三天后,你在笔记本上启动 Claude Code,代理在一次搜索往返内就回忆起了相关的记忆条目——无需等待git pull,无需文件扫描重建上下文。
zhu:因为两台机器都连接到同一个 Elasticsearch 集群。共享索引是事实来源,而不是本地文件系统。代理调用bridge recall,从共享索引获取上下文,无论哪台机器存储了它。
关键机制:SessionStart钩子运行bridge sync-memories,它会读取 Claude Code 的自动记忆文件(~/.claude/projects/<cwd>/memory/*.md),对每个文件计算哈希,仅重新索引变更的文件。本地记忆文件被视为每台机器上的事实源,bridge sync-memories将本地状态推送到 Elasticsearch。
注意:如果另一台机器上的编辑在切换前未被推送,新机器的本地状态会覆盖它。同一时间两台设备并发写入时,Elasticsearch 的文档版本控制能正确处理,但应避免同时编辑同一记忆文件。
诚实的前提:这要求 Elasticsearch Serverless 或任何可从两台机器访问的 Elasticsearch 实例。自建 ES 若位于 VPN 或防火墙后也能工作,但连接性是硬性要求。这不是一个纯本地方案。
适用场景与权衡
这种方法最适合:
- Elasticsearch 已在你的技术栈中:增加索引的开销很小,运维经验可直接复用。
- 你需要丰富的查询表达力:你想用完整的查询语言来检索记忆数据,而不是受限于供应商的轻量过滤 API。
- 你的 Agent 产生结构化产物:Markdown 文件包含一致的前置元数据,这是知识图谱连贯性的基础。如果 Agent 不输出结构化内容,图谱层的价值有限。
- 你需要跨设备或跨 Agent 记忆:共享 Elasticsearch 索引无需额外基础设施即可实现。
专用的记忆服务可能更胜一筹的场景:
- 你没有现有的搜索基础设施:为了记忆问题而引入 Elasticsearch 是合理的,但它仍是一项有分量的基础设施投入。
- 你只想要纯语义召回,零模式管理:专门的向量数据库服务抽象掉了索引映射,如果你不想操心映射和字段,这种 tradeoff 有价值。
- 你预期频繁变更模式:你控制的模式也是你需要维护的模式。如果记忆结构经常变化,自管理索引映射的迁移开销可能超过灵活性收益。专用服务往往帮你处理模式演进。
- 你需要全托管的 SaaS,运维负担最小:Elasticsearch Serverless 已大幅缩小这一差距,但并非零负担。
诚实的总结:agent-memory不是通用答案。它是在你已运行 Elasticsearch 且希望避免给 AI 栈增加额外依赖时的正确选择。
快速开始
只需三条命令即可启动一个可运行的系统:
gitclone https://github.com/jeffvestal/agent-memory&&cdagent-memory ./install.sh ./bridge statusinstall.sh会交互式地引导你输入凭证(或读取已有.env),创建 Jina v5 语义推理端点(若尚不存在),创建所有七个带正确映射的索引,并安装 Claude Code 钩子。脚本是幂等的——如果出问题或添加新机器,可重新运行。
运行前须知:
- Jina v5 推理端点:自管理 Elasticsearch 需要 Jina API Key;Serverless 则由 Elastic Inference Service 自动提供,无需 Key。
- Elasticsearch 版本:Serverless 或 9.3+ 均支持,因为混合召回使用了
DECAY和FUSE语法。 - Kibana Dashboard:若设置了
KIBANA_URL,install.sh会自动导入仪表盘(位于setup/dashboards/)。
你需要准备:
- Elasticsearch Serverless 端点(或 9.3+ 自管理集群)
- 具有
agent-*索引权限的 API Key - 本地安装
jq(macOS 可用brew install jq)
安装后验证:
bridge entity index-all# 索引已有 Markdown 文件bridge remember decision"安装确认成功"--title"setup"bridge recall"install"# 验证混合召回然后将hooks/settings.json.template中的钩子配置添加到你的 Claude Codesettings.json中。此后,每次会话启动、文件写入、会话结束都会自动更新记忆存储。
完整源码(含 Kibana 仪表盘)位于:
https://github.com/jeffvestal/agent-memory
