上一篇我们部署了模型路由插件,但验证时发现它"报错却不切换"。 从"不切换"到"真正自动切换",我们迭代了四轮,踩了三个 hook 语义陷阱, 最后在真实故障场景里验证通过:主模型欠费报错 → 秒级自动切到备用模型 → 正常回复。
这篇记录完整的调试过程------每一个"看起来合理"的设计,都被 2026.4.14 的运行时语义打脸一次。
一、开场:插件的"切换"为什么没发生
第 20 篇我们把 model-router 插件部署到了 4 个 worker(v2:before_prompt_build 失败推断 + llm_output 成功确认), 注册成功、gateway ready、一切正常。
然后真实验证来了:主模型 qwen3.6-plus 欠费,请求返回:
HTTP 400: Access denied, please make sure your account is in good standing.
For details, see: https://help.aliyun.com/zh/model-studio/error-code#overdue-payment
期望 :自动切到备用模型。 实际:报错,不切。



日志里连一条检测记录都没有。状态文件 switchCount=0------插件像睡着了一样。 接下来四轮迭代,每一轮都暴露一个"设计时没想到"的运行时语义。
二、第一轮(v2→v3):确定性错误被当成"连续失败"
v2 的切换逻辑是:
before_prompt_build(每次调用前):
上次调用失败 → 计数 +1 → 达到 3 次 → 探测确认 → 切换
问题:欠费是确定性错误,不是连续失败 。400 Access denied 重试一万次也不会好, v2 却要求"连续 3 次失败"才切------用户发 1 次消息 = 1 次失败 = 计数 1,不切。
修复 v3:失败后立即探测 网关拿真实错误体,确定性错误(quota/权限/模型不存在)当场切换; 只有探测无果(超时/不可达)才走连续失败计数(应对"隐身欠费")。
代码看起来没问题了。但验证......还是不切。
三、第二轮(v3→v4):检测是"被动"的,一次消息只有一次失败
v3 的检测点还是 before_prompt_build------它在下一次 LLM 调用前检查"上次是否失败"。 而真实对话里:
用户发 1 条消息 → run 开始 → 1 次 LLM 调用 → 失败 → run 结束
↑ 没有"下一次调用"了!
一次消息只产生一次失败,run 就结束了。检测永远等不到"下次调用"。
修复 v4:新增 agent_end hook(run 结束触发,带 success/error 字段)------ run 失败立即处理,不等下次消息。
结果:还是不切。而且这次能看日志了------agent_end 确实运行了(running agent_end (1 handlers)), 但我们的 handler 没有任何输出,也没报错。它像被静默吞掉了。
四、第三轮(v4→v5):两个 hook 语义陷阱,逐个实锤
陷阱 1:agent_end 的 success 字段是"假"的


给 agent_end handler 加了诊断日志,真相大白:
[model-router] agent_end 事件: success=true error=(empty)
LLM 调用失败(HTTP 400)时,agent_end 事件里 success=true、error=empty!
翻 2026.4.14 源码(attempt.ts)找到原因:
success: !aborted && !promptError,
error: promptError ? formatErrorMessage(promptError) : undefined,
promptError 只覆盖 prompt 构建阶段 的错误;LLM 调用阶段的错误(HTTP 400)根本不设置它 → success 恒为 true、error 恒为 undefined → 我们的 handler 第一行就 return 了。
教训 :hook 事件字段的语义要看运行时源码,不能看类型定义。 类型写着 error?: string,实际只有特定错误路径才会填。
陷阱 2:llm_output 在失败时也会触发,且误清计数
日志里另一行引起了注意:running llm_output (2 handlers)------失败 run 也触发了 llm_output!
而我们的 llm_output handler 无条件执行:
router.clearFailures(ref.id); // 清失败计数
lastSuccessAt = Date.now(); // 标记"成功"
这意味着:失败 run 也会把失败计数清掉、把"上次成功时间"更新------ before_prompt_build 的失败检测被自己人废掉了。
修复 v5:
// llm_output 在失败时也会触发(assistantTexts 为空)——只有真实成功才算
if (!event.assistantTexts || event.assistantTexts.length === 0) return;
五、验证实录:从 400 到正常回复,两跳完成
v5 部署后,真实故障场景验证:
消息 1:qwen3.6-plus → HTTP 400 欠费报错(失败)
消息 2:before_prompt_build 检测到上次失败
→ 立即探测网关 → 400: Access denied → classifyError = QUOTA_EXHAUSTED
→ 🔴 切换至 MiniMax-M3
→ 本条消息用 MiniMax-M3 调用成功,正常回复 🎉
日志实锤:
[model-router] probe agentteams-gateway/qwen3.6-plus → 400: Access denied...
[model-router] 🔴 agentteams-gateway/qwen3.6-plus 不可用(QUOTA_EXHAUSTED)→ 切换至 agentteams-gateway/MiniMax-M3
[agent/embedded] embedded run agent end: ... isError=false
状态文件:switchCount=1,unavailable 里记录了故障类型和检测时间。
链式切换也验证过(三个模型:欠费 → 不存在的模型 404 → 正常): 两次 🔴 ... 不可用 → 切换至 ...,最终兜底模型正常回复。
六、插曲:qwen3.8-max 的"挂起"坑
验证链中间本来放 qwen3.8-max(也是欠费),结果它请求挂起不返回 ------ 阿里云欠费有两种形态:qwen3.6 是快速 400,qwen3.8 是无限超时("隐身欠费")。 挂起意味着 run 要等 OpenClaw 的 30 分钟兜底超时(timeoutSeconds=1800)------ 8/19 那次 4.5 小时空转的翻版。
这就是为什么模型路由不能只认错误码:错误形态比错误类型更多。 最终测试链把 qwen3.8-max 换成不存在的模型(404 秒级失败),既验证了链式切换,又绕开了挂起坑。
七、收尾:这一轮的"教训"清单
| # | 教训 | 具体表现 |
|---|---|---|
| 1 | 确定性错误 ≠ 连续失败 | 欠费要当场切,不能等 3 次(v3) |
| 2 | "下次调用前检测"是伪主动 | 一次消息只有一次失败,run 结束就没有"下次"(v4) |
| 3 | hook 事件字段语义要看源码 | 类型写 error?,实际 LLM 失败时 success=true/error=empty(v5) |
| 4 | 失败路径也可能触发"成功" hook | llm_output 失败时也触发,误清计数(v5) |
| 5 | 错误形态 > 错误类型 | 同是欠费:快速 400 / 无限挂起,处理路径完全不同 |
| 6 | 日志是最好的调试工具 | 每轮都在 handler 里加诊断日志,实锤每一个假设 |
最深的感悟 :插件机制给了你"挂载点",但每个挂载点在你跑的版本上是什么语义、什么时候触发、 字段怎么填,只有运行时源码说了算。设计时想的"run 结束触发、带错误信息", 实测可能是"success=true, error=empty"。把假设变成日志,把日志变成证据,才能迭代到"真正可用"。
下一篇预告:Quota Guard 与 Model Router 双插件在 worker 上的协同(熔断 + 切换的分工与联动), 以及 Manager 侧如何消费插件状态做任务编排。
本文为《OpenClaw 源码解读》系列第 21 篇 · 实战篇 配套代码:C:\Users\ThinkPad\clawforge\plugins\clawforge-model-router(v5 已验证) 部署脚本:C:\Users\ThinkPad\clawforge\scripts\deploy\deploy-model-router.sh