跳转到正文
ieuforuruntime notes
↩ WRITING

Infrastructure / 05 MIN

LLM 网关为什么只能在首个 Token 前切换上游

从一次流式故障转移实现出发,拆解 SSE、AbortSignal、熔断器与可观测性之间的边界。

5 MIN READ

做一个 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 发出时,客户端已经观察到了三个事实:

  1. HTTP 响应已经开始;
  2. 当前模型开始生成内容;
  3. 后续 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 的实验中:

场景错误fallbackP50P95
主模型健康0 / 1000 / 10011.55 ms27.52 ms
主模型固定 5030 / 100100 / 10010.19 ms13.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 之后必须对已经发生的输出负责。