はじめに
LLMベースのコーディングエージェントを長時間動かしたことがある方なら、一度は「途中で文脈を見失って急に的外れな動きを始める」現象に遭遇したはずです。この崩壊の多くは、コンテキストウィンドウの管理に起因します。
Codex CLIは、この問題に対して単なる「古い会話を切り捨てる」アプローチではなく、多層的なコンパクション(要約による履歴圧縮)機構を実装しています。本記事では codex-rs/ のRust実装を直接読み解き、なぜCodexのコンテキスト圧縮が長時間セッションでも破綻しにくいのか、その設計思想と背景まで掘り下げて整理します。引用するコードはすべてOSSで公開されているもののみです。
対象リポジトリ
本記事のコード引用はすべて以下のOSSリポジトリから行っています。
クローンして手元で読み進めながら確認することもできます。
git clone https://github.com/openai/codex.git
cd codex/codex-rs
背景 — なぜコンテキスト圧縮が必要なのか
実装の話に入る前に、そもそもなぜこの機能が存在するのかを整理しておきます。ここを理解すると、Codexの個々の設計判断が「なぜそうなっているのか」が腑に落ちます。
問題1: コンテキストウィンドウは有限で、エージェントは消費量が大きい
LLMには扱えるトークン数の上限があります。近年は200Kや1Mトークンといった大きなウィンドウを持つモデルも登場しましたが、コーディングエージェントの文脈ではこれでも足りません。
理由は、エージェントが生み出す情報量が会話の何倍にもなるからです。ファイルを読めば数千行がコンテキストに入り、シェルコマンドを叩けばその標準出力が丸ごと積まれ、Web検索をすれば検索結果が追加されます。ユーザーとの実際のやり取りは数十行でも、その裏でツール呼び出しの応答が数万トークン単位で蓄積していきます。長時間タスクでは、会話本体よりツール出力のほうが圧倒的に支配的になります。
問題2: 計算コストとレイテンシは入力長に対して線形以上で増える
Transformerの自己注意機構は、原理的にはシーケンス長に対して計算量が増大します。コンテキストが膨らむほど、1ターンあたりの推論コストと応答待ち時間が悪化します。しかもエージェントは毎ターン履歴全体を再投入するため、過去のツール出力という「もう不要なノイズ」に対して、ターンのたびに課金とレイテンシを払い続けることになります。
問題3: 素朴な切り捨ては最悪のものを捨てる
ではコンテキストが溢れたら古いものから消せばよいかというと、これが最悪です。エージェントのタスクにおいて、最も重要な情報は会話の冒頭にあるからです。「何を達成したいのか」「どんな制約があるのか」というタスク定義と初期の意思決定は、たいてい先頭に置かれます。古いものから消す方式(FIFO truncation)は、まさにこの一番捨ててはいけない情報から削っていきます。結果として、エージェントは作業の途中で「自分が何のために動いていたか」を忘れます。
問題4: 入っていても読まれるとは限らない
仮にウィンドウに収まっていても、モデルは長大なコンテキストの中盤に置かれた情報への注意が弱くなる傾向が知られています。つまり「とにかく全部詰め込む」戦略は、コスト面でもパフォーマンス面でも、そして精度面でも報われません。
解くべき本質: 「転記」ではなく「状態」を残す
ここから導かれる洞察はシンプルです。エージェントにとって本当に必要なのは、過去のやり取りの逐語的な記録(transcript)ではなく、作業を続けるために必要な状態(state)です。
- 何を達成しようとしているか
- これまでに何を決めたか
- いま何が分かっていて、次に何をすべきか
- 続行に必要な重要データや参照
これらは、数万トークンの生ログよりはるかにコンパクトに表現できます。コンパクションとは、肥大化した転記から、本質的な状態を抽出し直す操作にほかなりません。Codexの実装は、まさにこの「状態の抽出と再構成」を丁寧にやっています。以降、その具体を見ていきます。
全体像
コンパクション関連の主要ファイルは以下です。
| ファイル | 役割 |
|---|---|
codex-rs/core/src/compact.rs |
ローカルコンパクション本体(モデルにインライン要約させる経路) |
codex-rs/core/src/compact_remote.rs |
リモートコンパクションV1(専用APIエンドポイント) |
codex-rs/core/src/compact_remote_v2.rs |
リモートコンパクションV2(feature flagで切替) |
codex-rs/core/src/tasks/compact.rs |
タスクディスパッチ層 |
codex-rs/core/templates/compact/prompt.md |
モデルに渡す要約指示プロンプト |
codex-rs/core/templates/compact/summary_prefix.md |
要約結果を履歴へ注入する際の前置きマーカー |
設計の最初の分岐点が、ローカル経路とリモート経路の二系統を持つ点です。プロバイダがリモートコンパクションに対応していれば専用APIに圧縮を委譲し、対応していなければローカルでモデルにインライン要約させます。判定は明快です。
pub(crate) fn should_use_remote_compact_task(provider: &ModelProviderInfo) -> bool {
provider.supports_remote_compaction()
}
二系統を持つ意図は、機能の可用性を切らさないためです。リモート経路は後述するように能力的に優れていますが、それが使えない環境(自前プロバイダやローカルモデルなど)でもコンパクション自体は必ず動く、というフォールバック設計になっています。これは「賢い機能ほど、退化したときの振る舞いを設計しておく」という堅実なエンジニアリングの表れです。
発動条件 — トリガー・理由・フェーズの3軸
コンパクションは「いつ・なぜ発動するか」が丁寧に場合分けされています。ここで重要なのは、Codexがこれを1つの軸ではなく、独立した3つの軸で分類している点です。codex-rs/analytics/src/facts.rs に定義があります。
pub enum CompactionTrigger { // 発動経路
Manual,
Auto,
}
pub enum CompactionReason { // 発動理由
UserRequested,
ContextLimit,
ModelDownshift,
}
pub enum CompactionPhase { // 発動したタイミング
StandaloneTurn,
PreTurn,
MidTurn,
}
トリガー(手動か自動か)は2種類、理由(ユーザー要求・コンテキスト上限・モデルのダウンシフト)は3種類、フェーズ(独立ターン・ターン前・ターン中)は3種類です。これらの組み合わせで「どういう圧縮なのか」を表現します。以下、代表的な発動パターンを、この3軸に対応づけながら見ていきます。
パターン1: 自動・コンテキスト上限・ターン前
最も基本的なトリガーです。モデルを呼び出す直前にトークン状況をチェックし、上限に達していれば圧縮します。codex-rs/core/src/session/turn.rs の run_pre_sampling_compact を見てみます。
async fn run_pre_sampling_compact(
sess: &Arc<Session>,
turn_context: &Arc<TurnContext>,
client_session: &mut ModelClientSession,
) -> CodexResult<()> {
maybe_run_previous_model_inline_compact(sess, turn_context, client_session).await?;
let token_status = auto_compact_token_status(sess.as_ref(), turn_context.as_ref()).await;
if token_status.token_limit_reached {
run_auto_compact(
sess, turn_context, client_session,
InitialContextInjection::DoNotInject,
CompactionReason::ContextLimit,
CompactionPhase::PreTurn,
).await?;
}
Ok(())
}
パターン2: 自動・コンテキスト上限・ターン中
ここがCodexの丁寧さがよく出ている部分です。1回のユーザー指示に対して、モデルは何度もツールを呼び出しながら多段階で推論を進めます。その途中でコンテキストが溢れることがあります。
if token_limit_reached && needs_follow_up {
run_auto_compact(
&sess, &turn_context, &mut client_session,
InitialContextInjection::BeforeLastUserMessage,
CompactionReason::ContextLimit,
CompactionPhase::MidTurn,
).await?;
}
素朴な実装は「次のターンの開始時にしか圧縮しない」ため、まさにこの「1つの指示を処理している最中に溢れる」ケースで破綻します。Codexはターンの境界だけでなく、ターンの内部でも圧縮できるようにしてあります。これにより、大量のファイルを読み込むような重いタスクでも、処理を中断せずに走り切れます。
ここで CompactionPhase に PreTurn と MidTurn という区別が現れます。同じ自動圧縮でも、発動した位置によって後段の挙動を変える必要があるためです(詳細は次節)。
パターン3: 自動・モデルダウンシフト・ターン前
3つ目はやや特殊ですが、実用上とても効いてくるパターンです。会話の途中でコンテキストウィンドウのより小さいモデルに切り替えたとき、新しいモデルには現在の履歴が収まらない可能性があります。Codexはこれを検出して、切り替え前に圧縮します。理由が ContextLimit ではなく ModelDownshift になる点が、前述の2パターンとの違いです。
async fn maybe_run_previous_model_inline_compact(
sess: &Arc<Session>,
turn_context: &Arc<TurnContext>,
client_session: &mut ModelClientSession,
) -> CodexResult<()> {
// ... 旧モデルと新モデルのコンテキストウィンドウを比較
let should_run = previous_model_limit_reached
&& previous_model_turn_context.model_info.slug != turn_context.model_info.slug
&& old_context_window > new_context_window;
if should_run {
run_auto_compact(
sess,
&previous_model_turn_context, // 旧モデルのコンテキストで圧縮
client_session,
InitialContextInjection::DoNotInject,
CompactionReason::ModelDownshift,
CompactionPhase::PreTurn,
).await?;
}
Ok(())
}
注目したいのは、圧縮を旧モデルの turn_context(より大きいコンテキストウィンドウを持つ設定)で実行している点です。新モデルに切り替えると収まらなくなるため、ウィンドウの大きい旧モデル設定のうちに先に要約してしまう、という発想です。発動条件も保守的で、「(新モデル基準で)上限に達している」「モデルが実際に変わった」「旧モデルのほうがウィンドウが大きい」の3つが揃ったときだけ走ります。モデルの自動切り替えとコンテキスト管理という、本来別々の関心事をきちんと連携させている良い例です。
パターン4: 手動・ユーザー要求・独立ターン
ユーザーが明示的に /compact を実行する経路もあります。こちらは独立したターンとして扱われます。
run_compact_task_inner(
sess.clone(),
turn_context,
input,
InitialContextInjection::DoNotInject,
CompactionTrigger::Manual,
CompactionReason::UserRequested,
CompactionPhase::StandaloneTurn,
).await?;
CompactionTrigger::Manual / CompactionReason::UserRequested / CompactionPhase::StandaloneTurn という、自動圧縮とは異なるメタデータが付与されます。後述する分析基盤で「この圧縮はユーザーが手で起こしたものだ」と区別できるようになっています。
フェーズによる挙動の違い — なぜ初期コンテキストの注入位置を変えるのか
ここはCodexの設計の中でも、コードのコメントが最も雄弁に意図を語っている箇所です。compact.rs の InitialContextInjection 列挙型を見てください。
/// Controls whether compaction replacement history must include initial context.
///
/// Pre-turn/manual compaction variants use `DoNotInject`: they replace history with a summary and
/// clear `reference_context_item`, so the next regular turn will fully reinject initial context
/// after compaction.
///
/// Mid-turn compaction must use `BeforeLastUserMessage` because the model is trained to see the
/// compaction summary as the last item in history after mid-turn compaction; we therefore inject
/// initial context into the replacement history just above the last real user message.
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub(crate) enum InitialContextInjection {
BeforeLastUserMessage,
DoNotInject,
}
このコメントから2つの重要な事実が読み取れます。
1つ目は、Pre-turnと手動圧縮では DoNotInject を使い、初期コンテキスト(システム設定やリポジトリ情報など)の再注入を「次の通常ターンに任せる」ことです。圧縮直後にすぐ次の通常ターンが来るので、そこで自然に初期コンテキストが入り直します。二重に注入する無駄を避けています。
2つ目が本質的に深い洞察です。「the model is trained to see the compaction summary as the last item in history after mid-turn compaction」と明記されています。つまり、コンパクション要約をどう扱うかは、単なるプロンプトの工夫ではなく、モデル側が訓練段階で学習している前提なのです。Mid-turn圧縮では、モデルは「要約が履歴の最後に来る」という形を期待しています。だからこそ初期コンテキストを末尾ではなく、最後の本物のユーザーメッセージの直前に差し込む必要があります。要約の位置をモデルの学習済みの期待に合わせている、ということです。
ここに、Codexのコンパクションが「優秀」と言える理由の核心の一つがあります。これはアプリ層だけで完結する小手先のテクニックではなく、モデルの訓練とクライアント実装が協調して設計されている機能なのです。
トークン計測のスコープ — 何を基準に「溢れた」と判断するか
しきい値判定は、単純に総トークン量を見ているわけではありません。2つのスコープを切り替えられます。
match turn_context.config.model_auto_compact_token_limit_scope {
AutoCompactTokenLimitScope::Total => (
active_context_tokens,
turn_context.model_info.auto_compact_token_limit().unwrap_or(i64::MAX),
None,
),
AutoCompactTokenLimitScope::BodyAfterPrefix => {
let window = sess.auto_compact_window_snapshot().await;
let baseline = window.prefill_input_tokens.unwrap_or(active_context_tokens);
(
active_context_tokens.saturating_sub(baseline),
...
)
}
}
Total はアクティブコンテキスト全体で判定します。一方 BodyAfterPrefix は、現在の自動圧縮ウィンドウの基準値(prefill_input_tokens)を差し引いた「その上に積み上がった増分」で判定します。
ここで差し引く prefill_input_tokens は「初期コンテキストのトークン数」と完全に同義ではない点に注意が必要です。コード上のコメントにあるとおり、これは現在の圧縮ウィンドウにおける絶対的なinput-token基準値で、サーバが観測した実測値が利用できればそれで更新され、なければ推定値が入ります(codex-rs/core/src/state/auto_compact_window.rs)。
/// Absolute input-token baseline for the current compaction window.
///
/// `body_after_prefix` subtracts this from later active-context usage. It is
/// not the growth itself; server-observed usage replaces estimated
/// resume/recompute baselines when available.
prefill_input_tokens: Option<AutoCompactWindowPrefill>,
なぜこの区別が必要なのでしょうか。コーディングエージェントは起動時に、システムプロンプト・ツール定義・リポジトリ構造といった大きな固定コンテキストを積みます。圧縮の対象にしたいのは、その基準値の上に積み上がった作業ぶんです。BodyAfterPrefix を使うと、基準値のサイズに引きずられずに「作業でどれだけ膨らんだか」だけを基準に判定できます。固定コンテキストが大きい構成でも、不要な早すぎる圧縮を避けられるわけです。閾値設計に「何を数えるか」という自由度を持たせている点に、実運用を見据えた配慮がうかがえます。
履歴置換のロジック — 状態をどう再構成するか
要約結果から新しい履歴をどう組み立てるか。compact.rs の build_compacted_history がその答えです。実装は引数で上限トークン数を受け取る build_compacted_history_with_limit に委譲しており、以下はその中身を要点だけ抜粋して整理したものです(selected_messages は委譲先のローカル変数で、後述のトークン予算でフィルタした結果です)。
// build_compacted_history_with_limit の要点(抜粋・簡略化)
fn build_compacted_history_with_limit(
mut history: Vec<ResponseItem>, // (1) 初期コンテキストを起点にする
user_messages: &[String],
summary_text: &str,
max_tokens: usize,
) -> Vec<ResponseItem> {
// user_messages を新しい順に走査し、max_tokens の予算内で選ぶ(超過分はtruncate)
// → selected_messages
for message in &selected_messages { // (2) 直近ユーザーメッセージ
history.push(ResponseItem::Message {
role: "user".to_string(),
content: vec![ContentItem::InputText { text: message.clone() }],
..
});
}
history.push(ResponseItem::Message { // (3) 要約本体
role: "user".to_string(), // userロールで注入
content: vec![ContentItem::InputText { text: summary_text }],
..
});
history
}
新しい履歴は「初期コンテキスト + 直近ユーザーメッセージ + 要約」という3層構造で再構成されます。それぞれの設計判断を、なぜそうするのかとあわせて見ていきます。
なぜ要約を user ロールで注入するのか
要約は assistant でも system でもなく user ロールで挿入されます。これは直感に反するかもしれません。要約はモデルが書いたものなのだから assistant では、と思うところです。
ここで効いてくるのが summary_prefix.md の前置きマーカーです。
Another language model started to solve this problem and produced a summary
of its thinking process. You also have access to the state of the tools that
were used by that language model. Use this to build on the work that has
already been done and avoid duplicating work. ...
要約は「別の言語モデルが書いた引き継ぎメモ」として、これから作業するモデルに input(user入力)として手渡されます。assistant ロールにしてしまうと「自分が直前にこう発言した」という文脈になり、system にすると恒久的なシステム指示と混ざってしまいます。user ロールで「前任者からの申し送り」として渡すのが、意味論的に最も整合的なのです。
なお、これらのプロンプトはコンパイル時にバイナリへ埋め込まれます。
pub const SUMMARIZATION_PROMPT: &str = include_str!("../templates/compact/prompt.md");
pub const SUMMARY_PREFIX: &str = include_str!("../templates/compact/summary_prefix.md");
なぜ直近メッセージを別途残すのか
要約に全面的に依存せず、直近のユーザーメッセージはトークン予算の範囲内で別途残されます。予算は経路によって異なり、ローカル圧縮では20Kトークン、リモートV2では64Kトークンが上限です。
// compact.rs(ローカル)
const COMPACT_USER_MESSAGE_MAX_TOKENS: usize = 20_000;
// compact_remote_v2.rs(リモートV2)
const RETAINED_MESSAGE_TOKEN_BUDGET: usize = 64_000;
選択ロジックは「新しいメッセージを優先し、予算に収まるものは原文のまま、予算を超える分はトークン単位で切り詰める(truncate)」という挙動です。つまり「全部を逐語的に残す」のではなく、「直近を優先しつつ予算内に収める」のが正確な動作です。
これは、要約という不可逆な圧縮で最も起きやすい失敗を構造的に防ぐための保険です。要約は「だいたいの流れ」を残すのは得意ですが、「ユーザーが直前に言った具体的な指示の細かいニュアンス」は丸めてしまいがちです。直近の指示が変質すると、エージェントは微妙にズレた方向に走り出します。そこで直近のユーザー発言は要約に通さず、予算内で優先的に残します。要約(大局)と直近メッセージ(直近の精度)を組み合わせることで、両者の弱点を補い合っているわけです。
履歴の差し替えとカウンタのリセット
組み立てた新履歴は、session/mod.rs で実際に差し替えられます。
pub(crate) async fn replace_compacted_history(
&self,
items: Vec<ResponseItem>,
reference_context_item: Option<TurnContextItem>,
compacted_item: CompactedItem,
) {
let mut state = self.state.lock().await;
state.replace_history(items, reference_context_item.clone());
state.start_next_auto_compact_window();
self.persist_rollout_items(&[RolloutItem::Compacted(compacted_item)]).await;
...
self.services.model_client.advance_window_generation();
}
start_next_auto_compact_window() が地味ながら重要です。これは圧縮ウィンドウの世代(ordinal)を1つ進め、前述の prefill_input_tokens の基準値をいったんクリアします。新しい基準値そのものは、この時点では入りません。その後 recompute_token_usage() が走ったタイミングで推定値が設定され、さらにサーバから実測値が得られればそれで上書きされます。圧縮した直後にまた「もう溢れている」と誤判定しないよう、計測ウィンドウを切り替えて起点をリセットしているわけです。
要約プロンプト — 「引き継ぎメモ」というメタファ
codex-rs/core/templates/compact/prompt.md の実物です。
You are performing a CONTEXT CHECKPOINT COMPACTION. Create a handoff summary
for another LLM that will resume the task.
Include:
- Current progress and key decisions made
- Important context, constraints, or user preferences
- What remains to be done (clear next steps)
- Any critical data, examples, or references needed to continue
Be concise, structured, and focused on helping the next LLM seamlessly continue the work.
このプロンプトの設計には2つの含意があります。
1つは「CONTEXT CHECKPOINT」という表現です。これはプログラムのチェックポイント/スナップショットの比喩で、いつでも再開可能な状態の保存点という位置付けを明確にしています。先ほど述べた「転記ではなく状態を残す」という本質が、プロンプトの言葉選びにそのまま現れています。
もう1つは「another LLM that will resume the task」という表現です。要約を読むのは未来の自分ではなく「引き継ぎを受ける別のモデル」だと枠付けしています。これにより、暗黙の前提に頼った曖昧なメモではなく、文脈をゼロから受け取る相手に通じる、自己完結した申し送りを書くよう誘導しています。要約すべき項目(進捗・意思決定・制約・残タスク・参照データ)も明示されていて、何を残し何を捨てるかの判断基準がプロンプト自体に組み込まれています。
ツール履歴は捨てる — 必要な情報は要約か再実行で取り戻す
長時間セッションでトークンを最も食うのはツール呼び出しの応答です。Codexはこれを思い切って捨てます。
match item {
ResponseItem::Message { role, .. } if role == "developer" => false,
ResponseItem::Message { role, .. } if role == "user" => /* 実メッセージのみtrue */,
ResponseItem::Message { role, .. } if role == "assistant" => true,
ResponseItem::Compaction { .. } | ResponseItem::ContextCompaction { .. } => true,
// ↓ ツール関連は全部捨てる
ResponseItem::FunctionCall { .. }
| ResponseItem::FunctionCallOutput { .. }
| ResponseItem::ToolSearchCall { .. }
| ResponseItem::ToolSearchOutput { .. }
| ResponseItem::CustomToolCall { .. }
| ResponseItem::CustomToolCallOutput { .. }
| ResponseItem::WebSearchCall { .. }
| ResponseItem::ImageGenerationCall { .. } => false,
_ => false,
}
ここで誤解しないようにしたいのは、捨てられたツール出力が自動的に別の形でモデルに再提供されるわけではない点です。圧縮後にモデルが参照できるのは、(1) 要約に書き込まれた情報、(2) 予算内で保持された直近メッセージ、(3) 再実行可能な現在の作業環境、の3つです。過去のファイル内容やコマンド出力そのものは履歴から消えます。必要なら、要約にその要点が残っているか、あるいはツールを再度呼び出して取り直すことになります。
それでもこの割り切りが成立するのは、コーディングエージェントの作業環境が「いつでも観測し直せる」性質を持つからです。ファイルもディレクトリ構造も、必要になった時点で読み直せば最新の状態が手に入ります。過去のツール出力という「その時点のスナップショット」を履歴に抱え続けるより、要点だけ要約に残し、詳細は必要時に再取得するほうが、はるかにトークン効率が良いわけです。多くのLLMアプリは会話履歴に全情報を詰め込むため、履歴を削ると取り返しがつきません。Codexは「履歴は積極的に削るが、本当に必要な情報は要約に残すか再実行で取り戻す」という方針を徹底することで、思い切った圧縮を可能にしています。この発想は、自前でエージェントを設計する際に学ぶ価値のあるポイントだと思います。
リモートコンパクションと encrypted_content
CodexにはResponses API側の専用エンドポイントを使うリモート経路があります。codex-rs/protocol/src/models.rs に専用variantが定義されています。
#[serde(alias = "compaction_summary")]
Compaction {
encrypted_content: String,
},
CompactionTrigger,
ContextCompaction {
#[serde(default, skip_serializing_if = "Option::is_none")]
encrypted_content: Option<String>,
},
クライアント側の挙動で興味深いのは、この encrypted_content がほぼパススルーされる点です。Codex CLI内では復号も意味解釈もされず、サーバから受け取った文字列をそのまま履歴に保持し、次のリクエストに添付して返します。唯一の例外は、トークン見積もりの際に文字列の長さ(content.len())を参照する箇所だけで、中身を解釈しているわけではありません(codex-rs/core/src/context_manager/history.rs の estimate_response_item_model_visible_bytes)。
input.push(ResponseItem::CompactionTrigger);
// ... サーバから戻ってきたCompaction itemをそのまま履歴に組み込む
if let ResponseItem::Compaction { .. } = item { ... }
CompactionTrigger を送り、戻ってきた Compaction を保持する。クライアントが担うのはこれだけです。中身が何であるかをクライアントは知らないし、知る必要もありません。
この設計の含意は深いです。テキスト要約という形式は、人間にも読めて扱いやすい反面、本質的に lossy(情報が落ちる)です。一方、クライアントが解釈しない不透明なコンテンツであれば、サーバ側はテキストに縛られない、より豊かな圧縮表現を作って次の推論に引き渡せます。「クライアントは中身を理解しなくてよい」という割り切りが、サーバ側の圧縮表現を自由に進化させる余地を生んでいるわけです。クライアント側を一切書き換えずにサーバ側の圧縮アルゴリズムだけを改良できるという、運用上の大きな利点もあります。
なお、encrypted_content の具体的なフォーマット(何をどうエンコードしているか)はOSSコードからは確認できません。サーバ実装はOSSに含まれていないためです。コードから言えるのは「テキスト要約だけでは伝えにくい情報を、サーバ側で保持して次ターンに引き渡せるチャネルが用意されている」という事実までです。それ以上の中身の推測は、本記事の射程の外とします。
観測可能性 — 圧縮を「測れる」ようにしてある
コンパクションは便利な反面、ブラックボックス化すると「なぜか急に賢くなくなった」というデバッグ困難な事象を生みます。Codexはこれを避けるため、1回1回の圧縮を計測対象にしています。
圧縮の開始時には CompactionAnalyticsAttempt が、開始時点のメタデータと圧縮前トークン数を記録します。
pub(crate) struct CompactionAnalyticsAttempt {
thread_id: String,
turn_id: String,
trigger: CompactionTrigger, // Manual / Auto
reason: CompactionReason, // UserRequested / ContextLimit / ModelDownshift
implementation: CompactionImplementation, // Responses / ResponsesCompact / ResponsesCompactionV2
phase: CompactionPhase, // StandaloneTurn / PreTurn / MidTurn
active_context_tokens_before: i64,
started_at: u64,
start_instant: Instant,
}
圧縮が完了すると、これに圧縮後トークン数や所要時間が加わり、最終的なイベント(facts.rs の CodexCompactionEvent)として active_context_tokens_before / active_context_tokens_after / duration_ms まで揃った形で記録されます。「いつ(phase)」「どの経路で(trigger)」「なぜ(reason)」「どの実装で(implementation)」「どれだけ縮んだか(before / after)」「どれだけ時間がかかったか(duration_ms)」がすべて残るわけです。
さらに、リモートコンパクションについては rollout-trace/src/compaction.rs 側で request / response / checkpoint のペイロード自体をロールアウトファイルに保存するため、後からオフラインで「この圧縮で何が捨てられ何が残ったか」まで検証できます(ローカルコンパクションのストリームは現状トレース対象外で、InferenceTraceContext::disabled() が渡されます)。
これまで紹介してきたメタデータ(CompactionTrigger, CompactionReason, CompactionPhase)が随所で引き回されていたのは、最終的にこの分析基盤に集約するためでもあったわけです。エージェントの長時間挙動という、本来観測しづらいものをきちんと測定可能にしている点は、地味ですが極めて重要な設計です。
なぜ「優秀」と言えるのか
ここまでの設計を踏まえ、Codexのコンパクションを優れたものにしている要素を、背景と結びつけて整理します。
1. ターン内圧縮による破綻耐性
ターン前(PreTurn)だけでなくターン中(MidTurn)でも圧縮でき、さらにモデルのダウンシフト(理由 ModelDownshift)にも対応することで、「マルチターン推論の途中で溢れる」「小さいモデルに切り替えた瞬間に溢れる」という、素朴な実装が最も破綻しやすいケースを正面から塞いでいます。ターン境界でしか圧縮しない設計との差が、長時間タスクの完走率に直結します。
2. モデルとクライアントの協調設計
InitialContextInjection のコメントが示すとおり、コンパクション要約の扱いはモデルの訓練段階に織り込まれています。アプリ層の小手先ではなく、モデルとクライアントが同じ前提を共有して設計されている。これが圧縮後も精度が落ちにくい根本的な理由です。
3. 状態と履歴の分離
ツール状態を会話履歴の外に出すことで、「履歴は積極的に捨てるが、状態は失わない」という構造を実現しています。これがトークン削減効率を支える最大の土台です。
4. 大局と精度の両取り
要約(大局を残す)と直近メッセージの逐語保持(精度を残す)を組み合わせ、要約の情報損失で最も起きやすい失敗モードを構造的に防いでいます。
5. 退化耐性と進化余地の両立
ローカル経路をフォールバックに持つことで可用性を切らさず、同時にリモート経路の encrypted_content パススルー設計で、クライアントを書き換えずにサーバ側の圧縮を進化させられる余地を残しています。
6. 測れること
CompactionAnalyticsAttempt とロールアウトトレースにより、圧縮の発生・効果・コストをすべて事後検証できます。観測できないものは改善できない、という原則に忠実です。
まとめ
Codexのコンテキスト圧縮は、単一の賢いトリックではありません。「エージェントに必要なのは転記ではなく状態だ」という本質的な認識を出発点に、
- トリガー・理由・フェーズの3軸による発動分類(手動/自動 × ユーザー要求/コンテキスト上限/モデルダウンシフト × 独立ターン/ターン前/ターン中)
- トークン計測スコープ(Total / BodyAfterPrefix)
- 履歴置換アルゴリズム(初期コンテキスト + 直近userメッセージ + 要約)
- モデルの学習済みの期待に合わせた要約の配置
- ツール履歴の積極的な廃棄 × ツール状態の分離保持
- ローカルとリモートの二系統、そして不透明な圧縮表現というチャネル
- 観測可能性のための計装
という複数の設計判断が、互いに噛み合って成立しています。一つひとつは派手ではありませんが、長時間動くエージェントを実用に耐えるものにするには、こうした地味で正確な積み上げが必要なのだと、コードを読むとよく分かります。
自前でLLMエージェントを設計する立場から見ても、特に「状態と履歴の分離」「要約と原文の両取り」「圧縮を測定可能にする計装」の3点は、フレームワークを問わず応用できる普遍的な設計指針です。Codexのコンパクション周りは、その実例として一度じっくり読んでみる価値があると思います。