Lazy loaded image
004-一次上游故障演练:我如何验证 LLM Access Gateway 的 fallback 与健康检查
字数 3172阅读时长 8 分钟
2026-4-6
type
Post
status
Published
date
Apr 6, 2026
slug
gateway004
summary
tags
gateway
category
icon
password

摘要

当上游 provider 出现异常时,LLM Access Gateway 是否还能以可预测、可观测、边界清晰的方式处理失败?
主要的四个验证点:
  1. primary 失败时,secondary 能否接管
  1. provider 异常后,健康状态能否显式暴露
  1. /readyz 是否真的反映 provider readiness
  1. SSE fallback 是否只发生在协议允许的边界内
如果这四点不成立,那么系统仍然只是一个“能转发请求的统一入口”,还不能算一个像样的模型接入网关。

一、问题定义

对一个接入网关来说,真正决定它是否可运维的是:
失败路径能否被解释、被关联、被验证。
模型接入层真正困难的部分是:
  • 上游失败之后,系统还能不能维持稳定行为
  • fallback 会不会发生在错误的边界里
  • provider 当前到底健不健康
  • 调用方与运维侧能不能看到同一套故障信号
如果这些问题没解决,那么统一入口就是无用的空壳:
  • 上游一挂,请求链路跟着挂
  • 流式请求一旦中断,调用方拿到的是不完整语义
  • 运维侧无法判断 provider 当前是否健康
  • /readyz 永远返回 200,健康检查失去意义
所以这一阶段要验证的是:
  • fallback 什么时候发生
  • fallback 在什么边界内允许发生
  • provider 失败如何进入状态机
  • 状态机能否影响路由与 readiness
  • 一次失败能否被串成一条完整事件链

二、验证目标

provider 故障处理可以收敛成下面五条约束:
  1. primary 失败时,secondary 可以接管
  1. backend 连续失败后进入 cooldown,并在 cooldown 内被跳过
  1. /debug/providers 能暴露 backend 健康状态
  1. /readyz 与 provider readiness 联动,而不是固定 200
  1. 流式请求只允许在首 chunk 之前 fallback;首 chunk 发出后,不再 retry / fallback
这里最关键的是最后一条。
因为流式代理最容易写错的地方,不是怎么输出 chunk,而是失败发生在什么时点
  • 首 chunk 已发给客户端,不能再偷偷切 provider
  • 上游中断后,不能为了“看起来成功”伪造 data: [DONE]
因此,这一阶段真正验证的是:
fallback 是否被严格限制在协议允许的窗口内。

三、当前实现的最小边界

3.1 主备 provider 路由

provider router 位于:
  • internal/provider/router/chat.go
当前策略很明确:
  • non-stream:primary 失败后,可回退到 secondary
  • stream:仅在首 event 之前失败时允许回退
  • 首 event 已发出后,后续中断不再 fallback,只上报中断
相关代码主要集中在:
  • StreamChatCompletion
  • awaitFirstStreamEvent
  • forwardStreamEvent
这意味着当前 fallback 不是“任何失败都尽量重试”,而是受协议边界约束。

3.2 健康状态与 cooldown

router 内部维护每个 backend 的最小健康状态:
  • consecutiveFailures
  • unhealthyUntil
  • lastProbeAt
  • lastProbeError
当连续失败达到阈值后,backend 会进入 cooldown。
在 cooldown 窗口内,该 backend 会被临时跳过,并记录:
  • provider_skipped_unhealthy
这说明当前实现已经不是“一次性重试”,而是一个最小健康状态机。

3.3 probe 与 readiness 外显

当前主进程会启动 provider probe loop,周期性探测 provider 健康。
相关代码位于:
  • cmd/gateway/main.go
  • internal/provider/router/chat.go
同时,HTTP 层暴露了两个直接对应的状态接口:
  • GET /debug/providers
  • GET /readyz
其中:
  • /debug/providers 返回每个 backend 的健康详情
  • /readyz 当所有 provider 都不健康时返回 503
这一步很关键,因为它把“内部健康状态”转成了“外部可观察信号”。

3.4 结构化事件日志

provider 事件会交给 observer,当前接的是 providerEventLogger
关键事件包括:
  • provider_request_failed
  • provider_fallback_succeeded
  • provider_skipped_unhealthy
  • provider_probe_failed
  • provider_probe_succeeded
  • provider_recovered
这让故障演练不只剩“最终成功或失败”,而能保留完整过程证据。

四、故障注入方式

这次演练没有引入额外故障平台,而是直接使用当前仓库已有的 mock provider 故障开关。
关键开关有两个:
  • APP_GATEWAY_PRIMARY_MOCK_FAIL_CREATE
  • APP_GATEWAY_PRIMARY_MOCK_FAIL_STREAM
它们分别对应:
  • primary 在非流式创建阶段失败
  • primary 在流式创建阶段、首 chunk 之前失败
这两个开关已经足够覆盖当前阶段最重要的两个 fallback 边界:
  • non-stream create fail
  • stream pre-first-chunk fail
因此,这一阶段验证的是:
机制是否成立,而不是故障类型是否穷尽。

五、演练场景

场景
注入方式
预期行为
关键观察点
正常状态检查
不注入故障
/readyz 返回 200/debug/providers 显示 healthy
status endpoint
非流式 primary 失败
APP_GATEWAY_PRIMARY_MOCK_FAIL_CREATE=true
/v1/chat/completions 仍返回 200,secondary 接管
response + provider event
流式 primary 首 chunk 前失败
APP_GATEWAY_PRIMARY_MOCK_FAIL_STREAM=true
SSE 仍正常返回并包含 [DONE],secondary 接管
SSE body + provider event
所有 provider 均不可用
backend 全部进入 cooldown 或同时失败
/readyz 返回 503
readiness
backend 被跳过
backend 仍在 cooldown 窗口内
router 记录 provider_skipped_unhealthy
logs + /debug/providers
这组场景覆盖了三类核心问题:
  • fallback 是否生效
  • 健康状态是否变化
  • readiness 是否真的反映系统状态

六、本地演练与示例

6.1 正常状态:provider healthy,网关 ready

先进入项目根目录,查看当前 provider 状态:
它会调用:
  • GET /debug/providers
  • GET /readyz
正常情况下,预期结果是:
  • /readyz 返回 200
  • /debug/providers 中至少一个 backend 为 healthy: true
插图1:/debug/providers 正常状态示例:
notion image
正常状态下,至少有一个 backend 处于 healthy: true。这说明 provider 健康状态已经被显式暴露,而不是只存在于内部逻辑中。
插图2:/readyz 返回 200 的示例:
notion image
/readyz 在正常状态下返回 200,说明网关当前从 provider 可用性视角看是 ready 的,而不是固定返回成功。

6.2 non-stream fallback:primary 失败后 secondary 接管

先进入项目根目录,以故障模式启动网关:
然后执行:
预期结果:
  • non-stream POST /v1/chat/completions 仍然返回 200
  • provider 日志中出现:
    • provider_request_failed
    • provider_fallback_succeeded
  • /debug/providers 里 primary 进入不健康或 cooldown 状态
这一步证明的不是“secondary 存在”,而是:
primary 创建失败后,系统能在协议边界允许的情况下完成接管,并维持原有 API 契约。
插图 3:non-stream fallback 成功的终端示例
notion image
primary 在创建阶段失败后,请求没有直接失败,而是由 secondary 接管并维持了 200 + chat.completion 的响应契约。
对应的 provider 事件图:primary 失败后 secondary 接管
notion image

6.3 stream fallback:只允许在首 chunk 前发生

以流式故障模式启动:
然后执行:
预期结果:
  • POST /v1/chat/completionstext/event-stream 返回
  • 响应末尾仍然有 data: [DONE]
  • provider 日志中出现:
    • provider_request_failed
    • provider_fallback_succeeded
这里最关键的不是“stream 成功了”,而是:
fallback 发生在首 chunk 之前,也就是协议承诺尚未生效的窗口内。
插图 4:stream 首 chunk 前失败后 fallback 成功的示例
notion image
这一步证明流式 fallback 并不是“任何时候都能切换 provider”,而是只在首 chunk 之前、协议边界允许的窗口内发生。
对应的 provider 事件图:stream 路径上的失败与 fallback
notion image
作为对照,我又补了一次 stream_partial 演练:
让 upstream 先发出一个 chunk,再主动中断连接。此时客户端仍会收到 HTTP 200 和部分 SSE 数据,但不会收到最终 data: [DONE],网关也不会再切换到 secondary。因为一旦首 chunk 已经发给客户端,协议承诺就已经生效,之后再 fallback 只会破坏流语义。
notion image
当 upstream 在首 chunk 发出后中断时,客户端只收到部分 SSE 数据,且没有最终 data: [DONE]。这说明网关不会在流已经对客户端生效后再偷偷切换 provider。
notion image
对应的 provider 事件记录为 provider_stream_interrupted,而不是 provider_fallback_succeeded,说明 fallback 被严格限制在首 chunk 之前。

6.4 所有 provider 不可用:/readyz 必须降为 503

当前实现中,/readyz 与 provider readiness 联动。
当所有 provider 都进入不健康状态,或都处于不可用窗口时,预期行为应当是:
  • /readyz 返回 503
这一步非常关键,因为它证明 /readyz 不是一个“永远返回 200”的假接口。
插图 5:/readyz 返回 503/debug/providers 全部 unhealthy 的示例
notion image
当所有 provider 都不可用时,网关不再伪装自己 ready,而是通过 503 把真实系统状态显式暴露给外部。
对应的 provider probe 失败事件图:
notion image

6.5 provider 事件日志:把结果变成过程证据

故障演练不能只看“结果是否成功”,还要看系统到底经历了什么。
这一阶段最重要的 provider 事件日志包括:
  • provider_request_failed
  • provider_fallback_succeeded
  • provider_skipped_unhealthy
  • provider_probe_failed
  • provider_probe_succeeded
  • provider_recovered
这些日志能回答:
  • 是哪个 backend 先失败了
  • fallback 是否真的发生了
  • 某个 backend 是否已经进入 cooldown
  • backend 是在什么时候恢复的
插图 6:provider 事件日志片段示例
notion image
此图注释:
这组结构化日志比单纯的 HTTP 返回码更能说明故障处理过程本身:失败发生在哪个 backend、fallback 是否成功、backend 是否被跳过或恢复。

七、最小排障路径

对这一阶段的系统来说,最实用的排障路径不是先打开一个很大的 dashboard,而是先让一次失败可以被串起来
可以按下面这条最小路径走:
  1. 记录 X-Request-Id / X-Trace-Id
  1. 根据 request / trace 在日志中定位这次请求
  1. 查看对应的 provider 事件:失败、fallback、跳过不健康 backend、恢复
  1. 查看 /debug/providers,确认 backend 当前健康状态
  1. 再看 /readyz/metrics,判断这是单次故障还是更广泛的异常趋势
这条路径的价值在于:
可观测性的第一步不是先堆 dashboard,而是先让故障可以被关联起来。
对网关来说,真正有用的不是“图很多”,而是一次失败能不能被解释清楚。

八、关键代码路径

如果只挑最能说明问题的代码路径,这一阶段最值得看的是下面四段。

8.1 provider router 的创建路径

  • internal/provider/router/chat.go
这里实现了:
  • non-stream primary → secondary 回退
  • 失败计数
  • cooldown 进入逻辑
  • provider_request_failed
  • provider_fallback_succeeded

8.2 stream 首 event 等待与边界控制

  • awaitFirstStreamEvent
  • forwardStreamEvent
这里是整个 stream fallback 语义的核心:
  • 没拿到首 event 之前,仍在“可回退窗口”
  • 首 event 一旦成功,协议承诺已经生效
  • 之后即使中断,也只能上报中断,不能再偷偷切流

8.3 probe 与 readiness 暴露

  • cmd/gateway/main.go
  • internal/api/handlers/health.go
这里把 provider 内部状态外显成:
  • /debug/providers
  • /readyz
这一步是从“内部逻辑存在”走向“外部可观测”的关键。

8.4 provider 事件结构化日志

  • providerEventLogger

九、这一阶段已经说明了什么

到这里,这个项目已经证明了四件关键的事:
  1. 主备 provider 不是静态配置摆设,而是能在异常时真实接管
  1. provider 健康状态不是隐式逻辑,而是有对外状态接口
  1. /readyz 与 provider 状态联动,不再是固定返回 200 的假检查
  1. SSE fallback 只在首 chunk 前允许发生,协议边界清晰
这四点一起成立,才意味着系统开始具备“接入治理网关”的工程形态。

十、结论

这次本地演练证明了:
在上游 provider 异常时,网关能够以可观测、可解释、边界清晰的方式处理失败。
更具体地说,它已经完成了一个最小但关键的闭环:
  • 失败会进入健康状态机
  • 健康状态机会影响后续路由
  • 路由结果可以通过接口和日志被观察
  • stream 场景不会为了“看起来成功”而破坏协议边界
  • 请求失败时,系统仍然有工程化的处理方式
回到首页