2026-10-27 · 深度笔记

流式输出(SSE)长连接对云主机的网络要求

SSE 看着像普通 HTTP,其实每个连接都要占资源。这篇讲连接数上限、空闲断开、心跳、以及反向代理缓冲这些真实会把流式响应搞坏的细节。

云主机AI推理SSE网络

我给自己的知识库做了个"边生成边显示"的输入框,本地开发正常,部署到云主机后用户反馈说看着像在转圈,但一个字都没有。

查下来不是后端的问题,是反向代理把流式响应缓冲住了。SSE 跟普通 HTTP 共享同一套网络栈,资源占用模型却完全不一样。

SSE 和普通请求的根本差别

普通请求是"来一个请求走一个来回,连接释放"。SSE 是"连接建立后一直挂着,直到服务关闭或客户端断开"。

一个 SSE 连接从握手到结束会持续占用:

这些资源你用 curl 测单条请求时,一个都不会暴露。

连接数:三层限制,取最小值

这是我踩得最疼的一个。三层限制,任何一层不够都会出问题,症状还各不相同。

第一层,系统 fd 上限。

# 查当前进程的 fd 上限
cat /proc/sys/fs/file-max              # 系统级
ulimit -n                              # 当前 shell
ls /proc/$(pgrep -f uvicorn | head -1)/fd | wc -l   # 实际用了多少

默认往往只有 1024。1000 个并发用户各占一个连接,加上游连接池,几千个 fd 轻松超掉。

症状分层很有意思:ulimit 生效但不超过 file-max 时,应用会抛 Too many open files;如果 file-max 也到了,内核会直接丢弃 SYN 包,客户端看到的是"连接超时"而不是"连接被拒绝"。后者难查得多,日志里什么都没有。

第二层,Nginx 的 worker 连接数。

worker_processes auto;
events {
    # 每个 worker 的最大连接数
    worker_connections 8192;
}

worker_connections 算的是所有连接,包括到上游的。客户端占一个,连接池再占一个,能承载的并发大约是它的一半。还有个坑:它也受 ulimit 约束,两者得一起抬。

第三层,内核的连接跟踪表。

# 查看 conntrack 表容量和当前占用
cat /proc/sys/net/netfilter/nf_conntrack_max
cat /proc/sys/net/netfilter/nf_conntrack_count

SSE 连接在 conntrack 表里会一直占条目,直到连接真正关闭。表满了新连接会被丢弃——症状和 fd 超限一样,都是连接超时。跑 Docker 时这张表默认几万个,几百个并发长连接很快就满了。

经验公式:并发用户数乘以 2(客户端 + 上游连接),再乘以 1.5 的余量,跟三层的限制取最小值。

空闲断开:中间设备会杀你

这是 SSE 最隐蔽的问题:没人报错,就是断了。

一个 SSE 连接长时间没有数据传输,中间任何一环都可能掐掉它:

环节典型空闲超时能不能配
云厂商负载均衡30 到 60 秒看产品文档,部分可调
Nginx proxy_read_timeout60 秒(默认)能配
应用层读超时视框架能配
中间的 CDN通常 30 到 100 秒一般不可配

问题在于,大模型的流式输出天然有停顿。模型在"想"的时候可能好几秒不吐 token,复杂推理能到十几秒。

于是有了这个组合:LB 的空闲超时 60 秒 < 模型思考时间。请求发过去,LB 认为这条连接是死的,悄悄断掉。客户端收到一个不完整的流,而且HTTP 状态码是 200——响应已经开始了,中途断开在协议层面不算错误。

做法是两层。

第一层,超时拉到远大于最坏思考时间。

location /v1/chat/stream {
    proxy_pass http://127.0.0.1:8000;
    proxy_http_version 1.1;
    proxy_set_header Connection "";

    proxy_buffering off;
    proxy_cache off;
    proxy_read_timeout 900s;
    proxy_send_timeout 900s;
    proxy_connect_timeout 5s;
}

第二层,加心跳。 每隔 15 秒发一个 SSE 注释行,让连接在中间设备眼里始终"活着":

import asyncio

async def sse_with_heartbeat(event_iter, interval=15):
    """在事件流空闲时插入心跳注释行,防止中间设备判定连接空闲。"""
    agen = event_iter.__aiter__()
    try:
        while True:
            try:
                event = await asyncio.wait_for(agen.__anext__(), timeout=interval)
            except asyncio.TimeoutError:
                # SSE 注释行,客户端会忽略但能让连接保活
                yield b": ping\n\n"
                continue
            yield event
    except StopAsyncIteration:
        pass
    finally:
        await agen.aclose()

心跳间隔要比最短的那层空闲超时小一个数量级。我取 15 秒是因为链路上最短一环是 60 秒;用了会 30 秒断连的 CDN,就得改 5 秒。

asyncio.wait_for 包一层还有个副作用好处:它顺带解决了"上游卡住"的问题。后端真挂了,你还可以直接断掉让客户端重连。

缓冲:为什么本地正常线上不正常

前面那个"一个字都不显示",根因就是缓冲。

SSE 靠 Content-Type: text/event-stream 和不使用 Content-Length 表达"这是个持续的流"。HTTP 代理不知道这个,只看到 text/* 响应,自然就想缓冲。

# 1. 关掉代理缓冲
proxy_buffering off;

# 2. 关掉缓存
proxy_cache off;

# 3. 关键:告诉上游别缓冲我
proxy_set_header X-Accel-Buffering no;

第三条是给 上游应用看的。Nginx 收到 X-Accel-Buffering: no 时,即使全局开了 buffering 也不缓冲这个响应。很多流式框架会带这个头,也有不会的——手动加更保险:

return StreamingResponse(
    gen(),
    media_type="text/event-stream",
    headers={
        "Cache-Control": "no-cache",
        "Connection": "keep-alive",
        "X-Accel-Buffering": "no",
    },
)

Cache-Control: no-cache 也别漏。CDN 那层如果不认这个头,会尝试缓存这个流——缓存策略和流式响应天然冲突。

还有一个本地测不出来的问题:gzip。gzip 压缩有缓冲,要攒够一块才输出。开了 gzip 的 SSE 会"憋一大段再突然出来",SSE 的 location 里要写上 gzip off;。 我一开始没找到原因,是因为本地直连时 curl -N 能看到逐行输出,上了 Nginx 就是一次性输出——问题在 Nginx 不在应用。这个对比方法我固定下来了:直连能流式、过代理不行,就是代理缓冲问题。

客户端侧的断线重连

即便服务端做对了,客户端也一定会遇到断线——网络切换、负载均衡重调度都会断。

SSE 规范自带重连机制,客户端会自动带上 Last-Event-ID,服务端要从那个位置继续发。所以发事件时要带 id:

async def event_stream():
    last_id = request.headers.get("last-event-id", "0")
    cursor = int(last_id)

    async for item in fetch_since(cursor):
        yield f"id: {item.id}\n".encode()
        yield f"data: {json.dumps(item.payload, ensure_ascii=False)}\n\n".encode()
        cursor = item.id

不做断点续传,客户端重连会从头开始,用户的整个回答被重放一遍。

压测时的注意事项

我压 SSE 时踩过一个坑:用 ab 或 wrk 测出来的并发数和真实场景完全不同。这类工具默认不保持连接、不处理流式响应,测的是"建连速率"不是"维持连接的能力"。

真实压测得自己写,比如这样:

import asyncio
import aiohttp
import time

async def one_client(session, sem, url, payload):
    async with sem:
        t0 = time.monotonic()
        first_token = None
        chunks = 0
        try:
            async with session.post(url, json=payload, timeout=aiohttp.ClientTimeout(total=300)) as r:
                async for line in r.content:
                    if line.startswith(b"data:"):
                        chunks += 1
                        if first_token is None:
                            first_token = time.monotonic() - t0
        except Exception as e:
            return {"error": str(e)}
        return {"ttft": first_token, "chunks": chunks, "total": time.monotonic() - t0}

async def main(n, url, payload):
    sem = asyncio.Semaphore(n)
    async with aiohttp.ClientSession() as session:
        return await asyncio.gather(*[one_client(session, sem, url, payload) for _ in range(n)])

要盯的不只是错误率,还有:首 token 延迟(TTFT)、中途断流比例、fd 使用量。中途断流最容易漏掉——它不在错误率里,只在"收到的 chunk 数偏少"。

收尾

教训是:它看起来像 HTTP,所以大家用 HTTP 的经验去配,结果全错。

资源占用模型、超时语义、中间设备行为、故障表现都不一样(200 状态码 + 内容截断,最坏的组合)。

每一层代理都要显式配置,SSE 没有默认行为可言。

至于 fd 上限、conntrack 表大小、LB 空闲超时这些数值,取决于实例规格、镜像类型和网络环境,别照抄,挨个查自己那台机器。

接着读