熔断不是开关是状态机:Claude Code 路由内核的 3 条红线

路由这层,是我低估得最狠的一块------谁都觉得自己会写:一个 switch、几次重试、失败换一家。真把它放到流式响应和共享上游中间,常见的翻车样本就两类【从业者判断】:要么为收集指标把整条 SSE 攒在内存里,要么一个粗糙的熔断把候选全弹开,客户端等来一个谁都没错的 502。

这个内核我写在 ccm 里。ccm 是一个 Rust 单二进制的本地模型路由控制平面(GitHub 开源,仓库地址见文末):Claude Code 连本地代理 127.0.0.1:13521,代理决定每一发请求打给哪个模型、失败落到谁、什么情况下宁可报错也不换。

整体架构在 78 篇,一句话带过:控制平面塞进一个本地代理,不做通用网关、不做分布式。本篇只深挖内核。

反方观点,评论区第一楼预定:LiteLLM、one-api 这类现成聚合代理早把多上游连通做完了,为什么用 Rust 再造一个?

我的回应:聚合层解决的是「都能连上」,而本文这三条铁律------熔断是状态机不是开关、流中绝不换模型、fallback 每次都花预算------里最硬的是流中不换模型:【从业者判断,2026-10】我没有在现成方案里找到哪一家把它写成 permanent non-goal;找到了,就不必这一篇。

另外两条不是独创,熔断和预算是成熟路由的公共财产,差异在细节执行(键按模型、跳过不占预算、500 不进熔断)。

现成方案没写成承诺的有两件:流中不换模型的 permanent non-goal,和每次路由决策可事后解释的 decision trace(第七节)------【从业者判断,2026-10】后者个人检索未命中,未逐家核验。

为什么是这三条?DESIGN §19 的不变量共 11 条,这三条互相咬合:熔断决定谁不许试(第三节),预算决定还能试几次(第四节),流中不换模型决定试了就不能反悔(第六节);其余的归 78/80/82 篇,本文不越界。

它们自 v0.3 定下、历经两次大版本扩容一条没松动------稳定不变的边界,才有资格叫内核。

一、流水线:排序和放行,必须分家

一次 /v1/messages 请求进入代理后,穿过五段(DESIGN §3):

text 复制代码
Route Resolution      解析目标(模型 / profile / 路由)
      ↓
Candidate Selector    候选排序(五种策略)
      ↓
Circuit Breaker       放行裁决(CLOSED / OPEN / HALF_OPEN)
      ↓
Policy Executor       header 超时 / 尝试预算 / fallback 状态码 / 退避
      ↓
Provider + Credential 解析凭据、改写 model id、打上游

注意第三段和第四段的分工:选择器只排序,熔断器只决定排在前面的候选能不能执行。DESIGN §2 原则 6 原文是 "Circuit breaker is a hard admission gate"------熔断是硬性准入闸门,不是打分公式里的减分项。

为什么分家?【从业者判断】排序的输入每个请求都在变(延迟、成功率),准入的输入只在健康度拐点变,两者更新频率差着量级;揉在一起写,最常见的结局是把「这个模型最近很慢」悄悄写成「这个模型不许用」,然后没人记得为什么。

代码布局上这几段就是 src/routing/ 下的四个模块:select.rs、circuit.rs、metrics.rs、decision.rs(外加一个只做模块声明的 mod.rs;DESIGN §14)。模块图即流水线图,我认为这是个健康信号。

二、候选选择:冷启动语义比公式重要

五种策略一张表(DESIGN §5、USAGE §5.1):

策略 排序依据 没数据时
ordered(默认) 配置顺序 primary → fallback 不适用
healthiest 成功率降序,并列比延迟 EWMA 尝试 <3 次按 1.0
lowest-latency 响应头延迟 EWMA 升序 未采样排在所有已采样之后
lowest-cost cost_weight 升序(手填静态值) 不适用
weighted 加权综合分降序 reliability <3 样本按 1.0;latency 无样本 0.5

weighted 的公式与默认权重(USAGE §5.2;权重和 ≤0 直接报配置错误):

text 复制代码
weighted_score = (w_r×reliability + w_l×latency + w_c×cost + w_q×quality) / Σw

reliability = 成功率(尝试 <3 次按 1.0)
latency     = 1 / (1 + latency_ewma_ms/1000)(无样本 0.5)
cost        = 1 / (1 + max(cost_weight, 0))
quality     = quality_weight,截断到 [0,1]

延迟 EWMA 的 alpha 是 0.2:新样本占两成、历史占八成,兼顾「最近变慢要看得见」和「单次抖动不改写排序」。

「尝试 <3 次按 1.0」这个 3 也有讲究。【从业者判断】样本门槛太低,单次抖动就能改写排序;太高,新模型长期停在「名义健康」的 1.0 上,坏模型杀不下去。

3 是个体面的小折中,ccm 把它写死在策略里而不是开成配置项,我赞成------不猜,是这里的纪律:参数开放配置,只会生产出没人能复现的路由行为。

真正值得抄的还是冷启动语义。【从业者判断】新模型零数据按 1.0(最健康)处理,等于把前几次请求当投资,给它被采样的机会;反过来按 0 处理,新模型会被永久饿死------永远排最后、永远没数据。

前三次可能打到坏模型的代价,由下一节的熔断和 fallback 兜住。【从业者判断】「中性值 + 兜底层」的组合,比任何单一打分公式都更决定路由层的实际体验。

两个口径顺带厘清:lowest-latency 的「未采样排最后」是排序规则的空值处理,不是打分,两回事;五种策略自 v0.3 起冻结(DESIGN §17:additional selection strategies 仍 out of scope),自适应路由也不做------这是另一个「不猜」。

三、熔断:布尔开关答不上来的三个问题

第一条铁律:熔断是状态机,不是开关。把熔断写成布尔------「连续错 3 次关 30 秒」------能跑,但三个问题它永远答不上来:冷却结束时谁来试第一发?那发探测失败了算谁的?冷却已过、探测未到之间的请求算什么?

ccm 的状态机(DESIGN §6):

text 复制代码
CLOSED ──连续失败 ≥ 阈值──▶ OPEN ──冷却到期──▶ HALF_OPEN
                                     │
            探测成功 ──▶ CLOSED       └──探测失败──▶ OPEN

三个答案分别是:恰好一个探测请求------单探测准入,其余并发请求跳过该候选继续路由;探测失败回 OPEN 重新冷却;中间窗口在 /_ccm/circuits 里有名字,HALF_OPEN_READY。

HALF_OPEN_READY 不是状态机里的第四个状态。DESIGN §6 原文说得很清楚,状态机是三态,HALF_OPEN_READY 是控制视图对「冷却已到期、探测未放行」这个窗口的呈现------它告诉你:下一个请求会成为唯一被放行的探测。我的看法:运维看板若把两者混为一谈,排障时容易误判并发量。

decision 记录长这样(USAGE §6.2 的示例,字段原样):

json 复制代码
"attempts": [
  { "attempt": 1, "model": "glm", "circuit": "OPEN",
    "result": "skipped: circuit OPEN until 1790985630000", "fallback": true },
  { "attempt": 2, "model": "minimax", "circuit": "CLOSED",
    "result": "HTTP 200", "fallback": false }
]

glm 熔断中被跳过------零上游调用------请求直接落到 minimax。「状态机」三个字值钱就值在这:跳过是 O(1) 的本地决策,不花任何上游延迟和配额。

minimax 这行的 fallback: false 说的是路由到这一行就不再换人------成功行恒为 false;跳过/失败行的 true 表示这次之后还会交给下一候选(失败行还要求剩预算)。它标记的是迁移动作本身,不是「这个模型是不是备胎」。

铁律一之下还有三条具体设计,一条比一条有争议:

键是模型,不是路由。 DESIGN §6 的理由原文:"A failing upstream model should be recognized as unhealthy even when reused by multiple Routes."。

拿样例配置说话(USAGE §3.1):coding-route 的 fallback 是 glm,fast-route 的 primary 也是 glm------glm 挂了,两条路由应同时知道。按路由键断路,同一个坏模型要在每条路由里各挂满阈值才被认出来。

v0.4 起多客户端共享一个代理,同样成立:某上游挂了,对所有客户端一起生效(USAGE §4.6)。

跳过不占预算。 被熔断跳过的候选不消耗 max_attempts(DESIGN §19 不变量 3)。跳过不是尝试------上游连包都没收到,占重试的坑没有道理。

计什么数,才是真争议。 进熔断失败计数的只有三种:请求/连接错误、响应头超时、fallback_on 表里的状态码(默认 429,502,503,504)。

500 默认不在表里------它原样透传给客户端,且按熔断视角的成功处理、会重置连续失败计数(USAGE §5.4 明确列为陷阱;metrics 里它仍计 http_errors,429 另计 rate_limited,只是熔断视角当成功)。

反方观点,评论区预定席:500 也是服务器错误,凭什么不算失败?

我的回应:【从业者判断】429/502/503/504 的语义是「这个部署现在不行」,换一家大概率恢复;500 的语义往往是「这个请求本身有问题」------模型拒答、payload 异常,换一家同样 500,甚至换成一家静默吞错的网关更糟。

把 500 当路由信号,等于用迁移在调试业务错误。想让 500 也触发 fallback?USAGE FAQ 给了一行解法:--fallback-on 429,500,502,503,504。语义你定义,默认值不替你猜。

完整时间线(USAGE §5.4 实测口径):glm 连续 503 达到阈值(默认 3 次)→ OPEN,后续请求零上游调用直接走 minimax → 30 秒冷却到期,第一个请求成为唯一探测 → 成功 → CLOSED,计数完全重置。

四、fallback:每次迁移都从预算里扣

第二条铁律:fallback 每次都花预算。执行序(DESIGN §7):排序 → 逐候选:查预算 → 问熔断 → 解析模型/provider/凭据 → 改写 model id → 发请求。触发迁移的只有三种:请求/连接错误、响应头超时、fallback_on 状态码;且要求同时满足还有下一个候选、还有剩余预算(USAGE §5.4)。

默认参数一张表(USAGE §5.2):

参数 默认 含义
max_attempts 3 单请求最大尝试次数
backoff_ms 200 尝试间退避
header_timeout_ms 30000 等上游响应头的上限
fallback_on 429,502,503,504 触发迁移的状态码
failure_threshold 3 连续失败几次熔断
circuit_open_ms 30000 熔断冷却

默认的 429,502,503,504 不是随手抄的。【从业者判断】这四个码对应网关侧的典型瞬态------限流、坏网关、上游不可用、网关超时,共同点是「换个实例/换家大概率好」;而 5xx 里的 500、4xx 里的 400 更像请求侧终态。默认表只收瞬态码,是对「fallback 治瞬态病」这个定位的忠实执行。

表外还有一个开关语义的坑:--circuit-enabled false 之后状态恒为 CLOSED、成败不再进熔断(USAGE §5.4)------是真关掉,不是「只记录不动作」。

预算的两条出口,都是「诚实报错优于假装成功」。

其一,预算耗尽且全部失败,代理返回 502,报错原文 CCM proxy error: all available route candidates failed for ...(USAGE §5.4)。

其二,最后一次可用尝试撞上 fallback 状态码时不再换报错------上游错误响应原样透传。此时 decision 里 selected 已设置、outcome 形如 HTTP 429,看着像成功,实为透传的失败(USAGE §6.2)。这条是故意的:客户端拿到上游原话,比拿到代理二手 502 更有诊断价值。

还有一条没人注意的前提:请求体在流水线前 buffer 一次、上限 16 MiB,model id 和凭据逐次尝试解析(DESIGN §7)。没有「body 只读一次」,fallback 连重放的资格都没有。

补一句边界:这套路由只活在代理模式。直连 ccm run <model> 是单模型直通,没有 fallback、没有熔断(USAGE §4.1)------想清楚要哪种再选模式。

五、串起来看:一条请求的一生

单看规则容易晕,拿样例配置推演一遍(coding-route:primary claude、fallback glm,默认策略;推演口径,与 USAGE §5.4 的 glm 503 实测简版一致)。

第 1 发请求:attempt 1 打 claude,HTTP 429。429 在 fallback_on 里,也进熔断计数------claude 连续失败 1。预算花掉 1/3,退避 200 ms,attempt 2 打 glm,HTTP 200,客户端拿到响应。用户体验:慢了一拍,但无感。

第 2、3 发请求:同样的剧本再演两遍。claude 连续失败到 3,达到 failure_threshold------熔断 OPEN。这三发里 glm 每次都正常接盘,客户端始终有响应。

第 4 发请求:剧情变了。claude 被 OPEN 跳过------零上游调用、不占预算;glm 直接承接,fallback 标记同样为 false------成功即终点。用户体验:反而比前几发更快------省掉了一次注定失败的上游往返。

30 秒冷却到期后的第一发:claude 进入 HALF_OPEN,这一发成为唯一探测。成功 → CLOSED,计数完全重置,claude 回到牌桌;失败 → 重新 OPEN,再冷却 30 秒。

这个推演里每一行都是前面三节规则的直接应用,没有一条新行为。好的路由内核就该这样:规则少到能口算,组合起来覆盖全部剧情。

六、流中绝不换模型:一条永久不变量

第三条铁律------流中绝不换模型------在文档里叫不变量 5,原文 "No mid-stream failover",DESIGN §17 给它的定语是 permanent non-goal------不是还没做,是永远不做。

时间锚点是上游响应头。响应头一到,请求就在这个模型上「承接」了;此后无论上游断流、翻译器碰到坏帧、还是上游在 200 流中途塞一个 error 帧(OpenAI 系过载时常见),代理只做一件事:在已提交的流上补一个 Anthropic error 事件,然后结束响应体(DESIGN §19 v0.4 注记、USAGE §5.5)。

按「失败发生在哪」整理成一张判定表,这张表就是本文的全部分水岭:

失败发生在 处理 依据
连接/请求错误 计失败、占预算、落下一候选 DESIGN §7
header_timeout_ms 内响应头没到 同上 DESIGN §7
响应头是 fallback_on 状态码 同上,改打下一候选 USAGE §5.4
响应头 2xx,体中途坏 error 事件收尾,不换模型 DESIGN §19、USAGE §5.5
非 fallback_on 状态码(如 500) 透传终态 USAGE §5.4

为什么这张表右列第二行往下就不能再迁了?【从业者判断】三条:

  • 半截 SSE 没法倒带。客户端可能已经消费并渲染了前半段,重放意味着重复内容或魔法拼接,协议上没有任何支持。
  • 重放等于双倍计费加不可控副作用。前半段的 output token 钱已经花了,换一家再来一遍不会退。
  • 这个窗口本就该小。响应头之前的失败上表前三行已经兜住;mid-stream failover 能补救的只剩「头已到、体中途坏」,这个窗口里,诚实的 error 事件比一次赌博式重放便宜得多。

客户端视角也值得说一句:Claude Code 从头到尾看到的是「恰好一个模型的恰好一个响应」,错误走 Anthropic 协议原生的 error 事件,绝不伪装成正常完成(USAGE §5.5 原文口径)。这条对上层应用是承诺,不是实现细节。

本篇最后一个反方观点,还是预定席:做成可配置的开关不行吗,默认关就好?

我的回应:开关的存在本身就是邀请------一旦有人打开它,上面三条代价就从「设计决定了不用付」变成「用户自己选的」。【从业者判断】永久非目标比配置项诚实:它把代价写进架构,而不是写进一个默认值等人翻动。

配套纪律:header_timeout_ms 只约束到响应头(DESIGN §7 原文 "Header timeout ends once response headers arrive")------判断点之前有超时兜底,判断点之后靠 error 事件收尾,两头都有明确终态。

已知债务照实说:非流式的 openai-compatible 翻译需要完整 JSON,上游响应体会整体读入(DESIGN §18.6,v0.4 接受的例外,header_timeout_ms 管不到它);流式路径不受影响------SSE 翻译逐块转换、单帧上限 1 MiB,从不缓冲整条流(DESIGN §19)。

七、观测与测试:点到为止,不越界

原则 7 原文 "Streaming must remain streaming":延迟量到响应头为止,usage 扫描逐块进行、单行 64 KiB 上限,任何观测需求都不许把流攒完再转发------这就是开头第一类翻车(为收指标把整条 SSE 攒在内存里)的正面解药。

每请求一条 decision 记录------配置序、打分序、每个候选的熔断裁决、每次尝试、最终选择与结局全在里面(DESIGN §9)。

它的存在理由原文:"A user or Agent must be able to explain why CCM selected, skipped, retried, or fell back for a specific request."。观测面全貌在下一篇(80)。

行为靠测试钉死,测试面分三类。v0.3 路由契约的 mock 集成测试覆盖前两类------fallback 链路(429 迁移)与熔断状态机(OPEN 跳过且零上游请求)(DESIGN §15)。

第三类是 v0.4 翻译契约------「已提交流的失败只发 error 事件、不中途换模型」。明细展开在 82 篇。

路由层的验收标准不是功能清单,而是每一次跳过都能被事后解释------decision 记录就是这层的证词。

尾巴:现在就能做的事

ccm 开源(github.com/Thneoly/ccm,MIT,Rust 单二进制,Windows + Linux)。代理跑着的时候,30 秒自检三连:

bash 复制代码
curl -s http://127.0.0.1:13521/_ccm/circuits
curl -s http://127.0.0.1:13521/_ccm/decisions | head -c 800
curl -s http://127.0.0.1:13521/_ccm/scores

第一看谁在 OPEN / HALF_OPEN_READY,第二看最近一次请求的完整裁决链(排序、每候选熔断裁决、每次尝试、最终选择),第三看当前候选打分明细。

没用过 ccm 的读者,拿三个问题审你自己的路由层:500 触发 fallback 吗,这是你想要的吗?熔断跳过的候选占不占重试预算?你的指标收集要不要把流式响应缓冲完?

三个里有一个答得含糊,你的路由层就藏着一个只在半夜两点被叫醒的 bug。


事实与来源

正文判据逐条对应的来源档(仓库 github.com/Thneoly/ccm,本地 D:/Project/ccm,以下节号与默认值均为 v0.5.1 时点(2026-10-11)的仓库文档口径):

  • 流水线结构、准入闸门原则原文、五种策略与 weighted 公式、EWMA alpha 0.2、熔断三态机与单探测准入、HALF_OPEN_READY 的视图语义、fallback 执行序、16 MiB 请求体、header_timeout_ms 语义原文、不变量清单(§19 共 11 条,正文明示引用 3/5;第六、七节的 SSE 不缓冲与 error 收尾口径另见 §19 v0.4 注记(5/6))、路由 decision trace 与 explain 原文、SSE 逐帧翻译 1 MiB 上限、非流式翻译缓冲债务:docs/DESIGN.md §2/§3/§5/§6/§7/§9/§17/§18.6/§19
  • 路由默认参数表、500 陷阱与 FAQ 修法、502 报错文案、熔断实测时间线、decisions 读法与示例 JSON、直连无路由边界、error 帧不伪装正常完成:docs/USAGE.md §4.1/§5.2/§5.4/§5.5/§6.2/§8
  • 五种策略行为列与空值处理:docs/USAGE.md §5.1;多客户端熔断共享(「某个上游模型挂了,对所有客户端一起生效」):docs/USAGE.md §4.6
  • attempts\[\].fallback 标记语义(成功行恒为 false;true 表示该次之后还会迁往下一候选,失败行还要求剩预算):源码口径,src/proxy.rs 尝试记录写入处
  • 样例路由构成(coding-route:primary claude、fallback glm;fast-route:primary glm):docs/USAGE.md §3.1
  • 策略冻结与自适应路由 out of scope:docs/DESIGN.md §17
  • 集成测试覆盖清单:docs/DESIGN.md §15;模块拆分沿革(v0.4 M0):docs/DESIGN.md §14;翻译不放松不变量注记:docs/DESIGN.md §19
  • 第五节「一条请求的一生」为按文档规则(USAGE §5.4、DESIGN §6/§7)的推演示例,非仓库记录的实测数据;USAGE §5.4 的实测简版时间线口径与其一致
  • 文中标【从业者判断】的十一处:引子翻车样本概括、引言对现成聚合层的检索判断两处(其一:LiteLLM/one-api 是否把「流中不换模型」写成 permanent non-goal;其二:现成方案是否把可事后解释的 decision trace 写成承诺------个人检索均未命中,未逐家核验)、排序/准入分家动机、3 样本门槛的折中论证、冷启动中性值动机、「中性值 + 兜底层」组合更决定实际体验的比较、500 不触发 fallback 的语义论证、默认 fallback_on 只收瞬态码的论证、流中不换模型的三条理由、「永久非目标优于配置项」------行为本身均有上述文档出处,动机与论证为个人判断,仓库文档未载;文中十一处【从业者判断】均为 2026-10-11 写作时点的个人判断
相关推荐
zhangfeng11331 小时前
中关村国际前沿科技大赛(创新药赛道)详细介绍 ai药物比赛
人工智能·科技
xiaoyunxuexibiji1 小时前
供应商质量协同如何实现闭环管理?从到货检验到整改与质量索赔
人工智能
你为她披上外套时我正站在窗外1 小时前
subtitler v2.10 发布:3 个社区 PR、一个一天修掉的 issue,和关于 uuencode 的真相
rust
FPGA小迷弟1 小时前
FPGA 与无人机:从飞控实时性到智能载荷的硬件加速实践
人工智能·ai·fpga开发·架构·无人机·ai编程
yyk333241 小时前
YOLOv4算法
人工智能·yolo·目标跟踪
蹄蹄1 小时前
从“一次性检测“到“年度服务“:检测机构收入结构变了,合同和应收账款也要跟着变
大数据·数据库·人工智能
CS创新实验室1 小时前
没有标签的数据,怎么“自动分成几堆“?——K-means 聚类的原理与坑
人工智能·kmeans·聚类
夏天的清晨1 小时前
聚类与2-OPT
人工智能·机器学习·聚类
数字化大讲堂1 小时前
AI Agent 时代来临!WorkBuddy 引发思考,微程科技布局业务融合新方向
人工智能·科技