Files
analysis_claude_code/s08_context_compact/README.ja.md
2026-07-31 03:15:58 +08:00

233 lines
13 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# s08: Context Compact — コンテキストはいつか満杯になる、場所を空ける方法が必要
[English](README.md) · [中文](README.zh.md) · [日本語](README.ja.md)
s01 → s02 → s03 → s04 → s05 → s06 → s07 → `s08` → [s09](../s09_memory/) → s10 → ... → s20 → s21
> *"Context will fill up — have a way to make room"* — 4層圧縮戦略、安価なものを先に、高価なものを後に実行。
>
> **Harness レイヤー**: 圧縮 — クリーンな記憶、無限のセッション。
---
## 課題
Agent が動いている途中で、止まってしまう。
bash、read、write は揃っており、能力は十分。しかし 1000 行のファイル(~4000 tokenを読み、さらに 30 のファイルを読み、20 のコマンドを実行したとします。各コマンドの出力、各ファイルの内容がすべて `messages` リストに蓄積されます。
コンテキストウィンドウには上限があります。満杯になると、API は即座に拒否します:`prompt_too_long`
圧縮しなければ、Agent は大規模プロジェクトではまともに動けません。
---
## ソリューション
![Compact Overview](images/compact-overview.ja.svg)
s07 のフック構造、スキルロード、サブ Agent の骨格を維持し、圧縮に焦点を当てるため一部のツールは省略。コアの変更点:各 LLM 呼び出し前に 3 層のプリプロセッサ0 APIを挿入し、token が閾値を超えた場合は LLM 要約1 APIをトリガー、API エラー時には緊急トリムを実行。
コア設計:安価なものを先に、高価なものを後に。
> **s09 との境界:** s08 は現在のセッションの有限なコンテキストを管理し、圧縮では詳細を失うことがある。s09 は圧縮後や将来のセッションにも残す情報だけを別の永続ストアに保存する。異なる障害を解くため、別のセッションとして扱う。
---
## 仕組み
![4層圧縮パイプライン](images/compaction-layers.ja.svg)
### L1: snip_compact — 無関係な古い会話を切り捨て
Agent が 80 ラウンドの会話を実行し、`messages` が 160 件まで溜まった。先頭の「hello.py を作って」は現在の作業とほぼ無関係だが、スペースを占有し続けている。
メッセージ数が 50 を超えた場合 → 先頭 3 件(初期コンテキスト)と末尾 47 件(現在の作業)を保持して中間を切り詰める。ただし切れ目だけは調整し、`assistant(tool_use)` と後続の `user(tool_result)` を分断しない:
```python
def snip_compact(messages, max_messages=50):
if len(messages) <= max_messages:
return messages
head_end, tail_start = 3, len(messages) - (max_messages - 3)
if head_end > 0 and _message_has_tool_use(messages[head_end - 1]):
while head_end < len(messages) and _is_tool_result_message(messages[head_end]):
head_end += 1
if (tail_start > 0 and tail_start < len(messages)
and _is_tool_result_message(messages[tail_start])
and _message_has_tool_use(messages[tail_start - 1])):
tail_start -= 1
snipped = tail_start - head_end
placeholder = {"role": "user", "content": f"[snipped {snipped} messages from conversation middle]"}
return messages[:head_end] + [placeholder] + messages[tail_start:]
```
切り捨て自体は単純なままで、境界だけを保護する。残ったメッセージ内の `tool_result` 内容はまだ蓄積され続けている。34 番目のメッセージに 30KB の古いファイル内容が残っているかもしれない。→ L2。
### L2: micro_compact — 古いツール結果をプレースホルダに置換
![古い結果のプレースホルダ](images/micro-compact.ja.svg)
Agent が連続して 10 個のファイルを読んだ。1〜7 回目の完全な内容はまだコンテキストに残っており、もう不要だが、大量のスペースを占有している。
直近 3 件の `tool_result` の完全な内容のみを保持し、それより古いものは 1 行のプレースホルダに置換:
```python
KEEP_RECENT_TOOL_RESULTS = 3
def micro_compact(messages):
tool_results = collect_tool_result_blocks(messages)
if len(tool_results) <= KEEP_RECENT_TOOL_RESULTS:
return messages
for _, _, block in tool_results[:-KEEP_RECENT_TOOL_RESULTS]:
if len(block.get("content", "")) > 120:
block["content"] = "[Earlier tool result compacted. Re-run if needed.]"
return messages
```
古い結果はクリーンアップされたが、1 件の新しい結果だけで 500KB の可能性がある。大きなファイルを `cat` するだけでコンテキストがいっぱいになる。→ L3。
### L3: tool_result_budget — 大きな結果をディスクに退避
![大きな結果のディスク退避](images/layer1-budget.ja.svg)
モデルが一度に 5 つの大きなファイルを読み、1 つの user メッセージ内の全 `tool_result` の合計が 500KB に達した。
最後の user メッセージ内のすべての `tool_result` の合計サイズを集計。200KB を超えた場合 → サイズ順にソートし、最大のものから順に `.task_outputs/tool-results/` に退避。コンテキストには `<persisted-output>` マーカー + 先頭 2000 文字のプレビューのみを残す。モデルはマーカーを見て完全な内容がディスク上にあることを認識し、必要に応じて再読み込みできる。
```python
def tool_result_budget(messages, max_bytes=200_000):
last = messages[-1]
blocks = [(i, b) for i, b in enumerate(last["content"])
if b.get("type") == "tool_result"]
total = sum(len(str(b.get("content", ""))) for _, b in blocks)
if total <= max_bytes:
return messages
ranked = sorted(blocks, key=lambda p: len(str(p[1].get("content", ""))), reverse=True)
for idx, block in ranked:
if total <= max_bytes:
break
block["content"] = persist_large_output(block["tool_use_id"], str(block["content"]))
total = recalculate_total(blocks)
return messages
```
最初の 3 層はすべて純粋なテキスト/構造操作0 API 呼び出し)だが、会話内容を「理解」することはできない。コンテキストがまだ大きすぎる可能性がある。→ L4。
### L4: compact_history — LLM 全量要約
![LLM 全量要約](images/auto-compact.ja.svg)
最初の 3 層がすべて実行されたが、超大規模プロジェクトで 30 分間連続作業すると、token がまだ閾値を超えている。
3 ステップのフロー:
1. **transcript を保存**:完全な会話を `.transcripts/` に JSONL 形式で書き出す。transcript は完全な記録を保持する。メッセージリストには要約だけが残り、元の詳細は以降のモデル呼び出しに入らない。
2. **LLM で要約を生成**:会話履歴を LLM に送り、現在の目標、重要な発見、変更済みファイル、残りの作業、ユーザーの制約などの重要な情報を保持するよう指示。
3. **メッセージリストを置換**:すべての古いメッセージを 1 件の要約に置き換える。
```python
def compact_history(messages):
transcript_path = write_transcript(messages) # 先に完全な会話を保存
summary = summarize_history(messages) # LLM で要約を生成
return [{"role": "user",
"content": f"[Compacted]\n\n{summary}"}]
```
**サーキットブレーカー**:連続 3 回失敗したらリトライを停止し、無限ループによる API 呼び出しの浪費を防止。
### 緊急: reactive_compact
API がまだ `prompt_too_long`413を返すことがある。コンテキストの増加速度が圧縮のトリガー速度を上回る場合。
この時 **reactive_compact** がトリガーされる。トリガー方式は compact_history より積極的413 エラー後の緊急対応)だが、圧縮方針はより温和で、末尾約 5 件のメッセージを保持し、早期の履歴だけを要約する。孤立した `tool_result` を残さないよう配慮する。
```python
def reactive_compact(messages):
transcript = write_transcript(messages)
tail_start = max(0, len(messages) - 5)
if (tail_start > 0 and tail_start < len(messages)
and _is_tool_result_message(messages[tail_start])
and _message_has_tool_use(messages[tail_start - 1])):
tail_start -= 1
summary = summarize_history(messages[:tail_start])
return [{"role": "user",
"content": f"[Reactive compact]\n\n{summary}"}, *messages[tail_start:]]
```
reactive compact にはリトライ上限がある(デフォルト 1 回)。さらに失敗した場合は例外をスローし、無限ループしない。完全なエラー回復ロジックは s11 に委ねる。
### 合わせて実行
```python
def agent_loop(messages):
reactive_retries = 0
while True:
# 3 つのプリプロセッサ0 API 呼び出し)
# 順序budget を先に実行し、大きな内容をプレースホルダ化する前に退避
messages[:] = tool_result_budget(messages) # L3: 大きな結果を退避
messages[:] = snip_compact(messages) # L1: 中間を切り捨て
messages[:] = micro_compact(messages) # L2: 古い結果をプレースホルダに
# まだ足りないLLM 要約1 API 呼び出し)
if estimate_token_count(messages) > THRESHOLD:
messages[:] = compact_history(messages)
try:
response = client.messages.create(...)
except PromptTooLongError:
if reactive_retries < MAX_REACTIVE_RETRIES:
messages[:] = reactive_compact(messages) # 緊急対応
reactive_retries += 1
continue
raise # リトライ上限超過、例外をスロー
# ... ツール実行 ...
# compact ツールモデルが能動的に呼び出した場合、compact_history をトリガー
if block.name == "compact":
messages[:] = compact_history(messages)
results.append({..., "content": "[Compacted. History summarized.]"})
messages.append({"role": "user", "content": results})
break # 現在のターンを終了し、圧縮後のコンテキストで新しく開始
```
**順序は変えられない。** L3budgetは L2microより先に実行する。micro が古い大きな `tool_result` を 1 行のプレースホルダに置き換える前に、budget が完全な内容を保存する必要があるためだ。
---
## s07 からの変更点
| コンポーネント | 変更前 (s07) | 変更後 (s08) |
|------|-----------|-----------|
| コンテキスト管理 | なし(コンテキストが無限に膨張) | 4 層圧縮パイプライン + 緊急対応 |
| 新規関数 | — | snip_compact, micro_compact, tool_result_budget, compact_history, reactive_compact |
| ツール | bash, read_file, write_file, edit_file, glob, todo_write, task, load_skill (8) | 8 + compact (9) |
| ループ | LLM 呼び出し → ツール実行 | 各ラウンド前に 3 層プリプロセッサを実行 + 閾値で compact_history をトリガー |
| 設計原則 | — | 安価なものを先に、高価なものを後に |
---
## 試してみよう
```sh
cd learn-claude-code
python s08_context_compact/code.py
```
以下のプロンプトを試してみてください:
1. `Read the file README.md, then read code.py, then read s01_agent_loop/README.md`連続して複数のファイルを読み、L2 の古い結果圧縮を観察)
2. `Read every file in s08_context_compact/`一度に大量の内容を読み込み、L3 のディスク退避を観察)
3. 20+ ラウンドの対話を繰り返し、`[auto compact]` または `[reactive compact]` が表示されるか観察
観察のポイント:ツール実行のたびに、古い tool_result は圧縮されているか?連続対話で token が閾値を超えたとき、要約が自動的にトリガーされたか?
---
## 次へ
コンテキスト圧縮により、Agent は長時間クラッシュせずに動けるようになった。しかし、圧縮のたびにユーザーが以前に伝えた偏好や制約も一緒に失われてしまう。Agent が重要なことを選択的に記憶できるようにできないか?
s09 Memory → 3 つのサブシステム:何を記憶するかの選択、重要情報の抽出、整理と統合。圧縮を越え、セッションを越えて。
<!-- translation-sync: zh@v2, en@v2, ja@v2 -->