跳转至

视觉上游超时穿过两层服务后,变成不可重试的内部错误

视觉识别阶段的上游超时,穿过 Python 运行时和 Go 产品边界后,任务记录里只剩"内部错误、不可重试"。死在哪个阶段、能不能重试,这两层信息都丢了。

排查

先排除基础设施,再归因到应用层。诊断只读查询了生产 PostgreSQL、API 持久日志、runtime 容器日志和用量日志,没有改代码、配置和数据库。当时 API、runtime、路由、数据库都健康,主要容器没有 OOMKilled、重启次数为零,内核日志近 7 天没有 OOM 记录。磁盘用到 94%、只剩 2.4 GiB,是独立的容量风险,但解释不了这次的模型报错,排查方向转到应用层的错误归因。

再看失败任务本身。近 7 天 6 个失败任务的原因各不相同:三个是约 300 秒任务超时,一个是上游流缺 finish_reason,一个是模型返回字段含非法 null,一个是资源恢复约 30 毫秒失败(属已知历史问题,不算新回归)。原因对不上,就不能只凭 timeout 断言是内核 bug,需要把每一段链路自己的原始错误找出来。

方法是沿三段记录逐段关联:供应商调用记录(provider attempt)、产品客户端抛出的异常、任务最终的 failure 字段。关键样本是 9 月 14 日的一次任务:

供应商视觉调用:120 秒超时
Python 产品客户端:ProductClientBusinessError
Go 任务层:internal_error / transient=false

三个阶段对下来,失败发生在供应商调用阶段,属临时故障,原本可以重试;跨语言传递时被统一映射成"内部错误、不可重试",阶段、上游错误码、可重试标记三个信息都在映射里丢了。产品自身的逻辑错误走同一个映射,两类失败到了任务层看起来一样。

第二个样本是 9 月 15 日的一次候选取消:候选整体 150 秒到期取消时,共享的视觉调用已经耗掉 108 秒。视觉调用本身没有失败,只是慢,把后续步骤的预算挤占了。这类样本同样在任务层留下失败记录,但性质和供应商超时不同。

近 7 天视觉调用统计:旧视觉模型成功三十余次、模型不存在一次、超时一次、取消一次,新模型成功二十余次;两侧成功调用的平均耗时分别约 73 秒和约 27 秒。平均耗时说明视觉链路慢是常态,不能只看失败那几例。

另有两例失败是"流在结束原因之前中断"(EOF before finish_reason)。内核检测到不完整流并拒绝是正确行为,根因在上游协议或网关链路,不算产品错误,从本次修复范围里划出去。

根因

两个机制问题叠加:

  • 错误统一映射丢语义。产品客户端把供应商异常统一包成 ProductClientBusinessError,任务层再落成 internal_error / transient=false。阶段、上游错误码、可重试标记都没保留,供应商超时和产品自身逻辑错误变成同一种记录,重试策略只能按不可重试处理。
  • 各层预算口径不一致。候选、视觉、runtime 各有自己的期限,资源恢复时间不计入预算。慢的调用会挤占后续步骤的预算,到期取消时,共享调用没失败也会被记成失败。

修复

一是保留错误语义:视觉供应商的错误逐层传递阶段、上游错误码和可重试标记。可纠正的参数错误返回工具错误,由现有内核的工具校验向模型反馈、同一轮修正;存储、权限异常继续终止,不转成可纠正错误。

二是统一预算口径:总预算取队列期限(5 分钟)与传输期限的较短者,资源恢复时间计入总预算;恢复完成时预算已耗尽就不再发新调用;运行中的请求过期时把取消传递给真实远端;取消整轮自动重跑,避免已经发生的工具副作用再次执行。没有简单调大各层超时,先验证的是供应连接、模型可用性、完整流与耗时。

验证

本地回归与正式发布完成。本地回归包括 Go 全量测试、Python 88 项单测和干净安装 vendored kernel 后的 88 项包测试。

取消行为用真实完整链路验证,没有用测试替身:传输和任务预算设成 1 秒,

任务终态:REQUEST_TIMEOUT
runtime 真实收到取消请求(POST /v1/responses/.../cancel)并返回 HTTP 200
任务记录:incomplete 终态,duration=1008.271ms

跨进程预算回归:250 毫秒预算内包含 100 毫秒资源恢复,远端收到相同的期限和取消;恢复完成后预算已耗尽的场景确认零新调用。

本地报告没有逐一列出所有上游故障类型的公网注入验证。修复覆盖的是映射逻辑和预算口径,不是每种上游故障都在线上重演过。