type
Post
status
Published
date
Apr 6, 2026
slug
gateway004
summary
tags
gateway
category
icon
password
摘要
当上游 provider 出现异常时,LLM Access Gateway是否还能以可预测、可观测、边界清晰的方式处理失败?
主要的四个验证点:
- primary 失败时,secondary 能否接管
- provider 异常后,健康状态能否显式暴露
/readyz是否真的反映 provider readiness
- SSE fallback 是否只发生在协议允许的边界内
如果这四点不成立,那么系统仍然只是一个“能转发请求的统一入口”,还不能算一个像样的模型接入网关。
一、问题定义
对一个接入网关来说,真正决定它是否可运维的是:
失败路径能否被解释、被关联、被验证。
模型接入层真正困难的部分是:
- 上游失败之后,系统还能不能维持稳定行为
- fallback 会不会发生在错误的边界里
- provider 当前到底健不健康
- 调用方与运维侧能不能看到同一套故障信号
如果这些问题没解决,那么统一入口就是无用的空壳:
- 上游一挂,请求链路跟着挂
- 流式请求一旦中断,调用方拿到的是不完整语义
- 运维侧无法判断 provider 当前是否健康
/readyz永远返回200,健康检查失去意义
所以这一阶段要验证的是:
- fallback 什么时候发生
- fallback 在什么边界内允许发生
- provider 失败如何进入状态机
- 状态机能否影响路由与 readiness
- 一次失败能否被串成一条完整事件链
二、验证目标
provider 故障处理可以收敛成下面五条约束:
- primary 失败时,secondary 可以接管
- backend 连续失败后进入 cooldown,并在 cooldown 内被跳过
/debug/providers能暴露 backend 健康状态
/readyz与 provider readiness 联动,而不是固定200
- 流式请求只允许在首 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 正常状态示例:
正常状态下,至少有一个 backend 处于healthy: true。这说明 provider 健康状态已经被显式暴露,而不是只存在于内部逻辑中。
插图2:
/readyz 返回 200 的示例:
/readyz在正常状态下返回200,说明网关当前从 provider 可用性视角看是 ready 的,而不是固定返回成功。
6.2 non-stream fallback:primary 失败后 secondary 接管
先进入项目根目录,以故障模式启动网关:
然后执行:
预期结果:
- non-stream
POST /v1/chat/completions仍然返回200
- provider 日志中出现:
provider_request_failedprovider_fallback_succeeded
/debug/providers里 primary 进入不健康或 cooldown 状态
这一步证明的不是“secondary 存在”,而是:
primary 创建失败后,系统能在协议边界允许的情况下完成接管,并维持原有 API 契约。
插图 3:non-stream fallback 成功的终端示例

primary 在创建阶段失败后,请求没有直接失败,而是由 secondary 接管并维持了200 + chat.completion的响应契约。
对应的 provider 事件图:primary 失败后 secondary 接管

6.3 stream fallback:只允许在首 chunk 前发生
以流式故障模式启动:
然后执行:
预期结果:
POST /v1/chat/completions以text/event-stream返回
- 响应末尾仍然有
data: [DONE]
- provider 日志中出现:
provider_request_failedprovider_fallback_succeeded
这里最关键的不是“stream 成功了”,而是:
fallback 发生在首 chunk 之前,也就是协议承诺尚未生效的窗口内。
插图 4:stream 首 chunk 前失败后 fallback 成功的示例

这一步证明流式 fallback 并不是“任何时候都能切换 provider”,而是只在首 chunk 之前、协议边界允许的窗口内发生。
对应的 provider 事件图:stream 路径上的失败与 fallback

作为对照,我又补了一次 stream_partial 演练:
让 upstream 先发出一个 chunk,再主动中断连接。此时客户端仍会收到 HTTP 200 和部分 SSE 数据,但不会收到最终 data: [DONE],网关也不会再切换到 secondary。因为一旦首 chunk 已经发给客户端,协议承诺就已经生效,之后再 fallback 只会破坏流语义。

当 upstream 在首 chunk 发出后中断时,客户端只收到部分 SSE 数据,且没有最终 data: [DONE]。这说明网关不会在流已经对客户端生效后再偷偷切换 provider。

对应的 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 的示例
当所有 provider 都不可用时,网关不再伪装自己 ready,而是通过503把真实系统状态显式暴露给外部。
对应的 provider probe 失败事件图:

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 事件日志片段示例

此图注释:
这组结构化日志比单纯的 HTTP 返回码更能说明故障处理过程本身:失败发生在哪个 backend、fallback 是否成功、backend 是否被跳过或恢复。
七、最小排障路径
对这一阶段的系统来说,最实用的排障路径不是先打开一个很大的 dashboard,而是先让一次失败可以被串起来。
可以按下面这条最小路径走:
- 记录
X-Request-Id/X-Trace-Id
- 根据 request / trace 在日志中定位这次请求
- 查看对应的 provider 事件:失败、fallback、跳过不健康 backend、恢复
- 查看
/debug/providers,确认 backend 当前健康状态
- 再看
/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
九、这一阶段已经说明了什么
到这里,这个项目已经证明了四件关键的事:
- 主备 provider 不是静态配置摆设,而是能在异常时真实接管
- provider 健康状态不是隐式逻辑,而是有对外状态接口
/readyz与 provider 状态联动,不再是固定返回200的假检查
- SSE fallback 只在首 chunk 前允许发生,协议边界清晰
这四点一起成立,才意味着系统开始具备“接入治理网关”的工程形态。
十、结论
这次本地演练证明了:
在上游 provider 异常时,网关能够以可观测、可解释、边界清晰的方式处理失败。
更具体地说,它已经完成了一个最小但关键的闭环:
- 失败会进入健康状态机
- 健康状态机会影响后续路由
- 路由结果可以通过接口和日志被观察
- stream 场景不会为了“看起来成功”而破坏协议边界
- 请求失败时,系统仍然有工程化的处理方式
分享
