Discourse AI: 네트워크 청크가 두 이벤트 사이의 빈 줄 내부에서 끝나면 Gemini 스트리밍 디코더가 응답의 나머지 부분을 삭제합니다

설명

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: {로 시작하는 경우에만 해당 세그먼트를 받아들입니다.

네트워크 청크가 두 이벤트를 구분하는 빈 줄(예: \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)이 재시도하지 않는, 조용히 짧아진 게시글이나 제목이 생성됩니다.

1개의 좋아요