从HTTP到SSE:大模型流式响应的底层原理与踩坑实录
一、流式响应不是什么新鲜事
2015年我刚写后端的时候,Transfer-Encoding: chunked 就是个老协议了。文件下载、日志推送、长轮询,都用过它。但大模型把它重新带回了视野------因为一个冷冰冰的数字。
TTFT(Time To First Token)。
GPT-4 生成一篇 2000 token 的文章,如果等全部生成完再返回,用户要等 15-30 秒。但第一个 token 其实 0.5 秒就出来了。为了这 0.5 秒,流式成了所有大模型 API 的标配。
我见过不少开发者,stream=True 一开,拿到 response 就开始消费,至于底层到底怎么传的、中间件有没有吞掉、CDN 有没有缓存------全然不知。直到线上炸了才开始查。
这篇文章把这些层拆开讲一遍。
二、HTTP 的根本矛盾:请求-响应模型 vs 持续生产
HTTP 设计之初是为了一次请求、一次响应。你发一个 GET,服务器给你一个完整页面,连接关掉,完事。
但大模型的工作方式是:你发一个 prompt,服务器开始生成,一边生成一边往外吐。模型需要几秒甚至几十秒才能把完整的 response 拼完。
两种方式解决这个矛盾:
2.1 Content-Length 模式
最直接。服务器等全部生成完,算一下 body 长度,塞进 Content-Length 头,一次性返回。
http
HTTP/1.1 200 OK
Content-Type: application/json
Content-Length: 5827
{"choices":[{"delta":{"content":"..."}}]}
优点:简单,所有中间件、CDN、浏览器都支持。
缺点:用户要等全部 token 生成完才能看到第一个字。大模型的 TTFT 优势被完全浪费。
2.2 Transfer-Encoding: chunked
服务器边生成边吐,每次吐一个 chunk,每个 chunk 前面是它的长度(十六进制)。
http
HTTP/1.1 200 OK
Content-Type: text/event-stream
Transfer-Encoding: chunked
1a
data: {"choices":[{"delta":{"content":"你好"}}]}
1b
data: {"choices":[{"delta":{"content":"世界"}}]}
0
每条 chunk 以 \r\n 分隔,最后发一个 0\r\n\r\n 表示传输结束。
这就是流式响应的底层机制。和大模型无关,HTTP 协议本身就有这个能力。
2.3 HTTP/2 Server Push 和 HTTP/3
HTTP/2 引入了 Server Push(服务器可以主动向客户端推送资源),HTTP/3 用 QUIC 替代 TCP 减少了连接建立的开销。
但大模型流式主要用的还是 HTTP/1.1 chunked + HTTP/2 的 stream multiplexing。主要是因为基础设施兼容性。CDN、反向代理、WAF,对 HTTP/1.1 chunked 的支持最成熟。换 H2/H3 的流式方案,中间件 403 一下你就傻眼了。
三、SSE(Server-Sent Events)到底是个什么协议
很多人以为 SSE 是大模型的专用协议。其实 SSE 是一个标准的 W3C 规范,2011 年就有了。
3.1 标准 SSE 格式
vbnet
event: message
data: {"content": "hello"}
event: done
data: [DONE]
每一段消息用空行分隔。每个字段可以有多行 data:。event 字段标记事件类型,id 字段用于断线重连时的 Last-Event-ID,retry 字段告诉浏览器重连等待时间。
3.2 为什么说"大模型的 SSE 不是标准 SSE"
几乎没有一个大模型厂商完全实现了 SSE 规范。
OpenAI 的流式格式长这样:
css
data: {"choices":[{"delta":{"content":"你好"},"index":0}]}
data: {"choices":[{"delta":{"content":"世界"},"index":0}]}
data: [DONE]
没有 event: 字段,没有 id:,末尾用 data: [DONE] 而不是标准 SSE 的 event: close 或连接关闭。Anthropic、Google、DeepSeek 各有各的变体。Anthropic 早期甚至直接用 application/json + chunked,根本不用 SSE 格式。
从工程角度说,这叫"借了 SSE 的壳",好处是兼容现有的 SSE 解析库,坏处是每个厂商的小差异都得单独处理。
3.3 SSE vs WebSocket:为什么大模型选择了 SSE
| SSE | WebSocket | |
|---|---|---|
| 方向 | 单向(服务端→客户端) | 双向 |
| 协议 | HTTP 之上 | 独立协议(ws://) |
| 自动重连 | 原生支持 | 需要自己实现 |
| 中间件兼容 | 好(纯 HTTP) | 差(需要额外配置) |
| 浏览器支持 | EventSource API | WebSocket API |
SSE 走的是 HTTP 协议,所有中间件不用动。WebSocket 需要反向代理、负载均衡器额外配置,很多 CDN 甚至不支持 WebSocket 的长时间连接。
大模型的流式本质是"单向下行数据",SSE 正好满足。不需要双向通信能力,犯不着上 WebSocket。
四、手写一个流式中转代理
这一章我们从零实现一个流式中转代理。不依赖任何大模型 SDK,只用标准库 + httpx,这样你能看清每一层在做什么。
4.1 基础实现
python
import asyncio
import json
import httpx
from starlette.applications import Starlette
from starlette.responses import StreamingResponse
from starlette.routing import Route
async def proxy(request):
"""中转请求到上游 API,流式转发响应"""
body = await request.json()
client = httpx.AsyncClient(timeout=300)
async def generate():
try:
async with client.stream(
"POST",
"https://api.upstream.com/v1/chat/completions",
json={**body, "stream": True},
headers={"Authorization": "Bearer sk-xxx"}
) as resp:
async for chunk in resp.aiter_bytes():
yield chunk # 直接透传原始字节流
finally:
await client.aclose()
return StreamingResponse(
generate(),
media_type="text/event-stream"
)
app = Starlette(routes=[Route("/v1/chat/completions", proxy, methods=["POST"])])
看着是不是很简单?这个版本能跑,但线上会挂得很惨。我们自己的线上服务 www.aifast.club 最早也是从这段代码起步的,然后一个一个坑踩过来。下面列的全都是真金白银换来的教训。
4.2 坑1:Nginx 默认 buffering 吃掉流式
这是最常见的线上故障。Nginx 默认开启 proxy_buffering on,会把上游的完整响应缓存到缓冲区,等上游发完再一次性发给客户端。后果就是:流式不流了。
nginx
location /v1/chat/ {
proxy_pass http://backend;
proxy_buffering off;
proxy_cache off;
chunked_transfer_encoding on;
proxy_http_version 1.1;
}
必须关掉 buffering 。proxy_buffering off 告诉 Nginx 收到 chunk 就立刻转发,不要攒。
4.3 坑2:CDN 不支持 streaming
Cloudflare、Akamai 等 CDN 在默认配置下会把流式响应转成非流式。Cloudflare 的解决办法是开启 Argo Streaming 或在响应头加 Cache-Control: no-transform。Akamai 需要配 Streaming Object Caching。
如果中间走了多层代理,排查方法是:从客户端逐层向上追踪,看每一层收到的是流式还是完整的响应。用 curl -N 测试:
bash
curl -N https://your-proxy.com/v1/chat/completions \
-H "Content-Type: application/json" \
-d '{"model":"gpt-4","stream":true,"messages":[{"role":"user","content":"hi"}]}'
能看到逐行输出就是流式,等半天才一次性全出来就是被缓冲了。
4.4 坑3:客户端断连后服务端还在跑
用户关掉页面了,但服务端还在继续生成 token。浪费算力,也浪费钱。
解决方案有两个层面:
应用层检测 :Starlette/FastAPI 的 request.is_disconnected() 或 ASGI 的 send 抛异常。
python
async def generate():
try:
async with client.stream(...) as resp:
async for chunk in resp.aiter_bytes():
if await request.is_disconnected():
break # 客户端已断开,停止生成
yield chunk
except Exception:
pass
finally:
await client.aclose()
传输层检测 :TCP 连接断开时,往 socket 写数据会触发 BrokenPipeError/ConnectionResetError。在 StreamingResponse 内部,如果客户端断连,yield 到 Uvicorn 的 ASGI send 时会抛出异常。但有些情况下这个异常不会立刻触发,需要配合心跳机制。
4.5 坑4:空闲超时断开
一条流式连接可能持续几分钟。中间件、负载均衡器、CDN 都有空闲超时设置。Nginx 默认 proxy_read_timeout 是 60 秒。如果某个模型生成中间卡了 60 秒(这种事比想象中频繁),Nginx 会断开连接。
解决方案:
- 提高超时阈值:
proxy_read_timeout 300; - 发送心跳包:每隔 30 秒发一个
data: {"type": "ping"}的空消息保持连接活跃 - 客户端的 fetch 请求也要设置
keepalive超时
4.6 坑5:流式 chunks 拼接出的 JSON 不完整
这不是理论问题,是真实发生过的事。某些 SDK 在解析流式响应时,假设每个 chunk 都是一条完整的 JSON。但当你经过某些中间件(比如自定义的日志代理或协议转换层)时,一个完整的 SSE 消息可能被切成两个 chunk 发送。
它可能是标准行为(TCP 层面天然就会分段),但某些解析库不处理这种情况。结果就是 JSON.parse 炸了。
靠谱的做法 :自己维护一个 buffer,只把 \n\n(SSE 的消息分隔符)之间的内容当成一条完整消息来解析。
python
buffer = ""
for chunk in stream:
buffer += chunk
while "\n\n" in buffer:
msg, buffer = buffer.split("\n\n", 1)
# 处理 msg
4.7 坑6:多路复用(HTTP/2)下的数据错乱
HTTP/2 的 stream multiplexing 允许多个请求共享一个 TCP 连接。正常情况下每个 stream 是独立的。但如果你在 HTTP/2 连接上开了多个流式请求,某些实现不完善的中间件 会把不同 stream 的 chunk 混在一起。
这个坑在 2024 年初某热门开源 API 网关上真实出现过。排查时最明显的特征是:两个不同的客户端收到了对方的数据片段。
规避方案:明确走 HTTP/1.1 转发流式流量,或确保 HTTP/2 的 stream ID 隔离机制正确实现。
五、踩过这些坑之后
上面这些坑,我们自己在做 www.aifast.club 的时候基本都踩了一遍。
最要命的是 Nginx buffering 和 CDN 不兼容------这两个问题表现一模一样(客户端收到的是非流式响应),但排查路径完全不同。前者改个配置就行,后者要跟 CDN 厂商来回拉扯。
其次是客户端断连检测。默认行为下,用户关了 tab,服务端还能继续生成几十秒。上线第一周就因为这个多烧了 30% 的 tokens。后来加了 is_disconnected 检测才解决。
还有一个没展开讲的细节是流式重试 。如果网络中途断了,客户端怎么恢复?标准 SSE 的 Last-Event-ID 机制理论上支持,但大模型 API 几乎都不实现这个字段。实际做法只能是从请求开始重放,但配合 prompt cache 可以大幅降低重试开销。
现在我们的服务每天处理百万级流式请求,这些坑基本都填平了。但说实话,每次有新模型接入,我还是会拿 curl -N 先测一把流式是否正常------这个习惯从第一次踩坑之后就没改过。
六、总结
流式响应的核心技术就三样:
- HTTP chunked transfer encoding(底层传输机制)
- SSE 格式(上层数据封装)
- 中间件逐层透传(每一层都不能缓冲或修改)
这三样都不是新技术。但是当它们串在一起、经过多层网络设备、加上不同厂商的私有变体,就会变成一张布满暗坑的地图。
理解底层原理不是为了炫技。线上出问题的时候,你知道该查哪里,就不慌了。
本文由 AI Developer Community 撰写。欢迎收藏转发,有疑问可以在评论区交流。