别让管家重读对话:记忆该由缓存还热的那份上下文来写

题记:从对话里提取长期记忆,最懂这段对话的不是事后来读档的管家,而是刚刚说完话、缓存还热的那份上下文本身。
1. 中心管家的三笔账⌗
MaxClaw 原来的长期记忆是这样写出来的:一个「记忆管家」住在主线程里,定期把各个线程积压的原文拉过来,渲染成压缩格式,逐页读,读出带证据的事实写进记忆库。
这个方案好理解,但有三笔账一直在亏:
| 问题 | 表现 |
|---|---|
| 贵 | 管家读的每一页都是冷输入。原线程刚才已经为同一段内容付过一次完整的上下文费用,管家再从零读一遍。 |
| 不准 | 管家看到的是渲染后的压缩稿:用户原话全文,助手正文截断,工具调用和结果截短。最能判断「这句话是决定还是随口一问」的语境,恰好在截掉的部分里。 |
| 挤占 | 设计文档里记了一组数:三天里主线程有约 600 个管家回合,真正对用户说话的约 100 个。主线程本该是注意力中枢,结果成了档案室。 |
第三条是这次改动的远因(docs/main-attention.md 打算让主线程只读各线程的进度报告,不再读原文),但这次落地只做第一阶段:把「读原文、写事实」从中心管家手里拿走,交给每个线程自己的影子,并且先不碰生产记忆,与旧管路并行对照。
2. 影子回合:在原对话末尾多问一句⌗
核心想法一句话:线程每次调模型时,框架把这次真实发出去的请求原样记下来;线程空闲下来后,如果提示缓存还没过期,就在这份请求的末尾接上模型的回答,再追加一条「这是影子回合,请提交值得记住的事实」,用同一个模型再调一次。
线程最后一次真实请求 影子回合请求
┌──────────────────────────┐ ┌──────────────────────────┐
│ instructions / system │ 逐字不变 │ instructions / system │
│ tools(含影子工具) │ ─────────► │ tools(含影子工具) │
│ messages[0..n] │ cache hit │ messages[0..n] │
└──────────────────────────┘ │ + 原回答(含 thinking 块) │
│ + 未完成工具调用的占位结果 │
│ + 影子问题(user) │
└──────────────────────────┘
影子问题本身仍附上本批未读行的渲染稿,每行带 m:<消息id> 前缀:原始前缀里没有数据库消息 id,模型要引用证据,得有真实 id 可抄。判断依据则来自前面的前缀:模型面对的是它自己刚才的全部上下文:完整的工具结果、没被截断的推理、用户每一次纠正的前因后果。这比任何渲染稿都全,而这些 token 大部分走缓存读取计价(具体折扣取决于 provider 配置,代码里按 provider 的价格快照记账,见 §6)。
这和博客上一篇空闲压缩用的是同一个模式:趁缓存热,沿冻结前缀做后台工作。不同的是,压缩产出的是给自己用的摘要,影子产出的是给全系统用的事实。
2.1 「逐字不变」比听起来难⌗
缓存命中的前提是前缀字节完全一致。实现里有四个地方专门为此服务:
记录真实请求,而不是重建。 runtime.ts 里的注释写得很直白:
/** Keep the actual last request, not a reconstructed replay that could alter frozen bytes. */
export function noteShadowPrefix(thread, sessionId, request, result, at = Date.now())
direct loop 在拿到模型结果的那一刻(deliverResult)调用它,存下 structuredClone 过的 messages、identity prompt、session context、tools、unavailableTools 和实际生效的 thinking level。从数据库重新拼一遍历史看似等价,但任何一处序列化差异(一个换行、一个字段顺序)都会让缓存整段失效。
回答原样回放。 回答里的 reasoningBlocks 和 providerBlocks 一并保留。带签名的 thinking 块如果丢了或被改写,回放就不再是同一份前缀,缓存和推理连续性一起断掉。
影子工具从第一回合就在工具列表里。 这是最反直觉的一点。如果影子回合临时往 tools 里加一个 shadow_memory_propose,tools 段变了,后面全部失效。所以这个工具被放进 ALWAYS_AVAILABLE_TOOL_NAMES,每个普通回合都看得见它,但由 TurnToolPolicy 拒绝调用:
if (name === SHADOW_PROPOSE_TOOL) return this.opts.shadowMemory ? null : '影子记忆工具只在框架的影子回合放行。'
if (this.opts.shadowMemory) return '影子记忆回合只提交对照提案,不执行普通工具。'
两行正好对称:普通回合不许调影子工具,影子回合只许调影子工具。它还被加进 TURN_LEVEL_TOOLS,于是不会出现在每回合开头的「本回合不可用工具」清单里(那份清单也是前缀的一部分)。测试 proposal tool is resident but has no availability delta 钉住了这条:普通策略下 unavailableAtTurnStart([SHADOW_PROPOSE_TOOL]) 返回空数组。
缓存断点钉在旧前缀末尾。 新增的 preserveCachePrefixMessages 让 Anthropic 适配器在旧请求最后一条消息的位置多打一个 cache 锚点:
...(opts.preserveCachePrefixMessages === undefined ? [] :
[{ kind: 'message', nthFromEnd: messages.length - opts.preserveCachePrefixMessages + 1 }]),
上一次请求的 history 断点落在它的最后一条消息上;影子请求多了三段尾巴,如果只有默认的 history 断点,它会落在新的末尾,旧断点就读不到。
prefix.wire.test.ts 是这一节的规格:用真实的 Anthropic provider(fetch mock 掉)发两次请求,断言第二次的 messages 前 n 条与第一次 JSON.stringify 完全相同,且 system、tools、thinking、tool_choice、cache_control 五个字段逐字相等,新消息恰好多 2 条(回答 + 影子问题),末条 role 为 user。
2.2 原线程还在等工具怎么办⌗
线程停下来的时机经常是「模型发起了一个工具调用,工具还没回来」,比如正在等用户回答提问卡。这时前缀末尾是一个悬空的 tool_use,协议要求下一条必须是对应的 tool_result。
影子分支不能执行那个工具(那是原线程的事),于是给每个悬空调用补一条占位结果:
影子分支:原线程的工具调用仍在进行;本分支未执行它,没有结果可作为证据。
后半句是写给模型看的:别把这条占位当成工具验证过的事实。
3. 冷了就换便宜的模型,热了才用贵的⌗
缓存不是一直热的。服务每分钟 tick 一次,先扫一遍所有记下的前缀,超过 provider 配置的缓存 TTL 就丢掉。走热路径的条件是全部满足:
| 条件 | 原因 |
|---|---|
| 存在前缀,且属于同一代(session) | 换代后旧前缀描述的是另一段历史 |
| 距记录时间 ≤ provider TTL | 过期后「热」只是幻觉,按冷输入计价 |
| 前缀的 tools 里有影子工具 | 否则影子回合无法提交,也不能临时加 |
任何一条不满足就走冷路径:palette:light 的轻量模型,读管家现成的渲染格式(renderBacklogRow),每页按 PAGE_TOKENS = 12_000 估算切分。冷路径沿用旧管家的读法,只是按线程分摊、固定走 light 档。
这个分叉的价值在于花钱的地方与信息量匹配:热路径用线程自己的(通常更强的)模型,但它几乎不花输入钱,看到的是全量上下文;冷路径必须重读输入,所以用便宜模型读压缩稿。没有哪条路径是「用贵模型重读全文」。
类别线程的冷路径会带上 contentCategory 走真实 router 的类别过滤。测试 category cold path goes through the real router category filter even when light lists an untagged model first 专门构造了 light 组第一个模型没有该类别标签的情况,断言实际被调用的只有打了标签的那个。隔离线程和主线程根本不进影子:ready() 的 SQL 直接排除,noteShadowPrefix 对主线程直接 return。
4. 什么时候跑,什么时候让路⌗
触发点散布在框架各处,都是同一个调用 shadowMemoryChanged(thread, reason):
| reason | 来源 | 说明 |
|---|---|---|
idle-or-ended | 分钟 tick | 线程空闲 ≥ 20 分钟、已换代、session 结束或线程非 active |
turn-boundary | SessionAgent 回合正常结束 | 只消化之前因忙而排队的触发 |
turn-failed | SessionAgent 回合失败 | 直接触发一次 |
waiting-max | ask_user_question | 线程停下来等用户 |
task-ended | 任务结果送达派出线程 | |
generation-ended | 换代、session 置为 ended | 在事务提交之后才通知 |
thread-<status> | 线程归档等 |
两个细节:
忙的时候排队,等用户的时候不排。 线程正在跑回合时,触发被记进 pending,等回合边界再执行;唯独 waiting-max 例外,因为那一刻线程虽然在「运行中」,实际是阻塞在提问卡上,前缀不会再变,正是最热的时候。
用户一开口,影子立刻让路。 message-handler 收到用户的新消息就调 cancelShadowMemory:撤掉排队的触发,abort 正在跑的影子请求,并删掉前缀(因为原线程马上要发新请求,旧前缀即将过时)。被取消的 run 记为 cancelled、错误原因 interrupted-by-max,提案一条都不提交:写入在一个事务里完成,事务里每一步都先 throwIfAborted()。测试用一个永不返回、只响应 abort 的假 provider 验证了这条路径。
失败也有刹车:同一代连续 3 次失败(OFFER_BACKOFF_AFTER = 3)后退避 6 小时(OFFER_BACKOFF_MS),沿用管家的常量。测试 failed model output is charged and uses the steward failure backoff 触发 4 次只调用 3 次,时钟拨过 6 小时后才有第 4 次;失败那次的 token 照样记账。
5. 证据:模型说了不算⌗
影子模型提交的每条提案都必须带证据指针,种类与生产记忆一致:
| kind | 指向 |
|---|---|
max-stated | 用户本人的消息(提问不算决定) |
max-accepted | 结论消息 + 用户明确认可的那条(沉默不算) |
tool-verified | 成功的工具结果消息 |
observed-outcome | 成功工具结果,或整代 sessionId |
校验分三层,越往下越硬:
- zod schema:每个证据项只能是
{messageId}或{sessionId}之一(.strict()),至少一条。 - 服务层:复用管家的
checkStewardEvidence,再额外要求证据及其血缘全部属于本线程。单个影子看不到别的线程,它引用别处的消息只能是幻觉或串台。 - 数据库触发器
shadow_proposal_evidence:隔离线程一律RAISE EXCEPTION;通过校验的行若引用了别的线程同样拒绝。即使服务层代码将来写错,表里也进不去违规数据。
关键的设计选择在第 2 层:校验失败不丢弃,也不连坐。一批里某条证据不对,它带着 validation_error 入表,其余合法的照常入表。测试 an invalid fact does not discard valid siblings 构造了一批两条、其中一条 kind 写错,断言入表 2 行、1 行带错误;cross-thread evidence is retained as a rejected comparison proposal, never as a production fact 断言跨线程引用被留作「被拒提案」,facts 表里零行。
被拒的提案是对照数据的一部分:影子模型多久编造一次证据、编在哪类事实上,正是切换前要知道的。
6. 并行对照:先证明,再切换⌗
第一阶段影子只写自己的两张表:shadow_memory_runs(每次运行的路径、模型、状态、各类 token、耗时)和 shadow_fact_proposals。生产 facts 表一行不碰,消息上的 episode_id(管家的消化标记)也不碰,cold path 测试显式断言了这两点。
对照脚本 scripts/shadow-memory-compare.ts 以只读连接跑在评估库上,给出三种口径的覆盖率,刻意不混在一起:
| 口径 | 怎么算 | 局限 |
|---|---|---|
exactCoverage | NFKC 归一化、去空白标点后文本相等 | 改个措辞就不算 |
evidenceCoverage | 同线程、证据指针有交集 | 同一证据可能读出不同事实,只算「邻居」 |
semantic.coverage | light 模型判等价配对 | 输出是「可复核的配对,不是真值」 |
compareItems 的返回值里有一行自我声明:
shared evidence is reported separately, not claimed as semantic coverage
测试 comparison separates exact content coverage from shared-evidence candidates 用「部署使用独立工作区。」和「部署有独立工作区」两条:文本覆盖 0,证据覆盖 1,后者被列为前者的 evidenceNeighbors。
语义对照也有自己的护栏:按类别标签集合分组,每组带着自己的类别走 router 过滤,只会落到有权读它的 light 模型;隔离行在构造 prompt 之前就被滤掉(测试断言请求 JSON 里不含该 id);只有一侧有数据的组不调模型;评估模型返回越界下标直接抛错,不能凭空造配对;预演模式下禁止调用。
成本按 path × model × status 聚合,计价用每次调用时 provider 配置的价格快照,脚本输出里明写这是「list price snapshot, not subscription billing」;任何一笔调用缺价格,整行 estimatedUsd 置 null,而不是少算。延迟给 p50 / p95。证据质量只检查指针身份(目标是否存在、max-stated 是否真指向用户消息、tool-verified 是否指向成功的工具结果),注释同样说明正确性仍需人读。
这一节没有任何「影子比管家好多少」的数字,因为一周对照还没开始。脚本的意义是让那个数字出来时,每一列的口径都已经写死。
7. 游标:从哪一行开始读⌗
最后一个提交解决的是一个容易被当成实现细节的问题:影子第一次上线,每个线程从哪里读起?
如果空表就从头读,等于把所有历史用影子重跑一遍:成本不说,对照窗口里会混进大量旧事实,与管家同期产出不可比。最终口径在 cursors.ts 里,一个事务一次初始化:
shadow_memory_activation.through_seq = 启用时全局 max(seq) (单例,只写一次)
对每个尚无游标的非主、非隔离 web 线程:
若任一代存在 episode_id(管家消化过)
→ initialization='steward',每代 baseline = 该代已消化的最大 seq
否则
→ initialization='enabled',每代 baseline = 启用水位以内的最大 seq
之后游标只由影子自己的已完成 run 推进:ready() 取 greatest(baseline, max(completed run.through_seq))。三个测试对应三条性质:
- 继承:管家消化过的旧行不读,未消化的积压留给影子,且影子不改
episode_id; - 无消化记录:已有历史全部跳过,启用后的新行才读;
- 独立:初始化后管家继续推进,再调一次初始化,baseline 不变;换代后新一代也不去继承管家位置,从自己的起点读。
为什么初始化后要彻底断开?因为对照的前提是两条管路各读各的。如果影子的游标跟着管家走,管家漏读的地方影子也会漏,覆盖率就失去意义。
8. 一个顺手修掉的小坑⌗
分支第一个提交和记忆无关:三个测试 fixture 把 provider key 设成单字符(比如 'k')且没有还原环境变量。后续测试套件的「密钥脱敏」断言读到这个泄漏的值,把普通文本里所有的 k 都当成密钥替换掉了。修法是在 fixture 里保存并还原调用方的环境,外加一个回归测试。
为什么会在这个分支冒出来?推断:影子测试新增了几个读环境变量的 provider,改变了测试文件的执行组合,让潜伏的污染第一次可见。这类问题的教训和正文一样:共享状态(环境变量也好,缓存前缀也好)要么逐字保持,要么用完还原。
9. 我认为它领先在哪,以及还欠什么⌗
把常见做法和这次的做法放在一起:
| 中心管家重读 | 每线程新开摘要器 | 影子回合 | |
|---|---|---|---|
| 输入成本 | 全量冷读 | 全量冷读 | 热:缓存读;冷:light 模型读压缩稿 |
| 看到的上下文 | 渲染压缩稿 | 通常也是压缩稿 | 热:原请求全量 |
| 判断者 | 另一个模型 | 另一个上下文 | 刚说完这段话的那个上下文 |
| 跨线程去重 | 能 | 不能 | 不能(留给独立低频任务) |
| 上线风险 | — | 直接替换 | 先写对照表,三种口径对照后再切 |
我认为真正的领先不是「省钱」这一项,而是把成本最低的时机和信息最全的时机对齐:缓存最热的那一刻,恰好也是上下文最完整、最不需要压缩的那一刻。常见的做法是在二者之间取舍(要么贵而全,要么便宜而残),这里两边都拿到了,代价是对前缀字节的极端苛刻。
还欠的账也写在代码里:
- 前缀只在内存里。
prefixes是进程内的Map,重启即丢,重启后第一次影子必走冷路径。我的判断是持久化前缀要存整份请求,量级接近一份上下文,目前不划算。 - 只覆盖 Anthropic 线路的缓存锚点。
preserveCachePrefixMessages只在 Anthropic 适配器里落了锚点;其他 provider 的前缀同样逐字保留,能否命中取决于它们自己的缓存机制。 - 热路径的输出用的是贵模型。 输入便宜了,影子回合的输出 token 按线程自己模型的价格计。这一项的真实占比要等对照数据,是成本表里要盯的一列。
- 单线程视角。 影子看不到别的线程,跨线程的合并、退役、核实仍需独立任务;设计文档里这部分留给「整理组」,不放进主线程。
这些都不阻塞第一阶段:它的目标只是让影子和管家在同一时间窗里各自产出,再用写死口径的脚本比一比。比完再决定切不切,这本身就是这次改动最值得照抄的部分。