mirror of
https://github.com/shareAI-lab/analysis_claude_code.git
synced 2026-09-20 12:13:38 +08:00
refactor: streamline the course to 17 lessons
This commit is contained in:
@@ -1,50 +1,39 @@
|
||||
# s09: Memory — 压缩会丢细节,要有一层不丢的
|
||||
# s09: Memory — 让重要信息跨会话保留下来
|
||||
|
||||
[English](README.md) · [中文](README.zh.md) · [日本語](README.ja.md)
|
||||
|
||||
s01 → ... → s07 → s08 → `s09` → [s10](../s10_system_prompt/) → s11 → ... → s18 → s19
|
||||
> *"压缩会丢细节, 要有一层不丢的"* — 文件仓库 + 索引 + 按需加载,跨压缩、跨会话。
|
||||
s01 → ... → s07 → s08 → `s09` → [s10](../s10_task_system/) → s11 → ... → s16 → s17
|
||||
> *"把以后还会用到的信息留下来。"* 文件存储 + 索引 + 相关性选择 + 按需召回。
|
||||
>
|
||||
> **Harness 层**: 记忆 — 跨压缩、跨会话的知识积累。
|
||||
> **Harness 层**:Memory 在会话之外保存可复用知识,并在相关任务中取回。
|
||||
|
||||
---
|
||||
|
||||
## 问题
|
||||
|
||||
s08 的 `compact_history` 会把当前目标、剩余工作、用户约束写进摘要,但细节会丢失:"用 tab 缩进不要用空格"可能被简化成"用户有代码风格偏好"。而且新开一个会话,连摘要也没了。
|
||||
Agent 开始新会话时,`messages` 里没有上一次的对话。用户之前说过的编码偏好、项目背景和排查线索,下次任务还可能用到。没有持久存储,这些信息只能由用户重新说一遍。
|
||||
|
||||
LLM 没有持久状态,所有信息都在上下文窗口里。上下文满了要压缩,压缩就有损。需要一层不参与压缩、跨会话保留的存储。
|
||||
|
||||
---
|
||||
|
||||
## 解决方案
|
||||
把完整 transcript 留下来适合归档,却不适合每次都发给模型。对话会越来越长,当前任务需要的信息很难定位,旧事实也可能已经过期。Memory 要解决的是两个问题:哪些信息值得跨会话保存,以及当前任务应该取回哪几条。
|
||||
|
||||

|
||||
|
||||
s08 的压缩管线保留,聚焦记忆。存储选文件系统:`.memory/` 目录下,每个记忆一个 `.md` 文件,带 YAML frontmatter(`name` / `description` / `type`)。文件多了需要索引:`MEMORY.md` 一行一个链接,注入 SYSTEM。
|
||||
|
||||
关键设计:索引常驻 SYSTEM prompt(可被 prompt cache 缓存),文件内容按需注入到当前 user turn(按 filename/description 匹配当前对话,不破坏 cache)。写入由每轮结束后的提取器完成:用户显式说"记住"或表达稳定偏好时,提取器会保存为记忆。文件积累多了,定期整理去重。
|
||||
|
||||
> **与 s08 的边界:** 压缩仍负责当前对话和 token 预算;记忆不会取代压缩管线,而是把选中的事实存到对话之外,并在之后按需召回。
|
||||
|
||||
四类记忆,各有用途:
|
||||
|
||||
| 类型 | 回答什么 | 示例 |
|
||||
|------|---------|------|
|
||||
| user | 你是谁 | "用 tab 不用空格" |
|
||||
| feedback | 怎么做事 | "别 mock 数据库" |
|
||||
| project | 正在发生什么 | "auth 重写是合规驱动" |
|
||||
| reference | 东西在哪找 | "pipeline bug 在 Linear INGEST" |
|
||||
|
||||
---
|
||||
|
||||
## 工作原理
|
||||
## 全部写进 system prompt,为什么不合适
|
||||
|
||||
最直接的做法,是把用户偏好和项目事实写进一个固定文件,启动时全部放进 system prompt。这样确实能够记住信息,但每次调用 LLM 都要重新发送全部内容。记忆越多,与当前任务无关的内容就越多,输入 token 和上下文窗口也会被持续占用。
|
||||
|
||||
s07 已经展示过一种更合适的读取方式:保留简短索引,只在需要时加载正文。Skill 由人编写并保持只读;Memory 则允许 Agent 从对话中提取内容,并在后续任务中再次使用。
|
||||
|
||||
因此,本章需要处理四件事:存储、召回、提取和整理。
|
||||
|
||||

|
||||
|
||||
### 存储:Markdown 文件 + 索引
|
||||
---
|
||||
|
||||
每个记忆是一个 `.md` 文件,YAML frontmatter 记录元数据:
|
||||
## 存储:一个记忆一个文件
|
||||
|
||||
每条记忆是 `.memory/` 下的一个 Markdown 文件,YAML frontmatter 记录 `name`、`description` 和 `type`:
|
||||
|
||||
```markdown
|
||||
---
|
||||
@@ -54,116 +43,125 @@ type: user
|
||||
---
|
||||
|
||||
User prefers using tabs, not spaces, for indentation.
|
||||
**Why:** Consistency with existing codebase conventions.
|
||||
**How to apply:** Always use tabs when writing or editing files.
|
||||
```
|
||||
|
||||
`MEMORY.md` 是索引,一行一个链接:
|
||||
`type` 有四类:
|
||||
|
||||
```markdown
|
||||
- [user-preference-tabs](user-preference-tabs.md) — User prefers tabs for indentation
|
||||
```
|
||||
| 类型 | 保存什么 | 示例 |
|
||||
|------|---------|------|
|
||||
| user | 用户的长期偏好 | “使用 tab 缩进” |
|
||||
| feedback | 以后仍适用的工作反馈 | “不要 mock 数据库” |
|
||||
| project | 稳定的项目事实 | “认证重写由合规要求驱动” |
|
||||
| reference | 外部资料或查找线索 | “流水线问题记录在 Linear INGEST” |
|
||||
|
||||
写入新记忆时自动重建索引:
|
||||
`MEMORY.md` 是索引,每行对应一个记忆文件。写入完成后,`rebuild_memory_index()` 根据文件重新生成索引:
|
||||
|
||||
```python
|
||||
def write_memory_file(name, mem_type, description, body):
|
||||
slug = name.lower().replace(" ", "-")
|
||||
filepath = MEMORY_DIR / f"{slug}.md"
|
||||
filepath.write_text(
|
||||
f"---\nname: {name}\ndescription: {description}\ntype: {mem_type}\n---\n\n{body}\n"
|
||||
)
|
||||
_rebuild_index()
|
||||
path = MEMORY_DIR / f"{memory_slug(name)}.md"
|
||||
path.write_text(memory_document(name, mem_type, description, body))
|
||||
rebuild_memory_index()
|
||||
return path
|
||||
```
|
||||
|
||||
### 加载:两条路径
|
||||
|
||||
**路径一:索引常驻 SYSTEM。** `build_system()` 在每次用户请求开始时读取 `MEMORY.md`,把记忆清单注入。记忆提取和整理只在本轮结束时触发,因此同一轮用户请求中不需要重复重建 SYSTEM。
|
||||
|
||||
**路径二:相关记忆按需注入。** 每次用户请求开始时,`load_memories()` 把最近对话和记忆目录(name + description)一起发给 LLM 做一次轻量 side-query,选出相关的文件名,再读文件内容临时注入到当前 user turn。最多 5 条,控制开销。
|
||||
|
||||
```python
|
||||
def select_relevant_memories(messages, max_items=5):
|
||||
files = list_memory_files()
|
||||
if not files:
|
||||
return []
|
||||
|
||||
# Build catalog: "0: user-preference-tabs — User prefers tabs..."
|
||||
catalog = "\n".join(f"{i}: {f['name']} — {f['description']}" for i, f in enumerate(files))
|
||||
|
||||
response = client.messages.create(model=MODEL, messages=[{"role": "user",
|
||||
"content": f"Select relevant memory indices. Return JSON array.\n\n"
|
||||
f"Recent conversation:\n{recent}\n\nMemory catalog:\n{catalog}"}],
|
||||
max_tokens=200)
|
||||
text = extract_text(response.content).strip()
|
||||
indices = json.loads(re.search(r'\[.*?\]', text).group())
|
||||
return [files[i]["filename"] for i in indices if 0 <= i < len(files)]
|
||||
```
|
||||
|
||||
如果 side-query 失败(API 错误、JSON 解析失败),降级到关键词匹配 name + description。
|
||||
|
||||
### 写入:每轮结束后提取
|
||||
|
||||
用户不会每次都说"记住这个"。偏好通常散落在正常对话中:"用 tab 比空格好"、"以后都用单引号"。
|
||||
|
||||
`extract_memories()` 在每轮结束时运行,条件是模型停止且没有 tool_use(说明对话告一段落):
|
||||
|
||||
```python
|
||||
# In agent_loop:
|
||||
if response.stop_reason != "tool_use":
|
||||
extract_memories(pre_compress) # 从压缩前快照提取新记忆
|
||||
consolidate_memories() # 检查是否需要整理
|
||||
return
|
||||
```
|
||||
|
||||
提取前先检查已有记忆,避免重复。提取 prompt 要求 LLM 返回 `{name, type, description, body}` 的 JSON 数组,只有确实有新信息时才写文件。
|
||||
|
||||
```python
|
||||
def extract_memories(messages):
|
||||
dialogue = format_recent_messages(messages[-10:])
|
||||
existing = "\n".join(f"- {m['name']}: {m['description']}" for m in list_memory_files())
|
||||
|
||||
prompt = (
|
||||
"Extract user preferences, constraints, or project facts.\n"
|
||||
"Return JSON array: [{name, type, description, body}].\n"
|
||||
"If nothing new or already covered, return [].\n\n"
|
||||
f"Existing memories:\n{existing}\n\nDialogue:\n{dialogue[:4000]}"
|
||||
)
|
||||
# ... parse response, write files ...
|
||||
```
|
||||
|
||||
### 整理:低频合并去重
|
||||
|
||||
记忆文件会积累。`consolidate_memories()` 在文件数达到阈值(默认 10)时触发,让 LLM 去重、合并矛盾、淘汰过时记忆:
|
||||
|
||||
```python
|
||||
CONSOLIDATE_THRESHOLD = 10
|
||||
|
||||
def consolidate_memories():
|
||||
files = list_memory_files()
|
||||
if len(files) < CONSOLIDATE_THRESHOLD:
|
||||
return # 太少,不值得整理
|
||||
# Send all memories to LLM, get back deduplicated list
|
||||
# Replace all files with consolidated results
|
||||
```
|
||||
|
||||
### Memory 适合保存什么
|
||||
|
||||
Memory 保存跨会话仍然有用的信息:用户偏好、反复出现的反馈、项目背景、常用入口和排查线索。它关注“以后还会用到什么”,并通过索引 + 按需加载把这些信息带回当前对话。
|
||||
|
||||
session memory 关注同一会话内的连续性:compact 之后,当前会话还需要保留哪些上下文。两者配合使用:Memory 管长期知识,session memory 管当前会话的压缩续接。
|
||||
索引用于选择相关记忆,正文仍然保存在各自的文件中。
|
||||
|
||||
---
|
||||
|
||||
## 相对 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) |
|
||||
| 循环 | 每轮只做压缩 | 每轮注入记忆 + 压缩 + 每轮结束后提取 + 定期整理 |
|
||||
每次用户发起请求时,`select_relevant_memories()` 读取最近的用户消息和记忆目录,让一次轻量模型调用选择最多五条相关记录:
|
||||
|
||||
```python
|
||||
prompt = (
|
||||
"Select memory records that are relevant to the current user request. "
|
||||
"Return only a JSON array of catalog indices, such as [0, 2]. "
|
||||
"Return [] when none are relevant."
|
||||
)
|
||||
```
|
||||
|
||||
如果模型调用或 JSON 解析失败,代码会退回关键词匹配。选择完成后,`load_memories()` 才读取对应文件,并限制召回正文的总长度。
|
||||
|
||||
```python
|
||||
relevant_memories = load_memories(messages)
|
||||
system = build_system(relevant_memories)
|
||||
```
|
||||
|
||||
`build_system()` 会明确说明:召回内容只是背景知识,不是新的用户命令;如果记忆与当前请求冲突,以当前请求为准。这样既能使用旧信息,也不会让旧记忆替用户发号施令。
|
||||
|
||||
---
|
||||
|
||||
## 提取:回合结束后保存可复用信息
|
||||
|
||||
用户不一定会明确说“请记住”。`extract_memories()` 在 Agent 完成本轮回答后检查当前对话,只提取以后仍可能有用的信息:
|
||||
|
||||
```python
|
||||
if response.stop_reason != "tool_use":
|
||||
force = trigger_hooks("Stop", messages)
|
||||
if force:
|
||||
messages.append({"role": "user", "content": force})
|
||||
continue
|
||||
if extract_memories(messages):
|
||||
consolidate_memories()
|
||||
return
|
||||
```
|
||||
|
||||
模型返回的内容只是候选,不会直接写盘。候选必须带有 `scope`:只有 `persistent` 才表示它应当跨会话保留;`current_task` 表示本次任务的命令、临时路径和临时限制。
|
||||
|
||||
`should_store_memory()` 负责最后的检查。字段不完整、带有“本次会话”或“当前任务”等临时含义、或者与已有记忆重复的候选都会被拒绝。比如“这次不要创建文件”只约束当前任务,不应该在下次会话中继续生效。
|
||||
|
||||
---
|
||||
|
||||
## 整理:合并重复和过期内容
|
||||
|
||||
记忆文件积累到一定数量后,内容可能重复、矛盾或过期。教学实现达到 10 条时调用 `consolidate_memories()`,让模型生成一份整理后的记录列表。
|
||||
|
||||
整理过程先解析并校验新列表,再替换旧文件。替换前会保存快照;删除或写入失败时,代码恢复原文件并重建索引:
|
||||
|
||||
```python
|
||||
snapshot = {
|
||||
path.name: path.read_text()
|
||||
for path in MEMORY_DIR.glob("*.md")
|
||||
if path.name != MEMORY_INDEX.name
|
||||
}
|
||||
|
||||
try:
|
||||
for path in MEMORY_DIR.glob("*.md"):
|
||||
if path.name != MEMORY_INDEX.name:
|
||||
path.unlink()
|
||||
for record in consolidated:
|
||||
path = MEMORY_DIR / f"{memory_slug(record['name'])}.md"
|
||||
path.write_text(memory_document(
|
||||
record["name"], record["type"],
|
||||
record["description"], record["body"],
|
||||
))
|
||||
rebuild_memory_index()
|
||||
except Exception:
|
||||
for path in MEMORY_DIR.glob("*.md"):
|
||||
if path.name != MEMORY_INDEX.name:
|
||||
path.unlink()
|
||||
for filename, content in snapshot.items():
|
||||
(MEMORY_DIR / filename).write_text(content)
|
||||
rebuild_memory_index()
|
||||
raise
|
||||
```
|
||||
|
||||
课程代码把整理触发条件简化为数量阈值。真实应用还需要根据数据规模和并发方式,决定何时整理以及如何避免多个进程同时改写同一份存储。
|
||||
|
||||
---
|
||||
|
||||
## 本节代码
|
||||
|
||||
| 组成 | 本节实现 |
|
||||
|------|---------|
|
||||
| Agent Loop | 保留消息、工具调用、工具结果和 hooks 触发点 |
|
||||
| 基础工具 | `bash`、`read_file`、`write_file`、`edit_file`、`glob` |
|
||||
| 存储 | `.memory/MEMORY.md` 索引 + `.memory/*.md` 文件 |
|
||||
| 召回 | 目录选择 + 关键词降级 + 正文长度上限 |
|
||||
| 写入 | 回合结束后提取 + 持久性检查 + 重复过滤 |
|
||||
| 整理 | 达到阈值后合并,失败时恢复原文件 |
|
||||
|
||||
> **与 s08 的边界:** s08 管理当前会话的上下文预算,s09 管理会话之外的可复用知识。Memory 是选择性存储,不是 transcript 的无损备份,也不会取代上下文压缩。
|
||||
|
||||
---
|
||||
|
||||
@@ -174,22 +172,19 @@ cd learn-claude-code
|
||||
python s09_memory/code.py
|
||||
```
|
||||
|
||||
试试这些 prompt(分多轮输入,观察记忆的累积和加载):
|
||||
1. 输入 `I prefer using tabs for indentation. Remember that.`,结束后检查 `.memory/` 是否新增记忆文件,`MEMORY.md` 是否出现对应索引;
|
||||
2. 输入 `q` 退出并重新运行程序,再问 `What indentation style do I prefer?`,确认新会话能够召回这条偏好;
|
||||
3. 再保存一条与代码格式无关的偏好,然后询问缩进问题,观察当前请求只加载相关记忆;
|
||||
4. 输入 `Do not create files in this session.`,确认这条临时要求不会成为下一次会话的持久规则。
|
||||
|
||||
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.`
|
||||
|
||||
观察重点:每轮结束后是否出现 `[Memory: extracted N new memories]`?`.memory/` 目录下是否生成了 `.md` 文件?`MEMORY.md` 索引是否更新?新一轮对话时 Agent 是否自动加载了之前的记忆?
|
||||
模型的具体措辞和提取数量可能变化,判断重点是 `.memory/` 中保存了什么,以及新会话是否只取回相关内容。
|
||||
|
||||
---
|
||||
|
||||
## 接下来
|
||||
|
||||
记忆、压缩、工具都已就绪。但 system prompt 还是硬编码的一大段字符串。加了新工具要手动加描述,换了项目要重写整个 prompt。prompt 应该运行时组装。
|
||||
Memory 解决了跨会话保留信息的问题,但复杂任务还需要记录每一步的状态和依赖关系。仅靠对话中的 TODO,程序退出后就无法继续追踪进度。
|
||||
|
||||
s10 System Prompt → 分段 + 运行时组装。不同项目、不同工具,拼出不同的 prompt。
|
||||
s10 Task System → 把任务、状态和依赖关系保存到磁盘。
|
||||
|
||||
|
||||
<!-- translation-sync: zh@v1, en@v1, ja@v1 -->
|
||||
<!-- translation-sync: zh@v3, en@v3, ja@v3 -->
|
||||
|
||||
Reference in New Issue
Block a user