从HTTP到SSE:大模型流式响应的底层原理与踩坑实录

从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-IDretry 字段告诉浏览器重连等待时间。

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;
}

必须关掉 bufferingproxy_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 会断开连接。

解决方案

  1. 提高超时阈值:proxy_read_timeout 300;
  2. 发送心跳包:每隔 30 秒发一个 data: {"type": "ping"} 的空消息保持连接活跃
  3. 客户端的 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 先测一把流式是否正常------这个习惯从第一次踩坑之后就没改过。


六、总结

流式响应的核心技术就三样:

  1. HTTP chunked transfer encoding(底层传输机制)
  2. SSE 格式(上层数据封装)
  3. 中间件逐层透传(每一层都不能缓冲或修改)

这三样都不是新技术。但是当它们串在一起、经过多层网络设备、加上不同厂商的私有变体,就会变成一张布满暗坑的地图。

理解底层原理不是为了炫技。线上出问题的时候,你知道该查哪里,就不慌了。


本文由 AI Developer Community 撰写。欢迎收藏转发,有疑问可以在评论区交流。

相关推荐
kismet7877 小时前
CLI会取代MCP吗?协议抽象 vs 本地执行
agent·ai编程
王中阳Go7 小时前
用Go写AI Agent:我从实战图书里总结了这些核心逻辑
后端·go·ai编程
OenAuth.Core7 小时前
从 Excel 到在线协作:2026 年值得关注的项目管理工具对比清单
ai编程·甘特图
码途漫谈8 小时前
Easy-Vibe高级开发篇阅读笔记(六)——CC教程之Superpowers
人工智能·笔记·ai·开源·ai编程
前端双越老师8 小时前
Superpowers 渐进式使用过程和最佳实践
agent·ai编程
QD_ANJING8 小时前
建议5月的Web前端开发都去飞书上准备面试...
前端·人工智能·面试·职场和发展·前端框架·状态模式·ai编程
Raytheon_code17 小时前
从零到一:我用微信小程序做了一款串珠DIY定制工具
css·微信小程序·html5·ai编程
狼爷19 小时前
解锁 AI 编程新高度:GitNexus 代码图谱 + ClaudeCode 精准开发实战
ai编程
甲维斯20 小时前
JCode支持Claude和第三方模型tokens统计!
人工智能·ai编程