yoi/tickets/context-compaction.md

10 KiB
Raw Blame History

コンテキスト圧縮: Prune + Compact

背景

長時間実行エージェントにとって、コンテキストウィンドウの管理はコア要件。 現状の Worker は history をそのまま保持し、オーバーフロー時の対策がない。

Claude Code の3層構造MicroCompaction / AutoCompact / Full Compactを参考に、 Insomnia では2層条件付き Prune + Compactで対処する。

参考: docs/ref/claude-code-compaction.md


前提: ToolOutput の再設計

Prune の設計は ToolOutput の構造に依存する。 現行の Inline/Stored enum を summary + content の2フィールド構造に改める。

詳細: tool-output-design.md実装済み

構造

pub struct ToolOutput {
    pub summary: String,          // 1-2行。常に残る
    pub content: Option<String>,  // 詳細。Prune で消える
}
Item::ToolResult {
    call_id: CallId,
    summary: String,
    content: Option<String>,
}

Prune との関係

  • summary: Prune 後も残る。「何をしたか」の最低限の情報
  • content: Prune 対象。None に置換するだけ
  • 巨大出力はツール側がファイルに退避し、content に見取り図を置く

削除対象

ToolOutput 再設計に伴い、以下を削除:

  • ToolOutput enumInline/Stored→ struct に置換
  • Content enum, auto_summarize, ToolOutputProcessor trait
  • BlobStore trait, FsBlobStore, BlobOutputProcessor
  • inspect_tool.rs(汎用の read_file/grep で代替)
  • Worker の output_processor フィールド

Phase 1: 条件付き Prune

概要

Claude Code の clear_at_least パターンに倣い、削れるトークン量が閾値を超える場合にのみ Prune を実行する。キャッシュを無駄に壊さない。

キャッシュの制約

全主要プロバイダAnthropic / OpenAI / Geminiで KV キャッシュはプレフィクスベース。 プレフィクス中のアイテムを変更すると、変更地点以降が全て再計算になる。

キャッシュ済み: [A, B, C, D, E]
Prune:         [A', B, C, D, E]   ← A の content を消した
再計算:        [A', B, C, D, E]   ← A' 以降すべて

Prune で得られるトークン節約 vs キャッシュ再計算コスト。 min_savings 閾値で「削る価値がある場合だけ」実行する。

コード配置

場所 内容
crates/llm-worker/src/prune.rs Prune アルゴリズム(集計 + 置換)
crates/pod/src/prune_hook.rs PruneHookHook<PreLlmRequest> 実装)

アルゴリズム

pub struct PruneConfig {
    /// Prune 対象外とする直近ターン数
    pub protected_turns: usize,
    /// この推定トークン数以上削れる場合にのみ Prune を実行
    pub min_savings: usize,
}

pub fn prune(items: &mut Vec<Item>, config: &PruneConfig) -> bool {
    // 1. ターン境界の特定UserMessage 出現位置)
    let turn_starts = find_turn_starts(items);
    if turn_starts.len() <= config.protected_turns {
        return false;
    }
    let boundary = turn_starts[turn_starts.len() - config.protected_turns];

    // 2. Prune 可能なトークン数を集計
    let mut total_savings: usize = 0;
    let mut prunable: Vec<usize> = Vec::new();

    for (i, item) in items[..boundary].iter().enumerate() {
        if let Item::ToolResult { content: Some(c), .. } = item {
            total_savings += c.len() / 4; // 粗い推定
            prunable.push(i);
        }
    }

    // 3. 閾値チェック
    if total_savings < config.min_savings {
        return false;
    }

    // 4. Prune: content を None にするだけ
    for &i in &prunable {
        if let Item::ToolResult { content, .. } = &mut items[i] {
            *content = None;
        }
    }
    true
}

PruneHook

pub struct PruneHook {
    config: PruneConfig,
}

#[async_trait]
impl Hook<PreLlmRequest> for PruneHook {
    async fn call(&self, context: &mut Vec<Item>) -> PreRequestAction {
        prune(context, &self.config);
        PreRequestAction::Continue
    }
}

特性

  • 条件付き: 集計して閾値を超えた場合のみ実行
  • 冪等: content: None のアイテムはスキップ
  • 非破壊: history 本体は変更しない。Prune 状態(どこまで刈ったか)を Pod が保持し、LLM リクエスト構築時に反映する
  • 単純: Prune = content = None。blob 参照の解析やサマリ生成は不要

Phase 2: Compact

概要

history 全体を要約で置き換える。 別の Worker要約専用・ツールなしで要約を生成する。

トリガー

Controller が input_tokens を追跡し、run 完了後に閾値と比較。

let last_input_tokens = Arc::new(AtomicU64::new(0));
{
    let tracker = last_input_tokens.clone();
    worker.on_usage(move |event| {
        if let Some(tokens) = event.input_tokens {
            tracker.store(tokens, Ordering::Relaxed);
        }
    });
}

サーキットブレーカー

const MAX_COMPACT_FAILURES: usize = 3;
// 3回連続失敗で compaction を無効化

Compaction フロー

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

Run 完了 → input_tokens > threshold
  ↓
Controller: history を要約プロンプトに変換
  ↓
Controller: 要約用 Worker 生成ツールなし、temperature=0
  ↓
要約 Worker: 構造化要約を生成
  ↓
Controller: [要約 Item, 直近 N ターン] で新 history を構築
  ↓
Controller: 新 SessionId で新セッションを作成SessionStart に compacted_from を記録)
  ↓
旧セッション JSONL はそのまま保全append-only 原則を維持)
旧セッション (abc-123):
  [entry0] → [entry1] → ... → [entryN]  ← そのまま残る

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

SessionStart の出自フィールド

LogEntry::SessionStart {
    ts: u64,
    system_prompt: Option<String>,
    config: RequestConfig,
    history: Vec<Item>,
    /// fork 由来の場合、元セッションと分岐点
    forked_from: Option<(SessionId, EntryHash)>,
    /// compact 由来の場合、元セッションと圧縮時点
    compacted_from: Option<(SessionId, EntryHash)>,
}
  • 通常の新規セッション: 両方 None
  • fork: forked_from = Some(...)
  • compact: compacted_from = Some(...)
  • EntryHash で元セッションのどの時点からの操作かを追跡可能

要約フォーマット

## Original Task
(元のユーザー指示)

## Completed Work
- (完了した作業。ファイルパス・関数名等の具体情報)

## Key Discoveries
- (判明した事実・制約・エラー)

## Current State
- (変更されたファイル、残タスク)

エラーハンドリング

  • 要約 Worker エラー → 警告ログ、スキップ、consecutive_failures++
  • 3回連続失敗 → セッション残りで compaction 無効化
  • Thrash loopcompaction 直後に再び閾値超過)→ エラーで停止

設定

マニフェスト

[compaction]
# Prune: 直近何ターンを保護するか(デフォルト: 3
prune_protected_turns = 3

# Prune: この推定トークン数以上削れる場合にのみ実行(デフォルト: 4096
prune_min_savings = 4096

# Compact: input_tokens がこの値を超えたら要約を実行(省略 = 無効)
compact_threshold = 80000

# Compact: 圧縮後に保持するターン数(デフォルト: 2
compact_retained_turns = 2
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CompactionConfig {
    #[serde(default = "default_prune_protected_turns")]
    pub prune_protected_turns: usize,       // default: 3
    #[serde(default = "default_prune_min_savings")]
    pub prune_min_savings: usize,           // default: 4096
    pub compact_threshold: Option<u64>,
    #[serde(default = "default_compact_retained_turns")]
    pub compact_retained_turns: usize,      // default: 2
}

デフォルト動作

  • [compaction] 省略: Prune も Compact も無効
  • [compaction] あり・compact_threshold 省略: Prune のみ有効

設計判断

判断 理由
ToolOutput を summary + content に Prune が content = None で済む。blob/inspect の複雑さが消える
BlobStore / inspect を削除 巨大出力はツール側の責務。フレームワークは summary/content を受け取るだけ
Prune は条件付き(min_savings KV キャッシュ無効化コスト vs 節約量。Claude Code の clear_at_least に倣う
Prune は request context を操作 history 本体を保全。session log の完全性を維持
Compact は run 間で実行 要約は LLM 呼び出しを伴う。ターンループ内では Prune が対処
サーキットブレーカー 連続失敗の無限ループ防止。Claude Code の知見
新しい trait は不要 設計原則3: Hook + Controller 制御 + set_history() で完結

実装順序

  1. ToolOutput 再設計 — enum → structsummary + content。Item::ToolResult の変更。単体テスト
  2. 旧モジュール削除 — BlobStore, BlobOutputProcessor, inspect_tool, ToolOutputProcessor, Content, auto_summarize。Worker から output_processor 除去
  3. prune.rs — 条件付き Prune アルゴリズム。単体テスト
  4. PruneHook — Pod に Hook 実装
  5. CompactionConfig — manifest にセクション追加
  6. LogEntry::Compacted — session_log に variant 追加
  7. compact() 関数 — Controller に compaction ロジック + サーキットブレーカー
  8. ProtocolCompactionStart / CompactionDone イベント追加

ステップ 1-2 は ToolOutput 移行として独立実行可能。 ステップ 3-4Pruneと 5-6Compact 準備)は並行可能。


依存チケット