等待补充状态前后端口径不一致,任务卡死¶
2026 年 8 月,一个图片解读产品出了一次任务恢复事故。用户上传照片,系统先做视觉观察,再结合知识库给出解读;信息不足时任务停下来向用户追问。有一个停在"等待用户补充"的任务:刷新页面想继续,原问题不见了;重新提交,又被告知已有一个进行中的任务。
两条问题路径¶
第一条路径,先核对数据。原问题不见了,可能是数据没写进去,也可能是前端没把它渲染出来。排查拿任务标识对了日志和数据库:等待任务可以复现,待处理追问记录完整,前一种可能排除,问题落在前端对任务状态的识别上。两层的识别口径不一致:
后端把 waiting_input 计入活动任务,新提交被拒绝,理由是防止重复任务。前端恢复逻辑只识别 queued 和 running,把等待补充当成已结束,刷新后不展示原问题。同一个任务在后端算"进行中",在前端算"已结束",两层各自按自己的口径走都说得通,叠起来就成了死锁:用户既不能继续旧任务,也不能创建新任务。
第二条路径出在续接的失败处理。一次补充回答遇到模型临时不可用,被直接置为失败,队列重试时参数校验报错(INVALID_ARGUMENT),任务被彻底置为失败。失败处理没有区分临时错误和永久错误,临时错误走了永久错误的处理路径。
修复¶
第一步统一前后端对活动状态的理解:任务接口返回原始请求标识,前端把 waiting_input 纳入恢复范围,登录后先执行恢复,立即读取并渲染原来的结构化问题,同时关掉重复提交入口。这一步有顺序要求:先恢复、确认没有活动任务才显示新上传页;如果反过来先开放新提交、再异步拉原问题,中间会有一段旧问题还没渲染、新任务已经可以提交的窗口。
第二步是产品规则调整,时间在原始事故修复之后,是另一个独立决定:只有 queued 和 running 占用实时任务名额、在登录时自动恢复;waiting_input 不再阻塞新任务,保留在历史记录里,用户打开对应会话后显式选择"继续补充"。数据库的活动任务唯一索引、入队冲突查询和前端展示必须用同一个状态集合,否则同一个任务在不同层会被当成不同的东西。
队列侧同步改失败处理。用户提交补充回答时任务进入 queued,原 input request 不清空,上一轮的 result_payload 也保留;补充回答遇到可重试的模型错误,重试耗尽后任务回到 waiting_input,原问题继续可见,用户可以重新提交,已有结果不丢;永久错误才置为失败,避免无限掩盖真实的协议或数据问题。内核侧保证结构化恢复的运行状态和记录幂等,相同请求标识的自动重试不会重复写入用户输入。
产品队列和内核要同时部署:只修前端,模型临时失败的那条路径仍然会产生无法恢复的任务。
同轮排查出的 SDK 问题¶
后续排查发现一条独立路径:模型在同一响应里并行调用了两次取证工具,两次都成功,SDK 在回填工具结果后的恢复阶段抛错,没有进入最终提交。工具调用日志都是成功,从日志得不到信息,定位看抛错的位置:UserError 出在工具结果回填之后、最终提交之前。
这个位置说明两件事。抛错发生在最终提交之前,所以不是最终提交环节的校验失败;也不能靠跳过最终提交绕过去——最终提交承担证据校验、artifact 写入和明确的完成状态,跳过了这些,整轮照样完不成。
当时的处理:关闭并行工具调用(每次模型调用显式 parallel_tool_calls=false),把工具调用串行化,避免 SDK 在同一个恢复点处理多个工具结果;产品自定义工具改用内核的统一构造器,拿到一致的参数规范化和可修复错误返回,参数错误返回给模型、允许在同一任务内修正,而不是整轮失败。这个修正只针对旧执行链上的那一次故障,终态职责没有变;后续架构改成模型返回结构化结果、由运行时校验并写入之后,才不再由模型主动做最终提交。
回归与验收¶
回归覆盖八条:接口返回等待补充状态和原始请求标识;刷新只自动恢复排队和执行中的任务;等待补充从历史记录显式恢复;临时失败重试耗尽回到等待补充、已有结果不丢;重新提交后任务完成;相同请求标识的内核重试只保存一条用户输入;每次模型调用显式关闭并行工具调用;产品自定义工具的参数错误在同一任务内返回给模型修正。
后续迁移验收完成过一次真实补充续接:同一任务先完成首轮(视觉识别、检索、取证后返回结构化追问),再用续接请求恢复到完成;恢复轮没有重复识图,模型调用计数和审计状态都对得上。原始故障没有逐一注入重现,验证范围就是恢复路径本身。