yoi/docs/compaction.md
2026-04-21 17:39:43 +09:00

10 KiB
Raw Blame History

コンテキスト圧縮 (Compaction)

長時間実行エージェントのコンテキストウィンドウ管理。 Prune段階的な出力除去と Compact履歴の要約置換の2層で対処する。


全体像

[各リクエストの合間]
LLM リクエスト
  ↓
PruneHook (pre_llm_request)
  → 古い ToolResult の content を除去summary は残す)
  → リクエストコンテキストのみ操作、history 本体は不変
  ↓
CompactInterceptor (pre_llm_request)  ← safety net
  → input_tokens > request_threshold なら Yield
  → Worker が WorkerResult::Yielded で正常終了
  ↓
Pod::handle_worker_result
  → persist_turn旧セッションに記録
  → compact() → resume()

[ターンの合間 — Pod::run 完了後]
Controller::try_post_run_compact  ← proactive
  → input_tokens > post_run_threshold なら compact() (best-effort)

Prune

古い ToolResult の contentNone に置換し、summary だけ残す。

設計判断

  • 条件付き実行: 推定トークン節約量が min_savings を超えた場合のみ。KV キャッシュの無駄な無効化を避ける
  • リクエストコンテキストのみ操作: history 本体は変更しない。Prune 状態を Pod が保持し、LLM リクエスト構築時に反映する
  • 冪等: content: None のアイテムはスキップ

ToolOutput の構造

pub struct ToolOutput {
    pub summary: String,          // 常に残る。「何をしたか」の最低限の情報
    pub content: Option<String>,  // Prune 対象。None に置換するだけ
}

Tool::execute()Result<ToolOutput, ToolError> を返す。 From<String> で自動変換可能(小さい出力は summary のみ、大きい出力は summary + content。 巨大な出力はフレームワークの責務外。ツール側がファイルに退避し、content に見取り図を置く。


Compact

用語

  • run = turn: 同じ概念。1 ユーザープロンプト → 完了までの単位
  • リクエスト: 1 turn 内で投げる個別の LLM 呼び出し。ツール使用で 1 turn に複数リクエストが発生する
  • リクエストの合間: 1 turn 内、次の LLM リクエストを投げる前の地点
  • ターンの合間: turn が完了して次の turn を待つ状態

トリガー2段階の閾値

  1. ターンの合間 (Controller): try_post_run_compact()input_tokens > post_run_threshold → best-effort
  2. リクエストの合間 (CompactInterceptor): pre_llm_requestinput_tokens > request_thresholdPreRequestAction::Yield

ターンの合間が proactive (小さい閾値): turn が完了した地点はタスクの自然な区切り。ここで先を見越して早めに compact する。 マニフェストの compact_threshold が対応。

リクエストの合間は safety net (大きい閾値): turn 内部でリクエストの合間にチェックするのは「暴走的に膨張した場合のみ止める」用途。 マニフェストの compact_request_threshold が対応。通常は発動しない。

両閾値は manifest で個別指定する。過去の設計では 9/8 倍で自動導出していたが、 比率に根拠がなかったため廃止。両方が Option<u64> で、片方だけの設定も可能 (そちら側のチェックだけが有効になる)。

Yield の仕組み

PreRequestAction::Yield は Worker のターンループを正常終了させる汎用プリミティブ。 Worker は Compact を知らない。「Interceptor が yield と言ったので抜ける」だけ。 Pod が Yielded を検知して compact → resume する。

PreRequestAction::Yield → WorkerResult::Yielded → Pod が compact → resume
PreRequestAction::Cancel → WorkerError::Aborted → エラー

安全機構

  • サーキットブレーカー: 3回連続 compact 失敗で無効化 (CompactState::disabled)
  • Thrash 検出: compact 直後に再び閾値超過 → PodError::CompactThrash
  • Yield 前の永続化: persist_turn を compact の前に実行。失敗しても旧セッションにデータが残る

セッション管理

compact は fork と同じ構造。旧セッションを保全し、新 SessionId で圧縮後のセッションを開始。

旧セッション (abc-123):
  [...entries...] → Outcome::Yielded (interrupted=true)  ← そのまま残る

新セッション (def-456):
  [SessionStart { compacted_from: (abc-123, entryN.hash), history: [要約 + 直近] }] → ...

SessionStartforked_from / compacted_from フィールドで出自を追跡可能。


compact 後の history 構造

全て system messageItem::Message { role: System })として注入。

[system prompt]                              ← 不変
[system: 構造化要約]                          ← compact worker の出力
[system: auto-read ファイル群]                ← read_required の結果
[system: リファレンス一覧]                     ← reference の結果
[直近 N トークン分の生の会話]                  ← pruned 状態で保持

直近の保護: トークンベース

ターン単位ではなくトークン数ベースで直近の会話を保護する。

ターン単位の問題: 自走エージェントは1ターン内で多数のリクエストを回す。 1ターンが膨大に長くなり、保護量がターン長に依存してしまう。 トークン数ベースなら、ターンの長さに関わらず一定量の直近コンテキストが保持される。

[compaction]
compact_threshold = 80000              # ターンの合間 (proactive)
compact_request_threshold = 90000      # リクエストの合間 (safety net)
retained_tokens = 8000                 # 直近保護トークン数 (Prune 済みで計測)
auto_read_budget = 8000                # compact worker の mark_read_required 合計上限
compact_worker_max_input_tokens = 50000 # compact worker 自身の累計入力トークン上限

Auto-Read とリファレンス

2段階のファイル参照:

  • Read (mark_read_required): 全文/範囲指定でコンテキストに注入
  • Reference (add_reference): 「読んだことがある」とだけ伝える。必要なら再読要求

auto-read の system message:

[Auto-read file: src/main.rs:42-142]
fn main() {
    let config = Config::load();
    ...
}

リファレンスの system message:

[Referenced files — read before compaction, contents not included]
- src/config.rs (read during task setup)
- tests/integration_test.rs (read during test implementation)
Use read_file to access current contents if needed.

auto-read も通常の history 内 system message なので、将来の Prune/Compact 対象になる。


compact worker

要約生成とファイル選定を行う使い捨て Worker。ツールなし・1リクエストの現行実装から、 ツール付きマルチターンに改善する。

ツール

read_file(path, offset?, limit?)          — ファイルを読んで判断する
mark_read_required(path, offset?, limit?) — auto-read 対象として指定
add_reference(path)                       — リファレンスとして追加
write_summary(text)                       — 構造化要約を出力/上書き(最後の呼び出しが採用)

フロー

  1. Pod が tools::Tracker::recent_files(5) で最近触られたファイルを抽出(デフォルトリファレンス)
  2. compact worker にプロンプトとして渡す:
    • pruned historysummary only、arguments/reasoning 除去)
    • デフォルトリファレンスの一覧
  3. compact worker が自律的に:
    • read_file で各ファイルを読み、必要性を判断
    • mark_read_required / add_reference で指定
    • write_summary で構造化要約を出力(呼び直し可)
  4. ターン終了時に write_summary 未呼び出し or read_required 空(かつファイル操作履歴がある場合)→ 追加プロンプトで促す

構造化要約の要件

目的: auto-read でコードは別途載せるので、要約は意思決定・コンテキスト・方向性の記録に特化する。

含めるべき内容:

  1. 何を、なぜやったか — 意思決定の記録。具体的な型名・関数名で言及
  2. ユーザーの指示・フィードバックの原文 — ニュアンス保持。重要なもののみ
  3. 発生した問題と解決策 — 同じ轍を踏まない
  4. 今どこにいて次に何をするか — compact 前後の一貫性

含めないもの:

  • コードの全文auto-read が担う)
  • 変更の diffgit がある)
  • 中間のやりとりの詳細(最終結論だけ)

要約フォーマット5セクション、1000-2000 トークン目安)

## Completed Tasks
### (タスク名)
- 完了した作業(具体的な型名・ファイル名で)
- 注意点 / 発覚した事実

## Active Task
### (タスク名)
- 目標
- 現状(何が済んで何が未着手か)
- 次のステップ

## Key Decisions
- (判断内容) — (理由)

## User Directives
- 「(ユーザー発言の原文)」 — 重要な指示・フィードバックのみ

## Current Work
直前に何をしていたか。2-3行

セッション中に複数タスクが切り替わっている前提で設計。 完了タスクは簡潔に(注意点・事実のみ)、進行中タスクは十分な詳細で。


設計の背景

Claude Code からの知見

Claude Code の compaction 実装(docs/ref/claude-code-compaction.md)を参考に、以下を取り入れた:

  • 条件付き Prune (min_savings): KV キャッシュ無効化コストとのトレードオフ。clear_at_least パターン
  • サーキットブレーカー: 連続失敗の無限ループ防止
  • 2段階のファイル参照 (Read vs Referenced): Claude Code は compact 後に「Referenced file」内容省略と「Read」内容保持を区別する。サイズと必要性で判断
  • 要約の具体性: 型名・関数名・ユーザー発言原文を保持。抽象的な要約は役に立たない

設計原則との対応

  • Prune は Worker 層の拡張。新しい trait は不要設計原則3
  • Compact は Pod 層の制御。Worker は Yield を知るが Compact は知らない
  • CompactInterceptor は Decorator パターンで HookInterceptor をラップ。既存の Hook 機構を壊さない