Skip to content

feat(gateway): 等待上游响应头期间发送 SSE 心跳 - #473

Merged
caigee-cmd merged 1 commit into
caigee-cmd:mainfrom
NaimioChan:feat/stream-heartbeat
Nov 8, 2026
Merged

caigee-cmd merged 1 commit into
caigee-cmd:mainfrom
NaimioChan:feat/stream-heartbeat

Conversation

@NaimioChan

Copy link
Copy Markdown
Contributor

上游响应头迟迟不来的时候,网关在整个等待期间一个字节都不写,这段时间完全由前置代理和客户端的超时计时器支配。

问题

流式请求从「客户端发完 body」到「上游吐出第一帧 SSE」之间是一段完全静默的窗口。有两个独立的计时器在这段窗口里工作,都能掐掉一个健康的请求:

  • 带固定响应预算的边缘代理。Cloudflare 的 120 秒 Proxy Read Timeout 非 Enterprise 不可调。
  • 客户端自己的空闲超时。

上下文越长,这个窗口越宽:长 prompt 上传本身就慢,prefill 也久,而这两段时间都发生在网关能输出任何东西之前。

做法

上游响应头超过 CLI2API_STREAM_HEARTBEAT_AFTER_MS(默认 20 秒)还没到,就先提交 SSE 响应头,然后每 CLI2API_STREAM_HEARTBEAT_INTERVAL_MS(默认 10 秒)发一个 : keepalive 注释帧,直到真实流出开始。注释帧按 SSE 规范会被所有解析器忽略,客户端无感。

取舍

提前提交响应头就放弃了用真实 HTTP 状态码回答的机会。所以计时器只在超过期限后才触发:快速失败(没有可路由账号、额度、鉴权)仍然返回原本的状态码;提交之后的失败降级为流内 error 事件,这是 OpenAI 兼容客户端本来就会处理的形式。

并发

net/http 的 ResponseWriter 不是并发安全的。三个 goroutine 会碰它:handler 本身、心跳计时器、pump。所以所有写入走同一把锁,Adopt/Abandon 会等 pump 完全退出之后才把 writer 交还给 relay。

第一版没做这个等待,-race 直接报出 data race(pump 和 handler 同时写同一个 ResponseWriter)。

验证

stream_heartbeat_proxy_test.go 里是一个对照组实验:真实 Handler + 真实 ChatExecutor,中间插一个模拟固定响应预算的 TCP 代理,上游故意 5 秒不发字节,代理预算 2.5 秒。

场景 代理收到首字节 结果
有心跳 1.0s 请求完整走完,0 次 524
无心跳(阈值调到 60s 模拟改动前) — 2.5s 预算耗尽,1 次 524

其余单元测试覆盖提交时机、快速失败保留状态码、无 Flusher 时降级、pump 在接管后停止、客户端断开记录。TestHeartbeatOverRealSocketDeliversBytesBeforeBody 走真实 TCP,实测首字节 134ms 到达而 body 在 700ms。

go test ./internal/gateway/ -race 全绿。

说明

这个改动治的是「首字节慢」和「生成慢」,治不了「上传慢」——上传阶段网关还没拿到请求,心跳无从下手。那部分在另一个 PR 里用 gzip 请求体解决,两个改动互不依赖。


本 PR 由 AI 辅助生成与测试,提交前已人工审阅。

@caigee-cmd

Copy link
Copy Markdown
Owner

感谢,心跳的取舍写得很清楚:快失败仍走真实状态码,只有确认这次尝试慢了才提前提交 SSE 头。写锁和 pump 退出等待也看过了。

合之前还有几处:

  1. Adopt() 为 true 时,后面还在设置 X-Qoder-Account / X-CLI2API-Account,但这时心跳已经 WriteHeader(200),这些头发不出去。X-CLI2API-Provider 只写在未提交分支,慢请求会丢掉它。账号和 provider 如果还要带上,需要在 setStreamHeaders 里、WriteHeader 之前就定下来。
  2. clientGone() 能看到心跳写失败,但 HandleChatCompletions 没有读它。finishRequestLog 在 RelayOpenAIStream 之前就把状态写成 streaming。客户端在心跳阶段断开时,这次请求仍会记成正常开流。
  3. changelog 里写心跳能避免「上传慢或 prefill 久」被掐断。上传发生在网关读完 body 之前,这段心跳还没启动。PR 正文里已经把上传慢划给 gzip 那个改动了,这里建议改成只说等待上游响应头 / prefill。

这个分支同样落后 main,和 #474 都动了 internal/gateway/openai.go,rebase 时会需要一起看一下。

A streaming chat request goes silent between "client finished sending the
body" and "upstream sent its first SSE frame". Two independent timers watch
that gap and both can kill a healthy turn: an edge proxy with a fixed response
budget (Cloudflare's 120s Proxy Read Timeout is not configurable below
Enterprise) and the client's own idle timeout.

Commit the SSE headers once the attempt proves slow (default 20s) and emit
`: keepalive` comment frames every 10s until the real stream starts. Comment
frames are ignored by every SSE parser, so a spec-following client cannot tell
the difference.

The trade-off is deliberate: committing headers forfeits the chance to answer
with a real HTTP status, so the timer only fires after the deadline. Fast
failures (no routable account, quota, auth) keep their proper status code; a
failure after the commit degrades to an in-stream error event, which
OpenAI-compatible clients already handle.

Timings are tunable per deployment with CLI2API_STREAM_HEARTBEAT_AFTER_MS and
CLI2API_STREAM_HEARTBEAT_INTERVAL_MS.

Net/http does not make ResponseWriter safe for concurrent use, so every write
goes through one mutex and Adopt/Abandon wait for the pump to exit before
handing the writer back. The race detector flags any regression here, which is
what caught the first draft.
@caigee-cmd
caigee-cmd force-pushed the feat/stream-heartbeat branch from d6722aa to f1c067b Compare November 8, 2026 07:02
@caigee-cmd
caigee-cmd merged commit 5ece646 into caigee-cmd:main Nov 8, 2026
6 checks passed
caigee-cmd added a commit that referenced this pull request Nov 11, 2026
…rtbeat

fix(gateway): 补修 #473/#474 合入后仍在的问题
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants