Agent 的记忆和状态 · 第 3 / 6 节

第 3 课:外部记忆:文件与检索

学习目标:

  • 解释为什么跨会话的记忆必须写到窗口之外,写进文件
  • 区分 CLAUDE.md 这类人工编写的记忆文件和 Auto memory 这类模型自己写的记忆文件
  • 说出「按需取回」相对于「全量载入」的取舍是什么
  • 能给一个记忆文件读写工具加上安全的路径边界检查

前置要求:完成第 2 课,理解摘要压缩和工具结果清理的区别 | 上一课 第 2 课 << | 下一课 第 4 课 >>

会话一结束,窗口里的一切就没了

上一课结尾留了一个没解决的问题:一个记录助手 Agent,用户上周告诉过它"不吃辣",这周开了一次新的对话,窗口里空空如也——它压根不知道用户上周说过这句话。

这不是截断、摘要压缩或者工具结果清理能解决的问题。这三种机制处理的都是"这一次对话内,窗口装不下了怎么办";而这里的问题是,这次对话从一开始就没有上周那次对话的任何内容。官方 Cookbook 把这条界线说得很直接:清理和压缩都只作用于当前上下文,对"新会话开始时窗口一片空白"这个问题谁也帮不上忙——记忆解决的就是这个问题1。窗口这个容器本身,生命周期只到这一次会话结束——会话一关,窗口里那些没被搬到别处的东西,就真的没了。

想让信息活过这次会话,唯一的办法是在会话结束前,把它写到窗口之外的地方——也就是写进外部记忆:一个不受这次对话生命周期限制的存储,通常就是磁盘上的文件。下次会话开始时,再把这个文件的内容读出来,重新塞进新一轮的上下文窗口。

CLAUDE.md:人写的、每次都整份载入的记忆

有一种最直接的外部记忆模式,就是让人类自己维护一份记忆文件,固定放在项目里,每次会话开始都完整读一遍。Claude Code 里的 CLAUDE.md 就是这种模式的代表:官方文档说,CLAUDE.md 文件会在每次会话开始时被载入上下文,和这次对话本身一起消耗 token 预算,官方建议的体量目标是每份文件控制在 200 行以内——文件越长,消耗的上下文越多,Agent 对指令的遵循度也会跟着下降。2 注意 200 行是软性建议,真正的硬上限是 4 MiB:超过这个大小的 CLAUDE.md 会被整个跳过。2

这份文件有个细节值得注意:官方文档说明,CLAUDE.md 里的块级 HTML 注释会在内容被注入到 Agent 上下文之前被剥离掉。2 也就是说,写在 <!-- --> 里的内容,人打开文件时能看到,但 Agent 读到的这份文件是不包含这段注释的——这给了人类一个"给自己留备注、但不占用 Agent token 预算"的办法。

CLAUDE.md 还有一个特性,跟第 2 课讲的摘要压缩直接相关:官方文档指出,项目根目录的 CLAUDE.md 能在 /compact 之后存活下来——压缩发生后,Claude 会重新从磁盘读取这份文件,把它重新注入会话。2 换句话说,这类记忆文件不是被摘要压缩"顺带保留"的,而是被单独重新读取、重新注入的——它压根不依赖那次压缩有没有把它的内容保留在摘要里。

Auto memory:模型自己写、按需取回的记忆

CLAUDE.md 是人写的,每次都整份载入。还有另一种互补的模式:让模型自己在对话过程中把值得记住的东西写下来,存成自己的记忆文件——Claude Code 把这套机制叫作 Auto memory(自动记忆)。它和 CLAUDE.md 的分工是互补的,一个对比表格把区别说得很清楚:CLAUDE.md 由人来写,Auto memory 由模型自己写。2

模型自己写的记忆,通常会拆成两层:一份索引文件(比如 MEMORY.md),外加一堆按主题拆开的具体记忆文件。索引文件本身也不是无限载入的——官方文档给出的规则是:每次会话开始,只加载 MEMORY.md 的前 200 行,或者前 25KB,以先达到的那个为准,超出这个门槛的内容不会在会话开始时被载入。2

这就是按需取回:会话一开始,Agent 看到的只是索引里的条目摘要(比如"这个话题的详细笔记存在某个文件里"),而不是每一份具体记忆文件的完整内容。官方文档说得很直接:主题文件不会在启动时被加载,Claude 在需要这份信息时,才用标准的文件工具按需去读2。只有当当前任务真的用得上某个主题时,那份具体记忆文件的内容才会被临时搬进这一轮的上下文窗口。

对照来看,CLAUDE.md 和 Auto memory 处理的是记忆的两个不同维度:

  • CLAUDE.md——人工筛选过、体量克制、每次都要用得上的规则和约定,适合"这个项目本来就该这么做"这类稳定信息,主动全量载入。
  • Auto memory——数量可能很大、只在特定任务下才用得上的具体细节,适合按需取回,避免把窗口预算浪费在这次任务根本不需要的记忆上。

两者都属于外部记忆,区别只在"谁来写"和"什么时候被载入"——这也呼应了第 2 课的心智模型:记忆的作用是把信息移出窗口、让它跨会话留存,至于具体是整份主动载入,还是按需取回,取决于这份信息有多稳定、有多常用。

给记忆文件的读写,加上安全边界

无论是 CLAUDE.md 这类人写的文件,还是 Auto memory 这类模型自己写的文件,一旦 Agent 拿到了读写记忆文件的工具,就得考虑一个具体的工程问题:这个工具能不能被诱导着去读写项目目录之外的文件?

一个只做字符串前缀匹配的路径检查,看起来能挡住"跳出记忆目录"的请求,但其实有个经典漏洞——如果记忆根目录是 /project/memory,一个天真的 startsWith("/project/memory") 检查,会把 /project/memory-evil 这样的路径也误判成"在目录内",因为它确实是以这串字符开头的,尽管这是一个完全不同的、位于记忆目录之外的目录。安全的写法要么让路径完全等于根目录本身,要么确保它是以"根目录加路径分隔符"开头:

abs === MEMORY_ROOT || abs.startsWith(MEMORY_ROOT + path.sep) 这个组合,才真正保证了只有"等于根目录本身"或者"根目录加分隔符开头"的路径能通过——/project/memory-evil 不会被误判成 /project/memory 目录内的路径,因为它不满足这两个条件里的任何一个。这个模式在第 6 课搭建持久记忆层的读写工具时会直接复用,第 5 课还会讲清楚,如果这道边界检查形同虚设,记忆文件本身会变成什么样的攻击目标。

小结

  • 会话一结束,窗口里没被搬走的内容就彻底消失了;想让信息跨会话留存,只能在会话结束前把它写进窗口之外的外部记忆
  • CLAUDE.md 由人来写,每次会话都整份载入上下文,官方建议的体量目标是 200 行(硬上限 4 MiB,超过会被整个跳过),块级 HTML 注释会在注入前被剥离,项目根目录的 CLAUDE.md 在 /compact 之后会被重新读取、重新注入2
  • Auto memory 由模型自己写,拆成索引文件加具体主题文件两层,索引只加载前 200 行或 25KB,主题文件不在启动时加载、需要时才被按需读取2,避免把窗口预算浪费在用不上的记忆上
  • 两者是互补关系:CLAUDE.md 适合稳定、每次都用得上的规则;Auto memory 适合体量大、只在特定任务下才需要的细节
  • 记忆文件的读写工具必须做安全的路径边界检查,abs === ROOT || abs.startsWith(ROOT + path.sep) 这个组合条件缺一不可,单独的 startsWith 检查存在同前缀绕过漏洞

>> 第 4 课:结构化状态:让 Agent 记住任务进行到哪

Footnotes

  1. Context engineering: memory, compaction, and tool clearing — https://platform.claude.com/cookbook/tool-use-context-engineering-context-engineering-tools

  2. How Claude remembers your project — https://code.claude.com/docs/en/memory 2 3 4 5 6 7 8 9

练习

01

下面四条信息,分别判断更适合写进 CLAUDE.md,还是写进 Auto memory 的某份主题文件里,并说明理由。

Level 1:给一份笔记选合适的记忆位置
  1. "这个项目统一用 4 个空格缩进,不要用 tab"——从项目建立那天起就没变过。
  2. "上周三排查过一次线上超时问题,根因是数据库连接池设置得太小,当时改成了 50"——一次性的历史事件记录,以后遇到类似问题可能会用得上。
  3. "用户在上次对话里提到,他们团队的代码审查流程是先过 lint 再找人审"——只有跟这个用户交互的会话才用得上。
  4. "本次对话中用户临时要求把某个函数改成同步写法"——只跟当前这一次任务相关,下次会话大概率不会再提。
完成标准 · 本地勾选
02

下面这段代码想限制一个记忆读取工具,只能读 MEMORY_ROOT 目录内的文件:

Level 2:诊断一段有漏洞的路径检查代码

找出这段代码里的安全漏洞,给出一个能够绕过这道检查、读到 MEMORY_ROOT 之外文件的具体路径例子,并写出修正后的检查条件。

完成标准 · 本地勾选

我的笔记

记下想法、痛点、没懂的地方。只写进这门课的附录,正课文件不动。