系列第 5 篇 / 共 6 篇。上一篇我们论证了「为什么工业级 Agent 必须有 Memory」, 把 user / feedback / project / reference 四类记忆模型摆上了桌。 本篇是 Memory 三部曲的核心实现篇——把「写一条 memory 之后,下次怎么取、怎么知道它过时了」这条全链路彻底拆开。

0. 三个把 memory 玩坏的常见姿势

文件式 memory 在 demo 里看起来再朴素不过:往 .md 里 append 几行,下次启动时再 read_to_string 读回来。但工业级落地里,绝大多数线上事故都来自三个看似无关、其实根因相同的坑:

  1. 写入时机不对,prompt cache 雪崩——你以为只是新增了一条 fact,结果 prefix 字节流偏移,整段 system + memory 段命中率归零,下一轮请求变得又贵又慢。
  2. 读取没做 verify,agent 用了过期 fact——memory 里写着「entry file 是 src/main.py」,三天前重构改成了 src/app/main.py,agent 却拿旧坐标去 patch 文件,silent 地引入 bug。
  3. 没监控 cache miss,错误悄悄发生——前两个坑如果可观测,至少能事后追溯;如果没人盯 cache_read_input_tokens 的曲线,就只能靠用户骂街才知道出事了。

claw-code 给这三个坑各自配了一套解法,分别落在 src/session_store.pysrc/transcript.pyrust/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.mdmemdir/project.mdmemdir/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.tsteamMemPaths.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 Pmemory → Read(P) verify → 若失败先 Grep 重新定位 → 再 patch
memory 说用户偏好 X,直接套用memory → 在当前 context 里二次确认(如 lint 配置仍存在)→ 再套用
memory 说库 L 在用 v1,直接生成 v1 APImemory → 读 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

1
fn maybe_auto_compact(&mut self) -> Option<AutoCompactionEvent>

触发阈值默认是 100K input tokens,可以由环境变量 CLAUDE_CODE_AUTO_COMPACT_INPUT_TOKENS 覆盖。命中阈值后,runtime 会做两件事:

  1. 调用 rust/crates/runtime/src/summary_compression.rs 把历史消息压成一份 summary,配合 record_compaction(summary, n_kept) 落账,并发出一个 AutoCompactionEvent,让上层订阅者(UI、metrics)知道刚刚发生了一次压缩。
  2. 紧接着调用 run_session_health_probe()——这一步是 OSS 项目里少见的 production-grade 操作。
1
fn run_session_health_probe(&mut self) -> Result<(), String>

这个 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.pyTranscriptStore.compact / replay / flush 三个方法的设计也是同一个哲学:每次结构性变更都要能复现回放,才允许提交。

5. PromptCache 失效作为一等信号

第三个坑——「错误悄悄发生」——是这一节的主题,也是本文第二个要立起来的核心观点。

claw-code 在 conversation.rs 里专门定义了一个 event 类型:

1
2
3
4
5
6
7
8
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct PromptCacheEvent {
    pub unexpected: bool,
    pub reason: String,
    pub previous_cache_read_input_tokens: u32,
    pub current_cache_read_input_tokens: u32,
    pub token_drop: u32,
}

这个 struct 的核心是 unexpected 这个布尔字段。Runtime 在每个 turn 后比较 current_cache_read_input_tokensprevious_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 比对锚点。

1
2
3
4
5
6
def assemble_prefix(static_system: bytes, store: MemoryStore) -> bytes:
    body = "\n".join(e.text for e in store.load_all()).encode("utf-8")
    return static_system + b"\n--- MEMORY ---\n" + body

def cache_fingerprint(prefix_bytes: bytes) -> str:
    return hashlib.sha256(prefix_bytes).hexdigest()[:16]

第二段:verify-before-use——把 memory claim 强制 round-trip 一次工具,不通过就 raise,永远不允许 agent 直接用 claim。

1
2
3
4
5
def verify_before_use(claim: str, tool: Callable[[str], str | None]) -> str:
    evidence = tool(claim)
    if evidence is None:
        raise RuntimeError(f"verify failed for claim: {claim!r}")
    return evidence

Demo 跑三个 turn:

  1. Turn 1:写一条 fact memory,调 verify_before_use 拿到 evidence,记一次 fingerprint。
  2. Turn 2:往 store 灌 40 条 trivial pref 把 token 总量推过 budget,触发 auto_compact_if_over(token_budget=200, keep_last=2)。compaction 完成后再记一次 fingerprint。
  3. 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.py
  • src/transcript.pyTranscriptStore.compact / replay / flush
  • rust/crates/runtime/src/conversation.rsmaybe_auto_compactPromptCacheEventrun_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