起:第 101 次还会犯同样的错

让一个 agent 跑满 100 个 session,第 101 次让它再做一遍上次踩过坑的事,它大概率还会再踩一次。 原因不在 LLM 不够聪明,而在所有有价值的信号都跟着 transcript 一起被 compact 掉了:第 30 个 turn 你纠正过一次"别用 emoji",第 60 个 turn 你确认过 deploy 走 Vercel 不走 Cloudflare,到第 101 次新开 session,那个修正只剩一句被压缩成 <summary>previous discussion</summary> 的残影,根本不可能影响下一次工具调用。

工业界的解法分两派:

  • embedding + 向量库:把 transcript 丢进一个 embedding 模型,存进 Pinecone / Chroma,查询时做向量召回。优点是写代码爽;缺点是黑盒、滞后、不可读、不能 git diff,调一个 chunk size 要重跑全库。
  • typed file-based memory:用普通的 markdown 文件,分类型放在固定路径,外加一个 MEMORY.md 索引。人能读、git 能 diff、迁移成本为零。这是 claw-code、Claude Code、Cursor 等一票工业级 CLI agent 的共同选择。

这一篇是 Memory 三部曲的开篇,回答最朴素的两个问题:为什么不是 RAG?为什么是类型化文件 + 索引? 后面两篇会接着讲:第 5 篇讲存储 / 检索 / 失效,第 6 篇讲 memory 与 agent 的协同进化。

关于 transcript compaction、compact_after_turns=12, keep_last=10 这些参数的细节,见本系列第 1 篇 Industrial Agentic Workflow。本文只关心 compaction 之外那部分应该被永久记住的信号。

1. Memory ≠ Chat History

最常见的误会是把 memory 当成"更长的 chat history"。这是错的。Transcript 和 memory 在三个维度上是正交的:

维度Transcript(对话历史)Memory(记忆)
生命周期单 session,会被 compact跨 session,持久
写入方式被动追加(每个 turn 自动塞入)主动写入(agent 决策 / 用户授意)
读出方式全量喂给 LLM(受窗口限制)经过 discovery + ranking 的 top-K 子集
结构线性、无类型类型化、可索引

claw-code 在 system prompt 装配阶段把这两个 slot 显式分开。src/transcript.py 负责前者(滑窗 + compaction),memdir/ 负责后者。两者最后在 getMemoryPromptSection() 那一步汇合到 system prompt 的 dynamic suffix——也就是说:每个 turn 开始之前,memory 都会被重新读、重新选、重新拼一次,它不是一段死掉的历史,而是活的、当下相关的索引。

这一区分极其重要:transcript 可以丢,memory 不能丢;transcript 是"上下文窗口里的临时缓存",memory 是"agent 的长期人格"。

2. 五类典型 Memory:从 user 到 team

打开 src/reference_data/subsystems/memdir.json,里面列出了 claw-code 的 8 个 TS 模块:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
{
  "archive_name": "memdir",
  "package_name": "memdir",
  "module_count": 8,
  "sample_files": [
    "memdir/findRelevantMemories.ts",
    "memdir/memdir.ts",
    "memdir/memoryAge.ts",
    "memdir/memoryScan.ts",
    "memdir/memoryTypes.ts",
    "memdir/paths.ts",
    "memdir/teamMemPaths.ts",
    "memdir/teamMemPrompts.ts"
  ]
}

memoryTypes.ts 把 memory 分成五类,每一类背后都是一种不同的信号衰减规律

  • user — 用户的身份、偏好、角色契约。这次和这个用户合作,用什么语言、有没有什么忌讳、是 staff eng 还是 PM。这类信号几乎不衰减,永远应该在 prompt 里。
  • project — 当前项目的状态机:谁在做什么、为什么、deadline 是哪天、上一次提交跑了什么测试。这类信号生命周期等于项目本身,可能两周可能两年。
  • reference — 外部系统的指针:Linear 项目 ID、Grafana dashboard URL、Notion 页面、内部 wiki。本身不存内容,存"去哪查"。
  • feedback — 用户给过的 do/don’t。是双向的:既包括"以后别这么做"(修正),也包括"这次做得对,下次照做"(确认)。这类是 agent 自我进化最关键的燃料,第 6 篇会展开。
  • team — 团队级别的共识。claw-code 单独把这类拆出来用 teamMemPaths.ts 处理,因为路径解析逻辑不同(要去找 ~/.agent/team/<team>/),prompt 模板也不同(在 teamMemPrompts.ts 里)。

每一类对应不同的 path resolver、不同的 scan 策略、不同的 age policy。比如 feedback 类型在 memoryAge.ts 里通常被赋予较长 TTL,而 reference 类型一旦超过 30 天没被命中就降权——因为外部链接更容易 stale。

简单 mental model:每一种 memory 类型 = 一种独立的衰减函数。把它们丢到一个 embedding 空间里再去 cosine 相似度,就是把这些信号全部抹平成一种东西,损失了最重要的结构。

3. MEMORY.md 是索引,不是 blob

很多人第一次看到 MEMORY.md 这个名字会以为它是"把所有记忆装在一起的大文件"。完全相反。在 Claude Code / claw-code 里,MEMORY.md 是一份短得离谱的索引文件,它只列出"有哪些 memory 文件、每个一句话讲什么"。

打开你眼前正在跑的这个 Claude Code session 的 MEMORY.md,它长这样:

1
2
- [User profile](user_role.md) — Zhanfeng Mo, runs mzf666.github.io; building llm-infra & cli-agent technical sub-sites.
- [cli-agent blog series](project_cli_agent_blog.md) — 6-part Chinese deep-dive on agentic workflow + memory system, claw-code as primary reference.

格式非常严格:- [Title](file.md) — one-line hook。每条不超过一行。 设计意图有两层:

  1. 索引短 → 每 turn 廉价地热在 cache 里。MEMORY.md 本体常年只有几百字节,可以稳稳地塞进 system prompt 的可缓存区段。
  2. entry 是 deferred load。索引里只放钩子(filename + 一句话 hook),LLM 在 reasoning 时如果觉得需要某条记忆的全文,再发一个 Read(user_role.md) 工具调用去取。绝大多数 turn 根本用不到全文,能省就省。

这是一种很 Unix 的设计:把"目录"和"文件内容"分开,目录便宜可以频繁扫,文件按需加载。它对应到 LLM 世界,就是把"我有哪些记忆"和"某条记忆具体说了什么"分成两个 token 预算池。

4. Discovery Pipeline:4 步把 memory 注入 prompt

类型化只是 what,真正的工业 know-how 在 how——如何在每个 turn 廉价地选对那一小撮 memory。claw-code 的 memdir 包把这件事拆成 4 个串行阶段:

flowchart LR
  subgraph Discovery
    P[paths: cwd→proj→user→team] --> S[memoryScan]
    S --> A[memoryAge: TTL, staleness]
    A --> F[findRelevantMemories: relevance score]
  end
  F --> Idx[MEMORY.md index]
  Idx --> SP[System prompt suffix slot]
  SP --> LLM[LLM]

每一步对应一个独立模块:

  1. paths.ts — 解析路径优先级链:cwd → project root → user home → team。后写的覆盖先写的;同一类 memory 在多层都有时,越靠近 cwd 的越具体、越优先。这个顺序与 git 的 .gitignore 解析、或者 shell 的 PATH 是同一种思想。
  2. memoryScan.ts — 走目录列出候选 entry。这里会跳过 .git/、隐藏目录、二进制文件,只保留 markdown。
  3. memoryAge.ts — 给每个 entry 算 recency 与 staleness:每类 memory 有自己的 TTL,超过 TTL 的 entry 会被降权或直接过滤。这一步避免了"三年前 reference 链接挤掉本周 feedback"。
  4. findRelevantMemories.ts — 真正的 ranker:综合 token overlap、role match、type weight,给出 top-K。这里不用向量嵌入,用的是 plain old token overlap + 启发式权重。原因下文专门讲。

最终 top-K 被 render 成上面那种 - [Title](file.md) — hook 的索引行,注入 system prompt 的 dynamic suffix slot。整条链路里没有任何 ANN 索引、没有任何 embedding 模型调用,全部跑在本地、纯文件 IO、毫秒级。

这里很多人会问:那相关性不就只能很粗吗?是的,而这正是它工作的原因——见下一节。

5. Cache 经济学:为什么"粗"反而是优势

PDF §3.3 提到一个常被忽视的常量:SYSTEM_PROMPT_DYNAMIC_BOUNDARY。它把整个 system prompt 切成两段——

  • 静态 prefix:工具定义、行为约束、style guide。这些字节在整个 session 不变,命中 KV cache,零摊销成本。
  • 动态 suffix:memory 索引、当前时间、cwd、git status。每个 turn 都可能变,变了就 cache miss

把这条推论展开,就得到 memory 系统设计的核心约束:

在单个 session 内部,memory 索引必须尽可能稳定。

如果你每个 turn 都重新跑一次 vector search,每次返回的 top-3 顺序略有抖动,那 system prompt 的字节序列就会持续变化,整段 dynamic suffix 之后的 KV cache 全部作废。在一个 200 turn 的 session 里,这意味着累计几百万 token 的重复编码——成本可能翻倍。

这就是为什么 claw-code 选择朴素 token overlap + 类型权重 + recency 平滑

  • 朴素,所以确定。同一个 prompt + 同一个 memory pool,永远给同一个排序。
  • 类型权重显式,所以可解释。你能在代码里看到 user=1.4, feedback=1.2, project=1.0, reference=0.7
  • recency 用半衰期函数而不是硬阈值,所以排序是平滑变化的,不会因为某个 entry 越过 7 天的边界突然跳进跳出。

更进一步:memory 的写入也只在 turn 边界发生,绝不在 turn 中途。原因相同——一个 turn 内部如果 memory 被改写,下一次工具调用拼装 prompt 时整段 suffix 改变,cache 又一次崩塌。所有 memory 写操作(包括用户喊出的 /remember、agent 自己 propose 的修订)都被攒到 turn 收尾时统一落盘。

这一节是 Memory 三部曲里最反直觉的一节:不是因为简单所以选了文件式,而是因为 cache 经济学逼着你必须简单。任何"看起来更聪明"的检索方案,都要先回答它怎么不破坏 prefix cache。

6. 一段最小可跑的 typed memory

为了把上面四步具体化,本文配了一个 typed_memory.py(见 code/04-why-memory-system/,145 行,stdlib only)。它做了五件事:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
@dataclass
class MemoryEntry:
    path: Path
    type: MemType            # 'user' | 'project' | 'reference' | 'feedback'
    updated_at: float
    summary: str

TYPE_WEIGHT = {"user": 1.4, "feedback": 1.2, "project": 1.0, "reference": 0.7}

def score(entry, prompt, now):
    overlap = len(set(tokens(prompt)) & entry.tokens())
    age_days = (now - entry.updated_at) / 86_400
    recency = 1.0 / (1.0 + age_days / 7.0)   # 半衰期 1 周
    return (overlap + 0.5 * recency) * TYPE_WEIGHT[entry.type]

discover() 走三层根目录(cwd / parent / ~/.agent),rank() 取 top-K,write_index() 渲染成 - [summary](file.md) — type=... 的 MEMORY.md。最后 assemble_prompt() 把它拼成 static_prefix + memory_index + dynamic_suffix

跑一次 demo,对同一个 memory pool 喂两个不同 prompt:

  • 输入 帮我写 cli-agent blog 第四篇 → 索引 top-2 是 project_blog + user_role
  • 输入 记得不要在文章里加 emoji → 索引 top-2 切换为 feedback_no_emoji + user_role

user_role 总是在——因为 type weight 1.4 的 baseline 让用户身份永远占一个 slot;而第二位会跟着 prompt 内容切换。这就是类型化 + 启发式排序的可观测性,是 vector DB 永远给不出来的。

7. 把这一切串起来:四个判断题

读到这里,你应该能用四个判断题验收一个 agent 的 memory 系统是否"工业级":

  1. 能不能 git diff? 如果不能,这个 memory 系统天然没法 code review,team 协作必然出事。
  2. 同一 prompt 是否给同一索引? 如果不能,cache 经济学一定崩。
  3. 每条 memory 有没有显式 type? 如果没有,TTL、权重、扫描策略就只能一刀切。
  4. MEMORY.md 是不是索引而不是 blob? 如果是 blob,prefix cache 也保不住。

claw-code 的 memdir 在这四题上都得分。这不是巧合,是被 LLM 工程的硬性约束(KV cache、token 预算、可观测性、git workflow)逼出来的最优解。

下一篇预告:Memory 存储、检索与失效

本文回答了 whywhat:为什么不用 RAG、为什么类型化、为什么索引/blob 分离、为什么 cache 经济学决定一切。 下一篇(Article 5: Memory 存储、检索与失效) 会回答 how

  • entry 的 frontmatter 长什么样、memdir.ts 的 atomic write 怎么保证不损坏索引;
  • findRelevantMemories.ts 的打分函数完整拆解,含 role match 与 negative filter;
  • TTL / staleness / 矛盾检测的三种失效路径;
  • 一个 ~200 行的 demo:memdir_lite.py,跑一遍 write → scan → age → rank 的完整链路。

第 6 篇则会进一步回答 agent 与 memory 的协同进化:feedback 如何反向修改 system prompt,memory 如何成为 agent 的"长期人格"。

下篇见。

引用

  • src/reference_data/subsystems/memdir.json — taxonomy 名单
  • memdir/{memoryTypes.ts, memoryAge.ts, memoryScan.ts, findRelevantMemories.ts, paths.ts, teamMemPaths.ts, teamMemPrompts.ts, memdir.ts} — 实现
  • src/transcript.py — compaction,与 memory 的对照
  • PDF §3, §3.3 — SYSTEM_PROMPT_DYNAMIC_BOUNDARY 与 cache 边界
  • 你眼前这个 Claude Code session 的 MEMORY.md — 最佳教材