Discourse AI: Gemini ストリーミングデコーダーが、2つのイベント間の空行の途中でネットワークチャンクが終了すると、返信の残りをドロップする

説明

Gemini の応答がストリーミングで送信される際、Discourse AI は場合によってはその最初の部分のみを処理することがあります。完全な応答は Google から受信され、ai_api_audit_logs.raw_response_payload に保存されます。ストリームは finishReason が STOP として終了し、エラーは記録されません。呼び出し元は、あるイベントまでのテキストを受け取り、それ以降は何も受け取りません。AI 翻訳の場合、切り詰められたテキストが完成した翻訳として保存されます。

release/2026.7 (2026.7.3) を実行しているセルフホストサイト、バンドルされた discourse-ai プラグイン、および標準サービスティアの gemini-3.8-flash で確認されました。3 日間の約 26,200 回の翻訳呼び出しのうち 3 回、監査ログの response_tokens が、同じ応答で Google が報告した最終的な candidatesTokenCount よりも低かったことがあります:241 中の 196、328 中の 84、179 中の 131 です。いずれの場合も、保存されたカウントは中間イベントの一つにおける Google の累積カウントと等しく、そのイベント以降でデコードが停止したことを示しています。

これは、#44262 で修正されたストリーム内エラーイベントとは別個の問題です。ここではエラーイベントは発生しておらず、そのプルリクエストによってデコーダーは変更されていません。

根本原因

DiscourseAi::Completions::Endpoints::Gemini::GeminiStreamingDecoder#decode はバッファを /\r?\n\r?\n/ で分割し、セグメントが data: { で始まる場合のみを受け入れます。

ネットワークチャンクが、2 つのイベントを区切る空白行の途中で終了する場合(例えば \r\n\r\n の最初の \r\n の後)、それ以前のイベントはパースされ、バッファは空になります。次のチャンクは残りの \r\n で始まるため、次のセグメントは "\r\ndata: {...}" になります。これは data: { で始まらないため、不完全な行としてバッファに保持されます。その後のすべてのイベントはそのバッファに追加され、認識されることはありません。ストリームは正常に終了し、残りのイベントはバッファとともに破棄されます。

チャンクが 2 つの改行の間に終了する場合、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: {")

この変更により、上記の再現手順では 4 つすべてのイベントが返され、\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 の補完はすべて、この方法でその末尾を失う可能性があります。翻訳の場合、結果として得られるのは、ローカライズ行が存在するためバックフィルによって再試行されない、静かに短縮された投稿またはタイトルです。

「いいね!」 1