流式与 SSE 语义
网关不会缓冲你的流
服务端的 WriteTimeout 是关闭的,SSE 分片按到达顺序转发,网关不攒够一批再发。
这也意味着前置反代必须关掉缓冲,否则你在客户端看到的是一次性到达的「假流式」:
proxy_buffering off;flush_interval -1三道时间闸门
流式请求会先后穿过三个不同的超时,它们的作用点不一样,混起来排查会走弯路:
| 闸门 | 值 | 作用点 | 触发后的行为 |
|---|---|---|---|
| 上游响应头超时 | OUTBOUND_HEADER_TIMEOUT_SECONDS,默认 60s | 发出请求 → 收到响应头 | 该次尝试判定失败,按重试预算换渠道 |
| 首字节超时 | 固定 30s | 收到 200 响应头 → 第一个流式分片 | 判定为「卡死的 200 流」,该次尝试失败 |
| SSE 保活间隔 | 固定 15s | 流式过程中网关与客户端之间 | 注入一帧 SSE 注释 : keepalive |
保活帧会重置首字节计时:一个持续发送 keepalive 的上游不会被误判为卡死。反过来,一个既不吐字节也不发心跳的上游,会在 30s 后被判定为断流。
NOTE
保活帧是 SSE 注释(以 : 开头),符合规范、客户端应忽略它。它存在的唯一目的是防止中间设备(反代、CDN、负载均衡)因为长时间无数据而掐断连接。
非流式请求的超时
非流式请求用渠道级配置 channels.non_stream_timeout_seconds(0 = 用默认值)。这是渠道属性,因为不同上游的慢请求特征差异很大:某些推理模型非流式返回就是要几分钟。
流式策略:强制流 / 强制非流
有些上游只在某一种模式下工作正常。渠道上的 stream_policy 可以覆盖客户端的选择:
| 值 | 含义 |
|---|---|
""(默认) | 客户端说什么就是什么 |
force_stream | 客户端要非流式,网关仍用流式请求上游,再在网关侧聚合成完整响应 |
force_non_stream | 客户端要流式,网关用非流式请求上游,再把结果包装成 SSE 发给客户端 |
这是为了适配「只实现了流式」或「流式实现有 bug」的上游,不改客户端行为。
重试与幂等
默认规则:凡是「生成并计费一个产物」的请求都不重试。 上游可能已经接受并计费了它,而失败信息还没回到网关——重放等于第二次扣费。
| 路径 | 默认 | 带 Idempotency-Key |
|---|---|---|
images/* | ❌ | ❌ 仍然不重试 |
audio/* | ❌ | ✅ |
videos/*、video/* | ❌ | ✅ |
music/* | ❌ | ✅ |
responses | ❌ | ✅ |
| chat / completions / embeddings / moderations / messages | ✅ | ✅ |
自定义路径(如 /v1/systemone) | ✅ | ✅ |
Idempotency-Key 是调用方对「上游能去重」的承诺,所以它能为音频、视频、responses 重新打开重试。 但图像不行——跨渠道 / 跨密钥的重放无法靠一个请求头去重,所以图像永不走这条路。
图像的例外:只重放「生成前的拒绝」
图像是唯一一个连 Idempotency-Key 都救不回来的家族,因为一张图的代价是一次性扣费、没有用量回执, 重复生成在事后无法对账。所以默认策略是一次到底。
部署方如果愿意承担这个风险,可以在控制台的运行时设置里勾选「图片请求允许故障转移」,或设置 IMAGES_FAILOVER_ENABLED(默认 false,见参考 / 环境变量;控制台保存即时生效)。 它不是「无脑重试」,而是把失败分成两半:
| 上游怎么回答 | 是否重放 | 说明 |
|---|---|---|
| 2xx | 成功 | — |
| 429、503、529、530 | 可重放 | 限流 / 过载:上游在开工前就拒绝了 |
| 400、401、403、404、405、413、415、422 | 可重放 | 请求本身被拒(端点不存在、格式不匹配、凭据失效) |
| 500、502、5xx 其它 | 默认不重放 | 可能已经在画了,也可能已经扣了——第二档开关可放行,见下 |
| 504、传输层超时、连接中断、2xx 空体 | 默认不重放 | 网关放弃了等待 / 没有可用状态码,图最可能就存在于此——第三档开关可放行,见下 |
打开后,一次被拒的图片请求会先在同一渠道的下一个 Key 上重放,再走到下一个渠道; 同一个 Key 不会被连撞两次(限流不会在毫秒级自愈)。但 504 / 超时 / 断流 / 2xx 空体仍然只发一次—— 这就是为什么你不该指望打开它就能救掉「上游画得太慢」这一类失败,那该调 OUTBOUND_IMAGE_HEADER_TIMEOUT_SECONDS(默认 300 秒)。
第二档:连 5xx 也重试。 如果连 500/502 也想换成「重试到出图为止」,打开 IMAGES_FAILOVER_INCLUDE_AMBIGUOUS(默认 false),它把上表中 「500、502、5xx 其它」那一行也变成可重放。这一档是明确拿「可能重复扣一次费」换成功率: 5xx 也可能是「图已经画完并扣费,只是回包失败」。而 504、超时、断流、2xx 空体在这一档下仍然只发一次。
第三档:连超时、504、断流也重试(最激进)。 如果连「图可能已经画完」的那几种也想重发, 再打开 IMAGES_FAILOVER_INCLUDE_UNCERTAIN(默认 false)。 打开后,上表中所有「不重放」的行全部变成可重放:504、传输超时、断流、2xx 空体,一律换 Key、换渠道、 换到有结果为止。这一档不再讨论失败是否安全,它是明确用「可能为同一张图付两次钱」换「一定出图」。
控制台里这三档合并成一个开关:打开「图片请求允许故障转移」时弹出的确认框里可以勾选上面两个 扩展范围,不勾就是最保守的第一档;关闭开关会清掉整个范围。环境变量仍然可以按档独立设置, 适合想把档位固定在部署层的场景。
唯一还会停手的是客户端自己断开:请求方已经走了,再画一张只会上账、没人收货。 网关自身的适配器错误(端点不支持、响应过大)也不重发——那是本地 bug,换渠道照样会失败。
这一档还会给所有失败开同一个 Key 的重发预算(沿用 CHANNEL_RETRY_TIMES,默认 1 次): 只有一个上游的自建部署没有别的 Key 和渠道可走。第二档只给 5xx 开这个预算, 429 / 503 这类拒绝仍然直接换 Key、换渠道,不会原地重撞。
名单是白名单:没列出的状态码一律不重放(第二档把 5xx 整段放进来,第三档把剩下的一并放进来)。 新增一个码等于新增一次「可能白付的钱」,所以必须先证明它发生在生成之前——第三档则是明知如此, 由部署方自己承担。
这条约束由测试钉死(internal/proxy/retry_safety_test.go):一张矩阵表钉「哪些路径算不可重放」, 另一张表钉「哪些回答算生成前的拒绝」。不要为了「提高成功率」放宽它。
视频与音乐没有注册端点,它们经
/v1/*兜底透传到达(见接入 / 概览)。 网关因此专门把它们列入不可重试家族——否则它们会因为「网关不认识这个路径」而被当成可重试的读操作。
客户端取消
客户端断开连接时,网关会取消对应的上游请求。对于流式响应,已经收到的用量会按已到达的部分估算入账,而不是记成 0。