適応型出力トークンエスカレーション設計
出力トークンに対して「低いデフォルト値 + 切り捨て時にエスカレーション」戦略を採用することで、GPUスロットの過剰予約を約4倍削減する。
課題
すべてのAPIリクエストは、max_tokensに比例した固定のGPUスロットを予約する。以前のデフォルト値である32Kトークンでは、各リクエストが32Kの出力スロットを予約していたが、実際のレスポンスの99%は5Kトークン未満である。これによりGPU容量が4〜6倍過剰に予約され、サーバーの同時実行数が制限され、コストが増加していた。
解決策
出力トークンのデフォルト値を上限付きの 8K に設定する。レスポンスが切り捨てられた場合(モデルが max_tokens に到達した場合)、上限を 64K にエスカレーションして自動的に1回リトライする。実際に切り捨てが発生するリクエストは1%未満であるため、平均スロット予約量を大幅に削減しつつ、長いレスポンスの出力品質を維持できる。
Architecture
┌─────────────────────────┐
│ Request starts │
│ max_tokens = 8K │
└───────────┬─────────────┘
│
▼
┌─────────────────────────┐
│ Stream response │
└───────────┬─────────────┘
│
┌─────────┴─────────┐
│ │
finish_reason finish_reason
!= MAX_TOKENS == MAX_TOKENS
│ │
▼ ▼
┌───────────┐ ┌─────────────────────┐
│ Done │ │ Check conditions: │
└───────────┘ │ - No user override? │
│ - No env override? │
│ - Not already │
│ escalated? │
└─────────┬───────────┘
YES │ NO
┌─────────┴────┐
│ │
▼ ▼
┌─────────────┐ ┌──────────┐
│ Pop partial │ │ Done │
│ model resp │ │ (truncd) │
│ from history│ └──────────┘
│ │
│ Yield RETRY │
│ event │
│ │
│ Re-send │
│ max_tokens │
│ = 64K │
└─────────────┘トークン制限の決定
有効な 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. Stream completes successfully (lastError === null)
2. Last chunk has finishReason === MAX_TOKENS
3. Guard checks pass:
- maxTokensEscalated === false (prevent infinite escalation)
- hasUserMaxTokensOverride === false (respect user intent)
4. Pop the partial model response from chat history
5. Yield RETRY event → UI discards partial output
6. Re-send the same request with maxOutputTokens: 64KRETRY 時の状態クリーンアップ(turn.ts)
Turn クラスが RETRY イベントを受信すると、不整合を防ぐために蓄積された状態をクリアする。
pendingToolCalls— 最初の切り捨てられたレスポンスに含まれていた完了済みのツール呼び出しが、エスカレーション後のレスポンスで重複して実行されるのを防ぐためにクリアpendingCitations— 引用の重複を防ぐためにクリアdebugResponses— 古いデバッグデータが残るのを防ぐためにクリアfinishReason— 新しいレスポンスの終了理由が使用されるようにundefinedにリセット
定数
tokenLimits.ts で定義。
| 定数 | 値 | 目的 |
|---|---|---|
CAPPED_DEFAULT_MAX_TOKENS | 8,000 | ユーザーによる上書きが設定されていない場合のデフォルト出力トークン制限 |
ESCALATED_MAX_TOKENS | 64,000 | 切り捨てリトライ時に使用される出力トークン制限 |
設計上の判断
デフォルトを 8K にした理由
- レスポンスの99%が5Kトークン未満である
- 8Kは、不要なリトライを発生させずに、やや長いレスポンスに対して適切な余裕を提供する
- 平均スロット予約量を32Kから8Kに削減(4倍の改善)
エスカレーション制限を 64K にした理由
- 8Kで切り捨てられた長い出力の大部分をカバーする
- 多くの最新モデル(Claude Sonnet、Gemini 3.x、Qwen3.x)の出力制限と一致する
- より高い値(例:128K)にすると、エスカレーションする1%未満のリクエストにおいてスロット最適化のメリットが相殺される
段階的エスカレーション(8K → 16K → 32K → 64K)を採用しなかった理由
- リトライのたびにレイテンシが増加する(レスポンス全体を再生成する必要があるため)
- 1回のリトライは、ほぼすべてのケースをカバーする最も単純なアプローチである
- 8Kでの切り捨て率が1%未満であるため、エスカレーションが必要なリクエストはほぼ存在しない。必要な場合は、16Kを大幅に超えるトークンが必要である可能性が高い
エスカレーションをリトライループの外側に配置した理由
- 切り捨てはエラーではなく、成功ケースである
- エスカレーション後のストリームで発生したエラー(レート制限、ネットワーク障害)は、誤ったパラメータでサイレントリトライされるのではなく、直接伝播する必要がある
- リトライループを本来の目的(一時的なエラーからの回復)に集中させることができる