和人说话、替人记事,是两份会互相拖后腿的活。这次把它们拆到两条线程,再把人说过的话当作积压交回去。

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 给了三条理由:

  1. tool-verified 证据要求持久的工具结果行;
  2. 行级标签触发器绑定在持久记录上;
  3. 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),“读报告,不读原文”“依据是现场,不是报告”等关键论点不能丢。

工具面也按线程分开,集成测试起一个真实主进程、用脚本化模型各跑一回合,直接断言发出去的请求:

线程身份有没有
Mainsystem.md + main.mdprogress_board watch_thread side_chat_message side_chat_inspect管家工具、report_progress
管家线程steward.md全部 steward_*进度板、side_chat_*、report_progress
普通 side chatsystem.mdreport_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 个测试文件。真正的设计决定其实只有三句话:管家不坐前台;前台是同一个我换把椅子;人在前台说的话,照样排进管家的队列。