题记:一条「这个服务部署在哪」的事实,放进向量记忆是一颗定时炸弹,放进仓库文档只是一行会被下一次提交改掉的字。

1. 先认个错:上一版口径把记忆当成了档案柜⌗

MaxClaw 的长期记忆只有一个写入者:记忆管家。它读各线程的对话,只收四类有证据的事实(用户亲口说的、用户明确认可的、工具验证过的、观察到的成功结果),写进向量库;开工简报和主动召回再按语义相似度把它们捞出来,塞进新对话。

在这次改动之前,写入路由的登记表(src/write-routing/sinks.ts)对「agent 自己验证出的项目知识」是这么安排的:

agent 的项目知识 → 留在对话里
                 → 管家读对话时写进记忆(绑定项目)
                 → 稳定后夜间「毕业」进仓库的 AGENTS.md

管家提示词里甚至有一节叫「首要产出:带证据的实体现状」——机器、容器、服务、仓库、账户的现役与退役、入口与位置,要求读到实体就用只读工具核实,写成 tool-verified 事实。这一节有数据撑腰:设计文档 docs/memory-steward.md §12.5 记过一次回放,没被开工简报覆盖的 27 次稳定事实查找里,有 21 次记忆里根本没有,缺的正是实体现状。

数据是真的,结论却选错了方向。缺的东西确实是「现状」,但这不能推出「现状应该放进记忆」。这次落地把那一节整段删掉,在 §12.5 头上加了一行「2026-10-31 收窄:本节不再有效」,并把整个系统里描述这条路由的地方——系统提示词、管家提示词、影子提示词、remember 工具描述、五个写入路由模块、四个 skill 文档——统一改成新口径。改动 30 个文件,+258 / −76,大部分是措辞,少量是机制。

2. 两类知识,三处不同⌗

新口径一句话:记忆只记人;agent 验证过的知识,由做事的线程当场写进项目文档。

为什么不能混放?把两类知识的生命周期并排摆一下就清楚了:

关于用户的事实agent 验证过的项目知识
例子(虚构)「用户宁可功能降级,也不接受数据出境」「这个服务的构建要先装某版本的依赖」
谁能纠正只有用户本人任何读代码、跑命令的人
什么时候失效用户改口时下一次提交、迁移、换机器时
失效信号在哪对话里仓库里(commit、删文件、改配置)
谁该读所有和他打交道的线程进这个仓库干活的线程
怎么被读到语义检索(召回阈值 cos ≥ 0.70,简报 ≥ 0.50)进入仓库时注入根 AGENTS.md

关键是第三、四行。项目知识的失效信号发生在仓库里,记忆库看不见。一次重构删掉了某条路径,向量库里那条「路径在 X」的事实依然 active、依然有一条当时成功的 tool-verified 证据,依然会因为和新问题语义相近被召回。证据链越严格,过时的事实反而越显得可信——它确实被验证过,只是验证的是三个月前的世界。

反过来,用户的偏好和决定不会因为某次提交而失效,也没有任何仓库文件能替他改口。它们的失效信号只在对话里,这正是管家读对话的意义所在。

所以分界线不是「可共享 vs 私有」(旧距离规则的第一条就是这么切的),而是失效信号发生在哪里。知识应该住在它的失效信号旁边。

3. 项目文档凭什么更合适⌗

「写进项目文档」不是指随便丢个 Markdown。MaxClaw 的项目文档有一套专门的维护工具 project_state,根 AGENTS.md 里有一块受管区,只经它改写:

动作语义
set_state改写当前状态,不追加日记;历史交给 Git
add_evidence / update_evidence记具体观察:条件、操作、尝试与成功次数、覆盖范围
register_doc子文档先写细节,再登记指针,不另立「当前状态」
retire_doc来源作废时删除(干净工作区内已跟踪的)或归档到 archive/,必须写 replacedBy

对照第 2 节那张表的「怎么纠正」:项目文档的纠错是一次 diff,可审阅、可 git revert,退役时还得写明谁取代了它。项目 Markdown 的每次回合改动还会经 doc-review 异步审核,意见在作者线程的下一个安全点送回。

而向量记忆的纠错是 replaces(新事实挂在旧事实的 superseded_by 链上)或 retire——前提是有人意识到它过时了。管家每天读的是对话,不是 git log。

还有一个时机问题。旧路由是「留在对话里,等管家读」,管家通常几分钟到一小时后才处理,还可能读的是渲染截断后的压缩稿。新路由是「做事的线程当场写」:验证这条知识的那份上下文最完整的时刻,就是验证刚完成的时刻。

4. 记忆收什么:三类,加一条不收清单⌗

管家提示词 prompts/steward.md 新的「记忆收什么」一节:

记忆只收三类:
1. 关于用户的事实、偏好、决定、纠正;
2. 带原委的归纳性事件;
3. 指向文档的指针。

不收,这些属于文档:配方、配置、部署状态和 commit、
API 用法、复现步骤、修复过程、项目进度。

第 2 类和第 3 类值得多说两句。

带原委的事件补的是第 1 类的短板。「用户不接受方案 A」是一条容易被误用的事实——下次遇到 A 的变体,模型会一刀切地回避。「agent 提议 A,用户否了,理由是 B」则带着适用条件:理由 B 不成立的场景,A 仍可讨论。

指向文档的指针是两个世界之间的桥。记忆不存配方本身,但可以存「某类问题有现成配方,见某路径」。提示词要求写指针前先用只读命令确认路径还在,用那次结果行做 tool-verified 证据。这样语义检索仍能把人领到正确的地方,而地方本身的内容由仓库负责保鲜。指针失效的代价也小得多:最坏是「文件不在了」,一眼可见,而不是一条看似可信的错误事实。

存量怎么办?管家提示词说约两万条旧事实的证据是 unknown。新规则把它们分流:

  • 属于「不收」范围的:不要为它们补证据升级,整理到时退役;
  • 指针类:路径能核实的,核实后写 tool-verified 新版本;
  • 用户亲口说过的:找到原消息,改写成 max-stated。

第一条是对上一版的直接反转:上一版恰恰鼓励「能用工具核实的(实体、路径、配置)核实后升级」。

5. 看法有范围,replaces 不是覆盖⌗

新口径里还有一条容易被忽略的规则:

看法要带适用范围和语气(随口抱怨还是正式拍板)。不同场景的看法并存,不互相取代。只有两句话说的是同一件事、同一范围时,才用 replaces。

这条是专门冲着向量记忆的一个系统性偏差来的。两句话语义相近,检索时会一起出现,模型很自然地把后一句当成对前一句的更新。设想用户先说「这个编辑器的界面是同类里最好的」,几周后又说「这个编辑器的插件生态我用不下去了」。两句都在评价同一个工具,向量很近,但说的是两个维度。用后者 replaces 前者,记忆就丢了一半真相。

「语气」同理。一句吐槽和一次拍板在文本上可能差不多,在记忆里的权重应该差很远。

6. 影子提案:先给它看已有的记忆⌗

MaxClaw 同时在跑一套影子记忆(前一篇别让管家重读对话讲过):每个线程空闲时,趁提示缓存还热,在原请求末尾追加一问,让同一模型提交事实提案,进对照表,不碰生产记忆。

口径收窄后,影子这边同步做了三件事。

6.1 同一口径,测试钉死⌗

影子指令 INSTRUCTION(src/shadow-memory/service.ts)换成和管家提示词相同的三类 + 不收清单。为了防止两份提示词以后各改各的,加了一条测试:

test('steward and shadow instructions carry the same collection scope', async () => {
  const steward = await Bun.file(new URL('../../prompts/steward.md', import.meta.url)).text()
  for (const phrase of ['带原委的归纳性事件', '指向文档的指针',
      '配方、配置、部署状态和 commit、API 用法、复现步骤、修复过程、项目进度',
      '不同场景的看法并存,不互相取代', '随口抱怨还是正式拍板']) {
    expect(INSTRUCTION).toContain(phrase)
    expect(steward).toContain(phrase)
  }
})

写入路由那边也一样。sinks.test.ts 除了正向断言记忆的 rejects 里有「不写进记忆,也不能写进记忆」,还反向断言 remember 工具描述里不能再出现旧口径的三种说法:

expect(remember).not.toMatch(/读对话时写入|写进长期记忆|会绑定项目/)

提示词就是规格,旧措辞残留一处,模型就多一个理由走回老路。

6.2 附上已有记忆,只附在最后一条消息里⌗

影子模型以前是闭着眼提案的:它只看到这一页未读行,不知道记忆里已经有什么,于是重复提案、该 replaces 的不 replaces。新增的 src/shadow-memory/known-memory.ts 给它三份材料:

材料来源
本线程已有的正式事实证据来自本线程的 active 事实(新加的 PgFactStore.activeFromThread)
本线程以前的影子提案通过校验、且没被更新的提案替换掉的
与这一页相关的已有事实用这一页的 user / assistant 正文做 embedding,取前 8 条(和 search_memory 默认 topK 一致),去掉已列出的

三份都经线程自己的 Viewer 读,类别线程的事实不会漏进无类别线程。测试 the question ends with the thread's facts... 构造了一条打了类别标签的相关事实,断言它不出现在提问里。

难点不在查询,在放哪儿。影子的热路径靠的是前缀逐字不变才能命中缓存;已有记忆每次都可能不同,塞进前缀任何位置都会让缓存整段失效。所以 renderKnownMemory 的注释只有一句:

/** Appended to the end of the final user message only; nothing before it may change (hot-path cache prefix). */

对应的测试 known memory sits only in the final user message 断言:请求的前 n 条消息与冻结前缀 JSON.stringify 完全相等,preserveCachePrefixMessages 等于 n,总长恰好 n + 2(原回答 + 影子提问),已有事实只出现在最后一条里。

渲染出来的段落开头就把第 5 节的规则再说一遍:

已有记忆(方括号里是 id)。已有的不重复提;内容有变化的,提案用 replaces
指向旧的那条的 id;说的不是同一件事、同一范围的,并存,不要 replaces。

6.3 一个 m: 前缀⌗

最小的改动也最说明问题。管家和影子读的渲染稿里,每行以 m:<消息id> 开头,模型引用证据时常常把 m: 一起抄进 messageId,于是证据指针查无此消息,整条提案被判无效。

修法是在提案边界剥掉它,且只在这里:

/** The backlog renderer tags every row `m:<id>`; models copy the tag along with the id. */
const stripRowTag = (value: unknown) => typeof value === 'string' ? value.replace(/^m:/, '') : value
const messageIdField = z.preprocess(stripRowTag, z.string().min(1))

只作用于 messageId 和 acceptedBy,sessionId 原样保留;剥完是空串(单独一个 m:)照样校验失败。测试把这三种情况各断言了一遍,再用一个返回 m:<真实id> 的假模型把冷路径完整跑通,断言提案 validation_error 为空。

渲染格式不改、提示词不加「别抄 m:」——外部输入在拥有它的边界校验一次,往里传的就是干净的 id。

7. 毕业:只剩用户的决定⌗

旧设计里有个「毕业」作业:项目绑定的记忆稳定 ≥ 7 天、被用过、可共享、仓库允许,就由夜间作业合并进 AGENTS.md 的「项目规则」一节。

这条通道保留了,但输入变了。现在项目绑定记忆的内容被定义为「用户关于项目的决定、偏好、规则与纠正,不含 agent 验证过的项目知识」;进入仓库时注入的「项目私有附注」标题也相应改成「用户关于本项目的决定、偏好与纠正」。

于是 AGENTS.md 里出现了一个清楚的分工:

根 AGENTS.md
├── 受管区(当前状态 / 观察证据 / 文档索引)  ← project_state,做事的线程当场写
└── ## 项目规则                              ← 用户的项目决定,记忆夜间毕业;
                                               或用户在消息里明确要求写入

agent 的发现不再绕道记忆再「毕业」回仓库。那条路绕了一大圈,终点本来就是仓库。

8. 代价与没解决的⌗

收窄不是白拿的,几件事要说清楚:

  • 执行靠模型,不靠分类器。 steward_write_fact 没有确定性规则拦截「配方类」事实;管家和影子是否遵守口径,取决于模型读懂提示词。测试守住的是措辞一致,不是判断正确。确定性守卫目前只有一条:只由问句支撑的偏好 / 决定 / 身份事实会被拒(提问不是决定)。
  • 依赖线程真的去写文档。 新口径把「沉淀知识」的责任从一个集中的管家推给了每个做事的线程。线程忘了写,知识就只留在对话里,随压缩消失。以前至少还有管家兜底读一遍。
  • 不属于任何仓库的知识没有家。 通用知识「留在对话里,不写持久处」。跨项目都有用、但又不只关于某台机器的经验,现在没有专门的去处。
  • 那 21/27 的缺口还在。 实体现状仍然是开工时最常缺的信息,只是现在它应该从项目文档的注入里来,记忆最多提供一个指针。这次落地改的是路由,没有补数据;缺口是否被填上,要等之后的回放评估。

我认为这些代价值得。一个记忆系统最怕的不是记得少,而是记得错还记得自信。用户的偏好错了,用户会纠正;项目知识过时了,没有人会想到去翻向量库。让知识住在它的失效信号旁边,系统才有机会自己发现它旧了。