给 AI 编码代理的 memory:存下来是简单的那一半
上周那篇 Harness 的文章里放了一份真实的会话简报,而里面有一行干的活比其他所有行加起来还多:
- **MCP Server** [PITFALL] (claude-code/vincent, 1 day ago): every new ID argument
name must be added to idArgumentResolvers in authz.go, or the cross-org check
silently skips it.
读到这一行的代理,省下了上一个代理搭进去的整个下午。很好。现在把它放旧六个月。同一句话,同样笃定的语气,被送到一个正在改某个文件的代理面前——而这个文件在这期间已经被两个人重写过了。这一行看上去没有任何不同。代理没有办法分辨,你也没有。
这才是编码代理 memory 的真正问题,而且这不是大多数工具在造的那一部分。把东西写下来,是简单的那一半。
为什么一堆笔记回答不了这个问题
今天代理 memory 常见的形态,一是代理往里追加内容的 markdown 文件,二是它写入的向量库。两者存都存得挺好,取也取得还行。但两者都没法告诉你某条笔记脚下的地面已经移动了,因为两者都不知道这条笔记在结构意义上到底是关于什么的。向量库知道这条笔记离 "gateway" 和 "headers" 这几个词很近。它不知道 ApiGateway 是你系统里的一个 container,不知道它有一个源码路径,也不知道那个路径上的代码三周前就已经和文档里的模型对不上了。
架构模型这三件都知道。这就是把 memory 放在架构模型旁边的全部理由,也是本文里唯一一件别家产品没法在一个 sprint 内抄走的事。
memory 现在已经在 Archyl 里了,所有套餐都有。下面是它做的事。
一条 memory 挂在元素上,而不是挂在某次对话上
一条 memory 是一个事实,类型是 note、convention 或 pitfall 三者之一,挂在某个 C4 元素上,或者挂在整个项目上。代理通过 MCP 写入;人则在 Agent Hub 里写,或者在图上任意元素的详情面板里写。
▶ remember(projectId: …, element: "ApiGateway", kind: "pitfall",
content: "The gateway strips custom headers longer than 8KB — payloads must go in the body.")
还有第四种类型 session_outcome,在一次工作会话结束时自动写入。一个持有八个租约的会话,产出的是挂在八个元素上的一条 memory,而不是同一段话的八份副本。这个形态在取回这一侧很关键:一个代理问起其中任意一个元素,只会拿到这条结果一次,而不会因为那次会话碰巧动了八样东西,就把同一份摘要读回八遍。
memory 的正文和标题在存储时是加密的,和产品里其他所有敏感内容列一样。
recall 按含义排序,而不是按共有的子串
recall 把语义相似度和词项重叠混在一起,权重是 0.55 比 0.45。一个问 "rate limiting" 的代理,会拿回别人写的那条关于 "throttling" 的笔记——这正是词面匹配的搜索会漏掉、而同事绝不会漏掉的情形。
向量是有意做成尽力而为的。没有配置 AI 提供方(OpenAI 兼容的或者 Ollama)时,就没有向量,打分保持纯词法的,和以前的行为一样。它是降级而不是崩掉,如果你在没有提供方的情况下自托管,这一点很要紧。而且在没有任何提供方时写下的 memory 也不会永远是二等公民:一旦配置好提供方,后台 worker 会把它们的向量补上。
把同一个事实写两遍,是在确认它
把项目已经知道的东西再说一遍,不会生成第二份副本。当余弦相似度超过 0.94 时,这次写入转而确认已有的那条 memory,响应里会说 deduplicated: true。一个代理再次断言自己学到的东西,是证据,不是噪音。
0.82 到 0.94 之间是有意思的那一段:很接近,但不是同一个事实。这些会被存下来,而那些近似匹配会通过 similarTo 返回,这样写的人就可以有意去调用 remember(supersedes: "Old title"),而不是悄悄地和一条仍然有效、仍然在被送出去的 memory 相互矛盾。
memory 从使用中学习
每一次 recall 都会记录它把哪些 memory 送给了哪个会话。会话结束时,usedMemories 点名它真正依赖过的那些。
这两个信号被有意地给了不同的权重。被送到一条 memory 是旁证。说自己用了它,是证词。所以只有引用会抬高一条 memory 的排名,而且是对数缩放的,上限 1.8x,这样一条受欢迎的 memory 就没法把纠正它的那条更新的埋掉。一条被送给五个会话却一次引用都没有的 memory,会拿到 0.75 的乘数,并被当作噪音处理。
是当作,不是删除。memory 里的任何东西都不会被某个启发式规则移除。被忽略的 memory 会带着它的曝光次数进入复核队列,由人来决定。同一条原则贯穿整个功能:纠正,从不抹掉。
一条 memory 有生命周期
新鲜度按 45 天半衰期衰减,起点是这条 memory 最后一次被确知为真的时刻,也就是它的创建时间或最近一次确认时间。confirm_memory 会把这个计时器归零,并把确认次数加一。remember(supersedes: …) 替换掉一个已经变了的事实:旧版本退出检索,但仍留在历史和图谱里,所以你还能看到项目去年相信的是什么。
在这之上还有按类型的权重,而且它们是有立场的:pitfall 记 3.0,convention 记 2.0,普通的 note 记 1.5,session outcome 记 1.0。对于一个马上要改代码的代理来说,"这个会咬你一口" 排在 "当时是这么回事" 前面。
真正让一条 memory 失效的,是漂移
上面这些都算是像样的记账。这一节才是 memory 属于架构工具的理由。
时间是真相的弱代理。两年前写下的、关于你的服务边界如何工作的 convention,多半今天还是对的。上个月写下的、关于一个此后已被重写的文件的笔记,多半是错的。衰减对这两者一视同仁,因为它手里只有一只钟。
真正让一条 memory 变得可疑的,是它那个元素背后的代码变了。Archyl 早就在确定性地计算这件事:漂移分数把文档里的模型和仓库比对,点名那些已经对不上的元素。你可以从 UI 跑,从 API 跑,或者用 drift-score GitHub Action 在每次 push 时跑。memory 现在接上去了。
当漂移检测发现某个元素已经不同步,挂在这个元素上的每一条 memory 都会被打上这件事发生的时间戳。最后一次确认时间早于这个时间戳的 memory,描述的是一个此后已经从它脚下移走的东西。由此有三件事发生:
- 它在排序里被降权,乘数是 0.6。是降权,不是隐藏:它可能是任何人写过的关于那个元素的唯一一句话,而把它藏起来比带着警告送出去更糟。
- 它出现在给人看的复核队列里。
- 代理会读到一条警告,就在简报里,用文字而不是元数据写着:
- **ApiGateway** [PITFALL] [VERIFY — the element drifted since this was written]
(claude-code/sarah, 96 days ago): the gateway strips custom headers longer than 8KB.
重新确认这条 memory 就会清掉这个标记,因为确认正面回答了漂移提出的那个问题:有人去看过了,它还成立。
这套机制的两半住在同一个产品里。知识在这里,能推翻它的那套模型对代码的比对也在这里。一个螺在聊天客户端上的 memory 层,只有前一半,而且没有任何办法拿到后一半。
memory 之间会互相链接
给一条 memory 一个标题,它就变得可寻址。之后任何别的 memory 都能在自己的内容里用 [[Title]] 引用它,Obsidian 那种写法。同一套语法也能按名字解析到 C4 元素([[ApiGateway]])和决策([[ADR-17]]);指向一个还不存在的标题的链接会保持待定,等到有人写下那条 memory 的那一刻自动挂上去。
这些链接不只是给人读的。recall 会顺着它们走:排在前面的匹配会把自己链接到的邻居一起带进来,并标上 via,你就能看到是什么把它们带来的。一条关于 gateway 的 pitfall,如果链接到了解释这条边界为什么存在的 ADR,那它到达时是连着理由一起来的。
knowledge map,以及我们扔掉的那张图
memory 面板的第一个版本是一张节点-连线图。它能渲染,能聚类,看起来就是那种你会截图发出去的东西。它回答的是"哪条 memory 链接到哪条 memory",而这不是任何人在问的问题。
人们真正需要知道的,是自己架构里哪些部分项目是理解的,哪些部分根本没人写过一个字。所以我们把它换掉了。面板现在为每个 C4 元素显示一个格子:关于它已知的是什么,这份知识有多新,上面有多少个 pitfall,以及,对于什么都没有的元素,一个看得见的空缺。它产出了一句以前任何仪表盘都没给过你的话:
3 of 19 elements documented
这句话让人不舒服,而且是有用的那种不舒服。那张图不是。
它做不到的事
语义 recall 需要一个 AI 提供方。 没有 OpenAI 兼容的 endpoint、也没有 Ollama,就意味着没有向量,排序会退回到词项重叠。这一页上其他所有东西照样能用。
元素匹配仍然是词法的。 memory 现在按含义排序了。但它前面那一步——由 find_relevant_context 决定你的任务和哪些元素有关——仍然是按名称、描述、标签和路径上的词重叠来打分的。一个关于 "checkout" 的任务,仍然不会浮现出一个叫 OrderProcessor 的 component。我们在很多代理,一个架构里就把这标为一个限制,现在依然如此。
有用性这个信号只有在代理主动声明用过什么时才存在。 archyl-harness skill 教会代理把自己的 sessionId 传给 recall,并在结束时点名 usedMemories。没有任何东西强制它这么做。一个没装这个 skill 就连上来的代理,只会产生曝光而没有引用,而这读起来和一条谁都没觉得有用的 memory 完全一样。
memory 的作用域是项目。 一条适用于整个组织的 convention,必须在每个需要它的项目里各写一遍。这是我们接下来要修的。
还有一句诚实的总体保留:memory 刚刚才发布。我们没有采用数据,没有基准测试,也没有哪个客户来告诉你它帮他们省下了什么。上面写的是代码实际在做的事,而这里面的每一点你都可以在自己的项目上验证。
从哪里开始
如果你已经在跑 Harness,那 memory 已经开着了。remember、recall 和 confirm_memory 是 coding 配置里那十六个 tool 中的三个。Claude Code 插件 0.8.0 版本正是教会代理那两个排序所依赖的习惯的那块:把自己的 sessionId 传给 recall,以及在结束时点名自己用过什么。
第一件值得做的事不是写 memory。是打开 knowledge map,读那行覆盖率。不管它显示的是多少,那就是在最懂你架构的那个人去度假之后,还能留下来的那部分架构的比例。先猜一下这个数字,再去看。
然后挑出那个流量最大、写下来的东西最少的元素,把你会在新人第一天告诉他的那个坑写下来。那就是下一个代理需要的 memory,而在有人把它敲进去之前,再好的检索也找不到它。
memory 是 Archyl Harness 的一部分:工作会话、预检 gate、Guard 钩子和 Fleet console。插件、skills 和 Guard 钩子以及 GitHub Actions 都是开源的,完整参考在 Harness 指南里。相关阅读:工作会话、很多代理,一个架构,以及为什么你的代理有一份规则文件而没有模型。