上一篇我们部署了模型路由插件,但验证时发现它"报错却不切换"。 从"不切换"到"真正自动切换",我们迭代了四轮,踩了三个 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=trueerror=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=1unavailable 里记录了故障类型和检测时间。

链式切换也验证过(三个模型:欠费 → 不存在的模型 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)
3hook 事件字段语义要看源码类型写 error?,实际 LLM 失败时 success=true/error=empty(v5)
4失败路径也可能触发"成功" hookllm_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 

Logo

中国智能体开发者社区,聚焦智能体与大模型开发,提供前沿资讯、实用工具链、开源项目及行业案例。通过技术沙龙、开发者大赛等活动,促进经验交流与协作,助力开发者快速构建创新智能应用。

更多推荐