做一个 OpenAI-compatible 代理并不难:接收请求、转发给模型供应商,再把响应返回给客户端。
真正麻烦的是这句话:
如果主模型挂了,就自动切到备用模型。
对于普通 JSON 请求,这只是一个重试策略。对于 SSE 流式响应,它却涉及一条不能越过的边界:一旦首个 chunk 已经发送给客户端,就不能再静默切换 Provider。
这篇文章记录我在 HonoBox 中实现故障转移时,对这条边界的理解。
非流式请求为什么简单
非流式请求的生命周期很清楚:
Client → Gateway → Primary Provider
↓ retryable error
Fallback Provider → Gateway → Client
在最终 JSON 返回前,客户端并不知道网关尝试过几次。只要错误满足重试条件,网关可以请求备用模型:
- 网络连接失败;
- HTTP 408、409、425、429;
- Provider 返回 5xx。
但 400、401、403 不能这样处理。它们通常意味着请求格式、模型配置或凭证有问题。把同一份错误请求送给另一个上游,只会增加延迟并掩盖真实配置问题。
因此,“失败就重试”必须先变成“对错误分类后,只重试瞬时故障”。
SSE 改变了响应的所有权
流式请求不是等所有内容生成完再返回。网关会不断把上游 chunk 写给下游:
data: {"delta":"这"}
data: {"delta":"是"}
data: {"delta":"一"}
当第一个 chunk 发出时,客户端已经观察到了三个事实:
- HTTP 响应已经开始;
- 当前模型开始生成内容;
- 后续 chunk 应当延续同一个语义上下文。
假设主模型输出“数据库事务应该”,随后连接断开。备用模型拿不到主模型的内部生成状态,它可能从头输出“在分布式系统中”。如果网关直接把两段拼起来,客户端看到的会是:
数据库事务应该在分布式系统中……
语法可能碰巧成立,但它已经不是任何一个模型完整生成的答案。更糟糕的是,工具调用、JSON mode 和结构化输出很可能直接损坏。
所以我的规则是:
尚未向客户端写入 chunk → 可以切换
已经向客户端写入 chunk → 返回流式错误,不再切换
这就是 retry-before-first-token。
实现时不能只看 HTTP 状态码
Provider 的错误大致分成三组:
function isRetryableProviderError(error: unknown): boolean {
if (isAbortError(error)) return false
if (error instanceof ProviderRequestError) return error.retryable
return error instanceof TypeError // fetch 网络错误
}
这里特意把 AbortError 排除。
如果下游用户点击停止,或者浏览器断开连接,网关应该通过同一个 AbortSignal 取消上游 fetch。它不是 Provider 故障,也不应该触发备用模型请求。否则用户明明取消了,服务器却还在后台继续生成并计费。
流式循环还需要记录是否已经产生输出:
let emittedChunk = false
for await (const chunk of provider.chatCompletionStream(request)) {
emittedChunk = true
await downstream.write(chunk)
}
捕获错误后,只有同时满足下面三个条件才进入 fallback:
错误可重试
下游没有取消
emittedChunk === false
熔断器解决的是“不要反复证明它坏了”
单次 fallback 还不够。
如果主模型持续返回 503,每个请求都先等待一次失败,再请求备用模型,网关会不断给故障服务增加压力,用户也要承担额外延迟。
熔断器维护三个状态:
closed 正常请求主模型
open 暂时跳过主模型
half-open 冷却后只放行一个探测请求
HonoBox 当前的策略是连续三次可重试失败后打开电路,30 秒后允许一个 half-open 探测。探测成功就关闭;失败则重新开始冷却。
这里有一个很容易漏掉的并发问题:half-open 状态不能同时放进十个请求。否则所谓“探测”会变成一次小型流量洪峰。因此状态里还需要保存 probeInFlight。
当前熔断状态保存在单进程内存中。这样实现简单、响应快,但多副本部署时每个实例会有自己的判断。真正的多节点网关需要把健康状态通过 Redis、数据库或控制面进行协调。这是一个明确的系统边界,不应该被 README 里的“生产级”三个字藏起来。
可观测性必须区分 requested model 和 served model
调用方请求的是主模型,但最终完成请求的可能是备用模型。如果日志只保存请求参数里的 model,就会产生错误结论:
- 主模型看起来成功率 100%;
- 备用模型看起来没有流量;
- fallback 成本无法计算;
- 故障发生时无法还原实际路径。
因此请求记录至少需要:
model 调用方请求的逻辑模型
served_model 实际完成请求的模型
is_fallback 是否发生故障转移
status_code 最终状态
latency_ms 完整网关耗时
Prometheus 指标也应该带上 served_model、状态码和 fallback 标签,并单独记录上游失败计数。这样才能回答:备用模型承担了多少请求、熔断后延迟是否下降、哪个 Provider 正在产生 5xx。
用故障注入验证,而不是只测 happy path
我给项目加了一个可重复执行的本地实验:启动 PostgreSQL 和确定性的 OpenAI-compatible mock Provider,让主模型固定返回 503,备用模型正常响应。
在本机 100 请求、并发 10 的实验中:
| 场景 | 错误 | fallback | P50 | P95 |
|---|---|---|---|---|
| 主模型健康 | 0 / 100 | 0 / 100 | 11.55 ms | 27.52 ms |
| 主模型固定 503 | 0 / 100 | 100 / 100 | 10.19 ms | 13.54 ms |
故障场景的稳态延迟反而更低,因为前三次失败打开电路后,后续请求不再等待已知故障的主模型。这是本地 mock 环境的工程验证,不代表公网吞吐量;它验证的是请求路径和状态转换是否符合预期。
实验还会自动断言:
- 健康场景全部由主模型处理;
- 503 场景全部成功切到备用模型;
- 流式请求在首 chunk 前可以切换;
- Prometheus 同时记录 fallback 和上游失败;
- 单元测试证明首 chunk 后的错误不会混合 Provider。
这次还发现了一个与 fallback 无关的生产问题
第一次运行端到端实验时,网关甚至没有启动。
TypeScript 编译和所有单元测试都通过了,但构建产物中的 ESM 相对导入缺少 .js,Node.js 直接抛出 ERR_UNSUPPORTED_DIR_IMPORT。
这提醒我:
tsc成功只代表生成了 JavaScript,不代表生成物能被目标运行时加载。
后来 CI 增加了 production smoke test:导入真正的 dist/app.js,发送一次请求并检查响应。它很小,却覆盖了类型检查完全触及不到的模块解析边界。
总结
LLM 网关的 fallback 不是在数组里换一个 URL。它同时受到以下状态约束:
- 错误是否可重试;
- 下游是否已经取消;
- 是否已经发送首个 chunk;
- 主模型电路是否打开;
- 日志记录的是请求模型还是服务模型。
我现在更愿意把可靠性理解为“明确每一条不可跨越的边界”,而不是简单增加重试次数。
对于流式 LLM 请求,最重要的那条边界就是:首个 token 之前可以换路,首个 token 之后必须对已经发生的输出负责。