流式输出 - 实时交互的体验优化 / Streaming Output for Responsive Interaction
📅 创建时间:2026-04-28 🏷️ 标签:#Streaming #SSE #流式输出 #实时交互 📚 前置知识:[[04 - 消息角色]]
📋 本章目标
- 理解什么是流式输出(Streaming)
- 理解 Server-Sent Events (SSE) 的原理
- 对比流式与非流式响应的差异
- 了解流式输出的应用场景
- 理解前端如何处理流式响应
第1部分:什么是流式输出?
1.1 基本概念
流式输出 = 数据不是一次性返回,而是"分段"返回
┌─────────────────────────────────────────────────────────────┐
│ 非流式 vs 流式对比 │
├─────────────────────────────────────────────────────────────┤
│ │
│ 非流式(Traditional): │
│ ┌─────────────────────────────────────────────────────┐ │
│ │ 用户请求 → [等待 3秒] → [一次性返回全部内容] │ │
│ └─────────────────────────────────────────────────────┘ │
│ ↓ │
│ 3秒后: │
│ "人工智能是计算机科学的一个分支..."(完整内容) │
│ │
│ ───────────────────────────────────────────────────────── │
│ │
│ 流式(Streaming): │
│ ┌─────────────────────────────────────────────────────┐ │
│ │ 用户请求 → [立即开始] → [逐token返回] │ │
│ └─────────────────────────────────────────────────────┘ │
│ ↓ │
│ 0秒:"" │
│ 0.5秒:"人" │
│ 1秒:"人工" │
│ 1.5秒:"人工智" │
│ 2秒:"人工智能" │
│ ... │
│ 3秒:"人工智能是计算机科学的一个分支..." │
│ │
└─────────────────────────────────────────────────────────────┘1.2 为什么需要流式输出?
用户体验的革命:
┌─────────────────────────────────────────────────────────────┐
│ 用户感知对比 │
├─────────────────────────────────────────────────────────────┤
│ │
│ 非流式: │
│ ┌─────────────────────────────────────────────────────┐ │
│ │ 你:发消息 │ │
│ │ AI:[加载中...][加载中...][加载中...] ← 等了3秒 │ │
│ │ AI:人工智能是...(突然显示完整内容) │ │
│ └─────────────────────────────────────────────────────┘ │
│ 用户感受:焦虑等待 │
│ │
│ 流式: │
│ ┌─────────────────────────────────────────────────────┐ │
│ │ 你:发消息 │ │
│ │ AI:人... ← 立刻开始显示 │ │
│ │ AI:人工... │ │
│ │ AI:人工智... ← 持续更新,用户感觉在"打字" │ │
│ │ AI:人工智能是... │ │
│ └─────────────────────────────────────────────────────┘ │
│ 用户感受:流畅、响应快 │
│ │
└─────────────────────────────────────────────────────────────┘1.3 感知响应时间
关键数据:
| 响应方式 | 感知延迟 | 用户体验 |
|---|---|---|
| 非流式 | 2-5秒 | 焦虑等待 |
| 流式 | <100ms | 即时响应 |
第2部分:Server-Sent Events (SSE) 原理
2.1 什么是 SSE?
Server-Sent Events (SSE) = 服务器向浏览器推送数据的技术
┌─────────────────────────────────────────────────────────────┐
│ SSE 工作原理 │
├─────────────────────────────────────────────────────────────┤
│ │
│ 浏览器 服务器 │
│ │ │ │
│ │ ──────── 建立连接 ─────────────→ │ │
│ │ │ │
│ │ ←──────── 数据流 ◄────────────── │ │
│ │ event: chunk │ │
│ │ data: "人" │ │
│ │ ←──────── 数据流 ◄────────────── │ │
│ │ event: chunk │ │
│ │ data: "工" │ │
│ │ ←──────── 数据流 ◄────────────── │ │
│ │ event: chunk │ │
│ │ data: "智" │ │
│ │ │ │
│ │ ←───────── 完成 ◄────────────── │ │
│ │ [DONE] │ │
│ │ │ │
│ │ ──────── 关闭连接 ─────────────→ │ │
│ │
└─────────────────────────────────────────────────────────────┘2.2 SSE 数据格式
标准 SSE 格式:
event: chunk
data: "今天"
event: chunk
data: "天气"
event: chunk
data: "很好"
event: done
data: "[DONE]"2.3 SSE vs WebSocket
| 特性 | SSE | WebSocket |
|---|---|---|
| 方向 | 单向(服务器→客户端) | 双向 |
| 复杂度 | 简单 | 复杂 |
| 兼容性 | 几乎所有浏览器 | 现代浏览器 |
| 重连 | 自动重连 | 需要手动处理 |
| 使用场景 | 实时推送 | 双向通信 |
| LLM 输出 | 完美支持 | 也支持 |
为什么 LLM 适合用 SSE:
LLM 的场景是"服务器推送到客户端"
客户端(浏览器)只需要接收,不需要发送
SSE 足够用,而且更简单第3部分:流式响应的数据格式
3.1 OpenAI 的流式响应格式
API 调用示例:
python
from openai import OpenAI
client = OpenAI()
stream = client.chat.completions.create(
model="gpt-3.5-turbo",
messages=[{"role": "user", "content": "什么是人工智能?"}],
stream=True # 开启流式输出
)返回的数据格式:
python
# 每个 chunk 是一个 Server-Sent Event
for chunk in stream:
print(chunk)典型 chunk 结构:
python
# Chunk 1
ChatCompletionChunk(
id="chatcmpl-xxx",
choices=[
Choice(
delta=ChoiceDelta(content="人"), # 第一个 token
index=0,
finish_reason=None
)
]
)
# Chunk 2
ChatCompletionChunk(
id="chatcmpl-xxx",
choices=[
Choice(
delta=ChoiceDelta(content="工"), # 第二个 token
index=0,
finish_reason=None
)
]
)
# 最后一个 Chunk
ChatCompletionChunk(
id="chatcmpl-xxx",
choices=[
Choice(
delta=ChoiceDelta(content=""), # 空内容
index=0,
finish_reason="stop" # 表示生成结束
)
]
)3.2 数据流解析过程
┌─────────────────────────────────────────────────────────────┐
│ 数据流解析过程 │
├─────────────────────────────────────────────────────────────┤
│ │
│ 服务器返回的原始数据: │
│ ┌─────────────────────────────────────────────────────┐ │
│ │ event: chunk │ │
│ │ data: {"id":"...","choices":[{"delta":{"content":"人"}}]}│ │
│ │ │ │
│ │ event: chunk │ │
│ │ data: {"id":"...","choices":[{"delta":{"content":"工"}}]}│ │
│ │ │ │
│ │ event: chunk │ │
│ │ data: {"id":"...","choices":[{"delta":{"content":"智"}}]}│ │
│ └─────────────────────────────────────────────────────┘ │
│ ↓ │
│ OpenAI SDK 解析后: │
│ ┌─────────────────────────────────────────────────────┐ │
│ │ chunk.choices[0].delta.content = "人" │ │
│ │ chunk.choices[0].delta.content = "工" │ │
│ │ chunk.choices[0].delta.content = "智" │ │
│ └─────────────────────────────────────────────────────┘ │
│ ↓ │
│ 拼接成完整内容: │
│ ┌─────────────────────────────────────────────────────┐ │
│ │ full_content = "人" + "工" + "智" + ... │ │
│ └─────────────────────────────────────────────────────┘ │
│ │
└─────────────────────────────────────────────────────────────┘3.3 完整代码示例
Python 后端:
python
from openai import OpenAI
from fastapi import FastAPI
from fastapi.responses import StreamingResponse
import json
app = FastAPI()
client = OpenAI()
@app.get("/stream-chat")
async def stream_chat(message: str):
def generate():
stream = client.chat.completions.create(
model="gpt-3.5-turbo",
messages=[{"role": "user", "content": message}],
stream=True
)
for chunk in stream:
# 提取 delta.content
content = chunk.choices[0].delta.content
if content:
# 发送 SSE 格式
yield f"data: {json.dumps({'token': content})}\n\n"
# 发送完成信号
yield f"data: {json.dumps({'done': True})}\n\n"
return StreamingResponse(generate(), media_type="text/event-stream")前端 JavaScript:
javascript
async function streamChat(message) {
const response = await fetch(`/stream-chat?message=${encodeURIComponent(message)}`);
const reader = response.body.getReader();
const decoder = new TextDecoder();
let fullText = '';
while (true) {
const { done, value } = await reader.read();
if (done) break;
// 解码数据
const chunk = decoder.decode(value);
// 解析 SSE 格式
const lines = chunk.split('\n');
for (const line of lines) {
if (line.startsWith('data: ')) {
const data = JSON.parse(line.slice(6));
if (data.token) {
fullText += data.token;
// 更新 UI
document.getElementById('output').textContent = fullText;
}
if (data.done) {
console.log('生成完成');
}
}
}
}
return fullText;
}第4部分:流式 vs 非流式时序对比
4.1 时间线对比
┌─────────────────────────────────────────────────────────────────────────────┐
│ 时序对比图 │
├─────────────────────────────────────────────────────────────────────────────┤
│ │
│ 假设总响应时间:5秒,响应长度:100 tokens │
│ │
│ 时间 0s 1s 2s 3s 4s 5s │
│ │ │ │ │ │ │ │
│ ───────────────────────────────────────────────────────────────────── │
│ │
│ 非流式: │███████████████████████│ │
│ [用户等待...] [完整显示] │
│ │
│ 流式: │█│█│█│█│█│█│█│█│█│█│ │
│ █ █ █ █ █ █ █ █ █ █ [逐字显示] │
│ │
│ ───────────────────────────────────────────────────────────────────── │
│ │
│ 用户感知: │
│ 非流式: [..........][完整结果] → 等了5秒才看到任何东西 │
│ 流式: [█][█][█][█][█][█]... → 立刻开始看到结果 │
│ │
└─────────────────────────────────────────────────────────────────────────────┘4.2 性能指标对比
| 指标 | 非流式 | 流式 |
|---|---|---|
| 首次响应时间 (TTFB) | 等待完整生成 | <100ms |
| 感知延迟 | 5-10秒 | <1秒 |
| 用户流失率 | 较高 | 较低 |
| 网络传输量 | 相同 | 相同 |
| 服务器负载 | 相同 | 略高 |
4.3 流式输出的优势
┌─────────────────────────────────────────────────────────────┐
│ 流式输出的四大优势 │
├─────────────────────────────────────────────────────────────┤
│ │
│ 1. 即时反馈 │
│ 用户立刻看到"AI在响应",减少等待焦虑 │
│ │
│ 2. 更自然的交互 │
│ 模拟人类打字聊天的体验 │
│ │
│ 3. 早期终止 │
│ 如果用户看到结果满意,可以提前终止请求 │
│ │
│ 4. 长输出友好 │
│ 对于长文本,不必等待完整生成才能开始阅读 │
│ │
└─────────────────────────────────────────────────────────────┘第5部分:流式输出的应用场景
5.1 ChatGPT 风格对话
典型场景:
用户输入 → 流式显示 AI 回复 → 逐字出现,模拟打字用户感知:
AI正在输入中... ← 马上显示
AI正在输入中...人工智能是... ← 持续更新
AI正在输入中...人工智能是计算机科学...
AI正在输入中...人工智能是计算机科学的一个分支...5.2 代码生成
典型场景:
用户:写一个Python快排函数
AI:def quicksort(arr):
│ 立刻显示函数定义
AI:def quicksort(arr):
if len(arr) <= 1:
│ 继续显示...
AI:def quicksort(arr):
if len(arr) <= 1:
return arr
...优势:用户可以提前终止不符合预期的输出
5.3 长文档生成
典型场景:
用户:写一篇关于AI的文章(5000字)
AI:[开始生成...]
文章标题:人工智能的过去、现在与未来
│
├── 第一章:人工智能的起源
│ 人工智能的概念最早可以追溯到...
│ 1950年,图灵提出了著名的...
│
├── 第二章:...
│
└── ...
用户可以在生成过程中就开始阅读,而不是等待5分钟5.4 实时翻译
典型场景:
用户输入:I love artificial intelligence
AI流式输出:我...我爱...我热爱...我热爱人工...我热爱人工智能
用户立刻获得大致翻译方向,最终得到准确翻译第6部分:流式输出的实现注意事项
6.1 常见问题与解决方案
问题1:SSE 被代理缓存
解决方案:
- 确保响应头设置正确
- Cache-Control: no-cache
- Content-Type: text/event-stream问题2:中文乱码
原因:UTF-8 编码被截断
解决方案:
- 使用 TextDecoder 正确解码
- 确保服务器正确设置编码问题3:连接超时
解决方案:
- 实现心跳机制
- 自动重连逻辑6.2 完整的响应头设置
Python (FastAPI):
python
from fastapi.responses import StreamingResponse
response = StreamingResponse(
generate(),
media_type="text/event-stream",
headers={
"Cache-Control": "no-cache",
"Connection": "keep-alive",
"X-Accel-Buffering": "no", # Nginx 禁用缓冲
}
)Node.js (Express):
javascript
res.writeHead(200, {
'Content-Type': 'text/event-stream',
'Cache-Control': 'no-cache',
'Connection': 'keep-alive',
'X-Accel-Buffering': 'no'
});6.3 前端兼容性处理
javascript
// 检测浏览器支持
if (typeof ReadableStream !== 'undefined') {
// 现代浏览器,支持流式处理
processStream(response);
} else {
// 降级为非流式
response.text().then(displayResult);
}核心总结
总结1:流式输出的本质
流式 = 不是一次性返回完整数据
而是通过持久连接,分段发送数据块
LLM 生成是顺序的(逐 token)
→ 天生适合流式输出
→ 每个 token 生成后立即发送总结2:SSE 是 LLM 流式输出的标准
SSE = Server-Sent Events
• 单向通信(服务器→客户端)
• 基于 HTTP 协议
• 简单易用
• 自动重连总结3:用户感知优化
| 方式 | 用户感知延迟 | 体验 |
|---|---|---|
| 非流式 | 3-10秒 | 等待焦虑 |
| 流式 | <100ms | 即时响应 |
总结4:实现要点
1. 服务器:使用 StreamingResponse,正确的响应头
2. 数据格式:SSE event: chunk / data: token
3. 客户端:TextDecoder 正确解码,逐块更新 UI
4. 错误处理:心跳、自动重连章节测试
测试1:SSE 方向
SSE(Server-Sent Events)是单向通信还是双向通信?
测试2:感知延迟
非流式响应和流式响应,哪个能让用户更快感知到 AI 正在工作?
测试3:数据单位
LLM 流式输出时,每个数据块通常包含什么?
测试4:应用场景
对于一个 5000 字的长文生成,哪个方案用户体验更好:流式还是非流式?为什么?
测试5:响应头设置
如果 SSE 响应被代理服务器缓存,应该在响应头中设置什么?
参考答案
测试1答案
答案:单向通信
解析:
SSE 的全称是 Server-Sent Events
意思是"服务器发送事件"
只有服务器可以向客户端推送数据
客户端不能通过 SSE 向服务器发送数据测试2答案
答案:流式
解析:
流式响应:
• 第一个 token 生成后立即返回(<100ms)
• 用户立刻看到 AI 开始输出
非流式响应:
• 需要等待所有 token 生成完毕
• 可能等待 3-10 秒才看到任何内容测试3答案
答案:通常包含一个或多个新生成的 token
解析:
OpenAI 流式响应中,每个 chunk 的结构:
{
"choices": [{
"delta": {
"content": "新token" // 新生成的 token
}
}]
}
例如:第一个 chunk 可能是 {"content": "人"}
第二个 chunk 可能是 {"content": "工"}
客户端需要把这些内容拼接起来测试4答案
答案:流式更好
解析:
1. 感知时间:
• 流式:<100ms 就能看到开头
• 非流式:可能需要等待 30 秒到 1 分钟
2. 用户体验:
• 流式:用户可以边看边读
• 非流式:必须等待完整生成
3. 早期终止:
• 流式:用户看到满意内容可以停止
• 非流式:必须等全部完成测试5答案
答案:Cache-Control: no-cache
解析:
问题原因:
代理服务器(如 Nginx)可能会缓存响应
导致客户端无法实时接收数据
解决方案:
在响应头中设置:
Cache-Control: no-cache ← 禁用缓存
X-Accel-Buffering: no ← Nginx 禁用缓冲相关笔记
- [[04 - 消息角色]] - 流式输出也是 messages 结构
- [[03 - 解码策略]] - token 生成过程
- [[06 - Prompt 工程]] - 通过 Prompt 控制输出长度
下一步学习
- [ ] 阅读 06 - Prompt 工程基础
学习状态:✅ 已完成