Beschreibung
Wenn eine Gemini-Antwort gestreamt wird, verarbeitet Discourse AI gelegentlich nur den ersten Teil davon. Die vollständige Antwort wird von Google empfangen und in ai_api_audit_logs.raw_response_payload gespeichert, der Stream endet mit finishReason STOP und es wird kein Fehler protokolliert. Der Aufrufer erhält den Text bis zu einem bestimmten Ereignis und danach nichts mehr. Bei der KI-Übersetzung wird der abgeschnittene Text als fertige Übersetzung gespeichert.
Beobachtet auf einer selbst gehosteten Site, die release/2026.7 (2026.7.3) mit dem mitgelieferten discourse-ai-Plugin und gemini-3.8-flash auf der Standard-Service-Ebene ausführt. Bei 3 von etwa 26.200 Übersetzungsaufforderungen über drei Tage war response_tokens im Audit-Log niedriger als die finale candidatesTokenCount, die Google in derselben Antwort meldete: 196 von 241, 84 von 328 und 131 von 179. In jedem Fall entspricht die gespeicherte Anzahl der kumulierten Anzahl von Google bei einem Zwischenereignis, sodass die Dekodierung nach diesem Ereignis gestoppt wurde.
Dies ist unabhängig von den In-Stream-Fehlerereignissen, die durch #44262 behoben wurden: Hier gibt es kein Fehlerereignis und der Decoder wurde durch diesen Pull Request nicht geändert.
Ursache
DiscourseAi::Completions::Endpoints::Gemini::GeminiStreamingDecoder#decode teilt seinen Puffer anhand von /\r?\n\r?\n/ und akzeptiert ein Segment nur, wenn es mit data: { beginnt.
Wenn ein Netzwerk-Chunk innerhalb der Leerzeile endet, die zwei Ereignisse trennt, beispielsweise nach dem ersten \r\n von \r\n\r\n, wird das vorherige Ereignis geparst und der Puffer geleert. Der nächste Chunk beginnt dann mit dem verbleibenden \r\n, sodass das nächste Segment "\r\ndata: {...}" ist. Es beginnt nicht mit data: {, daher wird es im Puffer als unvollständige Zeile behalten. Jedes spätere Ereignis wird zu diesem Puffer hinzugefügt und wird ebenfalls nie erkannt. Der Stream endet normal und die verbleibenden Ereignisse werden mit dem Puffer verworfen.
Das Gleiche passiert bei LF-getrennten Trennzeichen, wenn ein Chunk zwischen den zwei Zeilenumbrüchen endet.
Der Decoder ist bei v2026.7.3 und auf main bei b5548f76 (2026-10-05) identisch.
Reproduzieren
In einer Rails-Konsole:
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) }
Ergebnis: ["A", "B"]. Erwartet: ["A", "B", "C", "D"]. Verschieben der Chunk-Grenze auf einen beliebigen Punkt außerhalb des Trennzeichens, einschließlich der Mitte eines Ereignisses, liefert das erwartete Ergebnis.
Vorschlag zur Behebung
Führende Zeilenumbrüche in einem Segment vor dem Testen ignorieren, beispielsweise:
line = line.sub(/\A[\r\n]+/, "")
if line.start_with?("data: {")
Mit dieser Änderung liefert die obige Reproduktion alle vier Ereignisse, ebenso wie Grenzen nach \r\n, nach \r\n\r, innerhalb eines Ereignisses und bei LF-getrennten Trennzeichen.
Betroffene Aufrufe finden
Jede Zeile, die von dieser Abfrage zurückgegeben wird, ist ein Aufruf, den Google normal abgeschlossen hat und den Discourse nur teilweise dekodierte:
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
Auswirkung
Der Fehler hängt nur davon ab, wo die Chunk-Grenzen des Netzwerks liegen, ist also weder spezifisch für Übersetzungen noch für eine Service-Ebene; jeder gestreamte Gemini-Completion kann auf diese Weise seinen Endteil verlieren. Bei Übersetzungen ist das Ergebnis ein still verkürzter Beitrag oder Titel, den der Backfill nicht erneut versucht, da eine Lokalisierungszeile existiert.