本文讲 Grodex 的模型故障与降级------一个供应商挂了,主循环怎么自动切换、怎么不塌、怎么把账记清楚。
0. 先建立心智模型
模型是会挂的
02 篇讲了"三家编解码器 + 规范 IR",那是格式层 的适配。但真实世界里模型不只是"格式不同",它还会挂:5xx、限流、网络抖动、认证失效。一个 agent 循环如果只绑一个模型,模型一挂,整个会话就停摆。
Grodex 对"模型会挂"这件事的答案是三层防御:
- 候选路由(ModelRoute):配置里列多个候选(不同 provider / 模型 / 账号),第一个挂了切下一个;
- 熔断器(CircuitBreaker):每个候选自带一个三态熔断器,连续失败就"开路",不去反复打一个坏模型;
- 重试预算(RetryBudget):单个候选内部,可重试的错误退避重试,预算耗尽再谈切换。
但这里有个必须守住的底线:降级不是"随便换个模型接着跑" 。模型换了,能力可能不一样(这个会工具调用、那个不会;这个支持流式、那个不支持)。Grodex 用 Lossiness 门 约束:只有显式声明允许降级的能力才能降,没声明的,宁可不降也不偷偷降级。这一条,是整个降级机制的灵魂。
1. 全景:一次采样要过的三道闸

- 先问熔断器:当前候选的熔断器 OPEN 了?直接拒绝,连试都不试;
- 尝试采样:过熔断器后,真正发请求;
- 出错分类:失败后按错误类型决定------退避重试 / 切下一候选 / 直接致命(见 §4)。
这个循环包在采样 actor 里,主循环完全无感:对 TurnCoordinator 来说,它只是"采到样或拿到错误",不知道背后换了几次模型。
2. 候选路由:第一个挂了,切下一个
候选列表 + 优先级
ModelRoute 持有一串按优先级排序的候选(ModelCandidate),每个候选自带三个东西:binding(provider + model + 协议)、自己的熔断器、account_id(凭据路由,区域 6 的租约体系从这里取 key)。
配置长这样(TOML):
ini
[[model_routes.default.candidates]]
name = "primary-openai"
provider = "openai"
model = "gpt-5"
wire_protocol = "responses"
priority = 1 # 越小越先试
[[model_routes.default.candidates]]
name = "fallback-anthropic"
provider = "anthropic"
model = "claude-sonnet-5"
wire_protocol = "messages"
priority = 2
select_first 与 try_next
- Turn 开始 :
select_first()按优先级挑第一个熔断器没开 的候选------如果StickyScope::Session,还会先看看当前候选还健不健康,健康就继续用它; - 失败转移 :
try_next()从当前索引往后扫,跳过熔断器 OPEN 的,找到下一个;扫完没有 →RouteExhausted。
Turn 粘性:别在对话中途偷偷换模型
StickyScope 控制"粘住一个候选多久":默认 Turn(每回合从最高优先级重新开始)、可选 Step(Turn 内跨 Step 粘住)、Session(整个会话粘住)。为什么要有粘性?因为换模型 = 行为可能变 ------用户看到的是"同一段对话前后判若两人",所以默认只在回合边界才允许重新选择。
预算:别无限试
RouteAttemptBudget 限死失败转移的代价:最多试 5 个候选 (max_candidates)、单回合采样总时长上限 120 秒(turn_deadline)。试完了还没成,就老实报错,交给循环的压缩/重试/恢复去处理(见 §7)。

Lossiness 门:不静默降级
这是最关键的设计。路由用 with_declared_degradations 显式声明 允许降级的能力维度(比如 reasoning、parallel_tool_calls)。转移时,新候选如果缺了某个未声明可降级 的能力(比如你要推理、它不支持),直接判定 CandidateRejected------宁可这次调用失败,也不偷偷用一个缺能力的模型糊弄。 这正是设计文档里反复强调的"无静默降级"验收项:用户必须知道模型被换了、换成了什么、少了什么。
3. 熔断器:别反复打一个坏模型
每个候选背后是一个三态熔断器 (照 Grok 的 xai-circuit-breaker 模式):

- CLOSED:正常放行。用一个 60 秒滑动窗口记请求成败;
- OPEN :窗口内错误率 ≥ 50% 且样本 ≥ 10 个 → 熔断打开,所有请求直接拒(
BreakerOpen,带 retry_after),不再浪费请求去打一个正在坏的服务; - HALF-OPEN :冷却 10 秒后,放行一个探针请求(默认单探针)。探针成功 → 关闭熔断;探针失败 → 立刻重新打开。
实现上有几个值得讲的细节:
- 熔断判定是锁无关的快路径 (
is_open()一个原子读),高频检查不抢锁; - 失败状态码白名单默认
[429, 500, 502, 503, 504]------不是所有 4xx 都算失败(400 是业务错误,不该熔断); - HALF-OPEN 探针带"租约回收":探针被申请但超时没人用(请求半途崩了),别人可以接管,避免一个死探针卡死半开状态。
4. 重试预算与错误分类:每种错误都有自己的结局
采样 actor 内部跑着 classify_error------一个纯函数,输入 (错误, 尝试次数, 预算, 流进度),输出一个决定。决策顺序是有讲究的(注释原话:"decision order is load-bearing"):

错误分类表
| 错误类型 | 可重试? | 可转移? | 实际行为 |
|---|---|---|---|
| 认证失效 (401) | 否 | 否 | 致命,交给认证层刷新(预算内最多 2 次) |
| 客户端错误 (4xx) | 否 | 否 | 致命(业务错误,重试无用) |
| 上下文超长 | 否 | 否 | 致命,交给压缩(02 篇 §6) |
| 限流 (429) | 是(阈值内) | 是 | 退避重试;连续超阈值 → 致命 |
| 服务端 5xx | 是(预算内) | 是 | 退避重试;预算耗尽 → 切下一候选 |
| 网络/传输 | 是 | 是 | 首次尝试先重建客户端(HTTP/1.1 回退),再退避重试 |
| 空响应 | 是 | 是 | 退避重试 |
is_failover_eligible() 只有 Transport / 5xx / 限流 返回 true------只有这些"服务端/网络问题"才值得换一家试;认证和业务错误换模型也没用,直接返回。
退避:2s × 2ⁿ,封顶 30s,±20% 抖动
重试不是傻等:retry_backoff 用指数退避,基值 2 秒,每次翻倍,封顶 30 秒,再套 ±20% 抖动(用全局原子 + 线程 id 去相关,避免所有请求同时醒来打爆服务端)。
客户端重建:第一次传输错误先换"方言"
第一次传输错误不走普通重试,而是 RetryWithClientRebuild------重建 HTTP 客户端(回退到 HTTP/1.1)。这是从 Grok 学来的细节:某些代理/服务器对 HTTP/2 兼容性差,重建到 HTTP/1.1 往往就好了,不用浪费一次完整的退避。
子 Agent 用保守预算
主循环用默认预算(5 次重试 / 120s),子 Agent 用更保守的预算:2 次重试 / 60s / 最多 1 次认证刷新 / 限流阈值 1。因为子 Agent 挂了可以重派,不值得为它烧掉主循环的资源。
5. 语义提交栅栏:为什么"已经吐了字,就不能透明换模型"
这是整篇里最容易被忽略、但最能体现工程判断的一条。
StreamProgress 追踪流式响应里已经产生什么语义内容 :吐过文本了?工具调用开始了?一旦 has_crossed_semantic_fence() 为真(吐过文本或开始工具调用),classify_error 直接判定致命,禁止透明重试和切换。
为什么?因为已经流给用户的内容无法撤回。如果换个模型重试:
- 用户已经看到了旧模型吐的一半;
- 重试可能重复 或矛盾地输出;
- 你没法跟用户说"刚才那段作废,换个模型重说"------太荒谬了。
所以语义栅栏的规则是:没吐任何东西之前,怎么重试/切换都行;一旦吐了,就一条道走到黑 ------这条采样要么成功收尾,要么带着半截输出进入回合级恢复(压缩/重试/取消,见 §7)。透明降级只发生在"用户还没看到任何东西"的时候。
6. 可观测:每一次切换都入账
降级不是悄悄发生的。ModelRoute 发出 RouteEvent,落入两个观察面:
- 运行时事件流 :
CandidateSelected/CandidateSucceeded/CandidateFailed { failover: bool }/CandidateRejected(被 Lossiness 门拦下,带原因)/RouteExhausted/BreakerOpened; - rollout 日志 :
write_route_event写ModelRouteEvent进区域 7 的同一事实源------降级轨迹可重放、可审计,和别的动作一样。
也就是说:"这个回合模型从 openai 切到了 anthropic,因为 5xx" 这件事,和一次工具调用一样,是有据可查的事件。
7. 和其它区域的联动
降级不是孤岛,它和前面几篇讲的东西接成闭环:
- 与压缩:上下文超长错误 → 致命 → 交给 02 篇 §6 的压缩(强制压缩一次再重试);
- 与能力冻结 :Turn 内换模型必须过 Lossiness 门------新模型缺的能力如果是没声明可降级的,宁可不换(和 03 篇的"能力冻结"同一纪律);
- 与持久化 :
ModelRouteEvent进 rollout,崩溃恢复后能看到上次是怎么降级的(04 篇); - 与子 Agent:子 Agent 用保守预算,降级不了就失败重派(区域 8)。
8. 小结
一句话总结故障与降级:用"候选切换 + 熔断 + 重试"扛住模型会挂的现实,用"Turn 粘性 + Lossiness 门 + 语义提交栅栏"守住不糊弄用户的底线。 前者是可用性,后者是诚实------能降级,但绝不静默降级;能切换,但只在你还没看到任何内容之前;每次切换,都有一行事件可查。