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/SSE | WebSocket |
|---|---|---|
| 一段会话里每轮对话 | 一次独立请求-响应周期 | 整段会话 = 一条持久连接 |
| ”等响应头”第④步 | 每轮一次 | 仅会话开始握手时一次(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。
六、通用启示(可靠性工程)
- 任何”等首字节/等响应头”都必须有超时预算。默认”无限等”是生产事故的温床——客户端
Timeout=0或ResponseHeaderTimeout不设,等于把命门交给上游。 - 长连接把”概率失败点”从每请求 N 次压缩到会话 1 次。这是 WebSocket/gRPC/长轮询相较于纯短请求的核心可靠性优势,不止 LLM 场景。
- 重连要”换实例”而非”续命”。卡死是单实例坏运气,重发新请求(同号)比尝试恢复旧连接更稳。
- 观测要能区分 header-stall vs body-stall。两者根因不同(上游不响应 vs 流内部慢),兜底策略也不同。
关联知识
- ../llm-serving/生产部署与运维 — 本帖故障正是 LLM API 网关的传输层可靠性问题;监控指标、网关路由、重试限流在此落地
- Envoy 与 Nginx 对比 — 流式反代的正确配置(proxy_read_timeout / proxy_buffering)在 Nginx/Envoy 两侧对照
- ../llm-serving/LLM 推理引擎对比与选型 — 引擎暴露的 API 形态(SSE/WS)影响网关选型
- ../k8s/网络架构/K8s 网络架构总览 — L3/L4 网络底座;本文是 L7 应用层协议
- ../cilium/Cilium 知识总览 — 东西向流量治理;长连接对 CNI 连接跟踪(conntrack)更友好
- ../linux/网络内核参数调优 — 内核层的超时/连接参数(sysctl),与应用层 client.Timeout 互补
参考资源
- RFC 6455 - The WebSocket Protocol
- RFC 7230 - HTTP/1.1 Message Syntax and Routing(ResponseHeaderTimeout 语义)
- Go net/http Client.Timeout / Transport.ResponseHeaderTimeout
学习时间
| 阶段 | 时间 | 备注 |
|---|---|---|
| 内容创建 | 2026-07-20 | 提炼自 LINUX DO「首包卡死」故障分析帖:HTTP/SSE vs WebSocket 连接模型、响应头超时根因、生产兜底模式 |
状态标记
📖 已掌握 — 两种模式连接生命周期、响应头超时根因、WebSocket 绕过原理、超时+重连兜底模式 📝 待补充 — gRPC 流式对比、HTTP/3 下首包行为、具体网关(Envoy/Nginx)的 proxy_read_timeout 配置