两个候选都失败,最终错误只剩一个超时¶
双候选对照期间,一次任务两侧都失败了:一侧是具体的产品异常,另一侧是超时。但最终任务错误只剩超时,产品异常消失了;日志里还写了一条"单侧降级",尽管没有任何一侧成功。
排查¶
定位这次任务时以数据库任务状态为准,故障栈按 request_id 从持久日志里匹配取出。先把两侧各自的原始错误还原出来:一侧在候选期限(固定 150 秒)到期,没有返回结果;另一侧带回了具体的产品校验错误,它前面的共享视觉阶段耗时约 104 秒、HTTP 200 正常返回,说明视觉供应商本身没有失败,是视觉太慢挤掉了后续运行预算,剩下的错误属于产品自身的解析校验。
排查初期还有一个疑点要排除:审计记录里的状态是 completed,看起来像失败被记成了成功。核对源码后排除——completed 是请求终结和结果持久化的语义,失败路径同样走完成保存流程,业务任务的终态正确地记为 failed。
把每一侧的原始异常和最终任务错误对照,发现错误在两层各丢了一次。
根因¶
第一层在上游转发。代理处理流式响应的终止事件时,response.failed 一进分支就归成模型请求失败,之后只为少数几种错误码改判:
case "response.failed":
result.Status = "failed"
result.ErrorCode = ErrorCodeModelRequestFailed // 默认归类
// ... 提取错误详情 ...
if transient, _ := e["transient"].(bool); transient {
result.ErrorCode = ErrorCodeModelTemporarilyUnavailable
}
if code, _ := e["code"].(string); code == "max_turns_exceeded" {
result.ErrorCode = ErrorCodeRuntimeFailed
}
这次产品校验错误的错误码是 internal_error,不在特判列表里,落到默认类。"产品自身解析校验失败"穿过这层之后就成了"模型请求失败"。
第二层在产品聚合。两路结果汇合后的回退逻辑里,"单侧降级"的日志写在任何判定之前,两路都失败也会留下这条记录;超时判断又排在返回具体错误之前,任一侧超时就直接返回期限错误:
slog.Warn("preference prompt comparison fell back to one line", ...) // 判定之前无条件记录
if treatmentErr != nil && controlErr == nil && validAgentResponse(controlResponse) {
// 单侧成功,降级为单路
}
if controlErr != nil && treatmentErr == nil && validAgentResponse(treatmentResponse) {
// 单侧成功,降级为单路
}
if isTurnTimeout(controlErr) || isTurnTimeout(treatmentErr) {
return nil, context.DeadlineExceeded
}
// 到这里才轮到返回具体错误
一侧到期、另一侧带具体错误时,超时分支先命中,返回具体错误的代码执行不到。两层叠加后,一个产品校验错误到了任务层只剩"请求超时"。
修复与验证¶
修复在聚合层:单路成功时保留原有回退行为;两路都失败时各自记录原始错误,具体非超时错误不再被另一路的超时覆盖;双超时才返回超时。内核侧同时补了失败用量:从已完成的模型调用累加用量,失败和中止终态携带已观察到的消耗,成功路径不重复累加;内部错误归为运行时错误而不是模型请求失败。真实 HTTP 与审计测试覆盖了这些边界。
发布:上游全量测试、内核三百余项测试(含真实隔离数据库)、产品三百余项测试通过,唯一跳过的是一个需要手动启动浏览器服务的测试夹具。发布走正常流程——首次直接推送被仓库的 PR 规则拒绝,随后经正常 PR 合并完成,没有绕过规则。发布后 API、运行时、数据库健康,页面静态资源实际下载检查通过,切换后无排队和运行中任务。
这轮没有调用真实付费模型验证输出质量,模型测试用的是伪实现和合同测试。供应商未返回用量的调用、以及传输中断前未收到的终态,仍不保证有完整计量;这轮发布验证的是错误聚合和计量行为,不覆盖内容链路质量。
聚合多路错误时,超时不能覆盖具体异常:超时本身不带定位信息,是几类错误里信息量最低的。