Описание
При потоковой передаче ответа Gemini Discourse AI иногда обрабатывает только его первую часть. Полный ответ получает от Google и сохраняется в ai_api_audit_logs.raw_response_payload, поток завершается с finishReason STOP, и никаких ошибок не логируется. Вызывающая сторона получает текст до какого-либо события, а после него — ничего. При переводе с помощью ИИ усечённый текст сохраняется как завершённый перевод.
Наблюдается на самохостинговом сайте, работающем на release/2026.7 (2026.7.3), с плагином discourse-ai и моделью gemini-3.8-flash на стандартном уровне сервиса. В 3 случаях из примерно 26 200 запросов на перевод за три дня значение response_tokens в журнале аудита было меньше, чем итоговое candidatesTokenCount, сообщённое Google в том же ответе: 196 из 241, 84 из 328 и 131 из 179. В каждом случае сохранённое значение совпадает с накопительным счётчиком Google на одном из промежуточных событий, что означает, что декодирование остановилось после этого события.
Это отличается от событий ошибок внутри потока, исправленных в #44262: здесь нет события ошибки, и декодер не изменён этим pull request.
Корневая причина
Метод DiscourseAi::Completions::Endpoints::Gemini::GeminiStreamingDecoder#decode разделяет буфер по регулярному выражению /\r?\n\r?\n/ и принимает сегмент только в том случае, если он начинается с data: {.
Когда сетевой фрагмент (chunk) заканчивается внутри пустой строки, разделяющей два события, например, после первого \r\n в последовательности \r\n\r\n, предыдущее событие успешно парсится, и буфер очищается. Следующий фрагмент начинается с оставшегося \r\n, поэтому следующий сегмент выглядит как "\r\ndata: {...}". Он не начинается с data: {, поэтому остаётся в буфере как неполная строка. Все последующие события добавляются к этому буферу и также не распознаются. Поток завершается нормально, а оставшиеся события отбрасываются вместе с буфером.
То же самое происходит с разделителями только на LF, когда фрагмент заканчивается между двумя символами перевода строки.
Декодер идентичен в версии v2026.7.3 и в ветке main на коммите b5548f76 (2026-10-05).
Воспроизведение
В консоли Rails:
decoder = DiscourseAi::Completions::Endpoints::Gemini::GeminiStreamingDecoder.new
event = ->(text, tokens) { %(data: {"candidates": [{"content": {"parts": [{"text": "#{text}"}],"role": "model"}}],"usageMetadata": {"candidatesTokenCount": #{tokens}}}) }
chunks = [
event.("A", 11) + "\r\n\r\n",
event.("B", 38) + "\r\n",
"\r\n" + event.("C", 65) + "\r\n\r\n",
event.("D", 95) + "\r\n\r\n",
]
chunks.flat_map { |chunk| decoder.decode(chunk) }.map { |e| e.dig(:candidates, 0, :content, :parts, 0, :text) }
Результат: ["A", "B"]. Ожидаемый результат: ["A", "B", "C", "D"]. Перемещение границы фрагмента в любую точку вне разделителя, включая середину события, даёт ожидаемый результат.
Предлагаемое исправление
Игнорировать начальные переводы строки в сегменте перед проверкой, например:
line = line.sub(/\A[\r\n]+/, "")
if line.start_with?("data: {")
С этим изменением приведённое выше воспроизведение возвращает все четыре события, как и границы после \r\n, после \r\n\r, внутри события и с разделителями только на LF.
Поиск затронутых вызовов
Каждая строка, возвращаемая этим запросом, представляет собой вызов, который Google завершил успешно, а Discourse декодировал лишь частично:
SELECT l.id, l.created_at, l.post_id, l.topic_id, l.response_tokens AS stored_tokens, g.sent_tokens
FROM ai_api_audit_logs l
CROSS JOIN LATERAL (
SELECT MAX((m)[1]::int) AS sent_tokens
FROM regexp_matches(l.raw_response_payload, '"candidatesTokenCount":\s*(\d+)', 'g') AS m
) g
WHERE l.language_model LIKE 'gemini%'
AND l.raw_response_payload ~ '"finishReason":\s*"STOP"'
AND g.sent_tokens <> l.response_tokens
ORDER BY l.created_at
Влияние
Ошибка зависит только от того, где находятся границы сетевых фрагментов, поэтому она не специфична для перевода или конкретного уровня сервиса; любой потоковый ответ Gemini может потерять свой хвост таким образом. Для перевода результатом становится незаметно укороченный пост или заголовок, который не повторяется при обратном заполнении (backfill), так как строка локализации уже существует.