问题描述
当 Google 的 Gemini API 在流式回复过程中中途失败时,Discourse AI 会将该回复视为已完成。在启用 AI 翻译的情况下,失败前接收到的片段会被存储为帖子或主题标题的最终翻译结果。系统不会记录任何错误日志,且回填任务(backfill)永远不会重试该项,因为此时已存在一条翻译记录。
此问题在运行 release/2026.7(版本 2026.7.3,提交 f1caa6321287f918644fba9ff8943579fdcf027a)的自托管站点上被观察到,该站点使用了捆绑的 discourse-ai 插件。使用的 LLM 是通过 Google 提供商调用的 gemini-3.8-flash,服务层级设置为 flex。Flex 层级是 Google 进行负载削峰(shed load)的层级,因此会定期产生这种流中途失败的情况;下述的处理逻辑并不依赖于具体的服务层级。
在这些情况下,Google 返回 HTTP 200,流式传输一个或多个正常事件,然后在同一个流中发送一个错误事件:
data: {"candidates": [ ... ],"usageMetadata": { ... },"serviceTier": "flex","modelVersion": "gemini-3.8-flash","responseId": "..."}
data: {"error": {"code": 503,"message": "This model is currently experiencing high demand. Spikes in demand are usually temporary. Please try again later.","status": "UNAVAILABLE"}}
审计日志记录了 response_status 为 200,以及较小的 response_tokens 计数。存储结果的示例包括:一个 917 字符的帖子仅保存了 26 个字符(仅其问候语行),仅包含链接的帖子保存为 URL 的前 8 到 24 个字符,以及主题标题在单词中间被截断。
在该站点上的测量数据:394 次 Flex 层级的翻译调用中,有 23 次(5.8%)以这种方式结束。而针对同一模型的 952 次标准层级调用中,没有一次出现这种情况。
根本原因
DiscourseAi::Completions::Endpoints::Gemini#decode_chunk仅从每个解析后的流事件中读取candidates。如果事件的顶级键是error,则不会生成任何部分(parts),并且会在没有任何检查的情况下被跳过。DiscourseAi::Completions::Endpoints::Base仅根据 HTTP 状态码(response.code.to_i != 200)来决定是成功、重试还是失败。在此情况下状态码为 200,因此现有的针对 503 的重试逻辑永远不会被执行。- 随后流正常结束,调用者接收到迄今为止累积的文本。
DiscourseAi::Translation::PostLocalizer和TopicLocalizer将其保存下来。
截至 2026-10-03,main 分支上仍存在相同的代码。
建议修复方案
将流式 Gemini 事件中的顶级 error 对象视为完成失败:丢弃部分输出并抛出 CompletionFailed 异常,以便现有的重试处理机制可以应用于 503 等可重试代码,且调用者永远不会将片段接收为完整回复。
复现步骤
由于该故障取决于 Google 的负载情况,因此无法强制触发。在使用 Flex 层级 Gemini 模型并启用 AI 翻译的站点上,受影响的调用可以在事后的审计日志中找到:
SELECT id, created_at, post_id, topic_id, response_status, response_tokens
FROM ai_api_audit_logs
WHERE feature_name = 'translation'
AND raw_response_payload LIKE '%data: {"error"%'
AND response_tokens > 0
ORDER BY created_at
每一行都是一个返回 HTTP 200、产生了一些输出、然后携带错误事件的调用。post_localizations 或 topic_localizations 表中对应的行保存了截断后的文本。
临时解决方案
将翻译代理移至标准服务层级后,新的发生情况停止了。之前存储的片段必须使用上述查询找到,删除后重新翻译。