Skip to Content
ДизайнAdaptive Output Token EscalationЛимит выходных токенов и проектирование эскалации

Лимит выходных токенов и проектирование эскалации

По умолчанию используется заявленный моделью лимит на вывод, если только пользователь или окружение не настроили max_tokens. Эскалация и многораундовое восстановление применяются только в том случае, если ответ все равно достигает MAX_TOKENS.

Проблема

Каждый API-запрос резервирует фиксированный слот GPU, пропорциональный max_tokens. Низкое значение по умолчанию может уменьшить резервирование слотов, но при этом увеличивает вероятность обрезания (truncation) обычных больших ответов. В рабочих процессах записи файлов это может привести к появлению неполных аргументов вызова инструментов и заставить планировщик отклонить частичную запись.

Решение

По умолчанию используйте заявленный моделью лимит на вывод. Если ответ обрезается (модель достигает max_tokens):

  1. Эскалируйте до полного лимита вывода модели (с нижним порогом 64K, если текущий лимит ниже)
  2. Если ответ все еще обрезается, восстановите его, сохраняя частичный ответ в истории и внедряя сообщение о продолжении, до 3 раз
  3. Если попытки восстановления исчерпаны, откатитесь к рекомендациям планировщика инструментов по обрезанию

Это обеспечивает корректность для задач большой генерации и редактирования файлов. Операторы, которым требуется меньшее резервирование, по-прежнему могут установить QWEN_CODE_MAX_OUTPUT_TOKENS, и это явное значение будет учтено.

Архитектура

Запрос (max_tokens = значение пользователя/окружения или лимит вывода модели) ┌─────────────────────────┐ │ Ответ обрезан? │──── Нет ──▶ Готово ✓ │ (MAX_TOKENS) │ └───────────┬──────────────┘ │ Да ┌──────────────────────────────────────────────────┐ │ Уровень 1: Эскалация до лимита вывода модели │ │ ┌────────────────────────────────────────────┐ │ │ │ Извлечь частичный ответ из истории │ │ │ │ RETRY (isContinuation: false → сброс UI) │ │ │ │ Повторная отправка с max(64K, лимит модели)│ │ │ └────────────────────────────────────────────┘ │ └───────────┬──────────────────────────────────────┘ ┌─────────────────────────┐ │ Все еще обрезан? │──── Нет ──▶ Готово ✓ │ (MAX_TOKENS) │ └───────────┬──────────────┘ │ Да ┌──────────────────────────────────────────────────┐ │ Уровень 2: Многораундовое восстановление (до 3х)│ │ ┌────────────────────────────────────────────┐ │ │ │ Сохранить частичный ответ в истории │ │ │ │ Добавить сообщение: "Продолжайте напрямую" │ │ │ │ RETRY (isContinuation: true → сохранить UI)│ │ │ │ Повторная отправка с обновленной историей │ │ │ │ Модель продолжает с места остановки │ │ │ └──────────────┬─────────────────────────────┘ │ │ │ │ │ ┌──────┴──────┐ │ │ │ Успешно? │── Да ───▶ Готово ✓ │ │ └──────┬──────┘ │ │ │ Нет (все еще обрезан) │ │ ▼ │ │ попытка < 3? ── Да ──▶ цикл назад ↑ │ └───────────┬──────────────────────────────────────┘ │ Нет (исчерпано) ┌──────────────────────────────────────────────────┐ │ Уровень 3: Откат к планировщику инструментов │ │ ┌────────────────────────────────────────────┐ │ │ │ Отклонить обрезанные вызовы Edit/Write │ │ │ │ Вернуть рекомендацию: "Вы ДОЛЖНЫ разбить │ │ │ │ на части — сначала скелет, затем правки" │ │ │ └────────────────────────────────────────────┘ │ └──────────────────────────────────────────────────┘

Определение лимита токенов

Эффективный max_tokens определяется в следующем порядке приоритетов:

ПриоритетИсточникЗначение (известная модель)Значение (неизвестная модель)Поведение при эскалации
1 (высший)Конфигурация пользователя (samplingParams.max_tokens)min(userValue, modelLimit)userValueБез эскалации
2Переменная окружения (QWEN_CODE_MAX_OUTPUT_TOKENS)min(envValue, modelLimit)envValueБез эскалации
3 (низший)Лимит вывода модели/по умолчаниюmodelLimitDEFAULT_OUTPUT_TOKEN_LIMIT = 32KЭскалация до лимита модели (нижний порог 64K) + восстановление

«Известная модель» — это модель, имеющая явную запись в OUTPUT_PATTERNS (проверяется через hasExplicitOutputLimit()). Для известных моделей эффективное значение всегда ограничивается заявленным моделью лимитом вывода, чтобы избежать ошибок API. Неизвестные модели (кастомные развертывания, self-hosted эндпоинты) передают значение пользователя напрямую, так как бэкенд может поддерживать большие лимиты.

Эта логика реализована в трех генераторах контента:

  • DefaultOpenAICompatibleProvider.applyOutputTokenLimit() — OpenAI-совместимые провайдеры
  • DashScopeProvider — наследует applyOutputTokenLimit() от провайдера по умолчанию
  • AnthropicContentGenerator.buildSamplingParameters() — провайдер Anthropic

Механизм эскалации

Логика эскалации находится в geminiChat.ts и размещена вне основного цикла повторных попыток. Это сделано намеренно:

  1. Цикл повторных попыток обрабатывает временные ошибки (ограничения частоты запросов, невалидные стримы, валидация контента)
  2. Обрезание — это не ошибка, а успешный ответ, который был прерван
  3. Ошибки из эскалированного стрима должны напрямую передаваться вызывающей стороне, а не перехватываться логикой повторных попыток

Шаги эскалации (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 — сбрасывается в undefined, чтобы использовалась причина завершения нового ответа

Флаг isContinuation передается в UI, чтобы он мог решить, сбрасывать текстовые буферы (при эскалации) или сохранять их (при восстановлении).

Константы

Определены в geminiChat.ts и tokenLimits.ts:

КонстантаЗначениеНазначение
ESCALATED_MAX_TOKENS64 000Нижний порог для эскалации, если лимит модели низкий
MAX_OUTPUT_RECOVERY_ATTEMPTS3Макс. количество попыток многораундового восстановления после эскалации

Эффективный эскалированный лимит равен max(ESCALATED_MAX_TOKENS, tokenLimit(model, 'output')):

МодельЭскалированный лимит
Claude Opus 4.6131 072 (128K)
GPT-5 / o-series131 072 (128K)
Qwen3.x65 536 (64K)
Неизвестные модели64 000 (нижний порог)

Проектные решения

Почему не использовать значение по умолчанию 8K?

  • Значение 8K по умолчанию — это оптимизация резервирования слотов/емкости, а не требование корректности. Оно жертвует корректностью (большие ответы обрезаются) ради пропускной способности бэкенда (запрос резервирует слот GPU, пропорциональный max_tokens, поэтому меньшее значение приводит к меньшему избыточному резервированию).
  • Генерация больших файлов и вызовы инструментов редактирования могут законно превышать 8K, поэтому значение 8K по умолчанию превращает обычный запрос в цикл «обрезание → эскалация» (а в худшем случае — в цикл повторных попыток).
  • Claude Code сохраняет тот же лимит 8K, но скрывает его за фича-флагом (tengu_otk_slot_v1), который по умолчанию выключен для сторонних провайдеров («не проверено на Bedrock/Vertex»), то есть его поведение по умолчанию для сторонних сервисов — именно «использовать заявленный моделью лимит». Провайдеры qwen-code — все сторонние / OpenAI-совместимые / self-hosted, поэтому соответствие этому поведению с выключенным флагом по умолчанию является безопасным выбором; предполагать, что низкое значение по умолчанию безопасно для любого бэкенда, нельзя.
  • Компромисс по емкости не теряется, а просто становится опциональным: операторы self-hosted бэкендов с ограниченной емкостью могут установить QWEN_CODE_MAX_OUTPUT_TOKENS (например, 8000), чтобы восстановить меньшее резервирование на запрос. Фича-флаг в стиле GrowthBook намеренно не возвращается — в qwen-code нет такой инфраструктуры, а переменная окружения уже закрывает эту потребность.

Почему эскалировать до лимита модели, а не до фиксированных 64K?

  • Модели с более высокими лимитами вывода (Claude Opus 128K, GPT-5 128K) были неоправданно ограничены 64K
  • Использование фактического лимита модели охватывает подавляющее большинство длинных выводов без необходимости второй повторной попытки
  • ESCALATED_MAX_TOKENS (64K) служит нижним порогом для неизвестных моделей, где tokenLimit() возвращает значение по умолчанию 32K

Почему многораундовое восстановление, а не прогрессивная эскалация?

  • Прогрессивная эскалация (например, 16K -> 32K -> 64K) требует каждый раз регенерировать полный ответ
  • Многораундовое восстановление сохраняет частичный ответ и позволяет модели продолжить работу, экономя токены и снижая задержку
  • Сообщения восстановления дешевы (~40 токенов каждое) по сравнению с регенерацией больших ответов
  • Лимит в 3 попытки предотвращает бесконечные циклы, охватывая при этом большинство практических случаев

Почему эскалация находится вне цикла повторных попыток?

  • Обрезание — это успешный сценарий, а не ошибка
  • Ошибки из эскалированного стрима (ограничения частоты запросов, сетевые сбои) должны передаваться напрямую, а не молча повторяться с некорректными параметрами
  • Это сохраняет фокус цикла повторных попыток на его изначальной цели (восстановление после временных ошибок)
  • Ошибки восстановления перехватываются отдельно, чтобы избежать прерывания всего диалога
Last updated on