管家不该坐在前台:把记忆写入者搬出主对话

和人说话、替人记事,是两份会互相拖后腿的活。这次把它们拆到两条线程,再把人说过的话当作积压交回去。
1. 一张前台,两份工作⌗
MaxClaw 里有一条特殊的线程叫 Main:用户不在网页前时只和它说话,各个专题对话(side chat)的进度事件也汇到这里。
在这次改动之前,Main 还兼着另一份差事:记忆管家。整个系统的长期记忆只有它一个写入者——它定期被唤醒,逐页读各线程尚未消化的原文,带着证据指针写事实、合并、退役,每天还顺手做一次改进分诊。
两份工作单独看都合理,放在同一条线程里就开始打架:
| 维度 | 和人说话 | 写记忆 |
|---|---|---|
| 身份 | 需要人格:判断、语气、对上下文负责 | 刻意无人格(prompts/steward.md),不闲聊 |
| 模型 | 要判断力 | 最便宜的 light 档就够,量大 |
| 上下文 | 越小越好,只放进度 | 一次唤醒至多 4 代、约 8 万 token 的他人原文 |
| 密级 | 推送到手机要能说具体的话 | 读过哪个类别的线程,记录就带哪个类别的标签 |
| 节奏 | 来了就要回 | 分钟 tick,线程空闲 ≥20 分钟才去读 |
最隐蔽的冲突在密级。管家的记录是“标签携带者”:每一行都由触发器 messages_main_labels 算出它这一代读过哪些线程,contentCategory 取这些线程类别的并集,而且只增不减。于是管家读了一页带类别 X 的对话之后,整条 Main 的后续模型调用只能发给打了 X 标签的模型,推送只能发中立文本(deviceContentScope)。用户只是想问一句进度,前台已经被后台读过的东西“染了色”。
还有一个更朴素的问题:用户面对的是一个被明令“没有人格”的 loop。
2. 三种常见做法,各卡在哪⌗
| 做法 | 问题 |
|---|---|
对话 agent 自己记(回合末抽取 / 随手 remember) | 记忆质量跟着对话节奏走:回合末那点预算里抽事实,又快又糙。这套轻模型抽取流水线在这个系统里已经退役过一次 |
| 后台批处理作业 | 没有持久的工具结果行。管家写的 tool-verified 事实要求指针指向一次成功的验证调用,批处理作业的中间步骤无处安放 |
| 管家住在前台(旧方案) | 上一节那张表 |
真正需要的是:管家保有一条线程的全部机制(持久记录、工具调用、压缩、重启续跑),但这条线程不是用户面对的那条。
3. 管家搬进一条隐藏线程⌗
新的拓扑:
用户
│ 说话 / 回答卡片
▼
┌──────────────┐ side_chat_message / inspect ┌──────────────┐
│ Main │ ─────────────────────────────▶ │ side chat × N│
│ Claw + 角色 │ ◀──── progress-report 事件 ─── │ Claw │
└──────┬───────┘ └──────┬───────┘
│ 行进入积压(从管家线程建立起) │ 行进入积压
▼ ▼
┌───────────────────────────────────────────────────────────┐
│ web:memory-steward(隐藏 side chat,无人格,light 模型) │
│ 分钟 tick 唤醒 → 读积压 → 带证据写 facts → steward_finish │
└───────────────────────────────────────────────────────────┘
线程在启动时建一次(src/steward/thread.ts):
await stores.threads.create({
id: STEWARD_THREAD_ID, // 'web:memory-steward'
kind: 'side', transport: 'web', sealTier: 'open',
origin: { system: 'memory-steward' },
title: '记忆管家', ...
}, tx)
const main = await stores.prefs.get(MAIN_THREAD_ID)
await stores.prefs.patch(STEWARD_THREAD_ID, { modelRef: main.modelRef, thinkingLevel: main.thinkingLevel }, tx)
模型钉选从 Main 复制一次,之后各走各的。thread.test.ts 专门验这一点:建完之后把 Main 改成 frontier 档,管家线程仍是 light 档。前台换好模型,后台不跟着变贵。
为什么是一条“线程”而不是后台任务或影子进程?设计文档 docs/main-control.md 给了三条理由:
tool-verified证据要求持久的工具结果行;- 行级标签触发器绑定在持久记录上;
- tick 唤醒、压缩、重启续跑全部复用线程机制,不另写一套。
代价同样写明了:一条特殊线程要在约十处被排除——侧栏、Main 的 carry、收件箱分拣候选、进度板、记忆积压、影子记忆、引用 / fork、hiddenThreadSql……管家自己的线程绝不能变成管家的积压,否则它会读自己读过的东西,无限递归。
4. 主线程只是“带角色的同一个我”⌗
管家走了,Main 换回谁?没有新造一个“调度员人格”,而是同一个 Claw,叠一段角色说明。turn-runner 里的身份选择就三分支:
const identityPrompt = isStewardTurn ? stewardPrompt()
: isMainTurn ? mainIdentityPrompt(baseIdentityPrompt) // system.md + main.md
: baseIdentityPrompt // 其他线程:system.md
prompts/main.md 是热文档:每个 Main 回合都读一遍,身份文本进上下文段 key,改一次开新段、已冻结的段字节不变;读不到就沿用上次读到的,从未读到过才用内置的最小角色。它写的是位置,而非人格:读进度报告、不读原文;判完成看现场、不看报告;分清“偷懒停下”和“真卡住”;每次醒来按当前状态重排优先级;对人说话要短。
角色文档会漂,所以有测试看着它(main-prompt.test.ts):文中用反引号点名的每个工具都必须真实存在于技能注册表里(点名数 > 8),“读报告,不读原文”“依据是现场,不是报告”等关键论点不能丢。
工具面也按线程分开,集成测试起一个真实主进程、用脚本化模型各跑一回合,直接断言发出去的请求:
| 线程 | 身份 | 有 | 没有 |
|---|---|---|---|
| Main | system.md + main.md | progress_board watch_thread side_chat_message side_chat_inspect | 管家工具、report_progress |
| 管家线程 | steward.md | 全部 steward_* | 进度板、side_chat_*、report_progress |
| 普通 side chat | system.md | report_progress(常驻) | 以上两类 |
一个 agent、三种工位,差别全在提示词末尾那一段和工具清单上。“多 agent 系统”在这里其实是“一个 agent 坐不同的椅子”。
5. 用户在主线程说的话,变成积压⌗
这是整次改动里最容易被忽略、也最关键的一行。
旧方案里,Main 的行被排除在积压之外,理由很自然:管家就坐在 Main 里,用户的回答它当场就听见了。管家一搬走,这个理由消失——用户在 Main 里说“以后这类事别打扰我”,如果不进积压,就永远没人记。
src/memory/extraction.ts 的积压谓词因此改成:
AND m.channel <> 'web:memory-steward'
AND (m.channel <> 'max:main'
OR m.created_at > (SELECT st.created_at FROM threads st
WHERE st.id = 'web:memory-steward'))
时间下界选在管家线程的建立时刻:在那之前 Main 里是管家时代的工具结果,不是对话,不回灌。
但 Main 的记录本身仍是最高密级——它收到的是各线程的进度,可能带着类别标签。管家凭什么能读?stewardViewer 多了一个只属于它的 readsMain,hiddenThreadSql 对它换了一条规则:
const mainHidden = viewer.readsMain
? mainBeyondCategoriesSql(q, cats) // Main 当前上下文的类别 ⊄ 管家模型标签 → 仍隐藏
: q`scope_t.kind = 'main'` // 其他任何 agent:永远隐藏
只看 Main 的当前一代就够了,因为标签只增不减,更早的代不会比它更宽。Main 带着管家模型没有的类别时,它就像一个类别线程一样排队等着,而不是被降级读。读完一代,回报的是 Main 上下文的来源线程,于是管家线程自己的行也带上同样的标签——密级守恒。
影子记忆(一条对照管家质量的旁路)走同一口径:label_categories 从 session_evidence_threads 取。测试里先往 Main 写一条普通用户话,影子调用的 contentCategory 为 null;再写一条 fromThreadId 指向类别线程的进度行,下一次调用的 contentCategory 就变成那个类别。
把这几块拼起来,才是标题里那句“互不牺牲”的意思:
- 前台:用户的话立刻得到 Claw 的回答,上下文里没有别人的原文,没有管家回合的噪音;
- 后台:同一句话稍后以积压的形式被管家读到,按四类证据规则(
max-stated须是用户本人的人类行)写进记忆。
流畅是同步路径的事,质量是异步路径的事,两条路径共享的只有持久化的消息行。
6. 两个标签携带者⌗
搬家没有削弱隔离,只是把“携带者”从一个变成两个:
- 迁移把
messages_main_labels触发器的条件从channel = 'max:main'扩成IN ('max:main', 'web:memory-steward'),决策表的标签触发器同理; - 流向规则(
src/steward/flow.ts)泛化为“携带者”:它发出去的东西带当前上下文的类别,只能去读得了全部类别的线程(CarrierLabelFlowError); - 行复制(fork / clone)和引用对两条线程的带标签行一律拒绝(
MainRecordCopyError); - 有 agent 想读管家线程,得到的是诚实的错误:“这是记忆管家的记录,最高密级,不是空对话,需要的记忆用
search_memory找”。
7. 主线程的手:在安全点送达的消息⌗
Main 不读原文了,那它怎么干预一个跑偏的 side chat?两个新工具:
side_chat_inspect:一份小现场——是否在跑、最后一条回复(截到 1500 字)、最近 12 次工具调用及结局、用文件工具写过的路径、最新进度报告。不是对话原文。side_chat_message({ thread, intent, message }):nudge催促 /correct纠偏 /stop叫停。
送达时机是这里的精髓。注入泵原本只有一类事件能在运行中的回合的下一个安全点并入(后台 shell 退出),其他事件一律等回合边界。现在加了一条:
export function deliversAtSafePoint(row) {
return row.source.kind === 'shell-watch'
|| (row.source.kind === 'thread-event' && row.source.meta.kind === 'main-message')
}
main-message-delivery.test.ts 正面反面各一例:正在跑的对话立刻收到 Main 的“叫停”;同样在跑时到达的进度报告事件则一直等到回合结束才派发。叫停如果要等对方自己跑完才送到,就不叫叫停了。
在对方那里,这条消息显示为“来自 Main”,正文里写明“不是用户说的话;冲突时以用户为准”。每次发出在 main_actions 记一行,同一行文字作为工具结果回到 Main 自己的对话里;同一次工具调用重放幂等。
8. 没做的,和故意不做的⌗
| 事项 | 现状 | 理由 |
|---|---|---|
| Main 的模型 | 没动,上线后 Claw 在便宜模型上当 Main,直到用户重选 | 钉选是用户的决定 |
| Main 的旧上下文标签 | 随 session_evidence_threads 一代代继承;换成没打该标签的模型会如实报 CategoryModelError | “从头开始”能清,要不要清由用户定 |
| “不读原文” | 只是角色说明的约束,读历史的工具仍在 | 没有真实失败案例,不加硬限制 |
| 叫停 | 是一条消息,不是硬中止 | 同上 |
changedFiles | 只含文件工具写的路径,shell 改的看不到 | 要看工作区就自己跑 git status |
| 隐藏线程本身 | 过渡形态 | 终局是影子线程取代管家 |
最后一行我想多说一句。约十处排除点是一个明确的坏味道:每新增一种“遍历所有线程”的机制,都得记得跳过管家线程,漏一处就是管家读自己、或者侧栏冒出一条用户看不懂的对话。这次靠 isStewardThread / isLabelCarrier 这类集中判定和集成测试兜着,但它本质上是在用特例换机制复用。设计文档自己把它标成过渡,这是诚实的;它能不能真的过渡掉,取决于影子线程的质量能不能追上管家。
整次改动 69 个文件、+1601 / −292,16 个测试文件。真正的设计决定其实只有三句话:管家不坐前台;前台是同一个我换把椅子;人在前台说的话,照样排进管家的队列。