写在前面

learn-claude-code 项目目前有两条教程线,一条是之前的 s12 章节的,另一条是最近更新的 s20 章节的,大家如果刚学习这个项目的话推荐直接看最新的 s20 章节的教程即可,由于博主之前学习过 s12 章节的内容,因此打算把 s20 章节中新增章节的内容给补充学习,内容重复的章节博主这边就跳过了。

下面是旧版到新版的对应关系:

Legacy 12-lesson track Current 20-lesson track Topic
old s01 new s01 Agent Loop
old s02 new s02 Tool Use
old s03 new s05 TodoWrite
old s04 new s06 Subagent
old s05 new s07 Skill Loading
old s06 new s08 Context Compact
old s07 new s12 Task System
old s08 new s13 Background Tasks
old s09 new s15 Agent Teams
old s10 new s16 Team Protocols
old s11 new s17 Autonomous Agents
old s12 new s18 Worktree Isolation
new only s03, s04, s09, s10, s11, s14, s19, s20 Permission, Hooks, Memory, System Prompt, Error Recovery, Cron, MCP, Comprehensive Agent

从上表中我们可以看出我们需要补充的内容包括 s03(已补充)s04(已补充)、s09、s10、s11、s14、s19 以及 s20 八个章节的内容。

新版学习路径如下:

主线:能动手 → 能做复杂任务 → 能记住和恢复 → 能长期运行 → 能协作 → 能扩展并合体

在这里插入图片描述

前言

在上篇文章 Learn-Claude-Code | 笔记 | Tools & Execution | s04_new Hooks 中,我们介绍了开源项目 learn-claude-code 新版第四个章节 s04_new: Hooks 的内容,这篇文章我们继续跟着教程文档来学习记忆管理相关内容,记录下个人学习笔记,和大家一起分享交流😄

Note:本篇文章主要学习记录 新版教程 第三部分 Memory Management 中 s09: Memory 章节的内容。

githubhttps://github.com/shareAI-lab/learn-claude-code

referencehttps://chatgpt.com/

1. s09: Memory

到了 s09,整个 Agent 系统开始进入一个非常关键的阶段:它不再只是 “当前会话内的智能体”,而是开始拥有跨会话延续能力。

在前面 s08 Context Compact 中,我们已经解决了一个问题:当上下文越来越长时,系统可以通过压缩管线把冗长历史变成更短的摘要,从而让当前会话继续跑下去。但是 s08 的压缩本质上仍然是 “会话内续命”。它关心的是:这一轮对话太长了,怎么压缩后继续执行?

而 s09 Memory 要解决的是另一个更长期的问题:有些信息不应该只活在当前上下文里,而应该跨会话保存下来,并在未来真正需要时重新加载回来

比如用户说:

I prefer using tabs for indentation, not spaces.

这句话不是某一次任务的临时上下文,而是一个以后写代码时都可能需要遵守的偏好。如果它只存在于当前 messages 里,一旦会话结束、上下文压缩、或者下次重新打开项目,这条信息就可能丢失。Memory 系统的价值就在于,它把这些 “未来仍然有用的知识” 从短期上下文中提取出来,存入一个独立的长期记忆库。

所以,s09 的核心变化可以概括为一句话:s08 解决当前会话怎么不断,s09 解决跨会话知识怎么不丢

2. 问题

在没有 Memory 之前,Agent 的上下文完全依赖 messages[]。用户输入、模型响应、工具结果都会被不断追加进去,然后再通过 compact 机制压缩。这个机制对于当前任务是有效的,但它有一个天然缺陷:上下文是一条会话内的时间线,不是一个可长期维护的知识库。

如果用户在某一轮对话中表达了偏好、项目约束、常用命令、目录结构、排查经验,这些信息虽然短期内可能有用,但长期来看很容易被压缩掉、截断掉,或者在新会话中彻底丢失。更麻烦的是,如果每次都把所有历史对话塞回 prompt,成本会越来越高,噪声也会越来越多。

因此,Memory 系统要解决的不是 “怎么保存全部历史”,而是要解决三个更工程化的问题:

第一,哪些信息值得长期保存?不是所有聊天内容都应该进入记忆,只有用户偏好、长期反馈、项目事实、参考线索这类未来仍然有用的信息才应该被提取出来。

第二,保存之后怎么低成本加载?如果每轮都把所有记忆文件全部注入上下文,Memory 很快就会变成新的上下文污染源。因此系统需要一个便宜的索引常驻机制,再按需加载少量相关记忆。

第三,记忆越来越多之后怎么整理?长期运行的 Agent 一定会产生重复、过时、冲突的记忆,所以 Memory 不能只是追加文件,还必须有低频的合并、去重和剪枝机制。

这也是 s09 相对 s08 最重要的变化:上下文管理从 “压缩历史” 扩展为 “存储、加载、提取、整理” 的完整长期记忆系统

3. 解决方案

s09 的解决方案并不是把所有历史都塞进 prompt,而是引入一个独立的 .memory/ 目录,把长期知识从 messages[] 中拆出来。

整体结构大致如下:

.memory/
├── MEMORY.md              # 记忆索引
├── user-preference-tabs.md
├── project-facts.md
└── lcc-visual-preference.md

其中 MEMORY.md 是一个轻量索引,保存每条记忆的名称和描述;具体的记忆内容则放在一个个独立的 Markdown 文件中。这样做的好处是,系统每轮都可以低成本读取索引,让 Agent 知道 “有哪些记忆存在”,但不会一上来就把所有完整内容全部加载进上下文。

教程中的 memory-subsystems.svg 图正好概括了这套系统的四个子模块:

在这里插入图片描述

从图中可以看到,Memory 被拆成了四个动作:存储、加载、提取、整理。存储负责把值得保留的信息写成 .memory/*.md 文件,并更新 MEMORY.md 索引;加载负责在每轮模型调用前,根据当前请求从记忆库里挑选相关文件注入上下文;提取负责在每轮结束后,从最近对话里识别新的偏好、约束或项目事实;整理则负责在记忆文件积累到一定数量后进行去重、合并和剪枝。

这里最关键的设计不是 “写文件” 本身,而是 Memory 被设计成一个围绕 Agent Loop 的旁路系统。它不会替代原来的工具调用流程,也不会改变模型执行任务的主路径,而是在模型调用前后分别插入两类动作:调用前加载相关记忆,调用后提取新增记忆。

因此,s09 的主线可以理解为:

messages[]
   ↓
s08 压缩管线
   ↓
s09 记忆加载
   ↓
  LLM
   ↓
工具调用 / 返回结果
   ↓
每轮结束后提取新记忆
   ↓
必要时整理记忆库

这就让 Agent 拥有了一种更接近真实工程助手的能力:它不需要每次从零开始,也不需要把所有历史都背在当前上下文里,而是可以从长期记忆库中按需借用信息。

4. Memory Library 流程图分析

Web 教程中的 Memory Library 用 8 张图把 Memory 的生命周期讲得非常清楚。它不是在展示某个复杂 UI,而是在把 “学习、建索引、召回” 这三个阶段可视化。

1. A Fact Worth Keeping:一条值得保留的事实出现

在这里插入图片描述

第一张图中,用户在 Session A 中说:

Please keep LCC pages concrete for beginners.

这句话不是一次性的任务指令,而是一个未来仍然会影响输出风格的偏好:用户希望 LCC 页面面向初学者时更加具体,不要太抽象。

所以它符合 Memory 的保存条件。Memory 不是保存所有对话,而是保存 “未来还会用到的事实”。这正好对应代码中的 extract_memories():它会从最近对话里提取 user preferences、constraints、project facts,而不是把整段会话原封不动写入文件。

2. Stamp It After the Turn:在回合结束后盖章保存

在这里插入图片描述

第二张图强调 Memory 提取发生在有用工作结束之后,而不是一边执行任务一边不断打断主流程。

这和代码中的设计一致:只有当 response.stop_reason != "tool_use" 时,系统才执行:

extract_memories(pre_compress)
consolidate_memories()

也就是说,Memory 提取不是模型每次输出一个 token 就运行,也不是每次工具调用后都运行,而是在一次 turn 阶段性结束后再进行。这样主 loop 不会被记忆逻辑污染,模型仍然专注于当前任务;长期记忆则在任务完成后作为后处理发生。

3. Write One Memory File:写入单条记忆文件

在这里插入图片描述

第三张图展示了记忆写入后的状态:.memory 目录里出现了一个独立的记忆文件,比如 lcc_visual_preference.md

这对应代码中的 write_memory_file()。一条记忆不是被塞进一个巨大的 JSON 文件里,而是被写成单独的 Markdown 文件,并带有 frontmatter。这样的好处是后续可以单独读取、单独选择、单独整理,也方便开发者直接打开查看。

这一步可以理解为:系统把对话里的短期信号,沉淀成了一个长期知识单元。

4. Update the Catalog:更新 MEMORY.md 索引

在这里插入图片描述

第四张图展示的是 catalog 更新。新记忆写入后,MEMORY.md 不再为空,而是出现了这条记忆的标题、描述和类型。

这正好对应代码中 _rebuild_index() 的作用。每次写入新的 .md 文件后,系统都会重建索引,确保 MEMORY.md 始终反映当前记忆库。

这个索引非常关键。因为未来 Agent 不应该每次都读取全部记忆正文,而是先看 catalog,判断哪些记忆可能相关。也就是说,MEMORY.md 是长期记忆系统的 “目录层”,.memory/*.md 是 “内容层”。

5. A Future Request Arrives:未来请求到来

在这里插入图片描述

第五张图中,Session B 出现了一个新请求:

Continue improving the web lesson visuals.

此时用户没有再次说明 “希望面向初学者、要具体一点”,但这个偏好已经存在于 Memory 中。

这正是 Memory 的价值所在:用户不需要反复告诉 Agent 同样的偏好,Agent 可以在未来请求中通过记忆库找回相关信息。

如果没有 Memory,这条偏好只会存在于 Session A;到了 Session B,Agent 就会像第一次见到用户一样重新开始。而有了 Memory,跨会话连续性才真正成立。

6. Catalog Picks One:索引选择相关记忆

在这里插入图片描述

第六张图展示了按需选择过程。Memory Library 里有多条记忆,例如:

Beginner visual preference
LCC web paths
Verification commands

但当前请求和 “web lesson visuals” 最相关的是 Beginner visual preference。因此系统只选择这一条,而不是把所有记忆都加载进上下文。

这对应代码中 select_relevant_memories() 的逻辑。它先构造记忆 catalog,再让 LLM side-query 根据当前对话选择相关记忆。这样做的关键价值是控制上下文噪声:不相关的记忆应该留在架子上,只有当前需要的记忆才进入阅读栈

7. Build the Reading Stack:构造模型调用前的阅读栈

在这里插入图片描述

第七张图展示了模型调用前的阅读栈。当前请求在上,选中的记忆详情在下,它们一起进入 LLM。

这对应代码 load_memories() 返回的 <relevant_memories> 内容。系统会读取选中的记忆文件,把完整内容注入到 system prompt 后面:

if memories_content:
    system += "\n\n" + memories_content

因此模型看到的不只是当前请求:

Continue improving the web lesson visuals.

还会看到相关记忆:

Prefer cards, boards, shelves, and workbenches over abstract flowcharts.

这时模型生成的答案自然会更贴合用户过去的偏好。

8. Continuity Without Clutter:没有上下文污染的连续性

在这里插入图片描述

最后一张图总结了 Memory 系统最核心的价值:连续性不是靠把所有历史都塞回上下文实现的,而是靠 “索引 + 选择 + 按需加载” 实现的。

当前请求只加载相关记忆,不相关的 LCC web pathsVerification commands 等文件仍然留在记忆库里,不进入本轮上下文。

这就解决了长期记忆系统最容易踩的坑:如果什么都记、什么都加载,Memory 很快会变成噪声;而 s09 的设计是让记忆库变成一个 “可检索的书架”,不是一个 “越来越长的聊天记录”。

5. 工作原理(代码分析)

在正式开始代码分析之前,我们先来看看教程中提供的一张 memory-overview.svg 总览图,它展示了 Memory 是如何插入 s08 压缩管线和 Agent Loop 的:

在这里插入图片描述

这张图的重点是对比 s08 和 s09。

蓝色部分表示 s08 已经存在的能力:messages[] 会先经过压缩管线,包括 budget、snip、micro 和 autoCompact。也就是说,s09 并没有删除 s08 的上下文压缩能力,而是在它上面继续扩展。

紫色部分表示 s09 新增的 Memory 能力。模型调用前,会通过 Loading 阶段从 .memory/ 中选择相关记忆,并把它们注入上下文;模型调用后,如果一轮结束,则通过 Extraction 阶段从对话中提取新记忆;当记忆文件数量增长到一定程度时,Consolidation 会触发整理,进行去重、合并和剪枝。

所以这张图传达的核心是:Memory 不是替代 Context Compact,而是接在 Compact 管线上的长期知识层

从执行路径看,主循环仍然是:

messages[] → compact → LLM → tools → tool_result → messages[]

但 s09 在这个循环上额外插入了两个方向的记忆流:

.memory/ → Loading → LLM
LLM / messages → Extraction → .memory/

也就是说,Memory 和 messages 之间是双向关系。Memory 会影响当前模型调用,而当前对话又会反过来更新 Memory。这让 Agent 从 “一次性上下文驱动” 变成了 “长期知识驱动”。

OK,下面我们就来看看代码具体是如何实现的。

1. Memory 的存储结构

我们先来看 s09 新增的 Memory 存储部分。代码一开始就创建了 .memory/ 目录,并定义了 MEMORY.md 作为索引文件:

WORKDIR = Path.cwd()
MEMORY_DIR = WORKDIR / ".memory"; MEMORY_DIR.mkdir(exist_ok=True)
MEMORY_INDEX = MEMORY_DIR / "MEMORY.md"

这几行代码非常关键,因为它把 Memory 从普通的对话上下文里剥离出来,变成了工作区内一个独立的持久化目录。也就是说,Memory 不再依赖 Python 进程的内存,也不依赖当前 messages[] 是否还在,只要 .memory/ 目录存在,记忆就可以跨会话保留下来。

接着,代码定义了 Memory 的四种类型:

MEMORY_TYPES = ["user", "feedback", "project", "reference"]

这四类记忆分别对应不同的长期信息。user 更偏向用户身份、习惯和偏好;feedback 更偏向用户对 Agent 输出风格、工作方式的反馈;project 保存项目事实,比如目录结构、构建命令、业务约束;reference 则保存一些外部线索、文档入口、常用排查命令等。

这个分类看起来很简单,但它实际上是在给长期记忆加一个最小的语义结构。没有分类的话,Memory 就只是一堆文本;有了分类之后,系统后续做加载、提取、整理时,就可以更清楚地区分 “这是用户偏好” 还是 “这是项目事实”。

2. 单条记忆文件:Markdown + YAML Frontmatter

Memory 写入的核心函数是 write_memory_file

def write_memory_file(name: str, mem_type: str, description: str, body: str):
    """Write a single memory file with YAML frontmatter."""
    slug = name.lower().replace(" ", "-").replace("/", "-")
    filename = f"{slug}.md"
    filepath = MEMORY_DIR / filename
    filepath.write_text(
        f"---\nname: {name}\ndescription: {description}\ntype: {mem_type}\n---\n\n{body}\n"
    )
    _rebuild_index()
    return filepath

这段代码做了三件事。

第一,它把记忆名称转换成文件名。比如 Beginner visual preference 会被转换成类似 beginner-visual-preference.md 的文件。这样每条记忆都有一个稳定的文件入口。

第二,它把记忆写成 Markdown 文件,并在文件顶部放入 YAML frontmatter:

---
name: Beginner visual preference
description: Use concrete mental models for LCC web pages.
type: feedback
---

Prefer cards, boards, shelves, and workbenches over abstract flowcharts.

这个设计很重要,因为它同时满足了机器可读和人类可读两个要求。机器可以解析 namedescriptiontype,人也可以直接打开 .md 文件查看和修改具体内容。

第三,每次写入新记忆后都会调用 _rebuild_index()。这说明 MEMORY.md 不是手动维护的,而是由所有记忆文件自动生成的索引。换句话说,.memory/*.md 是详细内容,MEMORY.md 是轻量目录。这个结构正是后续 “索引常驻 + 内容按需加载” 的基础。

3. MEMORY.md:便宜的长期记忆索引

代码中还有一个 _rebuild_index(),它负责扫描 .memory/ 目录下的所有记忆文件,然后生成索引:

def _rebuild_index():
    files = list_memory_files()
    lines = ["# Memory Index", ""]
    for f in files:
        lines.append(f"- [{f['name']}]({f['filename']}) — {f['description']} [{f['type']}]")
    MEMORY_INDEX.write_text("\n".join(lines))

这里的关键点是:索引只保留简短描述,不保留完整正文。

这和我们平时做知识库很像。真正的大段内容放在独立文件里,而目录页只写标题、链接和一句话摘要。这样 Agent 每轮都可以看到有哪些记忆,但不会因为记忆库越来越大而把上下文撑爆。

这也是 Memory 系统与普通 RAG 的一个区别。这里不是先做 embedding 检索,而是先用一个轻量可读索引,让模型通过 name + description 判断哪些记忆可能相关。教程文档里也强调了这一点:教学版保留了 LLM side-query 的选择方式,并提供关键词匹配作为降级路径。

4. 记忆加载:索引常驻 + 相关内容按需注入

Memory 加载部分主要由 build_system()select_relevant_memories()load_memories 三个函数组成。

先看 build_system()

def build_system() -> str:
    index = read_memory_index()
    memories_section = f"\n\nMemories available:\n{index}" if index else ""
    return (
        f"You are a coding agent at {WORKDIR}."
        f"{memories_section}\n"
        "Relevant memories are injected below. Respect user preferences from memory.\n"
        "When the user says 'remember' or expresses a clear preference, extract it as a memory."
    )

这里做的是第一层加载:把 MEMORY.md 索引注入到 system prompt 中。

注意,它注入的不是所有完整记忆,而只是索引。这样模型每轮都知道 “有哪些记忆文件可用”,但不会把全部内容一次性塞进上下文。这个设计非常符合长期记忆系统的工程原则:目录可以常驻,正文必须按需加载

接着看 load_memories()

def load_memories(messages: list) -> str:
    """Load relevant memory content for injection into context."""
    selected_files = select_relevant_memories(messages)
    if not selected_files:
        return ""

    parts = ["<relevant_memories>"]
    for filename in selected_files:
        content = read_memory_file(filename)
        if content:
            parts.append(content)
    parts.append("</relevant_memories>")
    return "\n\n".join(parts)

这个函数做的是第二层加载:先通过 select_relevant_memories(messages) 选出当前请求相关的记忆文件,再读取完整内容,并包在 <relevant_memories> 标签中注入到上下文。

这里的 <relevant_memories> 很有工程意义。它给模型一个明确边界:下面这些不是用户当前说的话,也不是工具结果,而是系统从长期记忆中加载出来的相关背景。这样模型在理解上下文时不容易混淆来源。

真正决定 “选哪些记忆” 的是 select_relevant_memories()。它会把最近对话和记忆目录一起发给模型,让模型返回相关记忆的索引:

response = client.messages.create(
    model=MODEL,
    messages=[{"role": "user", "content": prompt}],
    max_tokens=200
)

如果模型选择失败,代码还会退回到关键词匹配:

keywords = [w.lower() for w in recent.split() if len(w) > 3]
selected = []
for f in files:
    text = (f["name"] + " " + f["description"]).lower()
    if any(kw in text for kw in keywords):
        selected.append(f["filename"])
        if len(selected) >= max_items:
            break
return selected

这就是一个典型的 harness 工程思路:优先使用模型做语义选择,但不能完全依赖模型;一旦 side-query 失败,还要有简单、稳定、便宜的降级路径。文档中也提到,教学版采用 “LLM side-query + 关键词降级” 的方式来控制开销和稳定性。

5. 记忆提取:每轮结束后从对话中抽取长期信息

Memory 系统不能只加载,还必须能自动增长。否则它就只是一个手动维护的笔记目录,而不是 Agent 自己能持续积累的长期记忆。

这部分由 extract_memories() 完成:

def extract_memories(messages: list):
    """Extract new memories from recent dialogue. Runs after each turn."""
    dialogue_parts = []
    for msg in messages[-10:]:
        role = msg.get("role", "?")
        content = msg.get("content", "")
        ...
        if isinstance(content, str) and content.strip():
            dialogue_parts.append(f"{role}: {content}")
    dialogue = "\n".join(dialogue_parts)

它首先从最近 10 条消息里整理出对话文本。这里不是扫描全部历史,而是只看最近窗口,因为新记忆通常来自刚刚发生的交互。这样可以避免提取 prompt 太长,也可以降低重复提取旧信息的概率。

接着,它会读取已有记忆,用来避免重复:

existing = list_memory_files()
existing_desc = "\n".join(
    f"- {m['name']}: {m['description']}" for m in existing
) if existing else "(none)"

然后构造一个专门的提取 prompt:

prompt = (
    "Extract user preferences, constraints, or project facts from this dialogue.\n"
    "Return a JSON array. Each item: {name, type, description, body}.\n"
    "- name: short kebab-case identifier (e.g. 'user-preference-tabs')\n"
    "- type: one of 'user' (user preference), 'feedback' (guidance), "
    "'project' (project fact), 'reference' (external pointer)\n"
    "- description: one-line summary for index lookup\n"
    "- body: full detail in markdown\n"
    "If nothing new or already covered by existing memories, return [].\n\n"
    f"Existing memories:\n{existing_desc}\n\n"
    f"Dialogue:\n{dialogue[:4000]}"
)

这段 prompt 的设计非常明确:只提取用户偏好、约束、项目事实;返回结构化 JSON;如果没有新信息或者已有记忆已经覆盖,就返回空数组。

这说明 Memory 提取不是 “见什么都存”,而是带有筛选标准的。真正值得保存的是未来还会影响 Agent 行为的信息,而不是每一轮对话的流水账。

如果模型返回了有效记忆,代码会逐条写入文件:

for mem in items:
    name = mem.get("name", f"memory_{int(time.time())}")
    mem_type = mem.get("type", "user")
    desc = mem.get("description", "")
    body = mem.get("body", "")
    if desc and body:
        write_memory_file(name, mem_type, desc, body)
        count += 1

因此,Memory 的写入链路就是:

recent dialogue
   ↓
LLM extraction
   ↓
JSON array
   ↓
write_memory_file()
   ↓
.memory/*.md
   ↓
_rebuild_index()
   ↓
MEMORY.md

这也是 s09 相比 s08 最核心的新增能力之一:上下文不再只是被压缩,而是会在每轮结束后被反向提炼成长期资产

6. 记忆整理:Consolidation / Dream

长期记忆如果只追加不整理,很快就会失控。所以 s09 还加入了 consolidate_memories()

CONSOLIDATE_THRESHOLD = 10

def consolidate_memories():
    """Merge duplicate/stale memories. Triggered when file count ≥ threshold."""
    files = list_memory_files()
    if len(files) < CONSOLIDATE_THRESHOLD:
        return

教学版的触发条件非常简单:当记忆文件数量达到 10 个时,触发整理。真实 Claude Code 中的 Dream 机制更复杂,有时间门控、扫描节流、会话门控和文件锁等多层保护;教学版这里用文件数量阈值做了简化。教程文档也明确说明,CC 的 Dream 实际有更复杂的四层门控,而教学版简化成文件数阈值。

整理时,代码会把所有记忆拼成 catalog:

catalog = "\n\n".join(
    f"## {f['filename']}\nname: {f['name']}\ndescription: {f['description']}\n{f['body']}"
    for f in files
)

然后要求模型执行四件事:

prompt = (
    "Consolidate the following memory files. Rules:\n"
    "1. Merge duplicates into one\n"
    "2. Remove outdated/contradicted memories\n"
    "3. Keep the total under 30 memories\n"
    "4. Preserve important user preferences above all\n"
    "Return a JSON array. Each item: {name, type, description, body}.\n\n"
    f"{catalog[:16000]}"
)

这里的整理逻辑非常像知识库维护:合并重复项,删除过时或冲突信息,控制总量,并优先保留重要用户偏好。

如果整理成功,代码会删除旧文件,再写入整理后的新文件:

for f in MEMORY_DIR.glob("*.md"):
    if f.name != "MEMORY.md":
        f.unlink()

for mem in items:
    ...
    write_memory_file(name, mem_type, desc, body)

这个过程意味着 Memory 不是单纯 append-only 的日志,而是一个会周期性重写和修剪的知识库。它关注的不是 “记录所有发生过的事”,而是 “保留未来最有用的信息”。

7. Agent Loop:Memory 如何插入主循环

最后看 agent_loop(),这是理解 s09 的关键。

def agent_loop(messages: list):
    reactive_retries = 0
    while True:
        # s09: rebuild system with current memory index + relevant memories
        system = build_system()
        memories_content = load_memories(messages)
        if memories_content:
            system += "\n\n" + memories_content

每轮进入模型前,系统都会重新构建 system prompt,并加载相关记忆。也就是说,Memory 加载发生在模型调用之前。

接着,代码保存了一份压缩前快照:

pre_compress = [m if isinstance(m, dict) else {"role": m.get("role",""),
    "content": str(m.get("content",""))} for m in messages]

这一步非常值得注意。因为 s09 仍然保留了 s08 的压缩管线:

messages[:] = tool_result_budget(messages)
messages[:] = snip_compact(messages)
messages[:] = micro_compact(messages)

如果直接从压缩后的 messages 中提取记忆,可能会漏掉一些原始细节。因此这里先保存 pre_compress,等到当前 turn 结束时,再从压缩前快照里提取记忆。这个细节体现了 Memory 与 Compact 的关系:Compact 服务于当前上下文长度,Memory 提取需要尽量保留原始语义

当模型不再调用工具时,系统进入结束分支:

if response.stop_reason != "tool_use":
    # s09: extract from pre-compression snapshot for full fidelity
    extract_memories(pre_compress)
    consolidate_memories()
    return

这就是 Memory 的提取时机:不是工具执行之前,也不是每个工具调用之后,而是在当前轮模型停止调用工具时。换句话说,等一次任务阶段性结束后,系统才回头看这轮对话里有没有值得长期保存的信息。

这和 s04 Hooks 中的 Stop Hook 思路非常接近。主 loop 保持简单,扩展逻辑挂在关键生命周期点上。只不过 s09 教学版没有显式复用 Hook 注册系统,而是直接把 extract_memories()consolidate_memories() 写在 stop_reason != "tool_use" 分支中。

所以 s09 的 agent loop 可以总结成:

  • 每轮开始:
    • build_system()
    • load_memories()
    • s08 compact
  • 模型调用:
    • LLM decides tool_use or final answer
  • 如果 tool_use:
    • 执行工具
    • tool_result 回灌 messages
    • 进入下一轮
  • 如果 stop:
    • extract_memories(pre_compress)
    • consolidate_memories()
    • 结束本轮

这也是 s09 最重要的工程结论:Memory 并没有推翻原来的 Agent Loop,而是在 loop 的前后加了两层长期知识管理能力

博主在给定下面的提示词情况下:

I prefer using tabs for indentation, not spaces. Remember that.

想通过调试看看整个过程发生了什么,我们来具体分析下:

Prompt 1:Loop 1

在这里插入图片描述

Prompt 1:第一次循环加载记忆

在这里插入图片描述

Prompt 1:第一次循环模型响应

在这里插入图片描述

Prompt 1:第一次循环工具执行结果

先看第一轮循环开始前的加载阶段。agent_loop() 一进入 while True,就会先执行 build_system(),然后调用 load_memories(messages)。从调试结果里可以很清楚地看到,此时 system 中只有基础的 system prompt 与 memory 相关说明,而 memories_content 为空字符串,这说明当前 .memory/ 目录中还没有任何可供检索的历史记忆。换句话说,s09 并不会 “假装自己记得”,而是会先显式检查当前记忆库里到底有没有东西,再决定是否往上下文中注入。

接着再看模型第一次响应。可以看到,这一轮返回的依旧是我们熟悉的 思考 block + 工具 block 结构:思考 block 说明模型已经识别出 “用户刚刚表达了一个明确偏好”,而工具 block 则选择执行一个 bash 调用,把这句偏好原样输出出来。

工具执行结果也证明了这一点。第一轮工具调用本身并没有写 memory 文件,它只是把用户偏好显式地落入了对话轨迹,使后续的 extract_memories(pre_compress) 能够基于完整上下文,把这条信息识别为 “值得长期保存的知识”。因此,这一轮最重要的意义不是 “执行了什么强工具操作”,而是 把一个未来可提取的长期事实,送进了当前会话链路里

Prompt 1:Loop 2

在这里插入图片描述

Prompt 1:第二次循环模型响应

在这里插入图片描述

Prompt 1:第二次循环提取记忆

Note:针对提取记忆函数 extract_memories(...) 博主简单修改了源码,遍历 response.content,找到第一个包含 text 属性的块。源码中是直接提取的 response.content 列表中的第一个元素的 text 文本,而针对于思考模型例如 deepseek-v4-pro 而言,第一个元素其实是 ThinkingBlock(没有 .text 属性),第二个才是真正的 TextBlock

在这里插入图片描述

Prompt 1:第二次循环写入记忆

到了第二轮循环,上一轮的 tool_result 已经被追加回 messages[],所以模型这次不再需要继续请求工具,而是直接给出自然语言确认,明确告诉用户:已经记住了这个偏好,之后会优先使用 tabs 进行缩进。这一点非常关键,因为它意味着此时 response.stop_reason != "tool_use",程序会进入 s09 新增的后处理分支,也就是:

extract_memories(pre_compress)
consolidate_memories()

这里最值得注意的是,extract_memories() 使用的不是已经被压缩过的 messages,而是前面保留下来的 pre_compress 快照。这样做的工程意义非常强:提取记忆时要看的是最完整、最高保真的原始对话,而不是已经被 budget / snip / micro 裁剪后的版本

调试里也能看到,提取函数会再次调用模型,让它根据 dialogueexisting_desc 去生成结构化 JSON。最终返回的文本 block 中,已经包含了一条完整记忆对象:nameuser-preference-tabstypeuserdescription 是 “User prefers tabs for indentation instead of spaces”,而 body 则是更完整的正文描述。

随后,系统把这条结构化记忆真正写入磁盘。一方面,在 .memory/ 目录下新建了 user-preference-tabs.md 文件,文件内部带有 YAML frontmatter;另一方面,又把这条记忆的摘要追加进 MEMORY.md 作为索引项。调试界面里还能看到控制台输出 [Memory: extracted 1 new memories],这说明从这一刻开始,这条偏好已经不再只是当前会话中的一句话,而是变成了 跨会话可复用的长期知识。也就是说,s09 在这一轮真正完成的,不是模型 “口头上说记住了”,而是系统层面把 “记住” 这件事落实成了可持久化的工程机制。

OK,本轮对话结束后我们继续使用下面的提示词观察下 Agent 是否用了 tab:

Create a Python file called test.py

Prompt 2:Loop 1

在这里插入图片描述

Prompt 2:第一次循环加载记忆

在这里插入图片描述

Prompt 2:第一次循环模型响应

在这里插入图片描述

Prompt 2:第一次循环工具执行结果

第二组提示词 "Create a Python file called test.py" 开始时,最应该观察的就是:这次循环开始前,系统是否真的把上一轮保存下来的记忆重新读了回来。调试结果给出的答案是肯定的。可以看到,build_system() 之后,load_memories(messages) 已经不再返回空字符串了:system 中先列出了 Memories available 的索引摘要,而 memories_content 里又进一步注入了一段 <relevant_memories> 内容,其中完整包含了 user-preference-tabs 这条记忆的 namedescriptiontype 和正文内容。

这说明 s09 的记忆加载不是粗暴地把整个 .memory/ 目录全塞进上下文,而是先利用 MEMORY.md 这个轻量 catalog 做筛选,再把当前请求真正相关的条目正文加载进来。也正因为这一段记忆在模型调用前已经可见,所以模型第一次响应里非常自然地写出了 “应该按照用户偏好使用 tabs” 的思考过程,并生成了一个 write_file 的工具调用。继续看工具参数中的 content,可以清楚看到函数体和 if __name__ == "__main__": 下面的缩进都采用了 \t,也就是制表符而不是空格。

工具执行结果进一步坐实了这一点。调试中 results[0]["content"] 显示文件已经成功写入,而右侧实际打开的 test.py 文件也能直观看到 print("Hello, world!")main() 调用前面都是 tab 缩进。这个现象非常重要,因为它说明第二轮并不是模型 “临时灵机一动” 想起了用户偏好,而是 Memory Loading 在 loop 的前半段已经把上一轮保存下来的长期知识重新回灌到了 prompt 中,从而直接影响了这一轮工具调用参数的生成。

Prompt 2:Loop 2

在这里插入图片描述

Prompt 2:第二次循环模型响应

进入下一轮循环后,工具结果同样已经被追加回 messages[],因此模型不再继续请求工具,而是直接给出自然语言确认:test.py 已经创建完成,并且所有缩进都使用了制表符(tabs)。这一点很好地说明了,虽然 s09 引入了 Memory 机制,但 原来的两段式 loop 节奏并没有被破坏:需要执行工具时,模型先走 tool_use;工具执行完成后,下一轮再根据结果给出总结或解释。Memory 的加入,并没有改写这个基本流程,只是在模型每次进入循环之前,先给它补充了一份跨会话上下文。

从工程角度看,这一轮更有价值的地方在于,它验证了 “memory 注入 → 影响工具行为 → 结果再反馈回 loop” 这条完整链路是通的。模型之所以能够稳定地在结果说明里强调 “用了 tabs”,根本原因并不是它在本轮内部临时推断出了偏好,而是上一轮提取下来的长期记忆已经成功参与了本轮推理。至于这一轮结束时的 extract_memories(),逻辑上依然会照常执行,但因为这轮对话没有新增值得长期保存的新偏好或新事实,所以不会重复写出一条等价 memory,从而保持记忆库的整洁性。这也进一步体现出 s09 的 Memory 并不是 “逢对话必记”,而是 只在有长期价值时才落盘

OK,本轮对话结束后我们继续使用下面的提示词观察下 Agent 是否记得:

What did I tell you about my preferences?

Prompt 3:Loop 1

在这里插入图片描述

Prompt 3:第一次循环模型响应

在这里插入图片描述

Prompt 3:第一次循环工具执行结果

第三组提示词变成了一个纯问答问题:"What did I tell you about my preferences?"。这一轮其实最能体现 Memory 机制的真正价值。调试结果显示,在 loop 一开始,系统再次调用 load_memories(messages),把 .memory/ 中与当前问题最相关的那条 user-preference-tabs 记忆重新加载进 system context。此时,模型在真正生成响应之前,就已经拥有了这条跨会话保存下来的背景知识。

也正因如此,模型第一次响应就不再需要任何工具调用,而是直接给出文本回答:你告诉过我,你更喜欢使用 tabs 而不是空格来进行缩进。这一点非常关键,因为它说明 Memory 在这里已经不是 “辅助工具调用的附属机制” 了,而是直接变成了模型回答问题所依赖的知识来源。换句话说,一旦记忆被成功注入,它在模型眼里就和普通上下文没有本质区别,可以直接进入推理链条,支持理解、生成与回答。

因此,这一轮最值得强调的不是 “模型回答对了” 这么简单,而是:s09 让 Agent 真正获得了一种跨会话、可持久化、可检索、可回灌的长期知识能力。第一轮把事实提取出来,第二轮把事实用于行为生成,第三轮则把事实用于直接问答,这三步完整串起来以后,Memory 的工程价值就非常清晰了。

把这三组提示词连起来看,s09 最重要的工程结论就非常明确了:Memory 并没有推翻原来的 Agent Loop,而是在 loop 的前后各插入了一层长期知识管理能力。进入循环之前,系统先通过 load_memories().memory/MEMORY.md 中检索与当前请求相关的历史知识,并把它按需注入到 system prompt;结束循环之后,系统再通过 extract_memories(pre_compress)consolidate_memories(),把本轮对话中新产生、又值得长期保存的信息提取出来并持久化写入磁盘。

第一组提示词验证的是 “没有历史记忆时,系统如何在回合结束后完成提取与存储”;第二组提示词验证的是 “历史记忆如何在下一轮加载回来,并真实影响工具调用与代码生成”;第三组提示词则进一步验证了 “记忆一旦被重新注入,就可以像普通上下文一样直接支持问答,而无需再借助工具”。所以从工程实现上看,s09 的核心并不是重新发明了一套 Agent Loop,而是 在既有 loop 前面补上了 recall,在既有 loop 后面补上了 extraction / consolidation,从而把原本一次性的上下文,扩展成了一个可持续积累、可重复利用的长期知识闭环

OK,以上就是 s09 Memory 工作原理的完整分析了。

那大家感兴趣的话可以试试下面这些 prompt 感受下记忆机制引入后的一些变化(分多轮输入,观察记忆的累积和加载):

1. I prefer using tabs for indentation, not spaces. Remember that.

2. Create a Python file called test.py(观察 Agent 是否用了 tab)

3. What did I tell you about my preferences?(观察 Agent 是否记得)

4. I also prefer single quotes over double quotes for strings.

6. 相对 s08 的变更

组件 之前 (s08) 之后 (s09)
记忆能力 无(压缩后偏好随摘要退化) 存储 + 加载 + 提取 + 整理
新函数 write_memory_file, select_relevant_memories, load_memories, extract_memories, consolidate_memories
存储 .memory/MEMORY.md 索引 + .memory/*.md 文件
工具 bash, read, write, edit, glob, todo_write, task, load_skill, compact (9) bash, read_file, write_file, edit_file, glob, task (6)
循环 每轮只做压缩 每轮注入记忆 + 压缩 + 每轮结束后提取 + 定期整理

7. 小结

s09 Memory 是 Learn Claude Code 这条教程线中非常关键的一节。前面的章节让 Agent 具备了工具调用、权限控制、Hook 扩展、任务拆解和上下文压缩能力,但这些能力大多还停留在 “当前会话” 里。到了 s09,系统第一次真正开始拥有跨会话的长期知识。

从工程实现上看,Memory 并没有把架构变复杂。它仍然遵循这个项目一直强调的原则:主循环保持简单,能力层不断外接。.memory/ 负责存储,MEMORY.md 负责索引,load_memories() 负责按需加载,extract_memories() 负责回合结束后提取,consolidate_memories() 负责低频整理。所有这些能力都围绕原来的 Agent Loop 展开,而不是重新发明一个新的运行框架。

更重要的是,s09 让我们看到一个成熟 Agent 系统必须具备的能力:它不能只会处理眼前的 prompt,还要能积累用户偏好、项目事实和长期反馈,并在未来合适的时候重新使用这些信息。

所以,如果说 s08 的关键词是 “压缩”,那么 s09 的关键词就是 “沉淀”。压缩让当前会话继续跑下去,沉淀让未来会话不再从零开始。

OK,以上就是本期想要分享的全部内容了。

结语

本篇文章我们围绕 s09 Memory 这一节,完整梳理了 Agent 是如何将跨会话长期知识引入系统,使其不再只依赖当前上下文的即时信息。

相比 s08 的 Context Compact,s09 的核心变化在于:Agent 不再只是压缩当前会话,而是能够主动沉淀、存储、检索和整理未来仍会使用的知识。通过 .memory/ 目录存储记忆、MEMORY.md 维护索引,以及 load_memories()extract_memories()consolidate_memories() 等函数,系统形成了一个按需回灌、低噪声的长期知识闭环。

从工程视角来看,Memory 的设计非常克制:它没有改变原有 Agent Loop 的执行节奏,而是在循环前加载相关记忆,循环后提取和整理新的长期信息。这样既保证了主循环的简洁性,又让 Agent 获得了跨会话连续性,能够记住用户偏好、项目事实和长期反馈,并在未来使用时自动注入上下文。

进一步来看,s09 的真正价值不只是 “存储信息”,而是让整个系统第一次具备了沉淀能力:过去的交互可以转化为可复用资产,未来的交互可以直接利用这些知识,从而显著提升长期任务执行的连续性和一致性。Memory 使 Agent 从一次性对话工具,进化为真正能够长期服务、可持续运作的智能助手。

总而言之,如果 s08 的关键词是 “压缩”,那么 s09 的关键词就是 “沉淀”。压缩保证当前会话顺利进行,沉淀保证未来会话不再从零开始。这一步,不仅完善了 Agent 的长期运行能力,也为后续章节的协作、多 Agent 系统和复杂任务管理奠定了坚实的知识基础。

下篇文章我们将来学习新版教程 s10 System Prompt 章节的内容,敬请期待🤗

参考

Logo

中国智能体开发者社区,聚焦智能体与大模型开发,提供前沿资讯、实用工具链、开源项目及行业案例。通过技术沙龙、开发者大赛等活动,促进经验交流与协作,助力开发者快速构建创新智能应用。

更多推荐