Adaptive Output Token Escalation Design
GPU スロットの過剰予約を約 4 倍削減するため、出力トークンに対して「低デフォルト+トランケーション時のエスカレーション」戦略を採用し、エスカレーション後の上限を超える応答にはマルチターンリカバリーを行う。
問題
API リクエストはそれぞれ max_tokens に比例した固定の GPU スロットを予約する。以前のデフォルト 32K トークンでは各リクエストが 32K の出力スロットを予約するが、99% の応答は 5K トークン未満である。これにより GPU 容量が 4〜6 倍過剰予約され、サーバーの同時実行性が制限され、コストが増加する。
解決策
出力トークンのデフォルトに上限付きの 8K を使用する。応答がトランケーションされた(モデルが max_tokens に達した)場合:
- エスカレーション: モデルのフル出力制限まで引き上げる(不明なモデルについては 64K を下限とする)
- それでもトランケーションされた場合、部分応答を履歴に保持し、継続メッセージを注入して リカバリー を最大 3 回行う
- リカバリーを使い果たした場合、ツールスケジューラのトランケーションガイダンスにフォールバックする
実際にトランケーションされるリクエストは 1% 未満であるため、この方法により長い応答の出力品質を維持しながら、平均スロット予約を大幅に削減できる。
アーキテクチャ
Request (max_tokens = 8K)
│
▼
┌─────────────────────────┐
│ 応答がトランケーション? │──── No ──▶ Done ✓
│ (MAX_TOKENS) │
└───────────┬──────────────┘
│ Yes
▼
┌──────────────────────────────────────────────────┐
│ レイヤ 1: モデル出力制限までエスカレーション │
│ ┌────────────────────────────────────────────┐ │
│ │ 部分応答を履歴から取り出す │ │
│ │ RETRY (isContinuation: false → UI リセット) │ │
│ │ max(64K, モデル出力制限) で再送信 │ │
│ └────────────────────────────────────────────┘ │
└───────────┬──────────────────────────────────────┘
│
▼
┌─────────────────────────┐
│ まだトランケーション? │──── No ──▶ Done ✓
│ (MAX_TOKENS) │
└───────────┬──────────────┘
│ Yes
▼
┌──────────────────────────────────────────────────┐
│ レイヤ 2: マルチターンリカバリー(最大 3 回) │
│ ┌────────────────────────────────────────────┐ │
│ │ 部分応答を履歴に保持 │ │
│ │ ユーザーメッセージを追加: "Resume directly..."│ │
│ │ RETRY (isContinuation: true → UI バッファ維持)│ │
│ │ 更新済み履歴で再送信 │ │
│ │ モデルは中断した場所から続行 │ │
│ └──────────────┬─────────────────────────────┘ │
│ │ │
│ ┌──────┴──────┐ │
│ │ 成功? │── Yes ──▶ Done ✓ │
│ └──────┬──────┘ │
│ │ No (まだトランケーション) │
│ ▼ │
│ attempt < 3? ── Yes ──▶ ループ戻る ↑ │
└───────────┬──────────────────────────────────────┘
│ No (使い果たした)
▼
┌──────────────────────────────────────────────────┐
│ レイヤ 3: ツールスケジューラへのフォールバック │
│ ┌────────────────────────────────────────────┐ │
│ │ トランケーションされた Edit/Write ツール呼出 │ │
│ │ を拒否 │ │
│ │ ガイダンスを返す: "You MUST split into │ │
│ │ smaller parts — write skeleton first, │ │
│ │ then edit incrementally." │ │
│ └────────────────────────────────────────────┘ │
└──────────────────────────────────────────────────┘トークン制限の決定
実効的な max_tokens は以下の優先順位で解決される:
| 優先度 | ソース | 既知モデルの値 | 未知モデルの値 | エスカレーション動作 |
|---|---|---|---|---|
| 1(最高) | ユーザー設定 (samplingParams.max_tokens) | min(userValue, modelLimit) | userValue | エスカレーションなし |
| 2 | 環境変数 (QWEN_CODE_MAX_OUTPUT_TOKENS) | min(envValue, modelLimit) | envValue | エスカレーションなし |
| 3(最低) | 上限付きデフォルト | min(modelLimit, 8K) | min(32K, 8K)=8K | モデル制限(64K 下限)+リカバリーにエスカレーション |
「既知モデル」とは、OUTPUT_PATTERNS に明示的なエントリがあるモデル(hasExplicitOutputLimit() でチェック)である。既知モデルの場合、API エラーを避けるため実効値は常にモデルの宣言済み出力制限で上限が設定される。未知モデル(カスタムデプロイメント、セルフホストエンドポイント)では、バックエンドがより大きな制限をサポートしている可能性があるため、ユーザーの値をそのまま渡す。
このロジックは以下の 3 つのコンテンツジェネレーターに実装されている:
DefaultOpenAICompatibleProvider.applyOutputTokenLimit()— OpenAI 互換プロバイダーDashScopeProvider— デフォルトプロバイダーからapplyOutputTokenLimit()を継承AnthropicContentGenerator.buildSamplingParameters()— Anthropic プロバイダー
エスカレーションメカニズム
エスカレーションのロジックは geminiChat.ts にあり、メインのリトライループの外側 に配置されている。これは意図的な設計である:
- リトライループは一時的なエラー(レート制限、無効なストリーム、コンテンツ検証)を処理する
- トランケーションはエラーではなく、成功した応答が途中で切られた状態である
- エスカレーション後のストリームからのエラーは、リトライロジックで捕捉されるのではなく、直接呼び出し元に伝播するべきである
エスカレーション手順 (geminiChat.ts)
1. ストリームが正常に完了 (lastError === null)
2. 最後のチャンクの finishReason が MAX_TOKENS
3. ガードチェックを通過:
- maxTokensEscalated === false (無限エスカレーション防止)
- hasUserMaxTokensOverride === false (ユーザーの意図を尊重)
4. エスカレーション後の制限を計算: max(ESCALATED_MAX_TOKENS, tokenLimit(model, 'output'))
5. 部分的なモデル応答をチャット履歴から取り出す
6. RETRY イベント (isContinuation: false) を生成 → UI は部分出力を破棄しバッファをリセット
7. 同じリクエストを maxOutputTokens: escalatedLimit で再送信リカバリー手順 (geminiChat.ts)
エスカレーション後の応答もトランケーションされた場合(finishReason === MAX_TOKENS)、リカバリーループが最大 MAX_OUTPUT_RECOVERY_ATTEMPTS (3) 回実行される:
1. 部分的なモデル応答は既に履歴に存在 (processStreamResponse でプッシュ済み)
2. リカバリーユーザーメッセージをプッシュ: OUTPUT_RECOVERY_MESSAGE
3. RETRY イベント (isContinuation: true) を生成 → UI はテキストバッファを継続用に保持
4. 更新済み履歴で再送信 (モデルは自身の部分出力+リカバリー指示を参照)
5. まだトランケーションされていて試行回数が残っている場合、ステップ 1 にループバック
6. リカバリー試行が例外をスローした場合(空応答、ネットワークエラー):
- 履歴から宙ぶらりんのリカバリーメッセージを取り出す
- リカバリーループを抜けるRETRY 時の状態クリーンアップ (turn.ts)
Turn クラスが RETRY イベントを受け取ると、不整合を防ぐために蓄積された状態をクリアする:
pendingToolCalls— 最初のトランケーション応答に完了したツール呼び出しが含まれ、それがエスカレーション後の応答で再現される場合の重複を防ぐためクリアpendingCitations— 重複引用を防ぐためクリアfinishReason— 新しい応答の finish reason を使用するためundefinedにリセット
isContinuation フラグは UI に渡され、UI はテキストバッファをリセットするか(エスカレーション)、そのまま保持するか(リカバリー)を決定できる。
定数
geminiChat.ts と tokenLimits.ts で定義:
| 定数 | 値 | 目的 |
|---|---|---|
CAPPED_DEFAULT_MAX_TOKENS | 8,000 | ユーザーオーバーライドがない場合のデフォルト出力トークン制限 |
ESCALATED_MAX_TOKENS | 64,000 | エスカレーションの下限(モデル制限が不明の場合に使用) |
MAX_OUTPUT_RECOVERY_ATTEMPTS | 3 | エスカレーション後の最大マルチターンリカバリー試行回数 |
実効的なエスカレーション制限は max(ESCALATED_MAX_TOKENS, tokenLimit(model, 'output')) となる:
| モデル | エスカレーション制限 |
|---|---|
| Claude Opus 4.6 | 131,072 (128K) |
| GPT-5 / o-series | 131,072 (128K) |
| Qwen3.x | 65,536 (64K) |
| 未知モデル | 64,000 (下限) |
設計判断
なぜ 8K デフォルトなのか?
- 99% の応答は 5K トークン未満
- 8K は少し長い応答に対して不要なリトライを発生させず、適度な余裕を提供
- 平均スロット予約を 32K から 8K に削減(4 倍改善)
なぜ固定の 64K ではなくモデル制限までエスカレーションするのか?
- より高い出力制限を持つモデル(Claude Opus 128K、GPT-5 128K)が不必要に 64K に制限されていた
- モデルの実際の制限を使用することで、2 回目のリトライなく大多数の長い出力をカバーできる
ESCALATED_MAX_TOKENS(64K) は、tokenLimit()がデフォルトの 32K を返す未知モデルに対する下限として機能する
なぜ段階的エスカレーションではなくマルチターンリカバリーなのか?
- 段階的エスカレーション(8K → 16K → 32K → 64K)では毎回完全な応答を再生成する必要がある
- マルチターンリカバリーは部分応答を維持し、モデルに続行させるため、トークンとレイテンシを節約できる
- リカバリーメッセージは大きな応答を再生成するのに比べて安価(約 40 トークン)
- 3 回の試行制限により、実用的なケースをカバーしつつ無限ループを防止
なぜエスカレーションがリトライループの外側にあるのか?
- トランケーションは成功ケースであり、エラーではない
- エスカレーション後のストリームからのエラー(レート制限、ネットワーク障害)は、誤ったパラメータで静かにリトライされるのではなく、直接伝播されるべきである
- リトライループの本来の目的(一時的なエラー回復)に集中させることができる
- 会話全体を中断しないよう、リカバリーエラーは個別に補足される