0. 两个现象,一个共同的形状
这篇复盘两次问题。它们表面上看没有关系:
第一次(2026-08-24):一个渠道上的所有模型都不可用了,而实际上只有一个模型有问题。
第二次(2026-09 初):/v1/responses 集中返回 502,而实际上上游返回的是一个 4xx。
但它们的形状是一样的:
我的系统在一个更粗的粒度上做了判断,而真实问题发生在一个更细的粒度上。
第一次是"健康检查的粒度是渠道,而问题是模型"。第二次是"错误映射的粒度是默认值,而问题是具体的状态码"。
这篇讲这两次的定位过程、修复方案,以及我在两次修复里共同遵守的一条:不引入不成比例的大改动。
1. 第一次:一个模型故障,封了整个渠道
1.1 现象
线上 test 渠道(id=1)一度所有模型请求失败。
这个描述本身就是关键线索:如果是一个渠道的网络或密钥问题,那"所有模型失败"是合理的。但如果是"所有模型失败",我第一件该确认的事是——真的所有模型都失败吗?
1.2 归因
排查的结论写在事故记录里:
- 09:19–09:26(北京时间)上游 Kimi-K3 节点异常,返回
500 {"message":"service do not have healthy model Kimi-K3 now"}。这是上游模型池故障,不是我方密钥或网络故障。- relay 每次用户请求重试 3 次,每次失败都记录一次渠道级健康失败;一次请求即可达到阈值,触发 test 渠道 5 分钟熔断。
- 健康/熔断粒度是渠道级,导致同渠道的 GLM-5.2、qwen3.7-max、MiniMax-M3、Kimi-K3、DeepSeek-V4-Flash-0731、DeepSeek-V4-Pro-0813 全部被路由排除。
- 客户端持续重试故障模型,熔断窗口被反复刷新,无法及时自愈。
最有力的一条证据是这句:
同一时间 DeepSeek-V4-Pro-0813 走同一渠道成功,证明故障集中在 Kimi-K3 上游节点。
同一个渠道、同一时间、另一个模型成功了。 这一条直接推翻了"渠道故障"的假设。
1.3 四个环节的连锁
把这四步拆开看,每一步单独都"合理":
| 步骤 | 单独看合理吗 | 合起来 |
|---|---|---|
| 上游只有一个模型节点坏 | 是上游的事 | — |
| 每次重试都记一次失败 | 合理(重试就是尝试) | 放大:一次请求 = 3 次失败记录 |
| 健康阈值按渠道算 | 合理(渠道是配置单位) | 误伤:整渠道被排除 |
| 客户端持续重试 | 合理(用户想重试) | 锁死:熔断窗口被反复刷新 |
四个单独的合理决定,串起来变成了一次"一个模型坏掉 → 整个渠道不可用"的故障。
这类问题我认为是系统设计里最难防的一种:每一处都有理由,但你不会去检查"它们在极端情况下会怎么叠加"。
1.4 为什么没有直接做"模型级熔断"
事故记录里有一节"方案评估",第一个结论是否掉那个最直观的方案:
直接将全部渠道健康改造成模型级持久化熔断并不是本次事故的最优首修。该方案需要同时修改健康 RPC、模型映射持久化、多实例状态同步、选择器和半开恢复探测,改动面大且需要单独定义一致的恢复策略。
四个子系统要同时改,而且"恢复策略"需要重新定义。 在一次故障之后做这种规模的改动,等于在一次不稳定的状态上再引入一次大变化。
而记录的最后一段把这件事的性质说清楚了:
完整模型级熔断保留为后续设计项:需要以
(channel_id, model_id)为键,明确阈值、TTL、半开探测和多实例一致性后再实施。
"保留为后续设计项"不等于不做,而是"先做完边界清晰的部分"。
1.5 三项对症修复
实际修了三处,每处都边界清晰。
修复一:同一次请求内的多次重试,合并成一次健康结算。
同一请求内,同一来源的多次 retry 合并为一次终态健康结算;切换到另一来源时,前一来源才结算一次。这样不会因 retry 放大渠道熔断,也不会重复减少选择器
inflight。
这一条直接切掉了连锁里的第二步。一次请求重试 3 次,之前记 3 次失败;现在只记 1 次终态。
这个修复还有一个不太显眼的收益:不会重复减少选择器的 inflight。 那是第 3 篇讲的在途计数——如果每次重试都减一次,一个账号的并发容量会被算错。
修复二:识别"模型不可用"这类错误,不让它推进渠道熔断。
将
service do not have healthy model/no healthy model识别为模型范围故障。该错误仍触发当前请求 fallback,并标记model_unavailable,但不推进渠道级熔断。
注意这里的两个动作是分开的:
- 仍然 fallback —— 这个请求还是要去试别的渠道,因为它确实失败了;
- 但不推进熔断 —— 因为失败的原因不是渠道坏了。
这就是第 3 篇里 upstreamAttemptHealthy 那个函数的来源。"要不要重试"和"要不要算渠道健康"是两个独立的判断,我在这次故障之前把它们合并了。
修复三:monitor 的 /models 探测要校验响应结构。
monitor-worker 的
/models探测必须解析为包含数组形态data或models字段的 JSON;HTML 200、缺字段或错误字段类型记录为invalid_response,不再误判为健康。
这一条看起来和故障没关系,但它是排查时发现的:渠道健康探测在一个 HTML 200 响应上判了"健康"。
一个配错了 base_url 的渠道,上游返回一个登录页或者错误页(HTTP 200 + HTML),探测脚本只检查了状态码,于是这个渠道一直显示健康。
200 只说明"请求成功了",不说明"这个响应是我要的东西"。 探测必须校验内容形状。
1.6 新增的回归测试
事故记录里列了三条:
增加回归测试:模型无健康节点 fallback、同渠道重试只结算一次、HTML 200 探测失败。
每条测试对应一个修复点,而且第三条(HTML 200)是我最容易漏的——因为它和主线故障没有直接因果,是排查时的副产品。如果不给它写测试,这个发现就只存在于事故记录里。
2. 第二次:4xx 被说成了 502
2.1 现象和迷惑性
/v1/responses 集中出现 502。
按老流程排查了一遍:容器状态、渠道测试、凭据刷新、上游地址、模型单独测试——全部正常。
这一轮排查很重要,因为它排除了所有"标准答案"。剩下唯一可能的方向是:502 不是上游给的,是我自己造的。
2.2 根因
发布说明里的表述:
根因:Responses 上游请求返回确定性 4xx 时,默认错误映射把未知状态统一改写为 502,既掩盖请求过大、媒体类型或参数不兼容等客户端可修复问题,也使服务端缺少准确、脱敏的原始上游状态证据。
两句话,两个问题:
第一个问题是语义错位:上游说"你的请求有问题"(4xx),网关说"上游/网关故障"(502)。
这两句话对应的用户动作完全不同:
- 4xx → 用户去改请求(可能是文件太大了、格式不对);
- 502 → 用户去重试、去报障。
我把它翻译错了,用户就会往错的方向试很久。
第二个问题是证据缺失:"使服务端缺少准确、脱敏的原始上游状态证据"。
这就是我在第 2 篇和第 17 篇都提过的那个"终止分支缺日志"。错误路径上没有日志,所以我看到 502 却不知道它原本是什么。
2.3 修复
- 明确映射 413、415、422,并仅在转换契约成立时允许 415/422 进入 Responses→Chat fallback;413 不重试。
- 上游 401/403 继续隔离在网关错误后,避免泄露或混淆上游凭据与客户端 Token 鉴权。
- 普通与流式路径增加有界、脱敏的错误记录和回归矩阵,覆盖 fallback 成功/失败、reservation 释放以及最终 HTTP 状态。
三条里我觉得最值得讲的是 "仅在转换契约成立时允许 415/422 进入 fallback"。
为什么这两个状态码可以 fallback,而 413 不行?
- 413(请求体过大):换个渠道也一样大。重试无意义。
- 415(媒体类型不支持)/ 422(参数不兼容):这是这个渠道的能力问题。换一个协议支持更好的渠道可能就成功了。
"换渠道能不能解决"是判断该不该 fallback 的标准,而不是"这个状态码看起来严不严重"。
而第二条:401/403 继续隔离在网关错误之后。
上游返回 401,可能是上游的凭据过期了。如果把原始响应透传给客户端,客户端会以为"是我自己的 API Key 有问题"——而他手里的 Key 明明是好的。
上游的认证失败和网关的认证失败必须区分开,否则用户会去重新生成一个不必要的 Key。
2.4 交付方式也是这个修复的一部分
发布说明里有一句关于交付的话:
当前生产已经运行修复等价镜像,发布 tag 不重启正在执行 72 小时 charge 验收的容器。
这是我在第 24 篇讲过的那条纪律的另一面:不要让一个无关的动作污染一个正在进行的测量。
那次有一个 72 小时的计费验收窗口在跑(第 8 篇提到的 observe → charge 灰度)。如果为了发布这个 tag 去重启 relay 容器,那个窗口的计时就作废了,得从头再来 72 小时。
所以做法是:镜像已经跑在生产上(等价于修复),tag 只是把它标记出来,不动容器。
3. 顺手发现的第三个问题:健康检查说错了就绪条件
这两次故障之外,还有一个同类问题被记录在 v0.26.6 里:
根因:
admin-api的系统选项和订阅 repository 只在启动时初始化。Compose 仅等待 MySQL 容器启动而非健康,栈重建时若 admin 抢先连接失败,这些能力会在该进程生命周期内永久关闭,例如/api/v1/admin/subscriptions持续返回 501。
这个问题的形状和第一次故障完全一样:就绪条件的粒度错了。
- Compose 认为"MySQL 容器启动了" = "数据库可用了";
- 实际上 TCP 3306 可能还没起来(第 22 篇提过的那个 healthcheck 用
localhost命中临时 Unix socket 的问题); - admin 启动时连不上,而那些能力只在启动时初始化一次——于是它们在这个进程的整个生命周期里都是关的。
表现是"订阅接口一直 501",看起来像功能没实现,实际是启动时序问题。
修复是:
Compose 的 MySQL、PostgreSQL、Lite 与 E2E 形态统一要求数据库 readiness/migrate 门禁完成后再启动 admin。
从"等容器启动"改成"等数据库真正就绪"。
这一条我放在这篇里,是因为它印证了那个共同形状:当一个"就绪/健康"信号的定义比真实条件粗时,它会在某个时序下变成错误的信号。
4. 三次修复的共同做法
回头看这三次(模型级误伤、4xx 语义、启动时序),我的处理方式是一致的:
第一,先确认"是不是我以为的那个问题"。
第一次靠"同渠道另一个模型成功"推翻了渠道故障假设。第二次靠排除法确认"502 是我自己造的"。
两处都花了不少时间在"排除错误方向"上,而这部分时间不是浪费——如果我跳过它直接改,很可能改错地方。
第二,区分"症状的粒度"和"根因的粒度"。
- 症状:整个渠道不可用 → 根因:单模型故障;
- 症状:502 → 根因:4xx 的默认映射;
- 症状:订阅接口 501 → 根因:启动时序。
三处的症状都比根因"粗"。 这是我认为最值的一条经验:当一个故障的表现范围比它可能的成因更大时,去找那个"被放大的环节"。
第三,修复要边界清晰,不做不成比例的大改动。
第一次明确写了"完整模型级熔断保留为后续设计项",并列出为什么(四个子系统 + 恢复策略)。第二次没有改整个错误映射体系,只明确了三个状态码。
在刚出过故障的系统上做大改动,是拿两次风险换一次修复。
第四,每个修复配一条回归测试,包括顺手发现的那个。
三个测试:模型无健康节点 fallback、同渠道重试只结算一次、HTML 200 探测失败。
第五,把"错误路径的证据"当成修复的一部分。
第二次故障的第二个根因就是"缺日志"。所以修复里包含"增加有界、脱敏的错误记录"。
没有这条,下次还是同一个过程:看到 502,然后花很久排除所有正常的方向。
5. 一个还没做完的事
第一次事故里我说"完整模型级熔断保留为后续设计项",需要"以 (channel_id, model_id) 为键,明确阈值、TTL、半开探测和多实例一致性"。
后来我做了被动模型健康监测((source_kind, source_id, model_id, upstream_model_id) 聚合,连续失败 3 次标 unavailable),但它只写观测表,不参与路由决策。
也就是说现在的状态是:
| 有观测 | 影响路由 | |
|---|---|---|
| 渠道级健康 | ✅ | ✅ |
| 模型级健康 | ✅ | ❌ |
"能看到某个模型不健康"和"路由会避开它"之间还差一步。 而这一步恰好就是第一次事故的核心诉求。
我暂时没做的原因是:一旦模型级健康影响路由,我就需要定义它自己的阈值、TTL、半开恢复、以及多实例一致性——而这些都是我在事故记录里说"需要先明确"的东西。
先观测、后决策,这个顺序我认为是对的(第 2 篇里 executor 的灰度也是同一套逻辑)。但要承认:这个设计项还开着。
6. 现在的状态
已经能用的:
- 同一请求内同一来源的多次重试合并为一次健康结算
model_unavailable类错误触发 fallback 但不推进渠道熔断- 模型级健康被动监测(观测表,不影响路由)
- monitor 的
/models探测校验响应结构,invalid_response单独归类 - Responses 的 413/415/422 显式映射;413 不重试,415/422 按契约决定是否 fallback
- 上游 401/403 与网关认证错误隔离
- 错误路径的有界、脱敏记录
- Compose 等数据库 readiness 而非容器启动,再启 admin
- 三次修复各有对应的回归测试
还没解决的:
第一,模型级健康不影响路由。 上面那一节,这是第一次事故的完整解,还没做。
第二,service do not have healthy model 是字符串匹配。 和第 6 篇的 invalid_grant 一样,我识别这个错误靠的是匹配上游的报错文案。上游改了措辞,这个判断就失效——而且失效的表现是"又开始误伤整个渠道"。
第三,413/415/422 是硬编码的三个状态码。 如果上游用别的状态码表达同类问题(比如 400 带特定文案),我还是要走默认映射。
第四,事故记录只有一次。 docs/incidents/ 下只有那一个文件,而 4xx→502 那次没有单独的事故记录——它的内容分散在 v0.27 路线图和 v0.26.6 发布说明里。复盘文档的形态不统一,以后要查会比较散。
第五,这两次都没有"发现时间"和"影响范围"的量化。 我记录了根因、修复和验证,但没有记录"从发生到发现用了多久""影响了多少请求"。而那两类数字是判断监控是否有效的最直接依据。
7. 这个系列的收尾
这篇是「micro-one-api 拆解」这一批的最后一篇。
如果要把整个系列压成一条,我会选这个系列的第三篇里那句:同一个信号,在不同层里的含义不一样。
而这篇的两次故障恰好是它的反例:我把一个信号的粒度搞错了,于是它在错误的层上生效了。
- 一个模型的故障被当成了渠道的故障;
- 一个 4xx 被当成了 502。
两次都是"信号的粒度大于问题的粒度"。我想这是这个项目里最容易复发的一类 bug,也是我在写这一系列时反复回到的地方。