1. 为什么“报错不切换”是模型路由插件最常见的翻车点
如果你正在给 OpenClaw 写模型路由插件,或者刚把model-router挂到 worker 上,大概率会遇到一个很反直觉的现象:主模型明明已经返回 400 欠费报错,插件却像没看见一样,日志里连一条检测记录都没有,状态文件里switchCount=0。这不是你配置写错了,而是 hook 的运行时语义和设计时的假设对不上。
OpenClaw 的模型路由插件本质上是一个“挂载点 + 状态机”的组合:你通过 hook 拿到调用前后的上下文,在合适的时机判断当前模型是否可用,然后决定要不要切到备用模型。听起来很简单,但 2026.4.14 这个版本里,before_prompt_build、agent_end、llm_output三个 hook 的触发时机和字段填充逻辑,和类型定义里写的并不完全一致。我按“报错不切换 → 秒级自动切换”这条线,把五次迭代拆开讲,每一轮都给出可复制的config.toml和settings.json骨架,以及验证切换是否真的生效的检查动作。
适合谁看:已经在用 OpenClaw 跑多模型、想加自动切换但一直不生效的开发者;准备接 TaoToken 统一 Key/API 通道、需要一套稳定路由骨架的人;以及想读懂 hook 语义、避免被类型定义误导的源码阅读者。下面所有配置都以 TaoToken 作为统一接入通道来演示,你换成自己的网关地址也能直接套用。
2. TaoToken 前置:统一 Key 与 API 通道怎么接
在写路由插件之前,先把模型通道统一掉。TaoToken 的作用是把多个模型的调用收敛到一个 API 入口和一把 Key 上,这样路由插件只需要面对一个网关地址,切换模型时改的是模型名而不是换一整套鉴权。官网入口在 https://taotoken.net/?utm_source=taotoken_aicg_blog_end&utm_medium=csdn&utm_campaign=rewrite&utm_content= ,API 基址是 https://taotoken.net/api ,注意 API 地址后面不加 UTM 参数。
你需要先拿到一把可用的 Key,在控制台里创建:https://taotoken.net/console?utm_source=taotoken_aicg_blog_end&utm_medium=csdn&utm_campaign=rewrite&utm_content= ,然后在 API Keys 页面生成:https://taotoken.net/api-keys?utm_source=taotoken_aicg_blog_end&utm_medium=csdn&utm_campaign=rewrite&utm_content= 。生成后先别急着写插件,用模型对话页面确认这把 Key 能正常出结果:https://taotoken.net/chat?utm_source=taotoken_aicg_blog_end&utm_medium=csdn&utm_campaign=rewrite&utm_content= 。这一步很关键,因为后面路由插件判断“模型不可用”时,探测请求走的就是同一条通道,如果 Key 本身有问题,你会把鉴权失败误判成模型故障。
接入文档在 https://taotoken.net/doc?utm_source=taotoken_aicg_blog_end&utm_medium=csdn&utm_campaign=rewrite&utm_content= ,里面写了兼容的请求格式和模型命名规则。我的建议是:在config.toml里把网关地址、Key、主模型、备用模型都写成变量,插件只读变量,不硬编码。这样后面五次迭代改逻辑时,配置层不用动。
注意:Key 不要写进会提交到仓库的文件里,用环境变量或本地未跟踪的配置文件承载。
3. 可复制配置:config.toml 与 settings.json 骨架
先给一份能直接跑的骨架。config.toml负责网关和模型清单,settings.json负责插件行为和 hook 开关。两份文件配合使用,缺一不可。
# config.toml [gateway] base_url = "https://taotoken.net/api" api_key_env = "TAOTOKEN_API_KEY" timeout_seconds = 30 [models.primary] name = "qwen3.6-plus" provider = "taotoken" [models.fallback] name = "MiniMax-M3" provider = "taotoken" [models.chain] order = ["qwen3.6-plus", "MiniMax-M3"] probe_on_failure = true{ "model_router": { "enabled": true, "hooks": { "before_prompt_build": true, "agent_end": true, "llm_output": true }, "switch_policy": { "deterministic_error_switch": true, "consecutive_failure_threshold": 3, "probe_timeout_ms": 3000 }, "state_file": "./state/model-router.json" } }这份骨架对应的是 v5 的最终形态。deterministic_error_switch控制确定性错误是否当场切换,consecutive_failure_threshold只在探测无果时兜底。hooks三个都打开,是因为后面你会看到,少开任何一个都会导致某类失败漏检。
部署脚本可以这样写,把插件目录和配置一起同步到 worker:
#!/usr/bin/env bash set -euo pipefail PLUGIN_DIR="./plugins/clawforge-model-router" TARGET="./workers/model-router" mkdir -p "$TARGET" cp -r "$PLUGIN_DIR"/* "$TARGET"/ cp ./config.toml "$TARGET/config.toml" cp ./settings.json "$TARGET/settings.json" echo "deployed model-router v5"跑完脚本后,worker 启动时会读取settings.json里的 hook 开关,注册对应的 handler。注册成功的标志是启动日志里出现running (N handlers),N 至少为 1。
4. 五次迭代:从“报错不切换”到“秒级自动切换”
4.1 v2→v3:确定性错误不能等“连续三次失败”
v2 的逻辑是:before_prompt_build里检查上次调用是否失败,失败就计数 +1,累计到 3 次才探测并切换。问题在于欠费返回的是HTTP 400: Access denied,这是确定性错误,重试一万次也不会好。用户发一条消息只产生一次失败,计数停在 1,永远到不了 3。
v3 的修复是:失败后立即探测网关拿真实错误体,把quota、权限、模型不存在这类确定性错误当场切换;只有探测无果(超时、不可达)才走连续失败计数。对应配置里deterministic_error_switch = true。代码骨架:
def classify_error(status_code, body): if status_code == 400 and "Access denied" in body: return "QUOTA_EXHAUSTED" if status_code == 404: return "MODEL_NOT_FOUND" return "UNKNOWN" def on_failure(ref, status_code, body): kind = classify_error(status_code, body) if kind in ("QUOTA_EXHAUSTED", "MODEL_NOT_FOUND"): switch_now(ref, kind) else: increment_failure(ref)4.2 v3→v4:检测点必须挪到 run 结束
v3 的检测点还在before_prompt_build,它检查的是“上一次是否失败”。但真实对话里,用户发一条消息 → run 开始 → 一次 LLM 调用 → 失败 → run 结束,根本没有“下一次调用”,检测永远等不到触发时机。
v4 新增agent_endhook,run 结束时立即处理,不等下一条消息。注册后日志里能看到running agent_end (1 handlers)。但这一版仍然不切,因为 handler 被静默吞掉了——问题出在字段语义上,下一节讲。
4.3 v4→v5 陷阱一:agent_end 的 success 字段是“假”的
给agent_end加诊断日志后真相大白:LLM 调用失败时,事件里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,实际只有特定错误路径才会填。
4.4 v4→v5 陷阱二:llm_output 在失败时也会触发并误清计数
日志里还有一行running llm_output (2 handlers)——失败 run 也触发了llm_output。而 v4 的 handler 无条件执行clearFailures和更新lastSuccessAt,等于失败 run 自己把失败计数清掉了,before_prompt_build的检测被废掉。
v5 的修复是加一道判断,只有真实成功才算:
def on_llm_output(event, router): if not event.assistantTexts or len(event.assistantTexts) == 0: return router.clear_failures(event.ref.id) router.mark_success(event.ref.id)4.5 验证实录:从 400 到正常回复,两跳完成
v5 部署后做真实故障验证。消息 1 用qwen3.6-plus,返回 HTTP 400 欠费报错。消息 2 触发before_prompt_build,检测到上次失败 → 立即探测网关 → 拿到400: Access denied→classifyError = QUOTA_EXHAUSTED→ 切换至MiniMax-M3→ 本条消息用备用模型调用成功,正常回复。
日志实锤:
[model-router] probe taotoken/qwen3.6-plus → 400: Access denied... [model-router] 🔴 taotoken/qwen3.6-plus 不可用(QUOTA_EXHAUSTED)→ 切换至 taotoken/MiniMax-M3 [agent/embedded] embedded run agent end: ... isError=false状态文件里switchCount=1,unavailable记录了故障类型和检测时间。链式切换也验证过:三个模型依次是欠费 → 不存在的模型 404 → 正常,两次切换后兜底模型正常回复。
5. 验证请求与成功结果:怎么确认切换真的生效
光看回复正常还不够,要确认是“切换后正常”而不是“主模型自己恢复了”。按下面三步查。
第一步,看状态文件。切换发生后,state/model-router.json里应该有switchCount递增,unavailable列表里有对应模型的故障类型和时间戳。如果switchCount一直是 0,说明检测根本没触发。
第二步,看日志里的探测行。每次切换前都应该有一条probe → 的记录,后面紧跟🔴 ... 不可用 → 切换至 ...。没有探测行就切换,说明走的是连续失败计数路径,不是确定性错误路径。
第三步,用一次真实请求验证备用模型确实在服务。可以临时把主模型名改成一个不存在的模型,观察是否秒级切到备用模型并正常回复:
curl -s https://taotoken.net/api/v1/chat/completions \ -H "Authorization: Bearer $TAOTOKEN_API_KEY" \ -H "Content-Type: application/json" \ -d '{"model":"MiniMax-M3","messages":[{"role":"user","content":"ping"}]}'返回里有正常的choices内容,说明备用通道本身可用。再结合状态文件里的切换记录,就能确认整条链路是通的。
6. 本篇常见错排查
报错不切换,日志无检测记录。先确认settings.json里before_prompt_build和agent_end都开了。只开前者会漏掉 run 结束即失败的场景,只开后者会漏掉调用前检测。
agent_end 触发了但 handler 无输出。大概率是success字段为 true 导致提前 return。加一行诊断日志打印event.success和event.error,如果看到success=true, error=empty而实际调用失败,就是踩了字段语义陷阱,改用探测网关拿真实错误体来判断。
切换计数被误清。检查llm_outputhandler 是否无条件执行了clearFailures。失败 run 也会触发llm_output,必须先用assistantTexts是否为空做判断。
同是欠费,有的快速 400,有的无限挂起。错误形态比错误类型更多。挂起会等到 OpenClaw 的兜底超时(timeoutSeconds=1800),期间 run 一直空转。测试链里尽量用会快速失败的模型(比如不存在的模型返回 404),避免把挂起模型放进验证链。
探测请求本身失败。如果探测走的是同一条通道,Key 失效会让所有模型都判为不可用。先用模型对话页面确认 Key 正常,再排查路由逻辑。
7. 下一步:把路由骨架接到你的工作流
这套骨架跑通后,你可以按自己的模型清单改config.toml里的models.chain,把主模型和备用模型换成实际在用的。如果后面要长期跑编码或 Agent 任务,建议把通道固定下来,用 Coding Plan 承载高频调用:https://taotoken.net/coding-plan?utm_source=taotoken_aicg_blog_end&utm_medium=csdn&utm_campaign=rewrite&utm_content= 。接入细节和参数说明都在文档里:https://taotoken.net/doc?utm_source=taotoken_aicg_blog_end&utm_medium=csdn&utm_campaign=rewrite&utm_content= 。Key 管理和新建入口在控制台:https://taotoken.net/console?utm_source=taotoken_aicg_blog_end&utm_medium=csdn&utm_campaign=rewrite&utm_content= 和 API Keys 页面:https://taotoken.net/api-keys?utm_source=taotoken_aicg_blog_end&utm_medium=csdn&utm_campaign=rewrite&utm_content= 。Claude Code 场景的接入示例在 https://taotoken.net/claude-code-anthropic?utm_source=taotoken_aicg_blog_end&utm_medium=csdn&utm_campaign=rewrite&utm_content= 。
最后留一个我踩过的坑:验证链里别放会挂起的模型,否则你会以为插件没切,其实是 run 还在等超时。把假设变成日志,把日志变成证据,路由插件才算真正可用。