流式输出实践:SSE 协议与 Markdown 增量渲染
LLM 生成一段 500 字的回复可能需要 10 秒。 如果等生成完了再返回,用户会以为网断了。 Streaming (流式输出) 是 AI 应用的标配。 它让用户能看到字一个个蹦出来(打字机效果),心理等待时间趋近于零。
1. 协议选型:SSE vs WebSocket
1.1 Server-Sent Events (SSE)
- 原理: 基于 HTTP 的长连接。服务器单向推送文本流。
- 优点: 简单,浏览器原生支持 (
EventSource),防火墙友好,支持自动重连。 - 缺点: 单向(Server -> Client)。
- 结论: 首选。因为 LLM 生成就是单向的。
1.2 WebSocket
- 原理: TCP 双向全双工。
- 优点: 双向实时(如实时语音打断)。
- 缺点: 复杂,心跳保活,状态管理麻烦。
- 适用: 实时语音对话、多人协作编辑。
2. 后端实现 (Python / FastAPI)
OpenAI SDK 默认支持 Streaming。 我们需要把这个 Iterator 包装成 SSE 格式返回。
python
from fastapi import FastAPI
from fastapi.responses import StreamingResponse
from openai import OpenAI
app = FastAPI()
client = OpenAI()
def generate_stream(prompt):
stream = client.chat.completions.create(
model="gpt-4",
messages=[{"role": "user", "content": prompt}],
stream=True, # 开启流式
)
for chunk in stream:
if chunk.choices[0].delta.content:
yield chunk.choices[0].delta.content
@app.get("/chat")
async def chat(prompt: str):
return StreamingResponse(generate_stream(prompt), media_type="text/event-stream")3. 前端实现 (React / Markdown)
3.1 接收流
使用 fetch + ReadableStream 读取数据。
javascript
const response = await fetch("/chat");
const reader = response.body.getReader();
const decoder = new TextDecoder();
while (true) {
const { done, value } = await reader.read();
if (done) break;
const chunk = decoder.decode(value);
setMessages((prev) => prev + chunk); // 增量更新 State
}3.2 增量渲染 Markdown
Markdown 解析器(如 react-markdown)通常需要完整的文本才能解析。 如果在生成过程中,Markdown 语法不完整(如 **bold 只输出了 **b),渲染会乱。 解决方案:
- Debounce: 降低渲染频率。
- Streaming-ready Parser: 使用支持流式的解析库,或者容错性强的库。
- 光标闪烁: 在最后加一个闪烁的光标
|,模拟打字机。
4. 复杂场景:JSON Mode Streaming
如果 LLM 输出的是 JSON,流式解析就很难了。 因为 {"key": "val 不是合法的 JSON。 解决方案:
- Partial JSON Parser: 使用
json-repair等库,尝试解析不完整的 JSON。 - Vercel AI SDK: 封装了
StreamData,专门处理流式结构化数据。
Streaming 看起来简单,细节魔鬼很多。
- Buffer: Nginx 或 CDN 可能会缓存 SSE,导致流变成“一坨”。需要配置
X-Accel-Buffering: no。 - Error Handling: 流中间报错了怎么处理? 做好 Streaming,是 AI 应用用户体验 (UX) 的第一步。