Discourse AI: lo streaming decoder di Gemini perde il resto della risposta quando un chunk di rete termina all'interno della riga vuota tra due eventi

Descrizione

Quando una risposta di Gemini viene trasmessa in streaming, Discourse AI elabora occasionalmente solo la prima parte di essa. La risposta completa viene ricevuta da Google e archiviata in ai_api_audit_logs.raw_response_payload, lo streaming termina con finishReason STOP e non viene registrato alcun errore. Il chiamante riceve il testo fino a un certo evento e nulla dopo. Con la traduzione AI, il testo troncato viene archiviato come traduzione completata.

Osservato su un sito self-hosted che esegue release/2026.7 (2026.7.3) con il plugin discourse-ai incluso e gemini-3.8-flash sul tier di servizio standard. In 3 su circa 26.200 chiamate di traduzione in tre giorni, response_tokens nel log di audit era inferiore al candidatesTokenCount finale riportato da Google nella stessa risposta: 196 su 241, 84 su 328 e 131 su 179. In ogni caso, il conteggio archiviato corrisponde al conteggio cumulativo di Google a un evento intermedio, il che significa che la decodifica si è interrotta dopo quell’evento.

Questo è separato dagli eventi di errore in-stream corretti da #44262: non c’è alcun evento di errore qui e il decoder non è stato modificato da quella pull request.

Causa principale

DiscourseAi::Completions::Endpoints::Gemini::GeminiStreamingDecoder#decode divide il buffer con /\r?\n\r?\n/ e accetta un segmento solo se inizia con data: {.

Quando un chunk di rete termina all’interno della riga vuota che separa due eventi, ad esempio dopo il primo \r\n di \r\n\r\n, l’evento precedente viene analizzato e il buffer viene svuotato. Il chunk successivo inizia quindi con il rimanente \r\n, quindi il segmento successivo è "\r\ndata: {...}". Non inizia con data: {, quindi viene mantenuto nel buffer come riga incompleta. Ogni evento successivo viene aggiunto a quel buffer e non viene mai riconosciuto. Lo streaming termina normalmente e gli eventi rimanenti vengono scartati insieme al buffer.

Lo stesso accade con separatori solo LF quando un chunk termina tra i due caratteri di fine riga.

Il decoder è identico in v2026.7.3 e su main in b5548f76 (2026-10-05).

Riproduzione

In una 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) }

Risultato: ["A", "B"]. Atteso: ["A", "B", "C", "D"]. Spostare il confine del chunk in qualsiasi punto al di fuori del separatore, incluso il centro di un evento, produce il risultato atteso.

Correzione suggerita

Ignorare i caratteri di fine riga iniziali di un segmento prima di testarlo, ad esempio:

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

Con questa modifica, la riproduzione sopra restituisce tutti e quattro gli eventi, così come i confini dopo \r\n, dopo \r\n\r, all’interno di un evento e con separatori solo LF.

Individuazione delle chiamate interessate

Ogni riga restituita da questa query è una chiamata che Google ha completato normalmente e Discourse ha decodificato solo parzialmente:

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

Impatto

Il difetto dipende solo da dove cadono i confini dei chunk di rete, quindi non è specifico per la traduzione o per un tier di servizio; qualsiasi completamento Gemini in streaming può perdere la sua coda in questo modo. Per la traduzione, il risultato è un post o un titolo silenziosamente accorciato che il backfill non ritenta, poiché esiste una riga di localizzazione.

1 Mi Piace