AgentMemory 源码分析:AI Agent 不忘记这件事怎么实现的

0 阅读5分钟

作为一个AI爱好者,我一直对agent的记忆系统很感兴趣,当初火热的龙虾潮就是养一只越用越好用的小龙虾,能够熟知你的爱好与偏向,我就是那时开始喜欢上AI的,因为这与我所熟知的大模型普通的问答对话不同,而是能真正的帮你做事,更重要的是有了自己的记忆。可是随着长时间的使用,AI记忆越来越臃肿,每次开始都要读取全部的记忆系统,不但占用上下文,还会降低agent的工作效率,后来对记忆压缩分类和加入索引向量,这才将效率提高,接下来会详细说一说这个的具体实现方法,为各位的小龙虾提供些许改进思路。因为我也是这样改进我的hermes的,以及我自己在做的一个安卓端的agent也会采用这个方法。因为我个人的文笔原因,还是让AI来辅助生成。


每次开启新会话,AI 就忘了上次做了什么。

这个问题在做长期项目时特别明显——你必须每次都重新贴一堆上下文,告诉 AI 上次的架构决策、用了什么框架、踩过哪些坑。

agentmemory 这个项目解决的就是这个问题。 8K stars,TypeScript写的,每一个会话能省约 92% 的 token。读了一遍源码,看一些觉得设计比较实的地方。


它干了什么

标准流程:

会话A 写代码
  ↓ 自动捕捉「架构决策用了 FastAPI」「文件在 /src/api/」「测试用 pytest」
  ↓ 压缩成记忆存入 SQLite
  ↓ 建两套索引:BM25(关键词)+ 向量(语义)

会话B 开新
  ↓ 自动检索相关记忆注入上下文
  ↓ AI 说「哦我记得上次的架构」

效果:

  • 检索召回率 R@5:95.2%
  • 每会话省约 ~17 万 token(开头不用自己贴了)
  • 零外部依赖、SQLite 本地存、699 个测试通过

核心机制:混合检索

最关键的部分在 hybrid-search.ts

存记忆时发生什么:

// remember.ts 简化版
async function remember(content: string, sessionId: string) {
  // 1. 去重:Jaccard 相似度超过 0.7 就更新已有记忆
  const existing = await findSimilar(content, 0.7);
  if (existing) {
    await updateMemory(existing.id, content);
    return;
  }
  
  // 2. 存 SQLite
  const memId = await kv.set(`memory:${uuid()}`, {
    content,
    sessionId,
    createdAt: Date.now(),
    type: classifyType(content) // pattern/preference/architecture/bug/...
  });
  
  // 3. 建 BM25 索引
  await searchIndex.add(memId, content);
  
  // 4. 建向量索引
  const embedding = await embedder.embed(content);
  vectorIndex.add(memId, sessionId, embedding);
}

简单来说就是压缩加索引,只保留关键文本

检索时发生什么:

// hybrid-search.ts 核心
async search(query: string, limit: number): Promise<SearchResult[]> {
  // 三路并行检索
  const [bm25, vector, graph] = await Promise.all([
    this.bm25Search(query),
    this.vectorSearch(query),
    this.graphSearch(query),
  ]);
  
  // RRF 融合
  const scores = new Map<string, number>();
  const sources = [
    { results: bm25,   weight: 0.4 },
    { results: vector, weight: 0.6 },
    { results: graph,  weight: 0.3 },
  ];
  
  for (const { results, weight } of sources) {
    results.forEach((r, rank) => {
      const prev = scores.get(r.obsId) ?? 0;
      scores.set(r.obsId, prev + weight / (rank + 60)); // RRF 公式
    });
  }
  
  return [...scores.entries()]
    .sort(([, a], [, b]) => b - a)
    .slice(0, limit)
    .map(([obsId, score]) => ({ obsId, score }));
}

三路检索互补的好处:BM25 找关键词匹配的,向量找意思相近但用词不同的,图谱找实体关联的。多了第二三路之后召回率上去是自然的。


记忆类型,对每个记忆进行分类。这就和卡帕西的claude.md文件里面说的类似

没全放一锅粥。六种类型,不同 TTL 和优先级:

类型用途
pattern发现的项目模式
preference编码偏好(测试工具、命名习惯)
architecture架构决策
bug修复过的 bug
workflow重复操作流程
fact默认类型

这个分类让系统知道哪些记忆更重要、应该保留多久。


与更简单方案的差异

最简单的方案是把所有历史记录存到文本文件里。问题是简单关键词匹配只能找到用了同一个词的记忆,找不到「意思相近但表述不同」的。

AgentMemory 多了向量搜索之后,召回率从大概 60-70% 升到 95.2%。

还有去重机制。文本文件需要手动维护,不然相似内容会占用一大堆行数。Jaccard 去重自动处理了这个问题。


值得看的设计

三个地方觉得比较实:

双索引:BM25 + 向量互补,不是二选一。单独一种必然有盲区。

RRF 融合:Reciprocal Rank Fusion 公式很简单,不用训练任何模型,效果不差。公式就是 weight / (rank + 60),60 是个平滑参数,防止低排名结果权重过低。

优雅降级:embedding 提供者挂了不会崩,静默降级到仅文本搜索。这种写法在个人项目里很实用。


当然,再厉害的记忆储存机制也有局限:

向量索引存在内存里。进程重启后向量丢失,要重建。记忆小的时候这不是问题,记忆多了就得换个方案。

嵌入生成依赖外部 provider。README 说有平滑降级,但文档没说清楚该怎么处理嵌入失败后的历史记忆。

记忆分类是预先定义的六种。以下是我自己在使用的记忆分类方法:

文件类型内容
<记忆/preference>preference偏好、习惯
<记忆/fact>fact配置、路径、地址
<记忆/architecture>architecture架构决策
<记忆/workflow>workflow工作流标准流程
<记忆/bug>bug踩过的坑
<记忆/pattern>pattern观察到的模式
<记忆/活跃上下文>context当前会话进度

这个记忆方法目前来看还是很好用,当然也一定有局限,这也是没办法的事。后续我还会再发一篇文章是关于记忆系统的去重以及遗忘功能的实现,总体来说和这个是互补的。当然也欢迎大家批评指正,我也不过是一个初学者,非常希望与大家共同学习进步。