Лимит выходных токенов и проектирование эскалации
По умолчанию используется заявленный моделью лимит на вывод, если только пользователь или окружение не настроили
max_tokens. Эскалация и многораундовое восстановление применяются только в том случае, если ответ все равно достигаетMAX_TOKENS.
Проблема
Каждый API-запрос резервирует фиксированный слот GPU, пропорциональный max_tokens. Низкое значение по умолчанию может уменьшить резервирование слотов, но при этом увеличивает вероятность обрезания (truncation) обычных больших ответов. В рабочих процессах записи файлов это может привести к появлению неполных аргументов вызова инструментов и заставить планировщик отклонить частичную запись.
Решение
По умолчанию используйте заявленный моделью лимит на вывод. Если ответ обрезается (модель достигает max_tokens):
- Эскалируйте до полного лимита вывода модели (с нижним порогом 64K, если текущий лимит ниже)
- Если ответ все еще обрезается, восстановите его, сохраняя частичный ответ в истории и внедряя сообщение о продолжении, до 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 (низший) | Лимит вывода модели/по умолчанию | modelLimit | DEFAULT_OUTPUT_TOKEN_LIMIT = 32K | Эскалация до лимита модели (нижний порог 64K) + восстановление |
«Известная модель» — это модель, имеющая явную запись в OUTPUT_PATTERNS (проверяется через hasExplicitOutputLimit()). Для известных моделей эффективное значение всегда ограничивается заявленным моделью лимитом вывода, чтобы избежать ошибок API. Неизвестные модели (кастомные развертывания, self-hosted эндпоинты) передают значение пользователя напрямую, так как бэкенд может поддерживать большие лимиты.
Эта логика реализована в трех генераторах контента:
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— сбрасывается в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 по умолчанию — это оптимизация резервирования слотов/емкости, а не требование корректности. Оно жертвует корректностью (большие ответы обрезаются) ради пропускной способности бэкенда (запрос резервирует слот 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 попытки предотвращает бесконечные циклы, охватывая при этом большинство практических случаев
Почему эскалация находится вне цикла повторных попыток?
- Обрезание — это успешный сценарий, а не ошибка
- Ошибки из эскалированного стрима (ограничения частоты запросов, сетевые сбои) должны передаваться напрямую, а не молча повторяться с некорректными параметрами
- Это сохраняет фокус цикла повторных попыток на его изначальной цели (восстановление после временных ошибок)
- Ошибки восстановления перехватываются отдельно, чтобы избежать прерывания всего диалога