← 記事一覧

Agent! がコンテキストウィンドウの半分で圧縮する理由と、それを 16K まで縮めてしまった 3 つのバグ

Agent! のコンテキスト圧縮が長いタスクを要約するタイミングをどう決めているのか、そしてフォールバックモデル、Ollama、272K の Codex ウィンドウに対する 9 月 28 日の修正を解説します。

長時間にわたるエージェントのタスクには、物理的な問題がつきまといます。ツールを呼び出すたびに、その出力がトランスクリプトに追加されていきます。ファイルの読み込み、ビルドログ、diff。やがてトランスクリプトはモデルのコンテキストウィンドウに収まらなくなり、プロバイダーはリクエストを拒否します。夜通し動くエージェントは 圧縮 しなければなりません。大事な情報を残しつつ、会話を縮めるのです。

圧縮処理は Agent/AgentViewModel/Messages/Compression.swift にあります。9 月 28 日の夜、ここに 3 つの修正が一気に入りました。いずれも同じ症状、つまりはるかに大きなウィンドウを持つモデルなのに、タスクが 16K トークンで圧縮されてしまうという問題に対するものです。以下では、この仕組みがどう動くのか、そして何がまずかったのかを見ていきます。

いつ圧縮するか:ウィンドウの半分、ただし上限つき

トリガーを担うのは CompactionState という構造体です。その中核は 1 つの関数だけです。

static func threshold(for contextWindow: Int, maxTokens: Int = 0) -> Int {
    let byFraction = Int(Double(min(contextWindow, compactionWindowCap)) * compactionFraction)
    let reservedOutput = maxTokens > 0 ? min(maxTokens, contextWindow / 2) : 8_192
    return max(2_000, min(byFraction, contextWindow - reservedOutput))
}

compactionFraction = 0.5、compactionWindowCap = 256_000 の場合、これは次のように読めます。

  • ウィンドウの 50% で圧縮する。 固定のトークン数ではなく割合です。残りの半分は出力の予算なので、入力と出力の合計が常に収まります。
  • 128K を超えることはない。 公称ウィンドウが 256K を超えるモデル(Claude 1M、MiniMax 1M、Gemini と Grok 2M)は、すべて 128K で圧縮されます。200K のウィンドウなら 100K で圧縮されます。
  • 応答の余地を常に残す。 予約した出力が収まらなくなるほど、しきい値を遅くすることはできません。
  • 2K を下回ることはない。 小さなローカルモデルのための下限です。

なぜ 2M のウィンドウを 128K で頭打ちにするのでしょうか。コメントは率直です。巨大な公称ウィンドウに対して割合に上限を設けないと、圧縮があまりに先延ばしになり、プロバイダー側で何らかの食い違いが起きたときに、圧縮ではなく致命的なコンテキストオーバーフローになってしまいます。たとえば、ルーターが報告より短いウィンドウで提供していたり、システムプロンプトのカウントが間違っていたりするケースです。早めに圧縮するコストは要約 1 回分ですが、遅すぎる圧縮のコストはタスクそのものです。

計測:まずプロバイダーを信じ、次に見積もる

しきい値と比較するには、トークン数が必要です。文字数からの推測は誤差が大きいため、measuredTokens は実測値を優先します。つまり、直前のリクエストに対してプロバイダーが報告した input_tokens です。この数値にはシステムプロンプトやツールスキーマも含まれており、これらはローカルの見積もりでは把握できません。見積もりが必要なのは、その報告 以降 に追加されたメッセージだけです。

報告がない場合は、古典的な「文字数 ÷ 4」の見積もりに 25% を上乗せした値にフォールバックします。密度の高いコードは 1 トークンあたり 3.3 文字程度になるからです。見積もりだけを根拠にコストのかかる圧縮を実行する前に、ループはオンデバイスのカウンターで確認します。

どう圧縮するか:段階的に

しきい値を超えると、tieredCompact が段階的に処理をエスカレートさせていきます。

  1. Tier 0:構造化された要約。 アクティブなモデルが トランスクリプト全体 を要約します。要約する側がまだツールの出力を参照できるよう、最初に実行されます。
  2. マイクロ圧縮: 古いツールの結果を、短く復元可能なスタブに置き換えます。restore_tool_result ツールを使えば、どれでも元に戻せます。
  3. 画像の除去: スクリーンショットは非常に大きく、うまく要約できません。
  4. Tier 1:Apple Intelligence による要約。 高速で、オンデバイスで処理されます。
  5. Tier 2:積極的な刈り込み。 中間のメッセージを 1 つの要約にまとめます。

ある 1 行のコメントがその思想を端的に表しています。構造的な圧縮は「機能ではなく安全機構」だというのです。Token Compression をオフにしても実行され、そのトグルに従うのは Apple Intelligence の段階だけです。

圧縮の後、モデルには、失うと最も困る情報が戻されます。未達成のゴール基準、進行中のプランのチェックリスト、そしてタスク中に編集したファイルのうち最大 5 つの現在の内容(合計で約 10K トークン)です。読み込みの重複排除キャッシュもリセットされるので、ファイルを再び読み込めるようになります。

最後に、 サーキットブレーカー があります。トランスクリプトを縮められない圧縮が 3 回続くと、ループは試行をやめます。ただし永久にあきらめるわけではありません。最後に失敗した時点からトランスクリプトがさらに 25% 増えると、再び試行します。

バグ:16K へ至る 3 つの道

9 月 28 日の修正は、どれも同じ数字にたどり着いていました。32K のフォールバックウィンドウでは min(16K, 32K − 8K) = 16K になります。200K 以上のモデルで 16K ごとに圧縮するということは、ほぼ絶え間なく要約を繰り返し、モデルが読んだばかりのファイルを忘れてしまうことを意味します。

1. フォールバックモデルが間違ったウィンドウを借りていた

Agent! はフォールバックチェーンに対応しています。プロバイダーが 429 を返したり、タイムアウトしたり、ネットワークから切断されたりした場合、タスクは次に設定されたプロバイダーで続行されます。タブごとにモデルを上書きすることもできます。ところが contextWindow(for:) は、実際に動いているモデルではなく、プロバイダーで グローバルに選択された モデルのウィンドウを参照していました。エントリーのないフォールバックモデルの場合は、32K の静的なサイズに落ちてしまっていたのです。

修正では、参照にモデルを追加しました。

func contextWindow(for provider: APIProvider, model: String? = nil) -> Int

現在では、メインループ、タブのタスク、フォールバック経路、サブエージェントのすべての呼び出し箇所で、実際に使用しているモデルが渡されます。

2. Ollama が学んだことを忘れていた

ローカルサーバーは、モデルごとの実際のウィンドウを非同期で報告します。Ollama は /api/show、LM Studio は /api/v0/models、vLLM は /v1/models を通じてです。だからこそループは、初回以降のすべてのイテレーションで refreshThreshold を呼び出します。タスク開始後に取得結果が届いても、ちゃんと反映されるわけです。

しかし Ollama では、取得したウィンドウが永続化されていなかったため、圧縮はずっとデフォルトの 16K のままでした。修正により、取得したコンテキストウィンドウは永続化され、ウィンドウが不明な場合はタスク開始時に取得するようになりました。あわせて新しいテストスイート OllamaContextWindowTests.swift も追加されています。

3. 気前のよい出力予算が入力予算を食いつぶした

これはわかりにくい問題です。Codex は GPT-6 Astra モデルのウィンドウを 272K と報告します。あるユーザーが Max Output Tokens を 256K に設定しました。旧コードは出力予算を丸ごと予約していました。

272K − 256K = 16K   →   threshold = min(128K, 16K) = 16K

新しいコードでは、出力用に予約するのは最大でもウィンドウの半分です。

let reservedOutput = maxTokens > 0 ? min(maxTokens, contextWindow / 2) : 8_192

同じケースで、今は min(128K, 272K − 136K) = 128K になります。割合による上限には引き続き上限適用後のウィンドウを使いますが、出力が収まるかどうかのチェックには 本当の ウィンドウを使います。そうしないと、1M ウィンドウでの Claude のデフォルト出力予算 500K によって window − maxTokens が負になり、しきい値が下限の 2K まで落ちてしまうからです。

これがあなたにとって何を意味するか

フォールバックプロバイダーやローカルモデルで長いタスク、特に夜通しのコーディングを実行しているなら、そうしたタスクは今後、はるかに多くの作業コンテキストを保てるはずです。「あのファイルを読み直さないと」というループが減り、細部が失われることも減り、要約に費やすトークンも減ります。これらの修正は 9 月 28 日に、Version 1.1.77(build 277)のリリース候補 6 とともに出荷されました。同時に、コンテキストオーバーフローと max_tokens エラーをプロバイダー横断で検出する機能も入っています。

エージェントを作る人にとっての教訓は普遍的です。 メモリのサイズは、実際に動いているモデルに合わせて決める こと。自前の見積もりよりプロバイダーのトークン数を信頼すること。そして、どれか 1 つの設定が他を圧迫しないよう、すべての予算に上限を設けることです。