Discourse AI: ошибка Gemini в потоковом ответе игнорируется, а частичный текст сохраняется как полный перевод

Описание

Когда API Gemini от Google прерывается на полпути во время потоковой передачи ответа, Discourse AI считает ответ завершенным. При включенном переводе с помощью ИИ фрагмент, полученный до сбоя, сохраняется как завершённый перевод поста или названия темы. Ошибка не фиксируется в логах, и повторная обработка (backfill) никогда не повторяет запрос для этого элемента, так как строка перевода уже существует.

Проблема наблюдалась на самохостинговом сайте, работающем на release/2026.7 (2026.7.3, коммит f1caa6321287f918644fba9ff8943579fdcf027a) с включенным плагином discourse-ai. В качестве LLM используется gemini-3.8-flash через провайдера Google, при этом уровень сервиса (service tier) установлен на flex. Уровень Flex — это тот, на котором Google сбрасывает нагрузку, поэтому такие сбои в середине потока происходят регулярно; однако описанная ниже обработка не зависит от уровня сервиса.

В таких случаях Google отвечает с HTTP-статусом 200, передает одно или несколько обычных событий, а затем отправляет событие об ошибке в том же потоке:

data: {"candidates": [ ... ],"usageMetadata": { ... },"serviceTier": "flex","modelVersion": "gemini-3.8-flash","responseId": "..."}

data: {"error": {"code": 503,"message": "This model is currently experiencing high demand. Spikes in demand are usually temporary. Please try again later.","status": "UNAVAILABLE"}}

В журнале аудита (audit log) фиксируется response_status 200 и небольшое количество response_tokens. Примеры сохраненных данных: пост из 917 символов сохранен как 26 символов (только строка приветствия), посты, содержащие только ссылки, сохранены как первые 8–24 символа URL, а названия тем оборваны посреди слова.

Замеры на этом сайте: 23 из 394 запросов на перевод на уровне Flex (5,8%) завершились именно так. Ни один из 952 запросов на стандартном уровне к той же модели не завершился подобным образом.

Корневая причина

  1. DiscourseAi::Completions::Endpoints::Gemini#decode_chunk считывает только candidates из каждого разобранного события потока. Событие, у которого верхнеуровневый ключ — error, не возвращает частей (parts) и пропускается без какой-либо проверки.
  2. DiscourseAi::Completions::Endpoints::Base определяет, является ли результат успехом, требует повторной попытки или является ошибкой, исключительно по HTTP-статусу (response.code.to_i != 200). Поскольку здесь статус равен 200, существующая логика повторных попыток для 503 никогда не срабатывает.
  3. Затем поток завершается нормально, и вызывающая сторона получает накопленный до этого текст. DiscourseAi::Translation::PostLocalizer и TopicLocalizer сохраняют его.

Такой же код присутствует в ветке main на 03.10.2026.

Предлагаемое исправление

Считать объект error верхнего уровня в потоковом событии Gemini ошибкой выполнения (failed completion): отбрасывать частичный вывод и вызывать CompletionFailed, чтобы существующая обработка повторных попыток применялась к кодам, допускающим повтор (таким как 503), и чтобы вызывающие стороны никогда не получали фрагмент как полный ответ.

Воспроизведение

Сбой зависит от нагрузки на стороне Google, поэтому его невозможно вызвать принудительно. На сайте, использующем модель Gemini на уровне Flex с переводом с помощью ИИ, затронутые запросы можно найти постфактум в журнале аудита:

SELECT id, created_at, post_id, topic_id, response_status, response_tokens
FROM ai_api_audit_logs
WHERE feature_name = 'translation'
  AND raw_response_payload LIKE '%data: {"error"%'
  AND response_tokens > 0
ORDER BY created_at

Каждая строка представляет собой запрос, который вернул HTTP 200, сгенерировал некоторый вывод, а затем содержал событие об ошибке. Соответствующие строки в post_localizations или topic_localizations содержат усеченный текст.

Обходное решение

Перенос агентов перевода на стандартный уровень сервиса прекратил появление новых случаев. Сохраненные фрагменты пришлось найти с помощью запроса выше, удалить и перевести заново.

1 лайк