AI Agent 记忆系统架构:Markdown 当唯一真相源,向量只做派生索引

举报
yd_278382871 发表于 2026/10/23 12:39:49 2026/10/23
【摘要】 ▎ 结论先说:单机、单人、几百条结论的场景,最可靠的 agent 记忆是「Markdown 文件即真相,其余全部是可重建的派生索引」。热路径只放索引指针,检索走按需的语义加词法双通道,写入只保留一个权威。按这条跑了一个多月、200 多条记忆、40 条回归集,recall@5 稳定在▎ 0.91;同时踩穿了三种“表面完全正常”的静默失效。大模型无状态。会话结束,上下文清空。所谓 agent 记...

▎ 结论先说:单机、单人、几百条结论的场景,最可靠的 agent 记忆是「Markdown 文件即真相,其余全部是可重建的派生索引」。热路径只放索引指针,检索走按需的语义加词法双通道,写入只保留一个权威。按这条跑了一个多月、200 多条记忆、40 条回归集,recall@5 稳定在
▎ 0.91;同时踩穿了三种“表面完全正常”的静默失效。

大模型无状态。会话结束,上下文清空。所谓 agent 记忆,是在模型之外维护一份持久化结论库,需要时把相关片段重新送进上下文。

社区至少有五类做法,它们不是同一件事的不同实现,混着选会出问题:

┌─────────────────┬───────────────────────────┬──────────────────────────────────────┬─────────────────┐
│ 类型 │ 代表 │ 核心机制 │ 适合谁 │
├─────────────────┼───────────────────────────┼──────────────────────────────────────┼─────────────────┤
│ 抽取-检索 │ Mem0、LangMem │ 抽事实存向量库,按查询召回 │ 多用户 SaaS │
├─────────────────┼───────────────────────────┼──────────────────────────────────────┼─────────────────┤
│ 时序知识图谱 │ Zep、Graphiti │ 事实带时间边 │ 强时序推理 │
├─────────────────┼───────────────────────────┼──────────────────────────────────────┼─────────────────┤
│ 分层自编辑 │ Letta、MemGPT │ 模型自管记忆分层,需整体采用其运行时 │ 单一 agent 产品 │
├─────────────────┼───────────────────────────┼──────────────────────────────────────┼─────────────────┤
│ 文件 / Markdown │ 各类 CLAUDE.md、AGENTS.md │ 人在回路编辑文本,按路径分层加载 │ 单机、可审计 │
├─────────────────┼───────────────────────────┼──────────────────────────────────────┼─────────────────┤
│ 平台原语 │ Cloudflare DO、Redis │ 只提供存储原语,不是现成产品 │ 自研底座 │
└─────────────────┴───────────────────────────┴──────────────────────────────────────┴─────────────────┘

单人单机、几百条结论、要求可审计可回滚,落在第四类。

整体架构:三层加载加一条写侧队列

┌──────────┬─────────────────────────┬────────────────┬───────────────────────┐
│ 层 │ 载体 │ 何时进上下文 │ 每轮成本 │
├──────────┼─────────────────────────┼────────────────┼───────────────────────┤
│ 常驻规则 │ 全局指令文件 │ 原生加载,每轮 │ 0 │
├──────────┼─────────────────────────┼────────────────┼───────────────────────┤
│ 索引 │ 一份 MEMORY.md │ 11 行主题指针 │ 0 │
├──────────┼─────────────────────────┼────────────────┼───────────────────────┤
│ 子索引 │ 按主题拆分的 11 份 │ 动手前按需读取 │ 0 │
├──────────┼─────────────────────────┼────────────────┼───────────────────────┤
│ 语义检索 │ 本地嵌入模型加向量索引 │ 手动触发 │ 0(进程空闲自动卸载) │
├──────────┼─────────────────────────┼────────────────┼───────────────────────┤
│ 词法检索 │ ripgrep 扫全部 markdown │ 交叉核对或降级 │ 0 │
├──────────┼─────────────────────────┼────────────────┼───────────────────────┤
│ 写侧队列 │ inbox 下的候选文件 │ 永不进上下文 │ 0 │
└──────────┴─────────────────────────┴────────────────┴───────────────────────┘

三个关键点:每轮进上下文的只有指针;向量索引是纯派生数据,删了能重建;写入走队列,人审之后才落正式条目。

决定一:Markdown 是唯一真相源

向量库里的内容能被检索,但很难被人直接读、diff、回滚,也回答不了“这条结论依据什么、什么时候改的”。而记忆的价值恰恰在于它是断言——错了要能改,改错了要能回滚。

Markdown 满足这四点:人可读、git 可 diff、可回滚、可被第二个 agent 直接读。代价是文件检索慢,所以所有检索能力都做成派生索引,允许它坏;坏了不是修索引,是重建。

这不是孤例。一份 basic-memory 架构评审的原话是「Markdown 是面向用户的真相源,数据库是运行镜像」;OpenClaw 的做法是「保持 Markdown 作为规范记忆面,在其上叠加语义检索」。

反例:把向量库当权威,换嵌入模型、索引损坏、迁移环境,任何一次都会让结论变成不可读的二进制。而调研过的 8 个成熟项目里,6 个没有任何检查命令,5 个不支持 markdown 无损往返,部分项目的 doctor 指的是“安装是否正常”而不是“记忆是否健康”。

决定二:常驻索引,拒绝每轮注入

早期做过每轮注入:每次用户提交时把相关记忆塞进上下文。实测单次 2507 ms,而且负收益——多数内容与本轮无关,挤占上下文还干扰判断。

改成三层:顶层索引永远 11 行,每行一个主题加条数。

这里有个硬约束:主流 harness 对自动加载文件有大小上限,超出部分静默丢弃。实测一份索引写到 60 条时占 20472 字节,距 25KB 上限只剩 7 行余量,超限不报错,只是下次少加载一段。

对策是索引分层:MEMORY.md 只放主题指针;每个主题一份子索引,一条一行;子索引每条指向独立文件,正文可以任意长。索引规模只跟主题数有关,跟记忆总量无关。

决定三:写入只能有一个权威

读路径可以并联:语义、词法、直接读文件同时存在没有冲突。写路径不行。两个组件都能写同一份记忆,就必然出现“谁说的算”,而且漂移是静默的。

写侧做成单一队列:

会话结束 → 调本地模型抽取候选 → 落 inbox/
→ 静置一段时间 → triage 草拟归类
→ 人审 → 落成 memory/*.md + 子索引加一行

模型只产候选,不直接写真相源。这一步看起来低效,但它把“模型幻觉写进长期记忆”挡在门外。

这里出过一次事故:队列限 3 个名额,统计靠文件名正则匹配。某次本地模型返回 503,脚本落了一份只写着“模型调用失败”的废料——它和真实候选占同一个名额。三份废料占满后,写侧静默停摆三天,所有新会话候选被丢弃,没有任何报错。

修法不是加大名额,而是区分语义:主提炼失败但画像段成功的,落成 .fail.md 后缀,排队不计数;三段全失败的,落到日志目录。失败产物不该和成功产物抢同一个资源。

检索:混合通道加 RRF 融合

语义通道用本地 bge-m3 算向量;词法通道用 BM25 风格打分,抓专有名词、路径、错误码这类语义模型容易漏的东西。两路结果用 RRF(倒数排名融合)合并:

score(d) = Σ 1 / (k + rank_i(d))

它不比较分数绝对值,只看名次,跨查询稳定——向量距离和 BM25 分数本来就不在一个量纲上。

当前实测(40 条回归集,203 个文件):recall@1 0.543、recall@5 0.914、recall@10 0.943、MRR 0.727。

两个“看起来很对”的假设,都被数据否决

H1:长文件不止一个主题,用平均分比取最小值更公平。

实测:recall@10 从 0.943 升到 0.971,recall@1 从 0.571 掉到 0.514,MRR 从 0.727 掉到 0.684。原因是长记忆文件常混多个主题,最好那块命中是对的,第二三块离题,取均值反而拖下来。

H2:漏召回是候选池太窄,把池宽从 8 倍加到 16 倍、32 倍。

三项指标逐位相同,漏掉的名单一个不变。

真根因是融合稀释。探针查到一条:最好块在全库排第 6(距离 0.4337,在阈值内),文件融合后掉到第 14。RRF 按名次给分,单层排第 3 的强命中拿约 0.0159,两层都排第 5 的平庸命中拿约 0.0308,后者赢。一个通道非常确信的答案,被两个通道都“还行”的答案压过去了。

先做探针定位,再改参数。否则会一直在错误的分支上做实验。

三个静默失效:表面正常,检索全是死路

一、索引只加不减。 一次全库精简(477 条压到 102 条)后,检索返回的命中全是已删除文件的路径。现场:索引记录 452 个文件,磁盘实际 114 个;412 个是幽灵,93 个真实文件没进索引。构建函数只增量嵌入,从不删除。更隐蔽的是清理也有
bug:只删向量表孤儿,不删状态表死键,第一次修复把状态判据挂在向量表上,被实测证伪——向量表清干净后状态死键 526 条全部漏网。两张表的孤儿不是包含关系。

二、阈值随语料漂移。 索引修好后仍有 6 条漏召回,不是找不到,是被阈值拒了。阈值是两周前用当时语料标定的:正例 0.182–0.367,负例 0.449–0.583。精简把多条内容压进合并条,向量距离整体右移:正例变成 0.222–0.475,负例 0.477–0.565,无重叠带缩到 [0.475,
0.477]。旧阈值正好切进新正例区间。移到中点 0.476 后正例全过,但余量已不足千分之三。

这不是参数没调好,是结构性退化。正例最低分低于负例最高分,分布重叠,不存在可用分割点。继续调参只是自我安慰。

三、常驻进程持有旧模块快照。 守护进程为省加载时间常驻内存(冷启动 5.24 秒,热态 0.16 秒),启动时把模块读进来,模块级常量也进了内存。改了文件里的常量,正在跑的进程仍用旧值,直到重启。更糟的是 reload 名不副实:只清数据缓存,没重新加载代码。

修法是把加载逻辑抽成函数,启动和 reload 共用,reload 真正重新迭代模块。安全性前提是模块级只有常量赋值,重跑幂等。

通用判据:常驻进程持有的是“加载那一刻的快照”,不是“磁盘的当前状态”。

三个失效的共同教训:–golden 报高分可能是假绿。陈旧的评测集配陈旧的索引,两边指向同一批幽灵,分数好看而检索指不到任何真实文件。修完派生数据必须重跑评估,并抽查命中文件是否存在。早期出过一次,官方口径 recall@1 报 0.840,实际返回全是死路径。

评测本身也会骗人

两个评测入口曾经并存:一个宽松(top-10、不过滤、永远 exit 0),一个严格(top-5、生产阈值、任一 MISS 即失败)。合并时漏改两处键名,都抛 KeyError,被主函数的 fail_open 吞成 exit 0。控制台全绿,实际逐条列表和日志一行没打。

修法:评测路径异常直接抛出。诊断路径静默成功,比失败更坏。

现在统一成一个入口,退出码三档:0 全过、1 有回退、2 评测集自身腐化。任何改动只有满足「recall@5 提升至少 0.02 且负例不退化」才保留。

时效:新旧并存,降权不过滤

采纳 ADD-only:新旧事实并存,失效条目降权而不删除。

valid_from: ‘2026-10-01’
valid_to: ‘2026-10-20’
supersedes: <旧条目标识>

检索侧对 valid_to 非空的条目,融合排序后乘 0.5,不删除。默认不过滤的理由:如果新条目本身是错的,旧条目被藏起来就没有兜底。

几个实现坑:

  • 反向边不要写。superseded_by 落地时删了——它和 supersedes 是同一关系的两个方向,双写必然漂。系统认什么,才允许写什么。
  • 过滤必须发生在排序前,否则 top-5 会静默缩成 2 条。
  • YAML 空值有四种写法:空串、null、none、~。只认 null 会把 ~ 当成真实日期,静默判定过期。
  • frontmatter 解析要剥引号,否则“有日期但已关闭”没法表达。

多端共享:不造新通道

Claude Code、Codex CLI 和另一个本地 harness 过去各记各的。直觉做法是造注入通道或开 MCP server,但三端本来就有原生指令加载面,只是被别的工具占用。真正的工作量是把索引接进去。

Claude Code 自动注入 MEMORY.md 全文(8855 字节),不改;另外两端在共享指令文件里,由外部工具管一个受管块,在其后追加独立块。

注入内容只放 11 行主题指针,552 字节,约 250 token。不注入全文——8855 字节里指针行只占 552,剩下是给人看的一句话摘要。对 agent 来说,主题名足够决定去读哪份子索引。

没装 MCP server 的理由:三端已有原生加载面,MCP 是重复通道;有独立证据表明它作为主通道在实践中失效(某项目两周零检索、355 会话审计只出现 19 次读取、8785 条记忆零自发现);写接口会绕开写侧队列的去抖和上限闸。

依赖外部工具管块,就要验证它只覆盖自己的块。实测方法:块后追加带标记的独立块,强制对方真重写一次(普通同步按哈希判断会直接跳过),检查块外内容是否逐字保留。

什么时候该换重武器

边界不是我们猜的,是 mem0 自己划的:文件记忆在「少于约 200 条稳定记忆、单用户、单 agent、无并发、事实以静态规则为主」时可用。

对照:单用户单机满足,静态性部分满足,条数已超。但分层让常驻索引仍在安全区,所以不是警报。

并发是真正的硬边界。多个 agent 同时写同一份记忆会损坏文件。这点踩过:一次索引漂移的根因就是并行会话,索引以为 452 个文件,磁盘只有 114 个。单人单机不等于单写者。

另一个参考:Letta 在 LoCoMo 基准上,纯文件系统方案没接任何专用记忆工具,得 74.0%(GPT-4o mini);Mem0 自己公布的最佳图变体是 68.5%。作者的结论是「agent
的能力比工具更重要」——会不会改写查询、会不会换关键词再搜,比检索机制本身影响更大。这个排名不该当精确结论用,但方向值得记:瓶颈往往不在精排,在 query 形态和内容组织。

换栈触发条件:某个成熟系统的健康检查从“安装正常”升级到“记忆健康”;写侧队列长期积压让人工裁决成为可测量瓶颈(此时根因通常是实现缺陷,先修实现);多跳查询 fail@5 达到 20% 以上且图数据库能在目标环境跑起来。

可直接抄的清单

  1. 先定真相源。只有一份,且是人能直接读的格式;其余全部标注为可删可重建。
  2. 常驻上下文压到指针级,并且能被检测出超限。
  3. 写入收成一个入口。模型只产候选。
  4. 失败产物不要和成功产物抢同一个资源名额。
  5. 给检索建评测集,至少 40 条,含正例负例,断言命中文件真实存在。
  6. 评测脚本的异常不要吞。
  7. 语料结构变了就重标阈值;一旦正负例重叠,停止调参。
  8. 常驻进程要能真正重载代码。
  9. 时效用字段表达,别用删除。
  10. 每次实验记录日期和口径。同一套系统混用两套口径,是假绿的常见来源。

最反直觉的一条:这套系统里最贵的工作量不在写代码,在判断哪个数字可信。三次静默失效、一次假绿评测、两次被否决的“合理假设”,都是同一个问题的变体——系统告诉你它很正常,而它不正常。

所以每次下结论前,去看一眼原始数据。分数、日志、返回的文件路径,随便挑一个抽查。

【声明】本内容来自华为云开发者社区博主,不代表华为云及华为云开发者社区的观点和立场。转载时必须标注文章的来源(华为云社区)、文章链接、文章作者等基本信息,否则作者和本社区有权追究责任。如果您发现本社区中有涉嫌抄袭的内容,欢迎发送邮件进行举报,并提供相关证据,一经查实,本社区将立刻删除涉嫌侵权内容,举报邮箱: cloudbbs@huaweicloud.com
  • 点赞
  • 收藏
  • 关注作者

评论(0)

0/1000
抱歉,系统识别当前为高风险访问,暂不支持该操作

全部回复

上滑加载中

设置昵称

在此一键设置昵称,即可参与社区互动!

*长度不超过10个汉字或20个英文字符,设置后3个月内不可修改。

*长度不超过10个汉字或20个英文字符,设置后3个月内不可修改。