Архитектура адаптивного увеличения лимита выходных токенов
Снижает избыточное резервирование GPU-слотов примерно в 4 раза за счёт стратегии «низкий лимит по умолчанию + увеличение при обрезке» для выходных токенов, а также многошагового восстановления для ответов, превышающих даже увеличенный лимит.
Проблема
Каждый API-запрос резервирует фиксированный GPU-слот, пропорциональный max_tokens. Предыдущее значение по умолчанию в 32K токенов означало, что каждый запрос резервировал слот на 32K выходных токенов, хотя 99% ответов занимают менее 5K токенов. Это приводит к избыточному резервированию GPU-ресурсов в 4–6 раз, ограничивая параллелизм сервера и увеличивая затраты.
Решение
Использовать ограниченный лимит по умолчанию в 8K выходных токенов. Если ответ обрезается (модель достигает max_tokens):
- Увеличить лимит до максимального значения модели (с минимальным порогом 64K для неизвестных моделей)
- Если ответ всё ещё обрезается, восстановить генерацию, сохранив частичный ответ в истории и добавив сообщение-продолжение (до 3 раз)
- Если попытки восстановления исчерпаны, применить рекомендации планировщика инструментов по обработке обрезанных ответов
Поскольку обрезается менее 1% запросов, это значительно снижает среднее резервирование слотов, сохраняя качество вывода для длинных ответов.
Архитектура
Request (max_tokens = 8K)
│
▼
┌─────────────────────────┐
│ 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 (наименьший) | Ограниченное значение по умолчанию | min(modelLimit, 8K) | min(32K, 8K) = 8K | Увеличивается до лимита модели (мин. 64K) + восстановление |
«Известная модель» — это модель, имеющая явную запись в OUTPUT_PATTERNS (проверяется через hasExplicitOutputLimit()). Для известных моделей эффективное значение всегда ограничивается заявленным лимитом вывода модели, чтобы избежать ошибок API. Для неизвестных моделей (кастомные развёртывания, self-hosted эндпоинты) значение пользователя передаётся напрямую, так как бэкенд может поддерживать большие лимиты.
Эта логика реализована в трёх генераторах контента:
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. Compute escalated limit: max(ESCALATED_MAX_TOKENS, tokenLimit(model, 'output'))
5. Pop the partial model response from chat history
6. Yield RETRY event (isContinuation: false) → UI discards partial output and resets buffers
7. Re-send the same request with maxOutputTokens: escalatedLimitШаги восстановления (geminiChat.ts)
Если увеличенный ответ также обрезается (finishReason === MAX_TOKENS), цикл восстановления выполняется до MAX_OUTPUT_RECOVERY_ATTEMPTS (3) раз:
1. Partial model response is already in history (pushed by processStreamResponse)
2. Push a recovery user message: OUTPUT_RECOVERY_MESSAGE
3. Yield RETRY event (isContinuation: true) → UI keeps text buffer for continuation
4. Re-send with updated history (model sees its partial output + recovery instruction)
5. If still truncated and attempts remain, loop back to step 1
6. If recovery attempt throws (empty response, network error):
- Pop the dangling recovery message from history
- Break out of recovery loopОчистка состояния при RETRY (turn.ts)
Когда класс Turn получает событие RETRY, он очищает накопленное состояние для предотвращения несоответствий:
pendingToolCalls— очищается, чтобы избежать дублирования вызовов инструментов, если первый обрезанный ответ содержал завершённые вызовы, которые повторяются в увеличенном ответеpendingCitations— очищается, чтобы избежать дублирования ссылокdebugResponses— очищается, чтобы избежать устаревших отладочных данныхfinishReason— сбрасывается вundefined, чтобы использовалась причина завершения нового ответа
Флаг isContinuation передаётся в 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
- Использование фактического лимита модели позволяет обработать подавляющее большинство длинных ответов без второй повторной попытки
ESCALATED_MAX_TOKENS(64K) служит минимальным порогом для неизвестных моделей, гдеtokenLimit()возвращает стандартные 32K
Почему многошаговое восстановление, а не постепенное увеличение?
- Постепенное увеличение (8K → 16K → 32K → 64K) требует полной регенерации ответа каждый раз
- Многошаговое восстановление сохраняет частичный ответ и позволяет модели продолжить генерацию, экономя токены и снижая задержку
- Сообщения восстановления дёшевы (~40 токенов каждое) по сравнению с регенерацией больших ответов
- Лимит в 3 попытки предотвращает бесконечные циклы, покрывая при этом большинство практических сценариев
Почему увеличение вынесено за пределы цикла повторных попыток?
- Обрезка — это успешный сценарий, а не ошибка
- Ошибки из увеличенного стрима (лимиты запросов, сетевые сбои) должны передаваться напрямую, а не молча повторяться с некорректными параметрами
- Позволяет циклу повторных попыток оставаться сфокусированным на своей исходной задаче (восстановление после временных ошибок)
- Ошибки восстановления обрабатываются отдельно, чтобы не прерывать весь диалог