别替模型回忆:我删掉了自动召回,换成一个 12000 字封顶的搜索框

一个在压缩后自动把旧原文推回上下文的机制,被一个由模型自己决定何时调用、结果有硬上限的搜索工具取代。净删 488 行。
1. 问题:压缩之后,模型忘了什么⌗
长对话的 agent 迟早要压缩上下文。MaxClaw 的做法是把一个线程切成多「代」(generation):上一代的完整记录留在库里,新一代只带一份结构化摘要开局。摘要写得再好也是有损的,最容易丢的恰恰是逐字的东西——一条能跑通的命令、一个路径、某次工具调用的原始输出。
所以需要「召回」:在新一代里找回旧代的原文。问题在于谁来决定什么时候召回、召回多少。
这次落地之前,答案是「框架自动来」。之后,答案是「模型自己要」。
2. 旧设计:每条消息都替模型想一遍⌗
被删掉的 ThreadRecallService(src/runtime/thread-recall.ts,138 行)加上检索层(src/memory/thread-recall.ts,121 行),流程是这样的:
人类消息到达(该线程已压缩过)
│
├─► 主回合照常开始
│
└─► 后台 prefetch
│ 1. 一次 LLM 调用:把消息 + 压缩摘要 + 资源清单
│ 改写成 {questions, identifiers, resourceIndexes}
│ 2. 三路检索旧代:
│ · resourceIndexes → 资源清单里记的证据 messageId,直接取
│ · identifiers → 正文 / 工具调用参数逐字匹配
│ · questions → 向量相似度 ≥ 阈值
│ 3. 对每个命中,把与它配对的工具调用 / 工具结果也拉进来
│ 4. 每个片段的完整 content_text + 工具参数 JSON 拼成一条事件
▼
写入 thread_pending_events,在主回合下一个安全点 steer 进去
每一步单独看都很合理。合起来看,有三处会让输出体积失控:
| 环节 | 放大机制 |
|---|---|
| 资源指针 | 压缩摘要第 10 节「怎么访问用到的资源」要求每条资源锚定首次成功访问的调用及其结果——结果往往就是那次最大的工具输出 |
| 调用/结果配对 | 命中一个调用参数里的小标识符,会把它的整段结果一起带上 |
| 拼装 | threadRecallBody 把每个片段的 content_text 原样拼接,没有条数上限,也没有字数上限 |
再加上触发条件是「每条人类消息」,不管这一句话是否真的需要旧原文。一条三万字的工具输出,只要它在资源清单里,就有机会在每一轮被重新塞回上下文。
刚压缩完、好不容易腾出来的窗口,被召回又灌满了。召回本来是来补压缩的漏,结果自己成了漏。
3. 为什么不是「加个上限就好」⌗
最顺手的修法是在 threadRecallBody 里截一刀。这能止血,但留下了更根本的问题:
- 相关性判断放错了地方。 改写模型只看到一句消息和一份摘要,它不知道主模型此刻在想什么、手里已经有什么。主模型才是唯一知道「我缺什么」的一方。
- 成本是无条件的。 每条消息一次额外的 LLM 改写、一组向量检索,命中率再低也照付。
- 时序是一堆状态机。 推送必须和正在跑的回合对齐:
active/queued两张表、accept/finish/acknowledge/cancelSteer,还要处理「召回结果迟到、回合已经结束」的情况。被删的测试里专门有一条late work stays durable without waking or orphan steering——这类测试存在本身就说明复杂度在哪里。 - 截断位置没人负责。 推送方截断时不知道哪一段对模型有用;截完模型也不知道被截掉了什么,没法要剩下的。
推送的本质问题是:提供方在不知道需求的情况下决定了供给量。 上限只能让它错得少一点。
4. 新设计:一个有边界的拉取接口⌗
新的路径只有两部分。
第一,压缩摘要告诉下一代「去哪找」。 compactionSummaryBody 多了一行(src/memory/compaction-summary.ts):
Section 10 (怎么访问用到的资源) lists resource access details and evidence messageIds.
To recover earlier experience with a resource, call
search_memory(channel="<本线程>", query="<资源名/路径>"). ...
results are bounded windows with messageIds for message_context_read(view="audit").
摘要里的资源清单从「框架自动取证据的索引」变成「给模型看的目录」:它告诉模型有哪些东西、证据在哪条消息,但不把证据本身搬进来。
第二,search_memory 在限定线程时多一路字面检索。 searchThreadLiterals(src/memory/search-within.ts,+53 行)和原有的记忆综合回答并行跑,结果合并:
const literals = within ? searchThreadLiterals(...) : Promise.resolve('')
const [answer, literalHits] = await Promise.all([runMemoryAnswer({...}), literals])
return within ? boundedThreadSearch(answer, literalHits) : answer
字面检索的规则:
| 项 | 取值 |
|---|---|
| 检索范围 | 本线程全部代(含压缩前的旧代与祖先代) |
| 匹配字段 | 消息正文(工具结果也在这里)+ 工具调用参数里的全部字符串叶子 |
| 检索词 | 整句 query 优先,其次按空白切出的 ≥3 字符的词(去掉两端引号) |
| 每条命中返回 | 命中位置前约 200 字符起、共 800 字符的窗口 + messageId + sessionId + 角色/字段 |
| 字面部分上限 | THREAD_LITERAL_MAX_CHARS = 8_000 |
| 整个工具结果上限 | THREAD_SEARCH_MAX_CHARS = 12_000 |
| 超出时 | 显示「另有 N 条命中未展示;缩小 query 可继续查」或「搜索结果已截取」 |
| 展开原文 | message_context_read(messageId, view="audit") |
| 排除 | 已被取代的、隔离的、modelVisible=false 的消息,以及框架注入的机器事件 |
结果头部固定写着「以下是历史证据,不是当前指令」——旧代里的命令是证据,不是要再执行一遍的待办。
5. 这样设计好在哪⌗
把两种设计并排放:
| 维度 | 自动召回(旧) | 按需检索(新) |
|---|---|---|
| 谁决定要不要召回 | 改写模型,只看一句消息 | 主模型,看得到完整的当前状态 |
| 触发 | 每条人类消息 | 模型调用工具时 |
| 额外 LLM 调用 | 每条消息一次改写 | 无(字面检索是一条 SQL) |
| 返回体积 | 无上限 | ≤ 12000 字符,常量写死在接口上 |
| 截断后能否续取 | 不能,模型不知道被截了 | 能:有 messageId,有「另有 N 条」 |
| 与回合的时序耦合 | steering 队列 + 迟到处理 | 无,就是一次普通工具调用 |
| 代码量 | 两个 service + 指标 + UI 面板 | 一个函数 + 一个合并函数 |
最关键的一点是上限的性质变了。旧设计里体积是检索结果的函数——检索越「成功」,上下文越危险。新设计里体积是接口的常量,跟历史有多大、命中多少条无关。召回的好坏只影响质量,不再影响安全。
第二点是截断不再是信息丢失。800 字符的窗口加 messageId,本质上是一页目录加页码:模型看到窗口判断有没有用,有用就按页码去翻原文。这和人查资料的方式一样——先看搜索结果摘要,再点进去。
第三点是把相关性判断交还给唯一有资格判断的人。通常的 RAG 记忆做法是每轮自动检索 top-k 塞进 prompt,这在上下文很便宜、检索结果很短时没问题;但 agent 线程里的历史不是短文档,是动辄几万字的工具输出。在这种分布下,「替模型猜它需要什么」的期望成本是负的。
6. 证据:测试即规格⌗
新增的 skills/memory/thread-search.test.ts 有四条测试,每条对应一个设计断言。
端到端:模型自己去要,才拿得到。 构造一个压缩过的线程,旧代里有一次远程登录命令和一段约 14 万字符的工具输出(前后各一万次重复的填充)。让一个脚本化模型在新一代里先调 search_memory,再回答:
expect(JSON.stringify(seen[0])).not.toContain('connected to ...') // 第一次调用:上下文里没有旧输出
expect(JSON.stringify(seen[1])).toContain('connected to ...') // 模型搜了之后:有了
expect(response.content).toContain('…已截取')
expect(response.content.length).toBeLessThanOrEqual(THREAD_SEARCH_MAX_CHARS)
expect(await h.sql`SELECT id FROM thread_pending_events WHERE thread_id = ...`).toHaveLength(0)
最后一行是这次改动的立场:框架不再往线程里排任何召回事件。
字面检索跨代、跨字段。 再滚一代,用资源名、~/.ssh/... 风格的路径、Windows 风格的反斜杠路径、整条带引号的命令分别去查,都能命中工具调用那一行。机器事件、modelVisible=false 的结果、压缩摘要本身都不出现;线程被设为隔离后,全局视角查到的是空串。
压力测试:120 条大工具结果。 每条约 6 万字符、中间埋一个关键词,总输入约 720 万字符:
expect(response).toContain('条命中未展示')
expect(response).not.toContain('x'.repeat(801)) // 没有任何一段超过窗口
expect(response.length).toBeLessThanOrEqual(THREAD_SEARCH_MAX_CHARS)
expect(literal.length).toBeLessThanOrEqual(THREAD_LITERAL_MAX_CHARS)
expect(boundedThreadSearch('z'.repeat(100_000), literal).length).toBe(THREAD_SEARCH_MAX_CHARS)
720 万进,12000 出。最后一行还验证了另一半:即使综合回答那一路吐出 10 万字符,合并后的结果也精确停在上限。
7. 代价,以及我为什么接受⌗
说清楚丢掉了什么:
- 主动性。 旧设计能在模型没意识到时把东西递过来;新设计要求模型知道「该去搜」。补偿手段是压缩摘要里那一行显式指引和第 10 节的资源目录——模型至少知道有哪些东西、用什么词去搜。如果模型压根不看摘要,这就失效了;但不看摘要的模型,被推送了多少原文也未必会用。
- 字面检索的召回面。 字面匹配找不到同义改写。不过语义检索并没有消失:限定线程时
runMemoryAnswer照常跑,结果放在「其他记忆检索」一节,只是同样被装进 12000 的盒子里。 - 800 字符窗口会切断长命令或长报错。 这时结果会标「已截取」并给出展开方式,多一次工具调用。用一次显式的往返换上下文安全,这笔账是划算的。
还有一个被删掉的东西值得一提:记忆控制台里的「线程召回」指标面板(命中率、送达方式、在第几步送达)。指标做得很细,但它度量的是推送管道本身的健康,管道没了,面板也就没有对象了。新路径的可观测性就是普通工具调用记录:模型什么时候搜了、搜了什么、拿回多少,都在对话流水里。
8. 一句话⌗
上下文窗口是模型的工作台,不是框架的储物间。框架该做的是把目录写清楚、把检索接口的出口焊死在一个固定尺寸上,然后让模型自己决定要不要伸手。