文章

HTTP 与 WebSocket 连接模型与超时

HTTP/SSE 与 WebSocket:连接模型、超时与流式传输

一段对话式 LLM 调用里,传输层选型直接决定可靠性和延迟。本文用一个真实故障——“流式 API 首包卡死”——讲清 HTTP/SSE 与 WebSocket 两种连接模型的本质差异、为何 HTTP 模式会概率性卡在”等响应头”、以及生产环境如何用超时预算 + 重连兜底。这套方法论不止适用于 LLM,任何长连接/流式服务都通用。


一、请求模式的两种连接生命周期

1.1 HTTP/SSE 模式(逐请求-响应周期)

每一轮模型调用都是一次独立的 HTTP 请求:重新建连(或复用 keep-alive 连接)→ 发出整段上下文 → 阻塞等响应头 → SSE 逐事件流式返回。下一轮从头再来。

以一次 /v1/responses 流式请求为例,在「网关 ↔ 上游」这一跳的生命周期:

① TCP 握手
② TLS 握手
③ 发出 HTTP 请求
④ 阻塞等待响应头(Response Status Line + Headers)
⑤ 收到响应头(如 200)
⑥ SSE 逐事件流式返回(response.created / delta / done)

唯一会长时间卡死的是第 ④ 步——等响应头。本机网络层问题(连不上上游)会在 ①②③ 中断,表现为连接失败/重连;而”上游收到了但不发响应头”则卡在 ④,且没有超时

1.2 WebSocket 模式(一段会话复用一条连接)

会话开始时只握手一次,把 HTTP 连接”升级”成一条长期存活的双向管道(返回 101 Switching Protocols);此后每一轮对话只是往这条已打开的管道里发几个,应答也是帧返回。

握手(①TCP ②TLS ③HTTP Upgrade → 101)  ← 仅此一次
─────────────────────────────────────
之后每轮:发帧 → 收帧(复用同一条连接,同一个 request_id)

二、澄清一个常见误解:HTTP 并非”每次新建连接”

很多人以为 HTTP 模式每轮都新建连接,所以慢。实际:

  • HTTP/1.1 keep-alive:同一 TCP 连接可串行跑多个请求
  • HTTP/2:单连接多路复用,多个请求并发跑在 stream 上
  • HTTP/3 (QUIC):基于 UDP,连接迁移更轻

但关键在于:无论连接是否复用,“等响应头”(第 ④ 步)在逻辑上每一轮请求都要经历一次。连接复用省的是 ①②(握手),省不掉 ④(每个逻辑请求的响应头等待)。

维度HTTP/SSEWebSocket
一段会话里每轮对话一次独立请求-响应周期整段会话 = 一条持久连接
”等响应头”第④步每轮一次仅会话开始握手时一次(101 很快)
承载单位每轮一个完整 HTTP 请求(头+体+SSE 响应)每轮只是若干”帧”
观测每轮一条请求日志一条连接开/关记一次,多轮复用同 request_id

三、首包卡死的根因:响应头超时缺失

3.1 故障现象

  • 调用卡在”thinking/首包”,无报错,看上去完全卡死
  • 停止再继续就正常 → 说明不是必然失败,是概率性”摇到坏运气”

3.2 根因:HTTP 客户端”等响应头”无上限

以 Go 客户端为例(通用原理,不限语言):

// 构造 HTTP 客户端时未设超时
client := helps.NewUtlsHTTPClient(ctx, cfg, auth, 0)  // timeout=0 → 不设 client.Timeout
// transport 层也未设 ResponseHeaderTimeout

httpClient.Do(httpReq) 的语义是:发出请求并阻塞,直到响应头到达才返回。当上游收到请求却迟迟不返回响应头(服务端排队/内部阻塞/中间代理丢包),这个调用就无限期阻塞在 第 ④ 步。

3.3 两种”卡死”形态要区分

形态描述卡在哪证据
header-stall(首包/响应头卡死)连 200 响应头都迟迟不发第 ④ 步(等响应头)逐帧日志显示选定上游后有数分钟空白才进入读流
body-stall(响应体卡死)200 头秒到,SSE 流开了却卡在首个内容第 ⑥ 步内健康请求 response.created 亚秒即到,零证据支持此形态

实测中 99% 是 header-stall——即卡在 http.Do 等响应头这一步,而非流内部。

3.4 为什么”停止再继续”能好

因为重发 = 重新建立一次请求实例,相当于重新摇一次骰子。卡死是单个请求实例的坏运气(可能是上游服务端瞬时排队),重发大多一两次就中。


四、WebSocket 为何直接绕过首包卡死

本质区别在”第 ④ 步的出现频率”:

  • HTTP/SSE:第 ④ 步(等响应头)每轮一次 → 每轮都有概率卡在 ④
  • WebSocket:第 ④ 步只在会话开始的握手时有一次(返回 101 很快),之后每轮只是轻量帧 → 把”概率卡死点”从”每轮 N 次”压缩成”整段会话 1 次”

所以启用 WebSocket 模式后,首包卡死基本消失——不是 WebSocket 更快,而是它消除了每轮都要经历的、且可能无限阻塞的响应头等待


五、生产兜底模式(HTTP 模式下的可靠性工程)

当无法用 WebSocket(上游不支持 / 中间件不支持)时,必须在 HTTP 模式上做兜底:

5.1 给”等首字节”套一个超时预算

first-event-timeout-seconds = 6   # 覆盖"等响应头 + 等首字节"
first-event-retries = 4           # 超时后同号重连次数
  • 实现:把”执行流式请求(含 http.Do)“丢到 goroutine,用一个带超时的 context 同时约束”等响应头 + 等首字节”
  • 预算取值:健康请求首字节 1~3 秒到,6 秒留足余量;卡死则零字节,触发超时
  • 关键:必须同时覆盖 ④(响应头)和 ⑥(首个内容字节),因为两者都可能无限阻塞

5.2 超时 → 同号重连(重摇骰子)

一旦超时,丢弃卡住的请求、用同一个账号重新发起。因为卡死是单请求实例的坏运气,重发=重新摇一次,实测多数一两次即中。

5.3 配置层开关

上游/中间件支持 WebSocket 时,显式开启会话级复用(对应配置项如 supports_websockets = true),让客户端走 WebSocket 而非 HTTP/SSE。


六、通用启示(可靠性工程)

  1. 任何”等首字节/等响应头”都必须有超时预算。默认”无限等”是生产事故的温床——客户端 Timeout=0ResponseHeaderTimeout 不设,等于把命门交给上游。
  2. 长连接把”概率失败点”从每请求 N 次压缩到会话 1 次。这是 WebSocket/gRPC/长轮询相较于纯短请求的核心可靠性优势,不止 LLM 场景。
  3. 重连要”换实例”而非”续命”。卡死是单实例坏运气,重发新请求(同号)比尝试恢复旧连接更稳。
  4. 观测要能区分 header-stall vs body-stall。两者根因不同(上游不响应 vs 流内部慢),兜底策略也不同。

关联知识

参考资源

学习时间

阶段时间备注
内容创建2026-07-20提炼自 LINUX DO「首包卡死」故障分析帖:HTTP/SSE vs WebSocket 连接模型、响应头超时根因、生产兜底模式

状态标记

📖 已掌握 — 两种模式连接生命周期、响应头超时根因、WebSocket 绕过原理、超时+重连兜底模式 📝 待补充 — gRPC 流式对比、HTTP/3 下首包行为、具体网关(Envoy/Nginx)的 proxy_read_timeout 配置