系列第 5 篇 / 共 6 篇。上一篇我们论证了「为什么工业级 Agent 必须有 Memory」, 把 user / feedback / project / reference 四类记忆模型摆上了桌。 本篇是 Memory 三部曲的核心实现篇——把「写一条 memory 之后,下次怎么取、怎么知道它过时了」这条全链路彻底拆开。
0. 三个把 memory 玩坏的常见姿势
文件式 memory 在 demo 里看起来再朴素不过:往 .md 里 append 几行,下次启动时再 read_to_string 读回来。但工业级落地里,绝大多数线上事故都来自三个看似无关、其实根因相同的坑:
- 写入时机不对,prompt cache 雪崩——你以为只是新增了一条 fact,结果 prefix 字节流偏移,整段 system + memory 段命中率归零,下一轮请求变得又贵又慢。
- 读取没做 verify,agent 用了过期 fact——memory 里写着「entry file 是
src/main.py」,三天前重构改成了src/app/main.py,agent 却拿旧坐标去 patch 文件,silent 地引入 bug。 - 没监控 cache miss,错误悄悄发生——前两个坑如果可观测,至少能事后追溯;如果没人盯
cache_read_input_tokens的曲线,就只能靠用户骂街才知道出事了。
claw-code 给这三个坑各自配了一套解法,分别落在 src/session_store.py、src/transcript.py 和 rust/crates/runtime/src/conversation.rs。本文按这三坑的因果顺序走一遍,最后用一个 < 150 LOC 的 Python demo 把核心不变量复现出来。
1. 文件 vs DB:为什么 Memory 不上数据库
第一个让人犹豫的设计选择:memory 该放数据库还是放文件?claw-code 给的答案非常旗帜鲜明——Session 走结构化 JSON、Memory 走 plain markdown,两者分流。
Session 那一侧,src/session_store.py 里定义了 StoredSession(session_id, messages, ...) 这种 dataclass,序列化后落到 .port_sessions/{id}.json。它需要严格的 schema、原子写、按 ID 索引——本质上是 transcript 的物化,给 resume / replay / debug 服务,DB-like 的诉求很自然。
Memory 那一侧则完全相反。它是 typed paths 下的纯 markdown 文件——memdir/user.md、memdir/project.md、memdir/feedback/*.md。选择文件而非 DB 的四条理由都很务实:
- human-readable:用户随时可以
cat、可以编辑、可以拒绝某条 fact 被记下来。memory 是给人和 agent 共同所有的资产,不是黑箱。 - git-versionable:team memory 直接进 repo,code review 流程天然就 review 了 agent 的"知识更新"。DB 行做不到这点。
- 零 schema migration:markdown 没有列、没有索引、没有 ALTER TABLE。新加一类 memory 就是新建一个目录。
grep可调试:production 出事的时候,工程师不需要 spin up 一个 sqlite client,一行rg "src/main.py" memdir/就够了。
这套选择背后还有一个隐含前提:memory 的 cardinality 不会无限膨胀。session 可能成千上万,memory 在合理使用下永远是「O(用户/项目数 × kind)」量级。文件系统对这个量级毫无压力。
2. 检索:static path 在前、dynamic relevance 在后
把 memory 写下去之后,下一轮 turn 启动时怎么把对的几条捞出来?claw-code 用了一个非常工业的两阶段流水线:
- Static path 阶段:paths registry(在 TS 子树里对应
memdir/paths.ts与teamMemPaths.ts)维护一份「已知存在、必扫」的固定路径清单。每次 turn 启动先按这份清单读盘,得到一个 bound 的 working set。 - Dynamic relevance 阶段:在 working set 里,再用
findRelevantMemories.ts做相关性打分,按 query / 当前文件上下文 / kind 过滤出 top-K。
顺序非常关键——必须 static 在前、dynamic 在后。把这两步反过来——先做相关性打分、再用 static path 兜底——表面上似乎更"智能",实际工程后果是灾难性的:相关性打分要扫的候选集会从「几十个 known path」膨胀到「整个 workspace 下所有 markdown」,O(N) 的代价里 N 直接变成了项目所有文件数。在 monorepo 里,这等于把 turn 启动延迟拖到秒级。
正确的心智模型是:先 bound working set,再 narrow within it。Static path 是粗筛,dynamic relevance 是精排,两个阶段串行执行,复杂度才能稳定在 O(K_paths) + O(|working_set|)。
3. Verify-before-use:从 Verification Agent 借来的硬纪律
到这里 memory 已经被写下、被读出、被排好序——但它还不能直接用。这是本文第一个想立起来的核心观点。
Memory 注入到 prompt 里的,从 LLM 的视角看是一段 context。但从工程语义看,它是 claim——「我宣称 entry file 在 src/main.py」「我宣称用户偏好 4-space 缩进」。Claim 的本质是过往观察的快照,它没有任何机制保证「现在仍然为真」。
claw-code 的硬纪律是:任何要被 agent 用来推荐改动 / 修改文件 / 给出回答的 memory claim,必须先经过工具 verify 取到 evidence。具体落地是:
Read(src/main.py)确认文件存在、看一眼前几行;Grep("def main", src/)确认入口函数还在;Glob("src/**/main.py")确认坐标没漂。
这条纪律的来源是 PDF 第 6.7 节的 Verification Agent,原文措辞掷地有声:
“You must run the command, never just read code.”
这不是吹毛求疵——它直击 agent 失败模式的核心。LLM 天然倾向于"看着像就当它是",而 memory 的存在恰恰会强化这种倾向:因为 memory 是被反复写下来的"重点信息",agent 对它的置信度会比对随机 context 更高,结果一旦它过时,agent 还会理直气壮地基于错误前提行动。
反模式 ↔ 正模式对照非常清晰:
| 反模式 | 正模式 |
|---|---|
| memory 说 X 在 path P,直接 patch P | memory → Read(P) verify → 若失败先 Grep 重新定位 → 再 patch |
| memory 说用户偏好 X,直接套用 | memory → 在当前 context 里二次确认(如 lint 配置仍存在)→ 再套用 |
| memory 说库 L 在用 v1,直接生成 v1 API | memory → 读 lockfile / pyproject.toml 取实际版本 → 再生成 |
把 verify 当成纪律意味着:memory 的价值不是"省去查证",而是"把查证的搜索空间收敛成 O(1)"。Memory 告诉你去哪儿验证,工具告诉你那儿是不是真的。
4. Compaction 的两层影响:触发 + health probe
写、读、verify 都聊完了,接下来是「memory 长大了怎么办」。session 上下文不可能无限增长——既有模型的 context window 上限,也有 prompt cache 的代价上限。rust/crates/runtime/src/conversation.rs 里给的解法叫 auto compaction:
| |
触发阈值默认是 100K input tokens,可以由环境变量 CLAUDE_CODE_AUTO_COMPACT_INPUT_TOKENS 覆盖。命中阈值后,runtime 会做两件事:
- 调用
rust/crates/runtime/src/summary_compression.rs把历史消息压成一份 summary,配合record_compaction(summary, n_kept)落账,并发出一个AutoCompactionEvent,让上层订阅者(UI、metrics)知道刚刚发生了一次压缩。 - 紧接着调用
run_session_health_probe()——这一步是 OSS 项目里少见的 production-grade 操作。
| |
这个 probe 干一件事:验证 compaction 之后 agent 还能不能正常 invoke 工具。具体做法是跑一次非破坏性的 glob_search 之类的轻量调用,确认工具 router、permission、session id binding 都还在。
为什么需要这一步?因为 summary_compression 是基于 LLM 的,它有非零概率把"agent 自己是谁、当前在哪个 worktree、有哪些 MCP 工具可用"这类身份性 memory 一并压成了散文。压完看起来文采飞扬,下一轮 agent 一调用工具就拿不到 binding,session 直接死掉。
Health probe 的存在,把这种 silent 损坏从「下一次用户触发功能时才暴露」提前到了「compaction 完成的瞬间」。这是对 transcript 层做了一次"sanity check"——src/transcript.py 里 TranscriptStore.compact / replay / flush 三个方法的设计也是同一个哲学:每次结构性变更都要能复现回放,才允许提交。
5. PromptCache 失效作为一等信号
第三个坑——「错误悄悄发生」——是这一节的主题,也是本文第二个要立起来的核心观点。
claw-code 在 conversation.rs 里专门定义了一个 event 类型:
| |
这个 struct 的核心是 unexpected 这个布尔字段。Runtime 在每个 turn 后比较 current_cache_read_input_tokens 与 previous_cache_read_input_tokens:如果用户没有显式触发任何应当导致 prefix 变化的操作(没新增 memory、没改 system prompt、没切 worktree),但 token_drop 仍然 > 0,就把 unexpected 标成 true,并在 reason 里写明诊断结论。
为什么要把它做成 first-class signal?因为 silent cache invalidation 是工业 agent 系统里最容易亏钱、最难定位的一类故障:
- 钱包黑洞:cache_read 走的是优惠定价(通常是常规 input 价格的 1/10),cache miss 直接退化成全价 input。一个高频用户 cache 命中率从 90% 跌到 30%,月度账单可以涨 3 倍,但功能"看起来"没坏。
- 延迟黑洞:cache 命中时 prefix 不需要重新跑 attention,TTFT 显著更短。Cache miss 后 p50 延迟翻倍,用户体感变卡,但日志里没有 error。
- 诊断地狱:如果不监控,工程师只能在用户投诉之后逐 turn 比对 prefix bytes,找出是哪一行 memory / 哪一段 index 顺序变了——这是噩梦。
PromptCacheEvent.unexpected = true 的常见触发原因有:某次 memory 写入位置在 prefix 中段而非末尾、relevance ranker 输出顺序非确定、index 文件被外部进程重写、CLAUDE.md 被编辑器重新格式化导致字节差。把这些都收敛成一个可订阅的 event,metrics dashboard 才能画出曲线、alert 才能在凌晨叫醒 oncall。
工程价值就一句话:可观测就能修,不可观测就死。Memory 子系统的稳定性,最终是被 PromptCache 这条 metrics 兜底的。
6. 全链路时序图
把上面五节串起来,单个 turn 内 Memory 的完整生命周期是这样:
sequenceDiagram
participant Turn
participant Mem as MemoryStore (.md files)
participant Cache as Prompt Cache
participant Tool
Turn->>Mem: discover+scan+rank
Mem-->>Turn: top-K entries
Turn->>Cache: assemble prefix (stable bytes!)
Turn->>Tool: verify-before-use (read_file/grep)
Tool-->>Turn: evidence
Turn-->>Mem: append/update entry
Note over Cache: PromptCacheEvent emitted if token_drop>0
注意时序里两个细节:
- assemble prefix 的字节稳定性是个契约。如果 ranker 输出顺序非确定,或者 markdown 渲染夹了 timestamp,就会破坏这个契约,触发 unexpected cache event。
- append/update entry 发生在 verify 与工具调用之后,而不是之前。这保证了写进 memory 的都是被 evidence 验证过的事实,而不是 claim。
7. 一个 < 150 LOC 的 Python demo
下面这个 demo 把上面的不变量浓缩成了 ~140 行 stdlib Python,可以直接 python3 memory_store.py 跑。完整代码在 cli-agent/code/05-memory-storage-retrieval/memory_store.py,这里只摘核心两段。
第一段:cache fingerprint = sha256(static prefix)——把 system prompt 与 memory 快照拼起来取 hash,作为本地版的 previous_cache_read_input_tokens 比对锚点。
| |
第二段:verify-before-use——把 memory claim 强制 round-trip 一次工具,不通过就 raise,永远不允许 agent 直接用 claim。
| |
Demo 跑三个 turn:
- Turn 1:写一条 fact memory,调
verify_before_use拿到 evidence,记一次 fingerprint。 - Turn 2:往 store 灌 40 条 trivial pref 把 token 总量推过 budget,触发
auto_compact_if_over(token_budget=200, keep_last=2)。compaction 完成后再记一次 fingerprint。 - Turn 3:什么都不做,再记一次 fingerprint。
Demo 末尾断言两个不变量:
- 三次 fingerprint 之间,变化次数恰好等于 1(只有 turn 2 的 compaction 应该改 prefix,turn 1 的写入是 prefix 末尾追加、在 demo 抽象里也算变化但与 turn 2 合并计数;turn 3 的 idle 必须 0 变化)。
fps[1] == fps[2]——idle turn 不许扰动 prompt cache,否则就是个 bug。
实际跑出来:
[turn 1] verified -> src/main.py exists, 412 bytes
[turn 2] auto_compact triggered=True, compactions=1
[turn 3] fingerprints=['18e95...', '8884d...', '8884d...']
[ok] fingerprint changes=1 (compaction-only, as designed)
把这个本地 invariant 翻译回生产侧,就是:任何让 fingerprint 在 idle turn 变化的 PR,都应该在 CI 阶段被卡住。这正是 PromptCacheEvent.unexpected 在生产环境扮演的角色。
8. 收束:写、读、防过时——三件事都靠纪律
这一篇我们把 Memory 子系统的实现内核拆成了五个互锁的部分:
- 存储选 file 不选 DB——是为了 human-readable + git-versionable + 零 schema migration。
- 检索先 static path 再 dynamic relevance——是为了把 O(N) 收敛成 O(K)。
- 使用必须 verify-before-use——是为了把 memory 当 claim 不当 fact,借自 Verification Agent 的硬纪律。
- 压缩触发后必跑 health probe——是为了把 compaction 引入的 silent breakage 提前到瞬间暴露。
- 失效通过
PromptCacheEvent.unexpected一等信号化——是为了不让钱包黑洞和延迟黑洞悄悄吃人。
五件事没有一件依赖"模型够强",也没有一件依赖"prompt 写得巧"。它们全都是工程纪律:在哪个时机写、按什么顺序读、用之前必须 verify、改完必须 probe、变化必须可观测。这正是工业级 Agent 框架与玩具 demo 的真正分水岭。
下一篇预告
第 6 篇也是本系列的收官:Memory 与 Agent 的协同演化。我们会把镜头从 memory 内部拉远,去看它怎么和 Hooks / MCP / CLAUDE.md 协作,把 agent 的"学习"变成跨会话、跨项目、跨团队的进化——以及这种演化模式对未来 agent 框架设计的隐含约束。
引用
src/session_store.pysrc/transcript.py(TranscriptStore.compact / replay / flush)rust/crates/runtime/src/conversation.rs(maybe_auto_compact、PromptCacheEvent、run_session_health_probe)rust/crates/runtime/src/summary_compression.rs- PDF §6.7(Verification Agent 纪律的来源)
- 本文 demo:
cli-agent/code/05-memory-storage-retrieval/memory_store.py