refactor: streamline the course to 17 lessons

This commit is contained in:
Haoran
2026-08-12 03:02:42 +08:00
parent ab35e59672
commit 7e2f2fd99b
250 changed files with 12179 additions and 18653 deletions

View File

@@ -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
> *「後のタスクでも使う情報を残す。」* ファイル保存 + index + 関連性の選択 + 必要時の recall
>
> **Harness レイヤー**: 記憶 — 圧縮とセッションを越える知識の蓄積
> **Harness レイヤー**Memory は会話の外に再利用できる知識を保存し、関係するタスクで取り出す
---
##
##
s08 の `compact_history` は現在の目標、残りの作業、ユーザーの制約をサマリに保持するが、詳細は失われる:「タブでインデント、スペース不可」が「ユーザーにコードスタイルの好みあり」と簡略化される。そして新しいセッションを開始すると、サマリすらない。
Agent が新しい session を始めると、`messages` に前回の会話はない。以前に伝えられた coding preference、project の背景、調査の手がかりは、次のタスクでも必要になることがある。永続的な保存先がなければ、ユーザーは同じ情報をもう一度伝えなければならない。
LLM には永続状態がなく、すべての情報はコンテキストウィンドウ内にある。コンテキストが満杯になれば圧縮され、圧縮は非可逆。圧縮に参加せず、セッションを越えて保持されるストレージ層が必要
---
## ソリューション
完全な transcript は記録には向いているが、毎回モデルへ送る方法は長続きしない。会話は増え続け、必要な情報を見つけにくくなり、古い事実が現在も正しいとは限らない。Memory が判断するのは、どの情報を session を越えて保存するか、現在のタスクでどの記録を取り出すかだ
![Memory Overview](images/memory-overview.ja.svg)
s08 の圧縮パイプラインを維持し、記憶に焦点を当てる。ストレージにはファイルシステムを採用:`.memory/` ディレクトリに各記憶を `.md` ファイルとして保存、YAML frontmatter`name` / `description` / `type`)付き。ファイルが増えたらインデックスが必要:`MEMORY.md` に 1 行 1 リンクを記録し、SYSTEM に注入。
重要な設計:インデックスは SYSTEM prompt に常駐prompt cache でキャッシュ可能、ファイル内容はオンデマンド注入filename/description で現在の会話にマッチ、cache を破壊しない)。書き込みは 2 つのパス:ユーザーが明示的に「覚えて」と言うか、毎ターン終了後にバックグラウンドで抽出。ファイルが蓄積されたら、定期的に整理して重複排除。
> **s08 との境界:** 圧縮は引き続き現在の会話と token 予算を担当する。記憶は圧縮を置き換えず、選んだ事実を会話の外に保存し、後から必要に応じて呼び戻す。
4 種類の記憶、それぞれ異なる質問に答える:
| タイプ | 何に答えるか | 例 |
|--------|-------------|-----|
| user | あなたは誰か | "タブでスペース不可" |
| feedback | どう作業するか | "DB をモックしない" |
| project | 何が起きているか | "auth 書き直しはコンプライアンス主導" |
| reference | どこで探すか | "パイプラインのバグは Linear INGEST" |
---
## 仕組み
## すべて system prompt に入れる方法が適さない理由
最も直接的な方法は、ユーザーの好みや project の事実を一つのファイルへ書き、起動時に全文を system prompt へ入れることだ。情報は残るが、LLM を呼ぶたびに全量を送り直す必要がある。記憶が増えるほど、現在のタスクと関係ない内容が input token と context を占有する。
s07 は別の読み方を示した。短い index を置き、必要なときだけ本文を読む。Skill は人が書く read-only の知識であり、Memory は Agent が会話から情報を抽出し、後のタスクで再利用できるようにする。
この章で扱うのは、保存、recall、抽出、整理の四つだ。
![Memory Subsystems](images/memory-subsystems.ja.svg)
### ストレージMarkdown ファイル + インデックス
---
各記憶は `.md` ファイル、YAML frontmatter でメタデータを記録:
## 保存:一つの記憶を一つのファイルへ
各 memory は `.memory/` の Markdown ファイルで、YAML frontmatter に `name``description``type` を持つ。
```markdown
---
@@ -54,141 +43,148 @@ 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` はインデックス、1 行に 1 リンク:
memory type は四種類ある。
```markdown
- [user-preference-tabs](user-preference-tabs.md) — User prefers tabs for indentation
```
| type | 保存する内容 | 例 |
|------|-------------|----|
| user | 長く使うユーザーの好み | 「indent には tab を使う」 |
| feedback | 今後も使える作業上の feedback | 「database を mock しない」 |
| project | 安定した project の事実 | 「認証の書き直しは compliance 要件による」 |
| reference | 外部資料や検索の手がかり | 「pipeline の問題は Linear INGEST にある」 |
新しい記憶を書き込むとインデックスを自動再構築:
`MEMORY.md` は index で、一行が一つの memory ファイルに対応する。書き込み後、`rebuild_memory_index()` がファイルから 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
```
### 読み込み2 つのパス
index は関連する記憶を選ぶために使い、本文は個別ファイルに残す。
**パス 1インデックスを SYSTEM に常駐。** `build_system()` は各ユーザーリクエストの開始時に 1 回だけ `MEMORY.md` を読み込み、記憶カタログを SYSTEM prompt に注入。記憶の抽出と整理はターン終了時にだけ実行されるため、同じユーザーリクエスト内で SYSTEM を繰り返し再構築する必要はない。
---
**パス 2関連記憶をオンデマンド注入。** 各ユーザーリクエストの開始時に、`load_memories()` は最近の会話と記憶カタログname + descriptionを LLM に軽量 side-query として送信し、関連するファイル名を選択、ファイル内容を読み込んで注入。上限 5 件でコストを制御。
## Recall先に選び、その後で本文を読む
ユーザーの request が始まると、`select_relevant_memories()` は最近のユーザー発言と memory catalog を軽量なモデル呼び出しへ渡し、関係する記録を最大五件選ぶ。
```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)
indices = json.loads(re.search(r'\[.*?\]', response.content[0].text).group())
return [files[i]["filename"] for i in indices if 0 <= i < len(files)]
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."
)
```
side-query が失敗した場合API エラー、JSON パース失敗、name + description のキーワードマッチにフォールバック
### 書き込み:毎ターン終了後の抽出
ユーザーが毎回「これを覚えて」と言うわけではない。好みは通常、通常の会話の中に散らばっている:「タブの方がスペースより良い」「これからはシングルクォートにしよう」。
`extract_memories()` は各ターン終了時に実行、モデルが tool_use なしで停止した場合にトリガー(会話が自然な区切りに達したことを示す):
モデル呼び出しまたは JSON parse に失敗したら、keyword matching へ fallback する。選択後にだけ `load_memories()` が対応するファイルを読み、recall する本文の合計長も制限する
```python
relevant_memories = load_memories(messages)
system = build_system(relevant_memories)
```
`build_system()` は、recall した内容が背景知識であり、新しいユーザー command ではないことを明示する。memory と現在の request が矛盾した場合は現在の request を優先する。これにより古い情報は利用できるが、古い記録がユーザーの代わりに命令することはない。
---
## 抽出turn の終了後に再利用できる情報を保存する
ユーザーが毎回「覚えて」と言うとは限らない。Agent が現在の返答を終えた後、`extract_memories()` は会話を確認し、今後も役立つ可能性がある情報だけを取り出す。
```python
# In agent_loop:
if response.stop_reason != "tool_use":
extract_memories(messages) # 最近の会話から新しい記憶を抽出
consolidate_memories() # 整理が必要かチェック
force = trigger_hooks("Stop", messages)
if force:
messages.append({"role": "user", "content": force})
continue
if extract_memories(messages):
consolidate_memories()
return
```
抽出前に既存の記憶を確認し、重複を回避。抽出プロンプトは LLM に `{name, type, description, body}` の JSON 配列を要求、本当に新しい情報がある場合のみファイルに書き込む
モデルの返答は候補であり、そのまま disk へ書く記録ではない。各候補には `scope` があり、`persistent` だけが後の session に残す内容を表す。`current_task` は一回だけの command、一時 path、現在のタスクだけの制約に使う
```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 は 1 つのセッション内の連続性を扱うcompact 後も現在の会話に残すべき文脈を保持する。両者は役割が分かれている。Memory は長期知識を扱い、session memory は現在のセッションを compact 越しにつなぐ。
最後の判定は `should_store_memory()` が行う。field が足りない候補、「この session」「現在の task」のような一時性を含む候補、既存 memory と重複する候補は拒否する。例えば「この session ではファイルを作らない」は現在の作業だけの制約であり、次の session まで有効にしてはいけない。
---
## 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) |
| ループ | 毎ターン圧縮のみ | 記憶注入 + 圧縮 + ターン終了後の抽出 + 定期整理 |
memory ファイルが増えると、重複、矛盾、古い情報が混ざる。学習用実装は 10 件に達すると `consolidate_memories()` を呼び、整理後の記録一覧をモデルに生成させる。
新しい一覧を parse して検証してから旧ファイルを置き換える。置き換え前には現在の記録を snapshot し、削除や書き込みに失敗したら元のファイルを戻して index を再構築する。
```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
```
学習用コードでは件数だけを threshold にする。実際の application では data 量に合う実行時期を選び、複数 process が同じ store を同時に書き換えないようにする必要がある。
---
## 試してみよう
## この章のコード
| 部分 | 実装 |
|------|------|
| Agent Loop | messages、tool call、tool result、hook の trigger point を維持 |
| 基本 tools | `bash``read_file``write_file``edit_file``glob` |
| 保存 | `.memory/MEMORY.md` index + `.memory/*.md` records |
| Recall | catalog の選択 + keyword fallback + 本文サイズ上限 |
| 書き込み | turn 終了後の抽出 + 永続性チェック + 重複除外 |
| 整理 | threshold 到達後に統合し、置き換え失敗時は旧ファイルを復元 |
> **s08 との境界:** s08 は現在の session の context budget を管理し、s09 は会話の外にある再利用可能な知識を管理する。Memory は選択的な保存であり、transcript の lossless backup ではなく、context compaction の代わりにもならない。
---
## 試してみる
```sh
cd learn-claude-code
python s09_memory/code.py
```
以下のプロンプトを試してみてください(複数ターンに分けて入力し、記憶の蓄積と読み込みを観察):
1. `I prefer using tabs for indentation. Remember that.` と入力し、turn の後に `.memory/` へ新しい record が増え、`MEMORY.md` に index entry が作られたか確認する。
2. `q` で終了し、program を再起動して `What indentation style do I prefer?` と聞く。新しい session でも preference を recall できることを確認する。
3. code formatting と関係ない別の preference を保存してから indentation を質問し、現在の request に関係する memory だけが読み込まれるか確認する。
4. `Do not create files in this session.` と入力し、この一時的な条件が次の session の永続ルールにならないことを確認する。
1. `I prefer using tabs for indentation, not spaces. Remember that.`
2. `Create a Python file called test.py`Agent がタブを使用したか観察)
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/` に何が保存されたか、後の session が関係する情報だけを recall したかだ。
---
## 次へ
記憶、圧縮、ツールはすべて揃った。しかし system prompt はまだハードコードされた文字列。新しいツールを追加するには手動で説明を書き、プロジェクトを変えるにはプロンプト全体を書き直す。プロンプトは実行時に組み立てられるべき
Memory は情報をセッション間で保持する。しかし複雑なタスクには、各作業の状態と依存関係も永続的に記録する必要がある。会話内の TODO だけでは、プロセス終了後に進捗を追跡できない
s10 System Prompt → セグメント + 実行時組み立て。異なるプロジェクト、異なるツール、異なるプロンプト
s10 Task System → タスク、状態、依存関係をディスクへ保存する
<!-- translation-sync: zh@v1, en@v1, ja@v1 -->
<!-- translation-sync: zh@v3, en@v3, ja@v3 -->