流式输出与 SSE
模型生成是秒级,不流式用户就对着白屏等。SSE 是 LLM 应用的标配。面试主线:为什么、原理、实现、踩坑。
为什么必须流式
- 首 token 延迟(TTFT)通常在 0.5-2 秒,全量生成要 5-30 秒
- 流式让用户边生成边看,体感快一个量级
- 对话型产品(ChatGPT 打字机效果)没有流式没法用
SSE 原理
SSE(Server-Sent Events):HTTP 长连接上的服务端单向推送。
HTTP/1.1 200 OK
Content-Type: text/event-stream
Cache-Control: no-cache
data: {"delta": "你"}
data: {"delta": "好"}
data: [DONE]- 文本格式:
data: 内容\n\n,事件间空行分隔 - 基于普通 HTTP(不用 WebSocket),浏览器 EventSource 原生支持
- 自动重连(浏览器内置)、单向(服务端推)
对比:SSE 单向推送适合 LLM 输出;WebSocket 双向适合交互(见 WebSocket 篇)。
后端实现
from fastapi.responses import StreamingResponse
@app.post("/chat")
async def chat(request: ChatRequest):
async def gen():
stream = client.chat.completions.create(
model="gpt-4o", messages=request.messages, stream=True)
for chunk in stream:
delta = chunk.choices[0].delta.content
if delta:
yield f"data: {json.dumps({'delta': delta})}\n\n"
yield "data: [DONE]\n\n"
return StreamingResponse(gen(), media_type="text/event-stream")要点:
- 流式响应不能套全局压缩中间件(内容长度未知,见 FastAPI 生产篇)
- 代理层(Nginx)要关缓冲(
X-Accel-Buffering: no),否则攒一批才发 - 错误处理:生成中断要在流里发错误事件,客户端能感知
前端对接
const es = new EventSource("/chat?q=你好");
es.onmessage = (e) => {
if (e.data === "[DONE]") return es.close();
append(e.data); // 增量渲染
};- EventSource 只支持 GET;POST 场景用 fetch + ReadableStream 解析
- 心跳:代理层空闲断开时,服务端定期发注释行
: keepalive\n\n保活 - 中断/取消:AbortController 断开,服务端感知连接断开停止生成
踩坑清单
| 坑 | 解法 |
|---|---|
| 代理缓冲(攒批) | X-Accel-Buffering: no / proxy_buffering off |
| 空闲断连 | 心跳注释行 |
| 中文乱码 | UTF-8 + 字符边界(分块可能切半个字符,要缓冲拼接) |
| 流中断无提示 | 流尾发 error 事件 |
| 压缩中间件 | 流式响应跳过压缩 |
面试追问
- SSE 和 WebSocket? SSE 单向 HTTP 推送(自动重连),WebSocket 双向。LLM 输出用 SSE 够
- 为什么流式? 首 token 快,边生成边看。体感从等待变对话
- 代理层怎么配? 关缓冲(X-Accel-Buffering: no),否则 SSE 攒批失去意义
- 流断了怎么感知? 心跳保活 + 流尾事件 + 客户端超时兜底
- 中文乱码? 分块可能切开多字节字符,缓冲拼接完整字符再发