13 KiB
s08: Context Compact — コンテキストはいつか満杯になる、場所を空ける方法が必要
s01 → s02 → s03 → s04 → s05 → s06 → s07 → s08 → s09 → 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 は大規模プロジェクトではまともに動けません。
ソリューション
s07 のフック構造、スキルロード、サブ Agent の骨格を維持し、圧縮に焦点を当てるため一部のツールは省略。コアの変更点:各 LLM 呼び出し前に 3 層のプリプロセッサ(0 API)を挿入し、token が閾値を超えた場合は LLM 要約(1 API)をトリガー、API エラー時には緊急トリムを実行。
コア設計:安価なものを先に、高価なものを後に。
s09 との境界: s08 は現在のセッションの有限なコンテキストを管理し、圧縮では詳細を失うことがある。s09 は圧縮後や将来のセッションにも残す情報だけを別の永続ストアに保存する。異なる障害を解くため、別のセッションとして扱う。
仕組み
L1: snip_compact — 無関係な古い会話を切り捨て
Agent が 80 ラウンドの会話を実行し、messages が 160 件まで溜まった。先頭の「hello.py を作って」は現在の作業とほぼ無関係だが、スペースを占有し続けている。
メッセージ数が 50 を超えた場合 → 先頭 3 件(初期コンテキスト)と末尾 47 件(現在の作業)を保持して中間を切り詰める。ただし切れ目だけは調整し、assistant(tool_use) と後続の user(tool_result) を分断しない:
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 — 古いツール結果をプレースホルダに置換
Agent が連続して 10 個のファイルを読んだ。1〜7 回目の完全な内容はまだコンテキストに残っており、もう不要だが、大量のスペースを占有している。
直近 3 件の tool_result の完全な内容のみを保持し、それより古いものは 1 行のプレースホルダに置換:
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 — 大きな結果をディスクに退避
モデルが一度に 5 つの大きなファイルを読み、1 つの user メッセージ内の全 tool_result の合計が 500KB に達した。
最後の user メッセージ内のすべての tool_result の合計サイズを集計。200KB を超えた場合 → サイズ順にソートし、最大のものから順に .task_outputs/tool-results/ に退避。コンテキストには <persisted-output> マーカー + 先頭 2000 文字のプレビューのみを残す。モデルはマーカーを見て完全な内容がディスク上にあることを認識し、必要に応じて再読み込みできる。
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 全量要約
最初の 3 層がすべて実行されたが、超大規模プロジェクトで 30 分間連続作業すると、token がまだ閾値を超えている。
3 ステップのフロー:
- transcript を保存:完全な会話を
.transcripts/に JSONL 形式で書き出す。transcript は完全な記録を保持する。メッセージリストには要約だけが残り、元の詳細は以降のモデル呼び出しに入らない。 - LLM で要約を生成:会話履歴を LLM に送り、現在の目標、重要な発見、変更済みファイル、残りの作業、ユーザーの制約などの重要な情報を保持するよう指示。
- メッセージリストを置換:すべての古いメッセージを 1 件の要約に置き換える。
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 を残さないよう配慮する。
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 に委ねる。
合わせて実行
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 # 現在のターンを終了し、圧縮後のコンテキストで新しく開始
順序は変えられない。 L3(budget)は L2(micro)より先に実行する。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 をトリガー |
| 設計原則 | — | 安価なものを先に、高価なものを後に |
試してみよう
cd learn-claude-code
python s08_context_compact/code.py
以下のプロンプトを試してみてください:
Read the file README.md, then read code.py, then read s01_agent_loop/README.md(連続して複数のファイルを読み、L2 の古い結果圧縮を観察)Read every file in s08_context_compact/(一度に大量の内容を読み込み、L3 のディスク退避を観察)- 20+ ラウンドの対話を繰り返し、
[auto compact]または[reactive compact]が表示されるか観察
観察のポイント:ツール実行のたびに、古い tool_result は圧縮されているか?連続対話で token が閾値を超えたとき、要約が自動的にトリガーされたか?
次へ
コンテキスト圧縮により、Agent は長時間クラッシュせずに動けるようになった。しかし、圧縮のたびにユーザーが以前に伝えた偏好や制約も一緒に失われてしまう。Agent が重要なことを選択的に記憶できるようにできないか?
s09 Memory → 3 つのサブシステム:何を記憶するかの選択、重要情報の抽出、整理と統合。圧縮を越え、セッションを越えて。