问题描述
当 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。在三天内约 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 补全都可能以这种方式丢失其尾部。对于翻译而言,结果是帖子或标题被静默缩短,而回填不会重试,因为本地化行已经存在。