捕获管线:一条观察是怎么进来的
本章讲什么: 跟着一次
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:287 的 isAutoCompressEnabled() 分叉证实:只有显式设了 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-start | await fetch 拿到响应,写 stdout | Claude Code 要读它的 stdout 注入对话 |
| 纯遥测 | post-tool-use stop notification | fire-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)做两件事:
- 把
<private>...</private>标签内的内容换成[REDACTED]。 - 用一组正则把常见密钥替换成
[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,匹配子串——WebFetch→web_fetch、grep/glob→search、bash→command_run、edit→file_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):
- 读现有 session,继承
agentId(只有 session 不存在时才用环境变量AGENT_ID,避免回填污染,observe.ts:142-152)。 kv.set(KV.observations(sessionId), obsId, raw)写原始观察。- 更新 session 的
observationCount/updatedAt/firstPrompt(observe.ts:228-248);若 session 不存在则隐式创建(为 OpenCode 这类跳过session/start的插件兜底,observe.ts:249-281)。 - 走压缩分叉(
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。