Discourse AI: o decodificador de streaming do Gemini perde o restante da resposta quando um fragmento de rede termina na linha em branco entre dois eventos

Descrição

Quando uma resposta do Gemini é transmitida em streaming, o Discourse AI ocasionalmente processa apenas a primeira parte dela. A resposta completa é recebida do Google e armazenada em ai_api_audit_logs.raw_response_payload, o stream termina com finishReason STOP e nenhum erro é registrado. O chamador recebe o texto até um certo evento e nada após ele. Com a tradução por IA, o texto truncado é armazenado como a tradução finalizada.

Observado em um site auto-hospedado executando release/2026.7 (2026.7.3) com o plugin discourse-ai incluído e gemini-3.8-flash na camada de serviço padrão. Em 3 de aproximadamente 26.200 chamadas de tradução ao longo de três dias, response_tokens no log de auditoria foi menor que o candidatesTokenCount final relatado pelo Google na mesma resposta: 196 de 241, 84 de 328 e 131 de 179. Em cada caso, a contagem armazenada é igual à contagem cumulativa do Google em um evento intermediário, portanto a decodificação parou após esse evento.

Isso é separado dos eventos de erro no stream corrigidos por #44262: não há evento de erro aqui, e o decodificador não foi alterado por essa pull request.

Causa raiz

DiscourseAi::Completions::Endpoints::Gemini::GeminiStreamingDecoder#decode divide seu buffer em /\r?\n\r?\n/ e aceita um segmento apenas se ele começar com data: {.

Quando um chunk de rede termina dentro da linha em branco que separa dois eventos, por exemplo após o primeiro \r\n de \r\n\r\n, o evento anterior é analisado e o buffer é esvaziado. O próximo chunk então começa com o \r\n restante, de modo que o próximo segmento é "\r\ndata: {...}". Ele não começa com data: {, portanto é mantido no buffer como uma linha incompleta. Todos os eventos posteriores são adicionados a esse buffer e também não são reconhecidos. O stream termina normalmente e os eventos restantes são descartados junto com o buffer.

O mesmo acontece com separadores apenas LF quando um chunk termina entre os dois caracteres de quebra de linha.

O decodificador é idêntico em v2026.7.3 e no main em b5548f76 (2026-10-05).

Reproduzir

Em um console 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) }

Resultado: ["A", "B"]. Esperado: ["A", "B", "C", "D"]. Mover o limite do chunk para qualquer ponto fora do separador, incluindo o meio de um evento, dá o resultado esperado.

Correção sugerida

Ignorar quebras de linha iniciais em um segmento antes de testá-lo, por exemplo:

line = line.sub(/\A[\r\n]+/, "")
if line.start_with?("data: {")

Com essa mudança, a reprodução acima retorna todos os quatro eventos, assim como limites após \r\n, após \r\n\r, dentro de um evento e com separadores apenas LF.

Encontrar chamadas afetadas

Cada linha retornada por esta consulta é uma chamada que o Google completou normalmente e o Discourse decodificou apenas parcialmente:

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

Impacto

O defeito depende apenas de onde os limites dos chunks de rede caem, portanto não é específico para tradução ou para uma camada de serviço; qualquer conclusão do Gemini transmitida em streaming pode perder seu final dessa maneira. Para tradução, o resultado é uma publicação ou título encurtado silenciosamente que o backfill não reprocessa, porque uma linha de localização existe.

1 curtida