记忆只记人,知识写进仓库

题记:一条「这个服务部署在哪」的事实,放进向量记忆是一颗定时炸弹,放进仓库文档只是一行会被下一次提交改掉的字。
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 的缺口还在。 实体现状仍然是开工时最常缺的信息,只是现在它应该从项目文档的注入里来,记忆最多提供一个指针。这次落地改的是路由,没有补数据;缺口是否被填上,要等之后的回放评估。
我认为这些代价值得。一个记忆系统最怕的不是记得少,而是记得错还记得自信。用户的偏好错了,用户会纠正;项目知识过时了,没有人会想到去翻向量库。让知识住在它的失效信号旁边,系统才有机会自己发现它旧了。