跳到主要内容

捕获管线:一条观察是怎么进来的

本章讲什么: 跟着一次 PostToolUse(工具调用结束)事件,看它怎么一步步变成一条可被搜索召回的结构化观察。这是项目的写入侧主线,也是「自动捕获」这个卖点的实现。

1. 先看全貌:七步流水线

README 的「Memory Pipeline」图把写入管线画成这样(README.md:861-868)——注意它画的是 LLM 压缩路径:

PostToolUse hook 触发
-> SHA-256 去重(5 分钟窗口)
-> 隐私过滤(剥离密钥、API key)
-> 存原始观察
-> LLM 压缩 -> 结构化 facts + concepts + narrative
-> 向量嵌入(6 provider + 本地)
-> 建 BM25 + 向量索引

一个关键事实先摆这:README 这张图画的是 LLM 路径,但实测默认走的是零 LLM 路径。0.8.8 之后(#138),每次工具调用都打一次 Claude 太烧 token,所以默认改走一条「零 LLM 合成压缩」(见 §4)——observe.ts:287isAutoCompressEnabled() 分叉证实:只有显式设了 AGENTMEMORY_AUTO_COMPRESS=true 才触发 mem::compress(LLM 路径),否则走 buildSyntheticCompression。也就是说,上图那一步实际拆成两条:

  • 默认:零 LLM「合成压缩」(正则/字符串规则,见 §4)。
  • opt-in:AGENTMEMORY_AUTO_COMPRESS=true 时才走 README 画的 LLM 压缩,生成更丰富的摘要。

下面顺着这条管线一步步拆,并在压缩那步聚焦实测的默认零 LLM 路径。

2. 起点:hook 脚本(进程边界)

这一步要解决的小问题: agent(Claude Code 等)和记忆服务是两个进程,怎么把事件递过去?

答案是 hook 脚本。它们是 src/hooks/ 下的独立 Node 脚本——不 import iii-sdk,只做一件事:从 stdin 读 JSON 事件,HTTP 打到本地 REST API,然后退出。

hook 分两类,退出策略不同(这是 AGENTS.md 里强调的坑):

类型例子行为为什么
注入上下文pre-tool-use session-startawait fetch 拿到响应,写 stdoutClaude Code 要读它的 stdout 注入对话
纯遥测post-tool-use stop notificationfire-and-forget,不等响应不能阻塞 agent 的下一步

纯遥测 hook 的退出有个精妙处理:发出 fetch 但不 await,再配一个 setTimeout(() => process.exit(0), 500).unref() 强制退出。原因 AGENTS.md 说得很清楚——不加这个 setTimeout,Node 的事件循环会一直等那个未 await 的 fetch settle,于是 hook 仍然会阻塞 agent 到 AbortSignal 超时,正好是 fire-and-forget 想修的那个 bug。

注入类 hook 还有一层防护:超时压得很紧。session-start.ts 把注册超时设成 800ms、注入超时 1500ms(src/hooks/session-start.ts:28-29),因为服务不可达时 5s 超时在并发扇出(Slack bot、多 agent)下会放大成正反馈循环,把 iii-engine OOM 杀掉(#221)。

3. 进门:mem::observe 函数

所有捕获最终汇到一个函数:mem::observe(src/functions/observe.ts:44,符号 registerObserveFunction)。它先做边界校验——sessionId / hookType / timestamp 三个字段必须是非空字符串(observe.ts:47-60),否则直接返回失败。

这是项目的一条铁律:在系统边界做输入校验(MCP handler、REST 端点、函数入口)。

3.1 去重(SHA-256,5 分钟窗口)

要解决的小问题: agent 经常在几秒内对同一文件做同样的读/写,不去重的话记忆里全是重复噪声。

思路:(sessionId, toolName, toolInput 前 500 字符) 算一个 SHA-256 指纹,5 分钟内见过就丢弃。

这部分逻辑在一个独立的 DedupMap 里(src/functions/dedup.ts,符号 DedupMap)——一个带 TTL 的内存 Map:

// src/functions/dedup.ts —— computeHash + isDuplicate(节选,真实源码)
computeHash(sessionId, toolName, toolInput) {
const input = typeof toolInput === "string"
? toolInput.slice(0, 500)
: JSON.stringify(toolInput ?? "").slice(0, 500);
return createHash("sha256").update(`${sessionId}:${toolName}:${input}`).digest("hex");
}

observe 里的用法(observe.ts:71-78):算出 dedupHash,若 isDuplicate 为真,直接 return { deduplicated: true },根本不落库。注意去重记账(dedupMap.record)被推迟到写入成功之后(observe.ts:202-204)——这样一次失败的写不会污染去重表,留给重试。

3.2 脱敏(剥离密钥)

紧接着对整个 payload 做隐私过滤(observe.ts:81-88):把 payload 序列化成 JSON 字符串,过一遍 stripPrivateData,再 parse 回来。

stripPrivateData(src/functions/privacy.ts:22)做两件事:

  1. <private>...</private> 标签内的内容换成 [REDACTED]
  2. 用一组正则把常见密钥替换成 [REDACTED_SECRET]——覆盖 OpenAI(sk-proj-)、Anthropic(sk-ant-)、GitHub(ghp_/github_pat_)、Slack(xoxb-)、AWS(AKIA…)、Google(AIza…)、JWT、npm token 等(privacy.ts:5-20)。

关键细节: 脱敏发生在落库之前,所以密钥从不会被写进 SQLite——这是「privacy first」卖点的硬实现,不是事后清洗。

4. 核心:零 LLM「合成压缩」

要解决的小问题: 原始观察(工具名 + 输入 + 输出)是非结构化的,直接塞进 BM25 既噪声大又不好打分。需要把它「压缩」成结构化字段——但又不想每次都烧 LLM token。

思路: 用纯启发式(正则 + 字符串规则)把 RawObservation 转成 CompressedObservation,零网络、零 token。这条路是 0.8.8 起的默认(src/functions/compress-synthetic.ts,符号 buildSyntheticCompression)。

它干的事很朴素但有效:

  • 推断类型(compress-synthetic.ts:12-42,符号 inferType):把工具名 normalize 成 word chunk,匹配子串——WebFetchweb_fetchgrep/globsearchbashcommand_runeditfile_edit。这样即使没 LLM,观察也带上了语义类型。
  • 抽文件路径(extractFiles):从 tool_input 里挑 file_path / path / pattern 等字段。
  • 拼 narrative:把 prompt、输入、输出用 | 连起来,截断到 400 字符。

产出的 CompressedObservation 长这样(类型见 src/types.ts:46-64):type / title / facts / narrative / concepts / files / importance / confidence。合成路径的 confidence 固定给 0.3(compress-synthetic.ts:100)——诚实地标记「这是启发式产物,不如 LLM 摘要可信」。

这是整个项目的一个核心取舍:用确定性的廉价压缩换掉「每次工具调用都打 LLM」的成本,代价是 facts/concepts 为空、narrative 较粗。但 BM25 搜索照样能跑,召回不至于挂。

5. 落库与建索引(在串行锁内)

上面的去重/脱敏/压缩准备好后,真正的写入被包在一把按会话的锁里(observe.ts:127):

// observe.ts:127 —— 同一会话的写入串行化
return withKeyedLock(`obs:${payload.sessionId}`, async () => { ... });

withKeyedLock(src/state/keyed-mutex.ts,符号 withKeyedLock)是个极简的 promise 链锁:同一个 key 上的任务排队执行。为什么需要它?因为观察写入是读-改-写(读 session、改 observationCount、写回),并发会丢更新。

锁内依次做(observe.ts:142-333):

  1. 读现有 session,继承 agentId(只有 session 不存在时才用环境变量 AGENT_ID,避免回填污染,observe.ts:142-152)。
  2. kv.set(KV.observations(sessionId), obsId, raw) 写原始观察。
  3. 更新 session 的 observationCount / updatedAt / firstPrompt(observe.ts:228-248);若 session 不存在则隐式创建(为 OpenCode 这类跳过 session/start 的插件兜底,observe.ts:249-281)。
  4. 走压缩分叉(observe.ts:287-333):
    • AUTO_COMPRESS=true → 触发 mem::compress(LLM 路径,异步 void)。
    • 默认 → buildSyntheticCompression(raw),把合成观察写回 KV,同步加进 BM25 索引 getSearchIndex().add(synthetic),再 vectorIndexAddGuarded 异步建向量。

这里 BM25 是同步内存写(立刻可搜),向量是异步且容错——vectorIndexAddGuarded(src/functions/search.ts:94)在嵌入维度不匹配或 embed 抛错时只 warn 不抛,绝不让一个挂掉的嵌入服务阻断主写入。

6. 回看这条管线的设计取舍

  • 写入路径全程不阻塞 agent:hook fire-and-forget,LLM 压缩默认关,向量建索引容错——目标是「捕获绝不拖慢你和 agent 干活」。
  • 廉价优先,质量可选:合成压缩是默认,LLM 压缩是 opt-in;BM25 同步,向量异步。便宜的先到位,贵的按需。
  • 边界即防线:去重在最前(省下游所有功),脱敏在落库前(密钥永不入库),校验在入口(脏 payload 早死)。

下一章看这些索引建好之后,召回是怎么把三路信号融合的 → 02-retrieval.md