type
Post
status
Published
date
Nov 19, 2025
slug
gateway004
summary
tags
gateway
category
icon
password
摘要
这篇只讲一件事:
stream fallback 只能发生在首 chunk 发给客户端之前。首 chunk 一旦发出去,后面就只能把中断如实暴露出来。
我一开始把 fallback 当成成功率问题。再往下做 stream,我才发现关键点不在成功率,在响应边界。首 chunk 一旦写给客户端,这条流就已经开始生效了。后面上游如果断掉,网关再切到另一个 provider,就会把两条不同上游的输出拼成一次响应。所以我想说的是,这篇真正要钉住的是流的边界,不是 fallback 有没有做。
后面所有内容都只服务这句。顺序也很简单:先确认 fallback 这件事已经成立,再看首 chunk 前后的分界线,最后补一眼
/readyz 503,确认 provider 状态已经开始往外暴露。这篇从头到尾只回答一个判断:首 chunk 之后不能再补一条新的流。先看四个场景
先把场景摊开,这四个场景就是全文的骨架。
场景 | 注入方式 | 观察结果 | 说明了什么 |
non-stream primary 失败 | APP_GATEWAY_PRIMARY_MOCK_FAIL_CREATE=true | POST /v1/chat/completions 仍返回 200,后备路径接住请求 | fallback 这件事本身已经成立 |
stream 首 chunk 前失败 | APP_GATEWAY_PRIMARY_MOCK_FAIL_STREAM=true | SSE 仍完整返回,并带 data: [DONE] | 首 chunk 发出去之前,还在回退窗口里 |
stream 首 chunk 后中断 | stream_partial 演练 | 客户端只收到部分 SSE,没有 data: [DONE],不再 fallback | 首 chunk 发出后,网关只会继续转发或暴露中断 |
所有 provider 都不可用 | backend 全部进入 cooldown 或同时失败 | /readyz 返回 503 | provider 状态已经开始影响对外 readiness |
先确认 fallback 这件事已经成立
因为我要先排掉一个误判:我后面说“不能 mid-stream fallback”,不是因为这里根本没做 fallback。
验证命令:
结果输出里我看的信号很简单。primary 在创建阶段失败后,请求没有直接掉下去,接口形态还是
200 + chat.completion。这说明请求已经被后备路径接住了。non-stream fallback 这件事本身已经成立。我再去核对
provider_event,看到的是:provider_request_failed
provider_fallback_succeeded
到这里,fallback 这件事已经不用再怀疑了。实现口径也很直接。
CreateChatCompletion 成功落到后备 backend 时,provider_fallback_succeeded 记录的就是当前 backend.Name。在这套主备配置里,后备路径确实接住了请求。分界线就在首 chunk
因为 stream 的关键不在“有没有回退”,而在“回退窗口到哪里结束”。
这里我只看两组对照:首 chunk 前失败,和首 chunk 后中断。前一组用来看回退窗口还在不在,后一组用来看边界跨过去以后还能做什么。这一节就是要把那条线画清楚。
先看首 chunk 前失败,命令:
这次请求最后还是完整 SSE,末尾也有
data: [DONE]。这组结果说明回退发生在首 chunk 写回客户端之前。首 chunk 发出去之前,回退窗口还在。对应的
provider_event 也是:provider_request_failed
provider_fallback_succeeded
我这里对“首 chunk 已经发给客户端”的口径也说具体一点。
internal/provider/router/chat.go 里的 awaitFirstStreamEvent 负责拿第一个正常 event,后面的 wrapStream 会把这个 event 交给 forwardStreamEvent 写回下游。客户端从这一步开始,才真正看见这条流。上游刚建连不算,单纯拿到 header 也不算。客户端已经收到第一段正文了。我又把这一步的指标口径一起核对了一遍。这里关心的是 fallback 之后到底是谁接住了,所以检查直接要求出现:
这组信号放在一起,结论已经很清楚了:首 chunk 发出去之前,回退窗口还在。所以我想说的是,首 chunk 前失败还能切 secondary
再看首 chunk 发出后中断。
我又补了一次
stream_partial。这次让 upstream 先发出一个 chunk,再主动断连接。客户端还是会收到 HTTP 200 和部分 SSE,但不会收到最后的 data: [DONE],网关也不会再切到 secondary。首 chunk 一旦发出去,网关后面就只剩继续转发或暴露中断这两种动作。这里最有价值的信号就是 partial SSE 和缺失的
[DONE]。这说明网关没有再拼一条新的流,而是把中断如实暴露出来了。所以我想说的是,这一步排掉了“首 chunk 后还能补流”的误判。事件也换成了:
provider_stream_interrupted
我后来把 partial 这组的指标口径也一起看了:
provider_stream_interrupted 记在 primary 上,backend="secondary" 的 provider_fallback_succeeded 不应该再出现。事件和指标都在指向同一件事:首 chunk 后已经不在回退窗口里了。这组对照把分界线说得很清楚了。首 chunk 一旦开始通过
forwardStreamEvent 写回下游,这条流就已经对客户端生效。后面能做的事情只剩两件:继续转发,或者把中断暴露出来。再切 provider,就会把两个上游的输出拼到一次响应里。所以,首 chunk 后不能再 fallback,不是策略保守,而是响应语义已经变了。最后补一眼 /readyz
因为这篇虽然主线是 stream 边界,但还要顺手确认 provider 状态已经开始往外露了。
这一段是补充,不是主线本身。我留它,是因为 provider 健康状态既然已经开始影响 fallback,就应该也开始影响对外 readiness。所以我想说的是,这里补
/readyz,是在看 provider 状态有没有走出 router 内部。按现在这版网关的口径,只要
BackendStatuses() 里还有一个 Healthy=true,Ready() 就会返回 true,/readyz 也会继续返回 200。所有 provider 都不可用时,/readyz 才会返回 503。这个定义很直白:只要还有 backend 能接流量,网关就在降级运行;所有 backend 都接不住了,503 才有意义。这里的 readiness 已经开始反映 provider 可用性了。provider_event 也把过程拆开了:provider_request_failed
provider_fallback_succeeded
provider_skipped_unhealthy
provider_probe_failed
provider_probe_succeeded
provider_recovered
provider_stream_interrupted
这组信号已经够我确认一件事:provider 状态不再只藏在 router 里面。相关代码也很直接:provider probe loop 从
cmd/gateway/main.go 启动,/debug/providers 和 /readyz 从 internal/api/handlers/health.go 往外暴露。所以我想说的是,provider 的健康状态已经开始有对外语义了。threshold、cooldown 和 probe 误判,这篇先不展开。这里先收在
stream 的边界上就够了。所以我想说的是,/readyz 这一段只是补一眼状态暴露,不再把文章写散。总结
stream 请求只有在首 chunk 发出去之前,网关才能切到备用 provider。首 chunk 发出去以后,这条流已经对客户端生效了,后面上游再断,网关只能暴露中断,不能再补一条新的流。
首 chunk 发出去之后,stream fallback 就不能再做了。
分享
