27届大模型面试准备(七十二):大模型流式推理服务与长连接工程------SSE、背压与首包优化
引言
前面几篇把"怎么把请求调度到卡上、怎么把卡喂满"讲透了。本篇聊用户真正感知到的那一端:流式输出(打字机效果)。用户问一个问题,前端要能在 200ms 内看到第一个字,然后稳定地一个 token 一个 token 蹦出来,断网还能续上。这背后是 SSE/WebSocket 长连接、服务端背压、首包(TTFT)优化、断点续传一整套工程。也是华为云大模型 API 岗、对话产品后端岗必问。
前文链接:A71 多租户 GPU 虚拟化与推理隔离、A70 推理服务负载均衡与智能请求路由、A61 服务可观测性与延迟吞吐剖析。
Browser/App 网关(LB) 推理服务(vLLM/TGI) GPU
│ │ │ │
│── GET /v1/chat ──▶ │ │ │
│ (SSE, stream) │── forward ───────────▶ │ prefill │
│ │ │───────▶ │
│◀── data: {"照") ──│◀── token#1 ────────────│◀────── │
│◀── data: {"片") ──│◀── token#2 ────────────│ decode 流式 │
│ ... │ ... │ (逐 token 回传) │
│◀── data: [DONE] ──│◀── 结束 ───────────────│ │
│ │ │ │
SSE 长连接全程保持,服务端按生成节奏 push,客户端按渲染节奏消费
表:流式协议选型
| 协议 | 方向 | 自动重连 | 实现复杂度 | 适用 |
|---|---|---|---|---|
| SSE (text/event-stream) | 服务端→客户端单向 | 浏览器原生 EventSource | 低 | 对话流式输出首选 |
| WebSocket | 双向 | 需自实现 | 中 | 需客户端上行(语音/打断) |
| 普通 HTTP + 客户端轮询 | 双向轮询 | 无 | 低但体验差 | 不支持 SSE 的古老环境 |
一、SSE 长连接的最小实现
SSE 本质是 Content-Type: text/event-stream 的分块 HTTP 响应,服务端用 flush 把每个 token 立即推给客户端,而不是等全部生成完才返回。
# FastAPI 流式端点(SSE)
from fastapi import FastAPI, Request
from fastapi.responses import StreamingResponse
import asyncio, json
app = FastAPI()
async def token_stream(prompt: str):
# 假设 engine.generate_async 是异步逐 token 生成器
async for piece in engine.generate_async(prompt, stream=True):
# SSE 格式:以 "data: " 开头,两个换行结尾
yield f"data: {json.dumps({'content': piece}, ensure_ascii=False)}\n\n"
await asyncio.sleep(0) # 让出事件循环,避免阻塞其他连接
yield "data: [DONE]\n\n"
@app.post("/v1/chat/stream")
async def chat(req: Request):
body = await req.json()
return StreamingResponse(
token_stream(body["prompt"]),
media_type="text/event-stream",
headers={"Cache-Control": "no-cache", "X-Accel-Buffering": "no"},
)
关键头:X-Accel-Buffering: no 关掉 Nginx 缓冲,否则 SSE 会被攒批;Cache-Control: no-cache 防止中间代理缓存。
二、服务端背压:生成快于消费怎么办
GPU 生成 token 的速度(可能 100+ tok/s)常快于客户端网络/渲染速度。如果服务端无脑猛推,内存里会堆满未发送的 chunk,连接一多就 OOM。正确做法是背压:客户端消费慢时,服务端生成器被"卡住"。
import asyncio
async def backpressure_stream(raw_gen, client_queue_max=128):
q = asyncio.Queue(maxsize=client_queue_max) # 有界队列即天然背压
async def producer():
async for tok in raw_gen:
await q.put(tok) # 队列满则 producer 自动挂起
await q.put(None) # 结束哨兵
async def consumer():
asyncio.create_task(producer())
while True:
tok = await q.get()
if tok is None:
yield "data: [DONE]\n\n"; break
yield f"data: {json.dumps({'content': tok}, ensure_ascii=False)}\n\n"
async for chunk in consumer():
yield chunk
当 q 满,producer 的 await q.put 挂起,推理引擎的 generate_async 也跟着停,自然把压力传导回调度层(连续批处理里该请求暂时不取新 token)。这就是"背压链":客户端慢 → 队列满 → 引擎暂停 → GPU 让给其他请求。
三、首包优化(TTFT)
用户最在意的是"多久出第一个字"。TTFT = 路由耗时 + 排队 + Prefill 耗时 + 首 token 网络传输。优化点:
-
预取/预热:长连接复用,避免每次握手建连。
-
优先级队列:流式对话请求进高优队列,减少排队。
-
Prefix Cache:系统提示词命中前缀缓存,Prefill 直接复用 KV,省掉重复计算。
-
分块 Prefill:超大上下文时把 Prefill 切成多块穿插 decode,避免单次卡死。
网关层首包计时(观测 TTFT 分布)
import time
@app.middleware("http")
async def measure_ttft(request, call_next):
start = time.perf_counter()
response = await call_next(request)
# 用 response 的流式回调记录首个 chunk 到达时刻
request.state.ttft_start = start
return response在 token_stream 第一个 yield 时打点:ttft = now - request.state.ttft_start
四、断线续传与去重
弱网环境下 SSE 可能中途断开。浏览器 EventSource 会自动重连,但重连会重新发请求、从头生成,浪费且重复。生产做法:
-
服务端给每条流式会话发
stream_id,客户端重连带Last-Event-ID。 -
网关按
stream_id缓存最近 N 个已发 token,重连后从断点续推,不重新进模型。 -
客户端做幂等:同一
stream_id的data按序号去重拼接。断点续传:网关缓存已发事件
class StreamResumer:
def init(self):
self.sent = {} # stream_id -> list[(seq, payload)]def on_send(self, sid, seq, payload): self.sent.setdefault(sid, []).append((seq, payload)) def resume_from(self, sid, last_seq): # 只补发 last_seq 之后的事件 return [(s, p) for s, p in self.sent.get(sid, []) if s > last_seq]
五、长连接资源治理
每个 SSE 连接占一个 worker 协程 + 一个网关连接。万人同时对话,连接数爆炸。治理:
- 连接数上限 + 空闲超时(无 token 超过 N 秒断连)。
- 网关层用支持长连接的反向代理(如 Envoy),而非短连接为主的 Nginx 默认配置。
- 多实例下,流式会话要粘性(同一 stream 落到同一后端),否则续传缓存失效。
面试速答
问:SSE 和 WebSocket 怎么选?
答:纯服务端推(对话输出)用 SSE,浏览器原生 EventSource 自动重连、实现简单;需要客户端上行(语音打断、实时控制)才上 WebSocket。
问:服务端生成比客户端消费快,怎么不 OOM?
答:有界队列做背压,队列满时推理生成器挂起,压力传导回引擎和调度层,让 GPU 服务其他请求,而不是在内存堆 chunk。
问:断网重连为什么会重复输出,怎么解?
答:EventSource 重连会重新请求从头生成。解法:服务端发 stream_id + 序号,重连带 Last-Event-ID,网关从断点续推,客户端按序号去重。
高频追问清单
- Nginx 默认会缓冲 SSE 吗?哪个响应头能关掉?
- 背压链断在哪一层最危险(网关/引擎/客户端)?
- 流式会话在多副本部署下怎么做粘性路由和续传缓存?
- TTFT 的构成拆解,哪一段最难优化、为什么?
- Prefix Cache 对首包优化的收益上限受什么限制?
- 万人长连接下,网关连接数和后端 worker 怎么配比?
- 客户端怎么区分"模型真的说完了"和"网络断了"?
- 流式输出如何保证 token 顺序不乱(并发 decode + 多候选)?