作者构建了一套AI编程用的工作记忆系统(50ms内按相关性召回笔记),却坦承纯文本MEMORY.md在可读性、可审计性上反而更优;探讨了复杂系统与简单工具的取舍。
我为 AI 编码智能体构建了一套工作记忆系统。笔记通过一个工具调用写入,通过另一个工具调用取出,按相关性排序,响应时间在 50 毫秒以内。按照大多数我在意的指标来衡量,这套系统比几乎所有人已经在用的替代方案更好——那个替代方案就是一个单独的 markdown 文件,智能体在每个会话开始时读取它,然后在执行过程中不断写入。
我想先站在那个替代方案一边,因为我认为它在某些方面是对 的,而我的产品是错的。
以下是两个论点。两者都为真。它们直接矛盾,而且我不认为这个矛盾能被干净地化解。
第一个论点,可读性。一份纯文本的 MEMORY.md 文件是完全可读的。你打开它,在你自己的编辑器里从头读到尾,找到错误的那一行并修正它,找到过时的那一行并删除它。git diff(显示文件两个版本之间具体哪些行发生了变化,绿色表示新增,红色表示删除)精确展示发生了什么变化以及何时发生的。其中没有任何环节需要你信任一个排序函数、一个守护进程(一个持续运行并监听请求的后台进程,这里指持有 vectr 记忆存储并响应工具调用的本地服务),或一个嵌入模型。它就是一个文本文件,你已有的所有文本文件处理工具无需任何修改就能作用于它。
我的存储里的笔记不是这样运作的。它们作为行记录存在于一个 sqlite(一个轻量级、基于文件的数据库引擎,运行在应用程序内部,不需要单独的服务器进程)文件里,由一个守护进程管理,通过一个搜索索引检索出来。我写这篇文章的时候里面有 749 条笔记,分布在七种类别中。当其中某条笔记出错时,你无法通过打开文件删除一行来解决。你必须知道它存在,知道它的 id,然后调用一个工具来撤销或遗忘它。这是一种真实的、令人不安的可读性损失,而且我认为把它轻描淡写为"以后再处理的 UI 问题"是不诚实的。
第二个论点,召回率。语义匹配在命中时优于什么都不是,在未命中时劣于平面文件,因为平面文件根本不会未命中。MEMORY.md 永远不会未命中,原因很简单:它从来不做选择。每次整份文件都进入上下文,所以根本不存在可能出错的检索步骤。我的存储有检索步骤。它可能把一条真正有用的笔记排到截断线以下,或者一个离题的查询在嵌入空间(一个表示文本片段含义的数值向量,检索时将查询的向量与每条笔记的向量进行比较,返回最接近的那些)中与目标毫无交集,于是那条笔记就回不来了。没有任何机制告知智能体这件事发生了。它自信地继续,遗漏了它早已拥有的一个事实。
第二个论点是这篇文章的核心。一个检索系统最严重的失败不是人类能注意到并抱怨的糟糕排序。它是一次静默的未命中:那条笔记就在那里,正确无误,恰好是任务所需,但查询就是没有落在它附近。我要在这里认真对待这种失败模式,而不是绕过它,因为绕过它正是让记忆系统在演示中看起来很棒、却在生产中悄然失效的那个惯用伎俩。
我不打算廉价地解决这个问题。我真实的立场是:平面文件在可读性和未命中率上全面胜出,在规模上全面落败。有趣的工程问题从来不是证明这种权衡不存在,而是在语义存储内部恢复平面文件这两个致胜属性——针对那些失去它们是不可接受的场景——同时不放弃当初需要语义存储的那部分规模。以下大部分内容讲的是这种恢复实际上做到了什么程度,以及在哪些地方它诚实地说还没有做到。
我想认真对待平面文件而不是为它立一个稻草人,因为把这篇文章写成稻草人的话很容易而且是错的。
一份包含二十或三十条笔记的 MEMORY.md,说实话,接近理想状态。一个记忆系统想要的所有属性,它都作为文本文件免费获得了:
它永远不会未命中。没有排序步骤,所以相关的事实不可能无法浮现。如果它在文件里,智能体每次都能看到它,一次都不落,因为整份文件每次都进入上下文。
它可以被 diff。版本控制已经理解它了。你可以在一个普通的 git diff 中看到一次会话具体增加了什么,不需要任何定制工具。
它可以被审查。人类可以在几分钟内读完整个文件,并对哪些行仍然正确形成自己的判断。
它可以被就地编辑。有错误的行,删掉它。过时的行,删掉它。从"我知道这是错的"到"它已被修正"之间没有任何间接层。
它有零检索延迟和零检索失败模式,因为它根本没有检索步骤。
阅读 MEMORY.md 就像把一本日志本贴在橱柜门内侧,而不是把书页整理到橱柜里交给图书管理员。打开门,整本日志就在那里,按记录的顺序排列。你用笔划掉一行,在底部加一条新的,不需要任何人为你决定哪些条目值得展示给你。权衡正如你所预期的那样:日志本只有在足够短、短到可以贴在门上一次性读完时才能保持可用。图书管理员可以超越这个规模。日志本不行,而且它从来没打算这样做。
这些都不是巧合,语义存储也不会自动获得其中任何一条。这是从选择最简单的表示方式中自然产生的结果:一份文件,完全注入,始终如一。很多记忆系统的宣传,包括我在不同时期的在内,都隐含地承诺会比这更好,却没有诚实地说出它们在为了获得什么而牺牲哪些属性。所以在我谈论我构建了什么之前,我要在这里明确:它所竞争的对象不是一个稻草人。在小规模下,作为一个真实的工程论点而非言辞上的让步,它接近正确。
平面文件的失败模式并不微妙,而且真的与文件本身无关。它关乎上下文窗口的大小(以 token 计量的文本总量,一个 LLM 在单次请求中能持有和关注的;任何注入的内容都占用与对话其余部分相同的固定预算)。
二十条笔记时,MEMORY.md 每轮花费你几百个 token(token 是语言模型实际计数并计入上下文窗口配额的小文本块,平均大约四分之三个单词),换来一个保证:不会有任何遗漏。这笔交易显然值得。749 条笔记时——我自己的存储实际达到的数量——同样的设计意味着将智能体曾经记录过的每一次发现、每一个坑、每一个决定和每一个任务检查点都注入到每一轮对话中,永远持续下去。这 749 条笔记中的大部分与智能体此刻正在做的事情毫无关系。你会把每个上下文窗口的很大一个固定部分花在与当前任务绝大多数情况下都无关的笔记上,而这恰恰是记忆系统存在要解决的 问题本身。
这里有一种令人不安的对称性:"永不遗漏"和"总是让你付出一切"是同一属性的两面,从两个不同角度看。平面文件不选择展示什么,这正是它永远不会无法展示正确东西的原因,也正是它无法超越"展示所有内容仍然负担得起"这个规模点的原因。
将完整的笔记历史注入每一轮不是一个检索策略。它的缺席。当语料库小到"所有内容"和"相关子集"大致是同一集合时,它看起来才是免费的。一旦这两个集合分叉——这发生在远早于 749 条笔记的时候——你不是在"有检索系统"和"无检索系统"之间选择。你是在"做排序的检索系统"和"注入垃圾的检索系统"之间选择,因为一次性注入所有 749 条笔记与不注入其中任何一条没有本质区别:智能体对两者都无法有效关注。
所以规模论证不是"语义搜索更智能"。它比那更窄、更不讨喜:超过某个语料库规模后,平面文件的核心保证——永远不会遗漏——变得负担不起,你被迫接受某种形式的排序,无论你是否喜欢它的失败模式。真正重要的问题是:你对接纳了那些失败模式之后该怎么办。这才是这篇文章的其余部分要讲的。
我想精确地界定我所说的"静默未命中"是什么意思,因为它很容易与"糟糕的排序"混为一谈,而两者不是同一种失败模式,也不该用同一种方式应对。
明显的失误是智能体能够做出反应的一种。它调用搜索,得到零结果,或者得到明显跑题的结果。这是一个令人不快的后果,但它是一个清晰可辨的后果:智能体知道有东西没起作用,可以回退、提问或用不同的关键词重新搜索。
隐性的失误则不同,不是程度上的差异,而是性质上的。正确的笔记存在。它是正确的。它正是当前任务所需的。但它就是没有出现在结果中,因为它排名低于截断点(截断点是排名候选列表被丢弃不再返回调用者的边界线,通常由 top-k 限制设定),或者因为查询的嵌入向量恰好没有落在它附近,或者因为调用者请求的 top-k(一次检索调用返回的最高排名结果数量;如果相关笔记排在第 k+1 位,无论它有多接近都会被丢弃)太浅,够不到它。返回结果中没有任何信号表明这发生了。智能体收到一个看起来合理、完整的答案,却没有办法知道在它下面一个名次就存在一个更好的答案。它自信地继续执行,错过了一条它已经花费精力记录过的事实。
隐性失误是一种文件柜,配有一个只递送他们判断相关的文件夹的助理。如果他们的判断差一个,你甚至永远不会知道那个文件夹存在。抽屉里没有缝隙,助理脸上没有错误表情,只是一叠看起来正常、完整的文件夹,恰好缺少了你需要的那一个。平面文件没有助理。你自己打开抽屉。
我敢说,这是检索系统可能有的最坏故障模式,比返回空结果和返回明显错误的东西更糟,恰恰因为它在人们通常追踪的每一个指标中都是不可见的。"平均相关性得分"或"基准集上的 top-1 准确率"这类仪表盘不会暴露它,因为这些指标是在你已经知道正确答案的查询上计算的。生产环境中的隐性失误是一个没有人监控的案例,按照定义,因为如果你在监控它,你就会捕获到它,它就不会是隐性的了。
截断点和嵌入距离并不是笔记消失的唯一方式。笔记也可以在排名运行之前就被排除:在查询隐式地想要另一种类型的笔记时被按类别过滤掉,范围限定到了错误的工作空间,或者因为被标记为 stale_flagged 而被丢弃,而调用者的默认视图会跳过已标记的笔记。这种故障从外表看起来是一样的,一个看似合理但有缝隙的答案,但修复方法不同:排名调整对排名器从未见过的笔记毫无作用。
同样的故障形态出现在这个项目的代码搜索端,不仅仅是记忆端。一个正确的答案可以坐在候选池中仍然永远到不了列表顶部,有两种真正不同的原因:有时正确的 chunk 根本没有进入候选集,因为它的嵌入向量离查询太远;有时它很好地进入了池中,但排名过程把一些表面更相似的东西排在了它前面。这是需要不同修复的不同 bug,把它们混为一谈就会在错误的阶段打补丁。两者都是隐性的,除非有人特意去检查,而几乎没有人会特意去检查。
交互式演示 1:笔记去哪儿了?
(位于规范帖子上的直播演示。)
两个滑块:排名召回的 top-k,以及目标笔记的相似度得分。观察一条真正相关的笔记在你收紧 top-k 时滑落到截断点以下,并注意到输出中没有任何东西会说明这一点。平面文件列无论你把滑块设在哪里都显示同一条笔记在同样的排名位置,因为它根本不在做任何排名。金色行是实际回答查询的那条笔记,使用的是现实istic的相似度得分而不是玩具般的 0.99:正确匹配很少得接近完美的分数,这恰恰是它可能输给几个看似合理但错误的笔记并落到小 top-k 以下的原因。
这篇文章中最有力地反驳我自己的产品的部分,这与排名无关。而是关于智能体是否首先调用了这个工具。
在一个对照基准臂(一个在受控实验中测试的特定配置,与 A/B 测试的一个分支相同的含义)中,我在一个真实的、非平凡的任务上运行了一个 AI 编码智能体:在一个大型现有代码库中的跨切面修复,这类任务有理由受益于回忆早期的发现。记忆服务器通过 MCP(模型上下文协议,一个开放协议,让 AI 编码智能体通过标准接口调用外部工具,比如记忆存储,而不是每个智能体定制集成)连接。工作空间的指令文件在 32 处提到了记忆工具:何时存储一个发现,何时在开始工作前回忆,每个记忆类型是做什么的。测试工具(管理智能体会话、工具接线和生命周期事件的周围代码,与模型自身的推理分开)在每次运行中验证指导文件在启动时确实到位,并且所有十个记忆工具都在模型的直接工具列表中,所以这不是配置静默加载失败这种无聊的故事。
在整个会话过程中,智能体对任何记忆工具的调用次数为零。
它解决了任务,而且解决得很好,通过 grep 和直接阅读文件的方式,就像一个完全没有安装记忆系统的智能体那样工作。指令在那里。工具已连接。模型 просто 没有去使用它们,而在 63 轮、多轮压缩的会话中没有任何东西提示它这样做。
现在我必须包括的部分,因为如果略过它就会使本节成为这篇文章所反驳的那种 cherry-picked 证据。那个零没有复现。我在一个证明每次运行配置的更坚硬的测试工具下重新运行了同一个臂,我还有来自同一个守护进程上兄弟臂的第三次运行。三次运行的结果是这样的:
所以"智能体从不调用记忆服务器"不是真正的发现,如果我让这个戏剧性的单次运行作为代表,我就是在我自己的数据上歪曲事实。查找类型的调用是随机的(随机的或概率性的,而不是固定的;这里意思是即使设置没有任何变化,计数在每次运行中不可预测地波动),在相同配置下从 0 到 10 不等。
在所有三次运行中稳定的是更窄的范围,而且我认为更具说服力。计算写入次数:三次会话中总共 14 次记忆调用中,恰好只有一次存储了任何东西。存储的笔记分别是 0 条、1 条、0 条。智能体偶尔会伸手去存储器查找东西,概率大约五五开。它基本上从不往里面放东西。而一个没有任何东西被写入的存储器在下一次会话中就没有东西可检索,这正是整个价值主张静默失败的方式,没有任何错误消息会告诉你这件事。
因此诚实的标题不是"智能体忽略记忆服务器"。而是"智能体不会自愿采用记忆工作流"。这个版本经得起复现。吸引眼球的那个则不行。
模型在训练和提示方面压倒性地倾向于磁盘文件记忆。对 markdown 文件的读取是本能反应:便宜、熟悉,每 个智能体在训练和实践中做过无数次的事情。必须被选择的 MCP 工具,一轮又一轮地与这种本能竞争,而且通常输掉—— invisibly,与隐性失误 invisible 的方式相同:没有错误,没有崩溃,只是一个会话安静地表现得好像记忆系统不存在。把两个发现放在一起,结论很直白:一个模型不触及的存储器的未命中率为 100%,无论它下面的排名有多好都没用。检索质量乘以零采用率还是零。
这就是重新定义文章其余部分的发现。存储器内部的隐性失误的修复和因为存储器从未被查询而导致的隐性失误的修复实际上是同一个修复:停止要求模型主动选择加入。
你无法通过指令达到可靠性。我已经在系统的另一个部分hard way 学到了这一点,让智能体在会话中可靠地调用 recall 需要生命周期钩子,而不是更强的指令,而上面的采用发现是同一个教训从另一个角度出发。修复必须存在于测试工具中,而不是在模型的配合意愿中。生命周期钩子(测试工具控制的检查点,会话开始、提示前、编辑前、提交前、压缩后,代码自动运行而不是等待模型决定行动)让存储器按照测试工具控制的 schedule 将笔记推入上下文,而不是等待被询问。
The reason silent misses persist as a class of bug is structural: nobody counts them, because counting them requires knowing, independently of the retrieval system, what the right answer was for a given query. That's expensive to build and easy to skip, which is exactly why it gets skipped.
I don't have a finished answer here, and I'd rather say that plainly than paper over it. What I do have is the conviction, earned from watching the code-search side of this project for a while now, that you cannot improve a number you have never computed. A recall-miss floor, a ground-truth set of query and note-that-should-have-surfaced pairs, checked against what the ranker actually returns, needs to exist as a measurement harness before any tuning decision on top of it means anything. Tuning a threshold, a top-k, an embedding model, against vibes instead of a measured miss rate is how you end up with a system that feels better in a demo and isn't actually better, because the demo's queries were never the ones that were failing.
This work is in flight and genuinely unfinished. I'm flagging it here rather than describing a solution I don't have, because the alternative, implying the miss-rate problem is solved when it isn't, is the exact kind of quiet dishonesty this post is trying not to commit.
This is the direct answer to the legibility complaint from the first section, and it's the one fix in this post I no longer have to describe as a plan. I want to walk through it in two stages, in the order they actually happened: one that shipped, and one I still haven't solved.
The store now continuously projects its current state to a human-readable file on disk. cat (the Unix command that prints a file's full contents to the screen) works again. grep (the Unix command that searches text for lines matching a pattern) works again. Code review and version control both work on your agent's memory again, the same way they work on a MEMORY.md, because the projection is a MEMORY.md-shaped file. Writes still only ever go through the store; the file is a view, not a second source of truth.
vectr memory export [--path FILE] [--workspace DIR]
It renders a workspace's working-memory notes to a read-only markdown file, MEMORY.md by default. It merged to vectr's main branch as commit bdfafd2 on 2026-08-18. vectr is a public repo, so the source is checkable: the render lives in agent/working_context_store/_export.py, reading from a notes_for_export() method on the store.
The rendered file groups notes by kind into a fixed set of sections in a fixed order, and within a section, notes are ordered by note_id ascending, roughly like this:
## Directives
- [#4] 2026-06-02 · Run tests inside the venv, not the global interpreter.
## Findings
- [#398] 2026-07-27 · Index rebuild reuses the embed cache on unchanged files.
- [#412] 2026-08-01 · REVOKED 2026-08-09 (wrong lock order) · workspace lock
must be released before daemon restart.
(That's an illustrative shape, not literal output.)
That revoked line staying in the file is the point, not an oversight. Export renders every note, including superseded and revoked ones, deliberately unfiltered, unlike the ranked recall() path. The file is an audit log of what memory learned, not a ranked view of what's relevant right now.
That determinism is what makes the file useful rather than decorative. Ordering by a stable id and using absolute dates instead of "3 days ago" means re-exporting an unchanged corpus is byte-identical, which is what makes git diff of the file meaningful instead of noise. A diff between two commits of MEMORY.md tells you exactly which notes changed between them, the same way a diff of a hand-edited file would, because nothing about the rendering itself introduces churn. All of these numbers are from the one corpus I actually have, 749 notes. I haven't measured render time or file size at ten times that, and I'd expect both to grow roughly linearly, not something worse, but that's an expectation, not a measurement.
I checked what reads the file back, because the whole point of calling this a mirror rather than a second copy of the data depends on the answer being nothing. I grepped every call site of the new export functions: exactly two, both write-side, both callers of the render function after a note write. Zero parse-back paths. The database stays the sole source of truth, and the export is lossy on purpose: embedding vectors, the full event history, anchor content h
Stage two: a write path, unsolved
Stage two is where it gets honest. The vision is clear: a bidirectional file, where writing to MEMORY.md is equivalent to calling the store's write API. The use case is obvious — the moment you can grep your agent's memory, a whole class of hand-edit workflows opens up. Direct memory patching without going through a UI. Bulk corrections from a shell pipeline. Copy-pasting a directive from a Slack message into a file. The same manual workflows you've always had with a text file, but with the store behind it.
The problem I haven't solved is conflict resolution. A flat file has no conflicts because it has no concurrency: one writer, one reader, the file is always in a state a human wrote. An agent that runs continuously in the background can write to the store while you're editing the file. Two writers, one file, no defined merge strategy. The naive version — last write wins — is wrong in the same way a flat file would be wrong if two humans edited it at the same time and one of them just won. The principled version — structured merge, semantic resolution — requires more infrastructure than a single commit, and I'm not going to pretend otherwise.
The honest version of stage two is that it's a user-education problem as much as a technical one. The file projection makes the store's state legible, which means a user can see what the agent has remembered and catch errors earlier. That's valuable even without the write path. The write path would make it better, but it's not a prerequisite for the trust improvement the export already delivers. I'm shipping stage one, and I'm not going to claim otherwise to make the story sound more finished than it is.
The flat file's legibility advantage wasn't really about the format. It was about a complete and consistent mental model: everything is a line in one file, nothing is hidden, nothing is inferred, nothing is probabilistically recalled. That model is simple to reason about and simple to verify, and it costs something in recall quality to maintain, which is why the ranked store exists in the first place.
The fixes in this post are not about eliminating that tradeoff. They're about being honest about where the tradeoff lands for different kinds of memory, and building infrastructure that makes the tradeoff visible rather than hidden. The unconditional tier keeps "never miss" available for the memories where a miss is unacceptable. The file projection makes the ranked store's state inspectable, which is the precondition for trusting it. The measurement harness makes the miss rate visible, which is the precondition for improving it.
None of these are solved perfectly. The measurement harness is incomplete. The write path is unsolved. The ranking itself — the core of whether the right note surfaces for the right query — still has no answer I find satisfying. But the gap between the flat file's legibility and the store's opacity is no longer a structural feature of the system. It's a list of specific unsolved problems, and that list is a much better place to be than where I started.
The flat file still wins on legibility. The store is catching up. The race is real, and I'm still in it.