前端转 AI 第 33 天:流式输出 - 让 AI 像打字机一样回复
昨日回顾
Day 32 把 LLM 客户端工程化了,重试、超时、降级、限流全有了。
但用户体验还是差——发一个问题,转圈等 3-5 秒,然后"唰"地一下全部出现。
ChatGPT 为什么爽?因为它是一个字一个字往外蹦。
这个效果叫流式输出(Streaming),今天把它加上。
Day 33 目标:搞懂 SSE 协议,让 FastAPI 支持流式,前端收到"打字机效果"。
一、为什么需要流式输出
1. 非流式的痛苦
当前体验:
用户:解释一下什么是递归
↓
(等待 5 秒,转圈圈)
↓
AI:递归是一种编程技术,它允许函数调用自身...
问题:
| 问题 | 后果 |
|---|---|
| 首字节延迟高 | 用户等 5 秒才看到第一个字 |
| 无法预估时长 | 不知道要等多久 |
| 长回答体验崩 | 生成 2000 字要等 30 秒 |
| 无法中断 | 看到一半想停也停不了 |
用户的心理:
- 等 1 秒:可接受
- 等 3 秒:开始焦虑
- 等 10 秒:以为挂了
- 等 30 秒:直接关掉
2. 流式的爽点
改造后:
用户:解释一下什么是递归
↓
(0.5 秒)
↓
AI:递归是...(后面还在继续出字)
体验差异:
| 维度 | 非流式 | 流式 |
|---|---|---|
| 首字节时间 | 5s | 0.5s |
| 用户感知 | 慢 | 快 |
| 可中断 | ❌ | ✅ |
| 长回答 | 等很久 | 边看边出 |
| 心理感受 | 焦虑 | 舒适 |
核心认知:
用户要的不是"总时长短",而是"感觉快"。
流式输出让"首字节时间"从 5 秒降到 0.5 秒——总时长没变,但感知快 10 倍。
3. 前端视角:流式就是"边下边播"
做过前端的你肯定熟悉:
- 视频流:边下边播,不等整个文件下完
- 图片渐进式加载:先模糊后清晰
- Suspense:先显示骨架屏,后替换内容
流式输出就是"文本版的边下边播"。
技术上用 SSE 实现,前端用 EventSource 接收。
二、SSE 协议详解
SSE = Server-Sent Events,服务器推送事件。
1. SSE 是什么
一句话:服务器可以不断往客户端推数据,客户端只负责接收。
和 WebSocket 的区别:
| 特性 | SSE | WebSocket |
|---|---|---|
| 方向 | 单向(服务器→客户端) | 双向 |
| 协议 | HTTP | 独立协议(ws://) |
| 复杂度 | 简单 | 复杂 |
| 自动重连 | 内置 | 要自己实现 |
| 浏览器支持 | 好 | 好 |
| 适用场景 | 流式文本、通知 | 聊天、游戏、实时协作 |
LLM 流式输出用 SSE——单向、简单、够用。
2. SSE 的数据格式
SSE 是一种特殊的 HTTP 响应:
HTTP/1.1 200 OK
Content-Type: text/event-stream
Cache-Control: no-cache
Connection: keep-alive
data: 第一段数据
data: 第二段数据
data: 第三段数据
规则:
Content-Type: text/event-stream(必须)- 每条消息以
data:开头 - 每条消息以
\n\n结尾(两个换行) - 可以包含其他字段:
event:/id:/retry:
更完整的例子:
event: message
id: 1
data: {"text": "你好"}
event: message
id: 2
data: {"text": "世界"}
event: done
data: [DONE]
OpenAI 的流式响应格式(简化):
data: {"choices":[{"delta":{"content":"你"}}]}
data: {"choices":[{"delta":{"content":"好"}}]}
data: {"choices":[{"delta":{"content":"呀"}}]}
data: [DONE]
每行都是一个 JSON,最后一个 [DONE] 表示结束。
3. SSE vs 普通 JSON
普通 JSON 响应:
HTTP/1.1 200 OK
Content-Type: application/json
{"reply": "完整的回复"}
SSE 响应:
HTTP/1.1 200 OK
Content-Type: text/event-stream
data: {"delta": "你"}
data: {"delta": "好"}
data: {"delta": "呀"}
区别:
- JSON:一次返回完整数据
- SSE:分多次返回碎片数据
核心:连接不关闭,持续推送。
4. 为什么不用 WebSocket
新手常问:WebSocket 不也能推送吗?
能,但对流式输出来说是"杀鸡用牛刀":
| 维度 | SSE | WebSocket |
|---|---|---|
| 协议 | 基于 HTTP | 独立协议 |
| 服务器 | 普通 HTTP 服务 | 需要协议升级 |
| 反向代理 | 和普通请求一样 | 需要特殊配置 |
| 负载均衡 | 简单 | 麻烦 |
| 客户端 API | EventSource | WebSocket |
| 断线重连 | 自动 | 手动 |
| 二进制数据 | ❌ | ✅ |
对"文本流式输出"这种场景,SSE 完胜。
WebSocket 更适合"双向通信 + 高频消息"——比如聊天室、游戏。
5. SSE 的局限
不是万能的:
- ❌ 不能传二进制(只能文本)
- ❌ 浏览器对并发 SSE 有限制(同域名最多 6 个)
- ❌ 不能自定义 header(
EventSource的限制,除非用 polyfill) - ❌ 移动端后台不能持续接收
LLM 流式输出只需要"服务器推文本",完美匹配。
三、OpenAI SDK 的流式 API
先看底层怎么拿到流式数据。
1. 最简单的流式调用
创建 day33/simple_stream.py:
"""最简单的流式调用"""
import os
from openai import OpenAI
client = OpenAI(
api_key=os.getenv("DEEPSEEK_API_KEY"),
base_url="https://api.deepseek.com/v1",
)
# 关键:stream=True
stream = client.chat.completions.create(
model="deepseek-chat",
messages=[{"role": "user", "content": "用三句话介绍 Python"}],
stream=True, # ← 关键
)
# 逐块接收
for chunk in stream:
content = chunk.choices[0].delta.content
if content:
print(content, end="", flush=True)
print() # 最后换行
输出效果:
Python 是一种高级编程语言。它简洁易读,适合初学者。
它拥有丰富的第三方库,广泛用于数据分析、AI 和 Web 开发。
看起来像一次性输出,实际是一个字一个字地打印出来的。
flush=True 是关键——否则 Python 会缓冲,看起来还是一次性输出。
2. 理解 chunk 结构
打印每个 chunk:
for chunk in stream:
print(chunk)
print("---")
输出:
ChatCompletionChunk(
id='chatcmpl-xxx',
choices=[
Choice(
delta=ChoiceDelta(content='Python', role=None, ...),
finish_reason=None,
index=0,
)
],
model='deepseek-chat',
...
)
---
ChatCompletionChunk(
id='chatcmpl-xxx',
choices=[
Choice(
delta=ChoiceDelta(content=' 是', role=None, ...),
finish_reason=None,
index=0,
)
],
...
)
---
...
关键点:
chunk.choices[0].delta.content:本次新增的文本片段chunk.choices[0].finish_reason:结束原因(最后一条才有)
注意是 delta 不是 message:
- 非流式:
choices[0].message.content - 流式:
choices[0].delta.content
3. 处理首个和末尾 chunk
第一个 chunk 可能没有 content(只有 role):
for chunk in stream:
delta = chunk.choices[0].delta
if delta.content is not None:
print(delta.content, end="", flush=True)
# delta.role 可能是 "assistant"(首个 chunk)
delta.content 为 None 的 chunk 要跳过——通常出现在第一个和最后一个。
完整的逐块处理:
for chunk in stream:
if not chunk.choices:
continue
choice = chunk.choices[0]
delta = choice.delta
# 内容
if delta.content:
print(delta.content, end="", flush=True)
# 结束
if choice.finish_reason:
print(f"\n[结束原因: {choice.finish_reason}]")
4. usage 信息
流式默认不返回 usage(因为生成过程中不知道总量)。
要拿 usage 需要额外参数:
stream = client.chat.completions.create(
model="deepseek-chat",
messages=[...],
stream=True,
stream_options={"include_usage": True}, # ← 关键
)
for chunk in stream:
# 最后一个 chunk 会带 usage
if chunk.usage:
print(f"\nPrompt tokens: {chunk.usage.prompt_tokens}")
print(f"Completion tokens: {chunk.usage.completion_tokens}")
注意:这个参数不是所有厂商都支持。 用之前查一下。
5. 异步流式
FastAPI 场景下要用异步:
import asyncio
from openai import AsyncOpenAI
client = AsyncOpenAI(
api_key=os.getenv("DEEPSEEK_API_KEY"),
base_url="https://api.deepseek.com/v1",
)
async def main():
stream = await client.chat.completions.create(
model="deepseek-chat",
messages=[{"role": "user", "content": "你好"}],
stream=True,
)
async for chunk in stream:
content = chunk.choices[0].delta.content
if content:
print(content, end="", flush=True)
asyncio.run(main())
区别:
await client...create(...):获取 stream 对象要 awaitasync for chunk in stream:迭代用 async for
Day 31 的 stream_chat 方法已经是这个写法,直接能用。
四、FastAPI 的 SSE 实现
现在把流式输出暴露成 HTTP 接口。
1. 用 StreamingResponse
FastAPI 内置 StreamingResponse——最简单的实现:
from fastapi import FastAPI
from fastapi.responses import StreamingResponse
app = FastAPI()
async def generate_stream():
"""异步生成器,逐块 yield"""
for i in range(5):
yield f"data: 第 {i} 块\n\n"
await asyncio.sleep(0.5)
@app.get("/stream")
async def stream():
return StreamingResponse(
generate_stream(),
media_type="text/event-stream",
headers={
"Cache-Control": "no-cache",
"Connection": "keep-alive",
"X-Accel-Buffering": "no", # 禁用 Nginx 缓冲
},
)
关键点:
1. generate_stream() 是异步生成器
用 yield 逐块返回,FastAPI 会边生成边发给客户端。
2. media_type="text/event-stream"
告诉浏览器这是 SSE 流。
3. X-Accel-Buffering: no
禁用 Nginx 缓冲——不加的话 Nginx 会攒一批再发,流式失效。
4. 块格式:data: xxx\n\n
SSE 协议要求。
2. 测试
curl -N http://localhost:8000/stream
-N 禁用 curl 缓冲,能实时看到输出:
data: 第 0 块
data: 第 1 块
data: 第 2 块
...
每 0.5 秒出一条。
3. 完整 LLM 流式接口
整合到我们的 API:
src/ai_assistant/api/routes.py 加新接口:
import json
from fastapi.responses import StreamingResponse
from ..llm_real import RealLLMError
@router.post("/chat/stream")
async def chat_stream(
req: ChatRequest,
storage = Depends(get_storage),
llm = Depends(get_llm),
memory = Depends(get_memory),
):
"""流式对话接口"""
logger.info("流式请求", message_length=len(req.message))
# 1. 加载/创建会话
if req.session_id:
session = storage.load(req.session_id)
if not session:
raise NotFoundError(f"会话不存在: {req.session_id}")
else:
import uuid
session = SessionModel(session_id=uuid.uuid4().hex[:8])
session.add_message("user", req.message)
# 2. 准备上下文
context = memory.get_context(session.messages)
messages = [{"role": "system", "content": "你是一个友好的 AI 助手"}]
messages.extend(m.to_llm_format() for m in context)
# 3. 异步生成器
async def event_generator():
try:
# 发送会话元信息
meta = {
"type": "meta",
"session_id": session.session_id,
}
yield f"data: {json.dumps(meta, ensure_ascii=False)}\n\n"
# 流式接收 LLM 输出
full_reply = []
async for chunk in llm.stream_chat(messages):
full_reply.append(chunk)
data = {
"type": "delta",
"content": chunk,
}
yield f"data: {json.dumps(data, ensure_ascii=False)}\n\n"
# 保存会话
reply = "".join(full_reply)
session.add_message("assistant", reply)
storage.save(session)
# 发送完成信号
done = {
"type": "done",
"message_count": session.message_count,
}
yield f"data: {json.dumps(done, ensure_ascii=False)}\n\n"
except RealLLMError as e:
error = {
"type": "error",
"message": str(e),
}
yield f"data: {json.dumps(error, ensure_ascii=False)}\n\n"
return StreamingResponse(
event_generator(),
media_type="text/event-stream",
headers={
"Cache-Control": "no-cache",
"Connection": "keep-alive",
"X-Accel-Buffering": "no",
},
)
4. 事件类型设计
SSE 消息用 type 字段区分语义:
| type | 含义 | 数据 |
|---|---|---|
meta | 会话元信息 | session_id |
delta | 内容片段 | content |
done | 生成完成 | message_count |
error | 出错 | message |
前端按 type 分别处理。
完整事件流示例:
data: {"type":"meta","session_id":"a3f9b2e1"}
data: {"type":"delta","content":"递归"}
data: {"type":"delta","content":"是"}
data: {"type":"delta","content":"一种"}
...
data: {"type":"done","message_count":2}
清晰、可扩展、易调试。
5. 测试
curl -N -X POST http://localhost:8000/api/chat/stream \
-H "Content-Type: application/json" \
-H "X-API-Key: test-key" \
-d '{"message": "用三句话介绍 Python"}'
输出(实时流):
data: {"type":"meta","session_id":"a3f9b2e1"}
data: {"type":"delta","content":"Python"}
data: {"type":"delta","content":"是一种"}
data: {"type":"delta","content":"高级"}
data: {"type":"delta","content":"编程语言"}
...
data: {"type":"done","message_count":2}
🎉 后端流式接口完成。
五、前端如何接收 SSE
现在讲前端怎么接。这是你熟悉的部分。
1. 方式一:EventSource(原生,简单)
EventSource 是浏览器内置的 SSE 客户端:
const eventSource = new EventSource("/api/chat/stream");
eventSource.onmessage = (event) => {
const data = JSON.parse(event.data);
console.log(data);
};
eventSource.onerror = (error) => {
console.error("SSE 错误:", error);
eventSource.close();
};
问题:
EventSource 只支持 GET 请求,不能传自定义 Header。
但我们有两个需求:
- POST 请求(要传 message)
- 自定义 Header(
X-API-Key认证)
EventSource 无能为力。
2. 方式二:fetch + ReadableStream(推荐)
用 fetch 手动处理流:
async function streamChat(message, sessionId, onChunk, onDone) {
const response = await fetch("/api/chat/stream", {
method: "POST",
headers: {
"Content-Type": "application/json",
"X-API-Key": "your-key",
},
body: JSON.stringify({
message,
session_id: sessionId,
}),
});
if (!response.ok) {
throw new Error(`HTTP ${response.status}`);
}
const reader = response.body.getReader();
const decoder = new TextDecoder();
let buffer = "";
while (true) {
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
// SSE 以 \n\n 分割每条消息
const messages = buffer.split("\n\n");
buffer = messages.pop() || ""; // 最后一段可能不完整
for (const msg of messages) {
if (!msg.trim()) continue;
// 解析 "data: xxx"
const line = msg.split("\n").find((l) => l.startsWith("data: "));
if (!line) continue;
const data = JSON.parse(line.slice(6)); // 去掉 "data: "
if (data.type === "delta") {
onChunk(data.content);
} else if (data.type === "done") {
onDone(data);
} else if (data.type === "error") {
throw new Error(data.message);
}
}
}
}
使用:
let fullText = "";
await streamChat(
"用三句话介绍 Python",
null,
(chunk) => {
fullText += chunk;
document.querySelector("#output").textContent = fullText;
},
(meta) => {
console.log("完成", meta);
}
);
核心思路:
fetch发 POST 请求response.body.getReader()拿到读流- 循环读块,解析
data: xxx - 按
type分别处理
为什么用 TextDecoder?
流式数据是 Uint8Array(字节),不是字符串。 TextDecoder 负责转字符串。
{ stream: true } 很重要——跨块的多字节字符(比如中文)可能被拆开,加这个参数保证解码正确。
3. 更优雅的封装:AsyncGenerator
前端也能玩"生成器"——用 async function* 封装成可迭代对象:
async function* streamChatAsync(message, sessionId) {
const response = await fetch("/api/chat/stream", {
method: "POST",
headers: {
"Content-Type": "application/json",
"X-API-Key": "your-key",
},
body: JSON.stringify({ message, session_id: sessionId }),
});
if (!response.ok) throw new Error(`HTTP ${response.status}`);
const reader = response.body.getReader();
const decoder = new TextDecoder();
let buffer = "";
try {
while (true) {
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
const messages = buffer.split("\n\n");
buffer = messages.pop() || "";
for (const msg of messages) {
if (!msg.trim()) continue;
const line = msg.split("\n").find((l) => l.startsWith("data: "));
if (!line) continue;
yield JSON.parse(line.slice(6));
}
}
} finally {
reader.releaseLock();
}
}
使用(超简洁):
let fullText = "";
for await (const event of streamChatAsync("你好", null)) {
if (event.type === "delta") {
fullText += event.content;
updateUI(fullText);
} else if (event.type === "done") {
break;
}
}
for await...of 是前端也能玩的语法——和 Python 的 async for 完全对应。
Python 后端用 async for,JS 前端用 for await——两种语言在异步流上完全对齐。
4. React 封装示例
在 React 里做流式聊天:
import { useState, useRef } from "react";
function useStreamChat() {
const [messages, setMessages] = useState([]);
const [isStreaming, setIsStreaming] = useState(false);
const abortRef = useRef(null);
const send = async (text) => {
// 加用户消息
setMessages((prev) => [...prev, { role: "user", content: text }]);
// 加一个空的 assistant 消息(流式填充)
setMessages((prev) => [...prev, { role: "assistant", content: "" }]);
setIsStreaming(true);
const controller = new AbortController();
abortRef.current = controller;
try {
const response = await fetch("/api/chat/stream", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ message: text }),
signal: controller.signal,
});
const reader = response.body.getReader();
const decoder = new TextDecoder();
let buffer = "";
while (true) {
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
const msgs = buffer.split("\n\n");
buffer = msgs.pop() || "";
for (const msg of msgs) {
if (!msg.trim()) continue;
const line = msg.split("\n").find((l) => l.startsWith("data: "));
if (!line) continue;
const data = JSON.parse(line.slice(6));
if (data.type === "delta") {
setMessages((prev) => {
const newMessages = [...prev];
const last = newMessages[newMessages.length - 1];
newMessages[newMessages.length - 1] = {
...last,