ARTICLE / ai
Agent的兜底链漏了最温柔的那种失败:内容过滤收尾不触发任何重试钩子
做 Agent 运维的人大多搭过这种保险:主模型挂了,自动切备用模型。限流、5xx、超时、断连——这些" provider 出事了"的场景都测过,钩子都接得住。但有一种失败长得完全不像故障:模型正常地开始回复、正常地结束数据流,只是最后交出来的是一份空白,收尾标记写着"内容过滤"。
OpenCode(anomalyco/opencode)社区 10 月 31 日的一份 issue(#60822)报告说:恰恰是这种最温和的失败,把整套兜底机制绕成了摆设。插件的重试钩子不会被调用,会话直接判死;如果挂掉的还是后台子任务,父任务收到的是一个 error 状态,而那个已经换了模型的子代理会原地坐着,直到有人手动给它发一条消息。
同一个拒绝,两条路,只有一条有救
报告者 dhaern 维护一个做多模型回退的插件(oh-my-opencode-slim),逻辑挂在 session.retry 钩子上:provider 失败时切到链条里的下一个模型,返回"重试"。这套机制对 HTTP 层的错误全部有效——Anthropic 以 HTTP 错误形式返回的内容策略拒绝,会被映射成 provider.content-filter 类型的错误送进钩子,插件照常换模型重试。
问题出在另一条通道。当 provider 不发 HTTP 错误、而是把流正常走完、仅在结束标记上写 finish_reason: "content_filter"(各家协议都有对应形态:Anthropic 的 refusal、OpenAI 的 content_filter、Gemini 的安全拦截、Bedrock 的 guardrails 触发,归一化后都是同一个 finish 值)时,运行器内部把它标成"provider 已失败",但构造给重试策略的错误对象只在"未标记失败"时才生成。两个条件一错开,重试判断整段被跳过,流程直达"步骤失败"。
报告者追到具体行号:publish-llm-event.ts 里设置标志位、step.ts 里三元判断跳过钩子、最终落到 StepFailedError——版本锁定在 2.2.4,dev 分支同一逻辑一致。这份追踪得到了官方机器人的正面确认:“代码正如你描述的那样工作”。
于是出现了这样的分裂:语义完全相同的一次模型拒绝,从 HTTP 错误门进来就有救,从流结束标记门进来就没救。插件作者的原话点出了要害——“插件能恢复一种形状的失败,却恢复不了另一种”。
子任务场景为什么伤得更重
根会话里碰到这事,损失是一轮失败的回复,人回来重新发一句就行。真正放大代价的是无人值守的后台子任务。
报告给了当天上午的实例:一个跑在 claude-opus-5-5 上的背景子代理,还没写出任何东西就撞上拒绝。插件其实看见了异常,也成功把子会话切到了 gpt-6.1-sol——但切换发生时,子任务的执行已经被判为失败,队列记账为 error,父任务收到的就是 <subagent state="error">。换好模型的子代理停在原地,空转,等人工唤醒。
这里有个结构性死角:插件能看到失败的时候,任务和作业状态都已经落定;插件没有权限让一个已结算的作业复活,也没有权限另起一个。换句话说,这不是"钩子没写好",而是恢复动作和状态结算之间的时序关系决定了插件层修不了它。机器人回复里承认了这一点:“到插件看见失败时,步骤和子任务作业都已结算为 error,插件无法绕过。”
“特性请求”标签下的回归之争
官方机器人把这个 issue 登记为 feature request,理由是钩子文档从未承诺覆盖基于 finish 信号的失败。报告者不接受这个定性,而他给出的证据让这场分歧本身成了最有信息量的部分。
他回到 v1 的代码验证:v1.19.5 里,content-filter 收尾会被转换成内容过滤错误、发布成与会话错误事件同一路径的信号——也就是说,v1 时代插件对"API 错误"和"内容拒绝"的处理天然统一。v2 把失败裁决权移交给 session.retry 钩子(引入该钩子的 PR #45999 自述这是"provider 失败的最终策略决定"),HTTP 形态的内容策略错误进了钩子,finish 形态的没有。从插件视角看,这不是缺一个新能力,而是一个原有能力消失了——现在它是唯一无法从插件侧恢复的 provider 失败类型。
这场标签之争对防守方的启示比结论更实用:判断你的 Agent 平台"兜底是否完整",不能读功能清单,要按失败形态枚举。HTTP 错误、流中途断开、流正常结束但内容为空、流正常结束但被安全策略截断——每种形态各自走一遍恢复路径,缺哪个角一目了然。v1 到 v2 的迁移还会带走旧路径的统一性,所以这张枚举表要在每次大版本升级后重跑,不是写完一次就归档。
修复的形状与它的克制
报告者随 issue 附上了补丁方向(PR #60836,同日提交):把 content-filter 收尾包装成与其他 provider 失败同级的内容策略错误,送进重试决策,默认提案保持"不重试"。这三个设计决定都值得抄:
第一,无钩子场景零行为变化。没装插件的用户看到的仍是今天同款失败,改动不会悄悄改变默认语义。第二,已有输出时的保护沿用现成机制:钩子返回重试时,若这一步还没产出任何内容就走 Retry,已经流出部分内容则走 Continue——与处理流中途限流的既有分支完全一致,不会出现半截步骤被执行两遍。第三,验证方式诚实:除了仓库测试(两个新用例,分别覆盖"产出前重试"和"部分产出后续跑",删掉补丁即变红;既有内容过滤测试原样通过),作者还把补丁摘到 v2.2.4 上,用本地 OpenAI 兼容 stub 返回 content_filter 收尾实跑——根会话和后台子任务双双完成换模型续跑,父任务状态从 error 变成 completed。
截至 11 月 2 日复核,该 PR 仍开放,尚未合入。issue 侧还挂着关联线索:#54862 报告了相邻缺口(内置重试耗尽后钩子同样被跳过),两处补齐才是完整的"所有 provider 失败都可裁决"。
边界说明
本文机制描述来自 issue 报告者的源码追踪(版本与 commit 已在原文标注)、官方机器人的复现确认、以及 PR 作者自报的本地实验记录;我们未独立部署复现该缺陷,作者实验中的具体模型名以其报告原文为准。需要强调的另一条边界是:修复解决的是"钩子收不到信号",至于收到之后该不该为内容拒绝换模型重试,属于各团队自己的策略问题——一份被上游安全策略拦下的请求,换个模型再问一遍未必是合规的做法,这一点平台文档没有义务替你回答。
本文面向授权安全研究与防御建设,未经授权对任何系统实施文中技术属违法行为。
参考来源:anomalyco/opencode issue #60822 与 PR #60836 原始页面(采集快照 2026-11-03);文中 #45999、#54862 转述自上述页面的关联列表,未单独采集。