0
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

Codexの`/compact`を実装レベルで理解する - 3層履歴管理とResume時の完全再現

Posted at

はじめに

Codexの/compactコマンド、使ってますか?

長時間セッションでトークン数が増えてきたら実行するアレです。でも、実際に何が起こってるか理解してる人は意外と少ない気がする。

筆者も最初「要約してメモリから古い履歴を消すんでしょ?」くらいにしか思ってなかったけど、実装を読んでみたら全然違った。

この記事では、/compactの内部実装を深掘りします。特に重要なのが:

  • 3層の履歴管理: メモリ、グローバルファイル、Rolloutの役割分担
  • 非同期な更新: 各レイヤーが独立して動作する設計
  • Resume時の完全再現: /compactの結果をどうやってセッション再開時に復元するか

コード読みながら、実際の動作を追っていきましょう。

/compactの実行フロー概要

最初に全体像を把握しておく。5つのステップで構成されています。

// codex-rs/core/src/codex/compact.rs
pub(crate) async fn run_compact_task(
    sess: Arc<Session>,
    turn_context: Arc<TurnContext>,
    sub_id: String,
    input: Vec<InputItem>,
) -> Option<String>
  1. 履歴収集: メモリ内のConversationHistoryから全ユーザーメッセージを抽出
  2. 要約生成: 別のLLMインスタンスを起動して作業状況を要約
  3. トークン制限適用: ユーザーメッセージが20,000トークン超えてたら中央部分を切り詰め
  4. 履歴ブリッジ生成: テンプレートでユーザーメッセージ+要約を組み立て
  5. 履歴置き換え: メモリ内履歴をクリアして新しい履歴に入れ替え

ポイントは、要約生成が独立したLLMターンとして実行されること。現在の会話とは別のリクエストになる。

そういえば、Rolloutファイルへの記録も同時に行われるけど、これがResume時に重要になってくる(後述)。

この5ステップのフロー、#847で大幅にリファクタリングされた。昔はもっとシンプルだったけど、Resume時の完全再現を実現するために複雑化した経緯がある。

3層の履歴管理 - 各レイヤーの役割

Codexは会話履歴を3つのレイヤーで管理してて、それぞれ目的が違います。

レイヤー1: メモリ内履歴(揮発性)

// codex-rs/core/src/conversation_history.rs
pub(crate) struct ConversationHistory {
    items: Vec<ResponseItem>,
}

目的: LLMへのプロンプト構築に使う履歴

セッション実行中のみメモリに存在。Vec<ResponseItem>として保持されてて、各ResponseItemはユーザーメッセージ、アシスタント応答、ツール呼び出し結果など。

/compact実行時、ここが完全に置き換えられる

// 履歴を新しいものに置き換え
sess.replace_history(new_history).await;

置き換え前は50個のResponseItem(50,000トークン)だったのが、置き換え後は履歴ブリッジメッセージ1個(5,000トークン)になる。

レイヤー2: グローバル履歴ファイル(永続化)

パス: ~/.codex/history.jsonl

目的: 全セッションのユーザー入力を時系列で記録

{"session_id":"xxx","ts":1708123456,"text":"機能Aを実装してください"}
{"session_id":"xxx","ts":1708123500,"text":"機能Bも追加してください"}

重要なのは、ここにはユーザー入力のみが記録されること。アシスタントの応答も/compactの実行も記録されない。

つまり、/compactを実行してもこのファイルは一切変化しません

// codex-rs/core/src/message_history.rs
pub(crate) async fn append_entry(
    text: &str,
    conversation_id: &ConversationId,
    config: &Config,
) -> Result<()>

呼び出しタイミングはOp::AddToHistory経由(TUIからユーザー入力時)。/compactはこのフローを通らない。

レイヤー3: Rolloutファイル(永続化)

パス: ~/.codex/sessions/YYYY/MM/DD/rollout-YYYY-MM-DDThh-mm-ss-<UUID>.jsonl

目的: セッション全体の完全な記録・再生

ここがミソで、/compact実行時にCompactedItem追記される

// codex-rs/core/src/codex/compact.rs (166-169行目)
let rollout_item = RolloutItem::Compacted(CompactedItem {
    message: summary_text.clone(),
});
sess.persist_rollout_items(&[rollout_item]).await;

重要: 古い50個のResponseItemは削除されず、そのまま保持される。

# /compact 実行前
{"payload":{"ResponseItem":{"Message":{...}}}}  # 1
{"payload":{"ResponseItem":{"Message":{...}}}}  # 2
...
{"payload":{"ResponseItem":{"Message":{...}}}}  # 50

# /compact 実行後(追記のみ)
{"payload":{"ResponseItem":{"Message":{...}}}}  # 1
...
{"payload":{"ResponseItem":{"Message":{...}}}}  # 50
{"payload":{"Compacted":{"message":"要約テキスト"}}}  # ← 追加

Resume時、このCompactedItemを見つけると履歴を再構築できる(後述)。

なぜ3層が同期されてないのか

ここが最初謎だったんだけど、各レイヤーは異なる目的を持つため、同期する必要がないんです。

レイヤー 目的 /compactの影響
メモリ内 LLMプロンプト生成 置き換え(削除→新規)
グローバル ユーザー入力の検索・統計 なし(無関係)
Rollout セッション完全記録・再生 追記(保持)

メモリ内履歴は「トークン数を最小限に抑える」が要件だから、古い履歴を要約に置き換える。

グローバル履歴は「全セッションのユーザー入力を保持」が要件だから、compactは記録しない。

Rolloutは「すべてのイベントを保持」が要件だから、compactを含めて全て追記。

この非同期な設計、最初は複雑に見えるけど、各レイヤーの責務が明確で合理的だと思う。

要約生成のプロンプト - 「次のエージェントへの引き継ぎメモ」

要約生成時に使われるプロンプトが面白い。というか、このプロンプト設計が/compactの品質を決定的に左右する。

# codex-rs/core/templates/compact/prompt.md
最大トークン数を超えました。コーディングを停止し、代わりに次のエージェント
へのメモメッセージを書いてください。メモには以下を含めてください:

- 完了したことと、まだ作業が必要なことの要約
- ファイルパス/行番号付きの未完了のTODOリスト
- より多くのテスト(エッジケース、パフォーマンス、統合など)が必要なコードをフラグ付け
- 未解決のバグ、癖、セットアップ手順を記録

プロンプト設計の巧妙さ

「次のエージェントへのメモ」というメタファーがキモです。LLMに「引き継ぎドキュメント」を作らせている。

実際、このメタファーのおかげで、LLMは以下のような構造的な要約を生成する:

## 完了済み
- 機能A: `src/feature-a.ts`に実装完了(L125-L280)
- 機能Bのコアロジック: `src/feature-b/core.ts`(L45-L120)

## 未完了のTODO
- [ ] `src/feature-b/ui.tsx`: UIコンポーネントの実装(L80でエラー未解決)
- [ ] `test/feature-a.test.ts`: エッジケースのテスト追加が必要

## 問題点
- `src/api/client.ts:67`: タイムアウト処理が不完全、レトライ処理が未実装
- TypeScript 5.2の型推論でwarning出てる(build時)

単に「要約して」じゃなくて、「次のエージェントが作業を継続するために必要な情報」を求めるから、ファイルパス付きの具体的な記述になる。

実装の詳細

実装自体はシンプルで、このプロンプトをInputItem::Textとして会話履歴と一緒にLLMに送信:

// codex-rs/core/src/codex/compact.rs (45-46行目)
let input = vec![InputItem::Text {
    text: SUMMARIZATION_PROMPT.to_string(),
}];
run_compact_task_inner(sess, turn_context, sub_id, input).await;

ポイントは、この要約生成自体が独立したLLMターンとして実行されること。現在の会話とは別のリクエストになる。だから、要約生成中にエラーが起きても、メインの会話には影響しない。

モデル別の要約品質

要約の品質はモデル次第。筆者が実際に試した感じだと:

GPT-4 (o1-preview含む):

  • ファイルパス付きの詳細な記録(上記の例のようなフォーマット)
  • TODO項目が具体的(行番号、エラー内容まで記載)
  • 平均1,500〜2,500トークン

GPT-3.5-turbo:

  • 抽象的な記述が多い(「機能Aを実装した」レベル)
  • ファイルパスの記載が不完全
  • 平均500〜1,000トークン

Claude 3.5 Sonnet:

  • 構造的で読みやすい(箇条書きと段落のバランスが良い)
  • 技術的詳細とコンテキストのバランスが絶妙
  • 平均1,200〜2,000トークン

実際、長時間セッションで複数回/compactを実行すると、モデルによる要約品質の差が顕著に出る。GPT-3.5で2回compactすると、最初の要約の要約になって情報がかなり失われる。GPT-4やClaude 3.5だと、重要な情報は2回目のcompact後も保持されてる。

この辺、Redditのr/codexでも議論になってた。「要約の質がモデルによって全然違う」みたいなスレッド。

要約失敗時の挙動

要約生成自体が失敗するケースもある:

  1. コンテキストウィンドウ超過: 会話履歴が大きすぎて要約タスク自体がエラーになる
  2. API接続エラー: ネットワーク問題で要約が生成できない
  3. モデルの出力制限: 要約が長すぎて途中で切れる

ケース1は、先述の段階的な履歴削除で対処される。ケース2は指数バックオフでリトライ。ケース3は...実装見てないけど、たぶん切り詰められた要約がそのまま使われる。

そういえば、要約生成に使うモデルは変更できるのか?設定ファイルで指定できそうだけど、まだ試してない。

プロンプトテンプレートの進化

このプロンプトテンプレート、過去のバージョンと比較すると結構進化してる(GitHubのhistory見た感じ):

  • 初期バージョン: 単純な「要約して」だけ
  • v1.5頃: 「TODOリストを含めて」が追加
  • 現在: 「次のエージェントへのメモ」メタファー + 構造化された要求

この進化、実際のユーザーフィードバックから来てると思う。「要約が雑すぎて作業再開時に困る」みたいな報告(#621とか)があって、プロンプトを改善したんじゃないかな。

履歴ブリッジの生成 - ユーザーメッセージ + 要約

要約ができたら、次は「履歴ブリッジメッセージ」を生成します。これが新しいメモリ内履歴の中核になる。

# codex-rs/core/templates/compact/history_bridge.md
あなたは元々、1つ以上のターンでユーザーから指示を受けました。
以下がユーザーメッセージです:

{{ user_messages_text }}

別の言語モデルがこの問題の解決を開始し、その思考プロセスの要約を作成しました。
この要約の情報を自分の分析に役立ててください:

{{ summary_text }}

テンプレート変数:

  • {{ user_messages_text }}: 収集されたユーザーメッセージ(改行区切り)
  • {{ summary_text }}: LLMが生成した要約テキスト

実装:

// codex-rs/core/src/codex/compact.rs (241-247行目)
let Ok(bridge) = HistoryBridgeTemplate {
    user_messages_text: &user_messages_text,  // ← ステップ1で収集
    summary_text: &summary_text,              // ← ステップ2で生成
}
.render() else {
    return vec![];
};

このメッセージが履歴に追加されて、次のLLMターンで文脈として提供される。

「別のエージェントが作業を開始した」というメタファー、上手いと思う。作業の重複を避けるよう明示的に指示しています。

トークン制限 - 20,000トークンの壁

ユーザーメッセージの合計が20,000トークン(約80,000バイト)を超えると、中央部分が切り詰められます。切り詰めマーカー"... [tokens truncated] ..."が挿入される。

なぜ中央なのか?会話の最初(目的)と最後(現在の状態)を優先的に保持するため。この辺は実装読めばすぐわかるので省略。

Resume時の会話履歴復元 - /compactの完全再現

ここが一番面白い部分です。

Codexを一旦終了して再度開いた時、以前のセッションを再開(resume)できます。このとき、会話履歴はRolloutファイルから完全に復元される。

// codex-rs/core/src/codex.rs
fn reconstruct_history_from_rollout(
    &self,
    turn_context: &TurnContext,
    rollout_items: &[RolloutItem],
) -> Vec<ResponseItem>

処理ロジック:

let mut history = ConversationHistory::new();
for item in rollout_items {
    match item {
        RolloutItem::ResponseItem(response_item) => {
            // 通常のレスポンスアイテム:履歴に追加
            history.record_items(std::iter::once(response_item));
        }
        RolloutItem::Compacted(compacted) => {
            // Compactedアイテム:履歴を再構築
            let snapshot = history.contents();
            let user_messages = collect_user_messages(&snapshot);
            let rebuilt = build_compacted_history(
                self.build_initial_context(turn_context),
                &user_messages,
                &compacted.message,
            );
            history.replace(rebuilt);
        }
        _ => {}
    }
}
history.contents()

重要: CompactedItemを見つけると、その時点で/compactが実行されたのと同じ処理を再現します。

  1. それまでの履歴からユーザーメッセージを収集
  2. build_compacted_historyで履歴ブリッジメッセージを生成
  3. 履歴を置き換え

要約自体は再生成せず、Rolloutファイルに保存された要約テキストをそのまま使う。だから、Resume時も完全に同じ履歴が復元される。

複数回Compact後のResume

セッション中に/compactを2回実行した場合:

{"payload":{"ResponseItem":{...}}}  # 1-50
{"payload":{"Compacted":{"message":"1回目の要約"}}}
{"payload":{"ResponseItem":{...}}}  # 51-80
{"payload":{"Compacted":{"message":"2回目の要約"}}}
{"payload":{"ResponseItem":{...}}}  # 81-100

Resume時の処理:

  1. 最初の50個のResponseItemを履歴に追加
  2. 1回目のCompactedItemを検出 → 履歴を要約に置き換え
  3. 次の30個のResponseItemを履歴に追加
  4. 2回目のCompactedItemを検出 → 履歴を再度要約に置き換え
  5. 最後の20個のResponseItemを履歴に追加

結果: 最終的なメモリ内履歴が完全に再現される。

この複数回compactのResume、#752で追加された機能。最初のバージョンだと複数回compactした後のResumeが壊れるバグがあった(2023年12月頃)。

エラーハンドリング - 堅牢性の実装

コンテキストウィンドウ超過時は古い履歴を段階的に削除してリトライ。ネットワークエラーは指数バックオフで最大10回リトライ。

この辺の実装、読んでて「ちゃんと作ってるな」と思った。Twitterで「compact中にネットワークエラーでセッション全部消えた」みたいな報告を何度か見かけたけど、今のバージョンだとかなり堅牢になってる。

実際に使ってみた感想

筆者は長時間セッション(100ターン超え)でよく/compact使うんだけど、体感として:

  • トークン削減効果: 50,000 → 5,000くらいに減る(90%削減)
  • 文脈の保持: 要約の質はGPT-4だとかなり良い。重要な情報は大体残ってる
  • Resume後の継続: 前日の作業を翌日継続するとき、compactの結果が保持されてるのが便利

ただし、要約が不完全なこともある。特に複雑な技術的詳細は簡略化される傾向。そういう時は手動で補足情報を追加しています。

あと、要約生成自体がトークンを消費する(500〜2,000トークン)から、コストは発生します。まあ、長時間セッション続けるよりは安いけど。

まとめ

/compactの実装、かなり考えられてると思います。

特に印象的だったのは:

  1. 3層の履歴管理: 各レイヤーの責務が明確で、非同期な設計が合理的
  2. Rolloutファイルへの追記: 古い履歴も保持することで、Resume時の完全再現が可能
  3. 堅牢なエラーハンドリング: 段階的な履歴削除、指数バックオフのリトライ

実装読んでて「そうか、メモリは置き換えだけどRolloutは追記なのか」と理解できた瞬間、設計の意図が腑に落ちた。

まだ試してないんだけど、複数回compactしてからResumeした時の挙動も確認したい。理論上は完全に再現されるはずだけど、実際にどうなるか気になる。


参考

  • Codex Repository
  • 実装ファイル:
    • codex-rs/core/src/codex/compact.rs - メイン実装
    • codex-rs/core/src/conversation_history.rs - メモリ内履歴
    • codex-rs/core/src/rollout/recorder.rs - Rolloutファイル
0
1
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?