出力トークン制限とエスカレーション設計
ユーザーまたは環境によって
max_tokensが設定されていない限り、モデルの宣言された出力制限をデフォルトとし、それでも応答がMAX_TOKENSに達した場合にのみエスカレーションとマルチターンリカバリーを使用します。
問題
すべての API リクエストは、max_tokens に比例した固定の GPU スロットを予約します。デフォルト値を低く設定するとスロット予約を減らせますが、通常の大きな応答が切り詰められる可能性も高くなります。ファイル書き込みワークフローでは、これにより不完全なツール呼び出し引数が生成され、スケジューラーが部分的な書き込みを拒否せざるを得なくなる可能性があります。
解決策
デフォルトではモデルの宣言された出力制限を使用します。応答が切り詰められた場合(モデルが max_tokens に達した場合):
- モデルの完全な出力制限までエスカレートする(現在の制限がそれより低い場合、64K を下限とする)
- それでも切り詰められる場合は、部分的な応答を履歴に保持し、継続メッセージを挿入して最大 3 回リカバリーする
- リカバリーが尽きた場合は、ツールスケジューラーの切り詰めガイダンスにフォールバックする
これにより、大規模な生成タスクやファイル編集タスクの正確性が優先されます。より低い予約を必要とするオペレーターは、引き続き QWEN_CODE_MAX_OUTPUT_TOKENS を設定でき、その明示的な値が尊重されます。
アーキテクチャ
Request (max_tokens = user/env value or model output limit)
│
▼
┌─────────────────────────┐
│ Response truncated? │──── No ──▶ Done ✓
│ (MAX_TOKENS) │
└───────────┬──────────────┘
│ Yes
▼
┌──────────────────────────────────────────────────┐
│ Layer 1: Escalate to model output limit │
│ ┌────────────────────────────────────────────┐ │
│ │ Pop partial response from history │ │
│ │ RETRY (isContinuation: false → reset UI) │ │
│ │ Re-send at max(64K, model output limit) │ │
│ └────────────────────────────────────────────┘ │
└───────────┬──────────────────────────────────────┘
│
▼
┌─────────────────────────┐
│ Still truncated? │──── No ──▶ Done ✓
│ (MAX_TOKENS) │
└───────────┬──────────────┘
│ Yes
▼
┌──────────────────────────────────────────────────┐
│ Layer 2: Multi-turn recovery (up to 3×) │
│ ┌────────────────────────────────────────────┐ │
│ │ Keep partial response in history │ │
│ │ Push user message: "Resume directly..." │ │
│ │ RETRY (isContinuation: true → keep UI buf) │ │
│ │ Re-send with updated history │ │
│ │ Model continues from where it left off │ │
│ └──────────────┬─────────────────────────────┘ │
│ │ │
│ ┌──────┴──────┐ │
│ │ Succeeded? │── Yes ──▶ Done ✓ │
│ └──────┬──────┘ │
│ │ No (still truncated) │
│ ▼ │
│ attempt < 3? ── Yes ──▶ loop back ↑ │
└───────────┬──────────────────────────────────────┘
│ No (exhausted)
▼
┌──────────────────────────────────────────────────┐
│ Layer 3: Tool scheduler fallback │
│ ┌────────────────────────────────────────────┐ │
│ │ Reject truncated Edit/Write tool calls │ │
│ │ Return guidance: "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 (最低) | モデル/デフォルト出力制限 | modelLimit | DEFAULT_OUTPUT_TOKEN_LIMIT = 32K | モデル制限までエスカレート (下限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 イベントを yield する (isContinuation: false) → UI は部分的な出力を破棄し、バッファをリセットする
7. maxOutputTokens: escalatedLimit で同じリクエストを再送信するリカバリーステップ (geminiChat.ts)
エスカレートされた応答も切り詰められた場合(finishReason === MAX_TOKENS)、リカバリー ループは MAX_OUTPUT_RECOVERY_ATTEMPTS (3) 回まで実行されます:
1. 部分的なモデル応答はすでに履歴にある (processStreamResponse によってプッシュされた)
2. リカバリーユーザーメッセージをプッシュする: OUTPUT_RECOVERY_MESSAGE
3. RETRY イベントを yield する (isContinuation: true) → UI は継続のためにテキストバッファを保持する
4. 更新された履歴で再送信する (モデルは自身の部分的な出力 + リカバリー指示を見る)
5. まだ切り詰められており、かつ試行回数が残っている場合は、ステップ 1 にループして戻る
6. リカバリー試行がスローされた場合 (空の応答、ネットワークエラー):
- 履歴から浮いたリカバリーメッセージをポップする
- リカバリー ループを抜けるRETRY 時の状態クリーンアップ (turn.ts)
Turn クラスが RETRY イベントを受信すると、不整合を防ぐために蓄積された状態をクリアします:
pendingToolCalls— 最初の切り詰められた応答に完了したツール呼び出しが含まれており、それがエスカレートされた応答で繰り返される場合の重複ツール呼び出しを避けるためにクリアされますpendingCitations— 引用の重複を避けるためにクリアされますfinishReason— 新しい応答の finish reason が使用されるようにundefinedにリセットされます
isContinuation フラグは UI に渡され、テキストバッファをリセットするか(エスカレーション)、保持するか(リカバリー)を決定できるようにします。
定数
geminiChat.ts および tokenLimits.ts で定義されています:
| 定数 | 値 | 目的 |
|---|---|---|
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 を使用しないのか?
- 8K デフォルトは正確性の要件ではなく、スロット予約/容量の最適化です。これは、バックエンドのスループット(リクエストは
max_tokensに比例した GPU スロットを予約するため、低い値ほど過剰予約が少なくなる)のために、正確性(大きな応答が切り詰められる)を犠牲にするものです。 - 大規模なファイル生成や編集ツール呼び出しは正当に 8K を超える可能性があるため、8K デフォルトは通常のリクエストを「切り詰め → エスカレート」の往復(最悪の場合、リトライループ)に変えてしまいます。
- Claude Code も同じ 8K キャップを維持していますが、サードパーティプロバイダーではデフォルトでオフになる機能フラグ(
tengu_otk_slot_v1)の背後にそれを隠しています(「Bedrock/Vertex では未検証」)。つまり、ファーストパーティ以外のサービスに対するデフォルト動作は、まさに「モデルの宣言された制限を使用する」です。qwen-code のプロバイダーはすべてサードパーティ / OpenAI互換 / セルフホストであるため、そのデフォルトオフの動作に一致させるのが安全な選択です。低いデフォルトがすべてのバックエンドで安全であると仮定することはできません。 - 容量のトレードオフは失われたのではなく、オプトインにしただけです。容量が制約されているセルフホストバックエンドのオペレーターは、
QWEN_CODE_MAX_OUTPUT_TOKENS(例:8000)を設定して、リクエストごとの低い予約を復元できます。GrowthBook スタイルの機能フラグは意図的に再導入されていません。qwen-code にはそのようなインフラストラクチャがなく、環境変数ですでにニーズがカバーされているためです。
なぜ固定の 64K ではなくモデル制限までエスカレートするのか?
- より高い出力制限を持つモデル(Claude Opus 128K、GPT-5 128K)が不必要に 64K に制約されていた
- モデルの実際の制限を使用することで、2回目のリトライなしで非常に長い出力の大部分をカバーできる
ESCALATED_MAX_TOKENS(64K) は、tokenLimit()がデフォルトの 32K を返す不明なモデルの下限として機能する
なぜ段階的なエスカレーションではなくマルチターンリカバリーなのか?
- 段階的なエスカレーション(例: 16K -> 32K -> 64K)では、毎回完全な応答を再生成する必要がある
- マルチターンリカバリーは部分的な応答を保持し、モデルに続行させることで、トークンとレイテンシを節約する
- リカバリーメッセージは、大きな応答を再生成するのに比べてコストが安い(各約 40 トークン)
- 3 回の試行制限は、無限ループを防ぎながら、ほとんどの実用的なケースをカバーする
なぜエスカレーションはリトライループの外側にあるのか?
- 切り詰めは成功ケースであり、エラーではない
- エスカレートされたストリームからのエラー(レート制限、ネットワーク障害)は、誤ったパラメータでサイレントにリトライされるのではなく、直接伝播されるべきである
- リトライループを元の目的(過渡的なエラーのリカバリー)に集中させる
- リカバリーエラーは、会話全体を中止しないように個別にキャッチされる