故障转移怎么挑下一家:候选队列与「忽略当前供应商」

2026-08-10

先说本篇最反直觉的那一处:当 CC Switch 的本地代理打开了自动故障转移,「当前供应商」这个概念在挑候选人这一步是不存在的。

大多数人对故障转移的心智模型是「先打当前这家,挂了再换下一家」。cc-switch 的代码不是这么写的。src-tauri/src/proxy/provider_router.rs 里选候选人的逻辑分成互斥的两条分支:故障转移开着时,它只读 get_failover_queue(app_type) 返回的队列,按 P1 → P2 → …… 的顺序生成候选列表,队列里没有的 provider 一律不参与,包括被标记为「当前供应商」的那一家(provider_router.rs:51-78,对应的单元测试在 provider_router.rs:398-426);故障转移关着时,它才去查当前供应商,并且只返回这一个(provider_router.rs:79-96)。

也就是说,队列决定一切。你如果把某家配成了「当前供应商」却忘了把它加进故障转移队列,开着故障转移的时候,代理转发的流量根本不会经过它。

以下全部基于我们本地 clone 的 cc-switch 仓库快照 c39c903(提交日期 2026-08-10),仓库内版本号 3.19.2。我们只读源码与文档文本,没有安装也没有运行过这个桌面应用,所以下面不会有任何关于界面、速度或使用感受的描述。

候选名单是怎么生成的

provider_router.rs 里这段的顺序值得逐步拆开看:

先按 app 读取该应用的代理配置,拿到自动故障转移的开关。开着的分支里,它先取该 app 全部 provider,再取故障转移队列,把队列项映射成 provider id 的有序列表,用队列的顺序覆盖一切。然后对每个 id 做两件事:从全量 provider 里取实体(取不到就跳过),以及查熔断器。

熔断器的 key 是 "app_type:provider_id" 拼出来的(provider_router.rs:19-20:70)。这个细节的意思是:同一个 provider 在 Claude 下和在 Codex 下是两个独立的熔断器,一边熔断不影响另一边。

筛选时调用的是 breaker.is_available()。这个方法有个明确写在注释里的性质:它不占用 HalfOpen 的探测名额circuit_breaker.rs:125-132)。为什么要专门做一个不占名额的查询方法?因为紧接着,转发层还要再问熔断器一遍。

同一个熔断器,一次请求里被问了两遍

这是第二处容易看漏的地方。候选筛选阶段用 is_available() 只读地过一遍,真正要向某个 provider 发请求之前,转发层会再向熔断器要一次放行许可——调的是 allow_provider_requestforwarder.rs:412-444)。与只读的 is_available() 不同,这一道许可是会占掉 HalfOpen 那一个探测名额的——后面讲尝试次数上限时,我们会看到代码为此专门调整了检查顺序。

两次调用语义不同,位置也不同:一次在「谁有资格进名单」,一次在「现在允许你发这一枪吗」。中间隔着请求体的整个预处理过程,状态是可能变的。

顺带指出与用户手册的一处口径差。docs/user-manual/zh/4-proxy/4.3-failover.md 的故障转移流程 mermaid 图画的是「发送到当前供应商 → 失败 → 检查熔断状态」(4.3-failover.md:86-92);而代码里熔断放行检查发生在发起请求之前forwarder.rs:434-448),并且如前所述,开着故障转移时压根不以「当前供应商」为起点(provider_router.rs:51-78)。两处不一致,我们只陈述差异,以实读的仓库状态为准,不推断原因,也不拿它评价这个项目。

只有一家时,熔断器被整体绕过

第三处反直觉:候选列表长度为 1 时,也就是 providers.len() == 1,转发层会整体绕过熔断器检查,直接放行(forwarder.rs:412-444)。

这意味着熔断器在两种情形下都不生效:

  • 故障转移关闭时,候选选择那一层就明说了跳过熔断器检查(provider_router.rs:79-96);
  • 故障转移开着但队列里只剩一个可用候选时,转发层这一层再绕过一次。

这两处绕过是相互独立的两段代码,读的时候别把它们当成一件事。逻辑上也说得通:只有一家可打的时候,熔断了也没有别家可去。但对使用者来说,结论是具体的——故障转移队列里只放一家,等于没有熔断保护。熔断器的三个状态与那几个阈值另有一篇专门在讲,本篇只用到「它在这两种情况下不介入」这一条。

名单为空时的两种错误,含义完全不同

候选列表最终为空,provider_router.rs:98-106 会分成两个分支:

条件抛出的错误日志码
候选总数 > 0 且被熔断数 == 候选总数AllProvidersCircuitOpen[FO-004]
其余情况NoProvidersConfigured[FO-005]

这张表的读法是:[FO-004] 表示队列里确实有 provider,但它们全部处在熔断状态;[FO-005] 表示没能凑出任何候选——在故障转移开着的情形下,可能是队列为空,也可能是队列里的 id 没能映射出对应的 provider 实体(provider_router.rs:51-78)。

这里要补一个限定,否则排查会跑偏::98-106 这段判定是在两条分支之外统一做的。 故障转移关着的时候走的是另一条分支——只返回当前供应商一个(provider_router.rs:79-96),如果这时候取不到当前供应商,候选同样是空的,落到的也是 [FO-005]。所以看到 [FO-005] 的第一件事,是先确认这个 app 的自动故障转移开关到底是开是关,再决定去查队列还是去查当前供应商。本篇下面的讨论都以「故障转移已开启」为前提。

排查时这两个码给出的下一步动作是不一样的:看到 [FO-004],去看熔断状态;看到 [FO-005],按上面那条限定先分清是哪条分支。错误到 HTTP 状态的映射规则集中记在 error_mapper.rs:7-32 的注释里,其中「无可用 Provider」与「重试耗尽」两类都映射到 503,指望靠 HTTP 状态码把这几种情况区分开是不现实的,必须去看日志里的 FO 码。日志码的完整清单另有专篇,本篇只用这两个。

尝试次数的上限,比队列长度更早生效

队列排得再长,也不等于会挨个试完。forwarder.rsmax_attempts = max_retries + 1(saturating_add,forwarder.rs:143-148:217-219),并且这个上限检查放在熔断器 allow 之前(forwarder.rs:423-432),注释写明这样做是为了避免超限之后还去占用 HalfOpen 的探测名额。

max_retries 是每个 app 独立的数据库配置。src-tauri/src/database/schema.rs 里列级 DEFAULT 写的是 3(schema.rs:131-135),但四条 seed 行各不相同:claude 是 6(schema.rs:152)、codex 是 3(schema.rs:161)、gemini 是 5(schema.rs:170)、grokbuild 是 3(schema.rs:179)。折算成尝试上限就是:claude 最多试 7 个 provider,gemini 最多 6 个,codex 与 grokbuild 最多 4 个。

所以在 Claude 下把故障转移队列排到十几家,第 8 位往后在单次请求里是走不到的。要不要调大这个值取决于你的用法与上游情况,项目没有给出通用建议值,我们也不给。这些是数据库 seed 里的默认配置,不是对实际运行结果的任何保证。

还有一个与「挑下一家」直接相关的细节:三个整流重试标记(签名 / budget / media)是在 provider 循环内部声明的,每个 provider 各持有一份forwarder.rs:417-421)。注释说明这样做是为了不让第一家留下的标记短路后续 provider 的整流流程。换句话说,第一家因为签名问题被整流过一次,不会导致第二家丧失同样的整流机会。

切换成功之后才发生的事,还有四道闸门

上面讲的都是「这次请求打给谁」。真正把配置改掉、让下次不经过代理的调用也换家,是另一条链路,而且是在请求成功之后异步触发的。

forwarder.rs:513-528:请求成功后,比较实际使用的 provider 与请求开始时记录的当前 provider,不同则 failover_count += 1,然后 tokio::spawn 一个异步任务去调 failover_manager.try_switch。注意它是 spawn 出去的,不阻塞本次响应。

这个 try_switch 里有四道闸门(failover_switch.rs):

  1. 去重FailoverSwitchManager"app_type:provider_id" 记录进行中的切换,相同切换重复进来直接返回 Ok(false)failover_switch.rs:41-72)。高并发下同一秒里几十个请求都转移到了同一家,只会执行一次切换。
  2. 接管检查:切换前先查该 app 的 proxy_config.enabled没有被代理接管的应用不执行切换failover_switch.rs:81-94)。
  3. 串行锁SwitchLockManager 给每个 app_type 一把 tokio Mutex,同一个 app 的切换串行、不同 app 并行,注释说明是为了防止并发切换导致 is_current 与 Live 备份不一致(switch_lock.rs:1-4:26-41)。
  4. 落盘动作hot_switch_provider → 重建托盘菜单 → 向前端 emit provider-switched 事件,payload 里带 source: "failover"failover_switch.rs:100-131)。

第 4 步值得单独说一句:这个字段的存在意味着「代理自动转移导致的切换」与「你手动切的」在事件层面是可区分的。至于 hot_switch_provider 内部具体改写了哪些文件,它在 services/proxy.rs 里,那个文件我们没有读过,所以本文不写它的实现细节。这里要提醒的是:CC Switch 这类操作会读写 ~/.claude~/.codex 等真实 CLI 配置文件,里面存着本机的 API Key,属于本机敏感数据,涉及备份与同步时请自行评估。

你可以自己核的四步

想验证上面的说法,或者排查「为什么没转到我以为的那家」,按这个顺序走:

第一步,确认是队列问题还是当前供应商问题。 打开 src-tauri/src/proxy/provider_router.rs,看 :51 起的分支——只要故障转移是开的,候选来源就只有 get_failover_queue。你的「当前供应商」不在队列里,它就不会被打到。

第二步,看日志里的 FO 码。 [FO-004] 是全部候选被熔断;[FO-005] 是候选为空,先看自动故障转移开关是开是关,开着就去查队列,关着就去查当前供应商取没取到。别指望用 HTTP 状态码把它们分开。

第三步,比对尝试上限与队列长度。 队列长度大于 max_retries + 1 时,尾部的 provider 在单次请求里到不了。四条 seed 值见上一节,你自己的库里是否被改过,以 proxy_config 表里该 app 的实际行为准。

第四步,确认切换有没有落盘。 转移发生在转发层,落盘由异步的 try_switch 完成,且要求该 app 的 proxy_config.enabled 为真。请求确实转移了但配置没变,先看这道闸门。

什么情况说明不是这个原因? 如果日志里既没有 FO 码、也没有 [FWD-002](全部 provider 失败)这类转发失败记录,那问题多半不在候选选择这一层,而在更前面——请求根本没走到转发环节,或者压根没有经过本地代理。这时候该去查的是应用路由(接管)有没有真的开启、请求有没有打到代理的监听端口上,那是另一条链路的事,本篇的所有结论都不适用。

一句话收束

cc-switch 的故障转移是队列驱动而不是当前供应商驱动的,熔断器在候选筛选与实际发送两个位置以不同语义各介入一次,而候选只有一家时它被整体绕过。这三条串起来,就能解释大部分「为什么转到了这家 / 为什么没转」的困惑。

上面所有阈值与默认值都是我们在 c39c903 快照里读到的代码与数据库 seed,不是运行时观测结果,也不构成对实际表现的任何保证。这个项目迭代很快,你重新 clone 之后,以自己读到的行为准——能复用的不是这些数字,是「去 provider_router.rs 看分支、去日志看 FO 码」这套核查路径。


本文依据 CC Switch 官方仓库(github.com/farion1231/cc-switch)的 README、docs/ 下的用户手册与发布说明、 src/config/ 的预设定义与 src-tauri/src/ 的后端源码整理,核对日 2026-08-10,对应仓库快照 c39c903。 本文内容为仓库源码与文档口径,我们没有安装或运行过这个桌面应用, 因此不涉及界面外观、操作手感与切换速度的任何描述。 文中出现的阈值与默认值均为源码中的默认配置,不构成对实际运行结果的保证。 该项目仍在快速迭代,版本与默认值随时可能变动,请以仓库最新内容为准。 安全相关做法请结合自身环境评估,本文不构成安全方案建议。

想系统学会用 AI?报名体系课或加入会员,照着学、照着用。