实现流式输出:Server-Sent Events (SSE) 与 Fetch API
流式输出是智能体前端的"生命线",这篇文章把两种主流方案讲透,附带可运行的 React 代码
流式输出对于智能体(AI Agent)来说几乎是必备功能。用户问一个问题,如果界面转圈七八秒才突然跳出一大段文字,体验非常焦虑。但如果是文字一个字一个字往外蹦,用户就会觉得"它在思考,它在打字",心理等待时间缩短一半以上。
大模型生成回答的过程本身就是逐字生成的,等待全部生成完再一次性返回,浪费了用户宝贵的等待时间。流式输出的做法是:后端每生成一个 chunk 就立刻推给前端,前端收到一个 chunk 就立刻渲染出来。
2026 年,几乎所有生产级智能体都已经标配流式输出。这篇文章我会把 Server-Sent Events (SSE) 和 Fetch API 流式响应 两种方案从头到尾讲清楚,包括原理、代码实现、踩坑经验和性能优化。文中有完整的 React + TypeScript 代码,你可以直接复制到项目里用。
一、两种流式方案对比
下面这张流程图可以让你快速理解两种方案的本质区别:

再来看一个序列图,展示 SSE 模式下前后端的交互时序:

SSE (EventSource) 是浏览器原生 API,使用极其简单,自动处理重连。缺点是无法自定义请求头(比如不能携带 Bearer Token),只能通过 URL 参数传递认证信息,并且是单向的(服务端→客户端)。
Fetch API + ReadableStream 更灵活,可以携带任意请求头,支持用户主动打断生成(AbortController),支持更细粒度的错误处理。缺点是实现稍复杂,需要手动解析数据帧和处理重连逻辑。
在实际项目中,两者我都会用:内部测试或简单场景用 SSE 快速验证;需要 Token 认证、需要用户打断生成、或需要发送复杂请求体时,用 Fetch 流式方案。
二、方案一:SSE (EventSource) 实现流式输出
2.1 后端实现(Python FastAPI)
SSE 协议要求服务端设置 Content-Type: text/event-stream,并且每次发送的数据格式为 data: {json}\n\n。每条消息以两个换行符结束。
python
from fastapi import FastAPI
from fastapi.responses import StreamingResponse
import asyncio
import json
app = FastAPI()
async def generate_stream(prompt: str):
# 模拟调用 LLM 并逐字返回
# 实际项目中你会调用 OpenAI/Claude/DeepSeek 的 stream=True 接口
full_response = f"你问的是:{prompt},让我想想......"
for char in full_response:
yield f"data: {json.dumps({'type': 'text', 'content': char})}\n\n"
await asyncio.sleep(0.05)
# 发送结束事件
yield f"event: done\ndata: {{}}\n\n"
@app.get("/api/agent/stream")
async def agent_stream(prompt: str):
return StreamingResponse(
generate_stream(prompt),
media_type="text/event-stream"
)
关键点:
- 每个
data:行必须以\n\n结尾,浏览器才能识别为一条完整消息。 - 可以自定义
event:字段来区分消息类型,例如event: thought用于显示思考过程,前端可以分别监听。 - 结束标记用
event: done通知前端关闭连接。
2.2 前端 React Hook:useSSE
由于 EventSource 不支持自定义请求头,认证信息只能通过 URL 参数传递,例如 /api/stream?token=xxx。
typescript
// hooks/useSSE.ts
import { useState, useRef, useCallback } from 'react';
interface StreamMessage {
type?: 'text' | 'thought' | 'tool_call' | 'done' | 'error';
content?: string;
[key: string]: any;
}
export function useSSE() {
const [isStreaming, setIsStreaming] = useState(false);
const eventSourceRef = useRef<EventSource | null>(null);
const startStream = useCallback((
prompt: string,
onMessage: (msg: StreamMessage) => void,
onDone?: () => void,
onError?: (err: Event) => void
) => {
if (eventSourceRef.current) {
eventSourceRef.current.close();
}
// 如果有 token,通过 URL 参数传递(不推荐,仅用于简单场景)
const token = localStorage.getItem('token');
const url = `/api/agent/stream?prompt=${encodeURIComponent(prompt)}${token ? `&token=${token}` : ''}`;
const eventSource = new EventSource(url);
eventSourceRef.current = eventSource;
setIsStreaming(true);
// 监听默认的 message 事件(无 event 字段的数据)
eventSource.onmessage = (event) => {
try {
const data = JSON.parse(event.data);
if (data.type === 'done') {
eventSource.close();
setIsStreaming(false);
onDone?.();
} else {
onMessage(data);
}
} catch (e) {
console.error('解析 SSE 消息失败', e);
}
};
// 监听自定义事件(后端通过 event: xxx 标识)
eventSource.addEventListener('thought', (event: any) => {
try {
const data = JSON.parse(event.data);
onMessage({ type: 'thought', content: data.content });
} catch (e) {}
});
eventSource.onerror = (error) => {
console.error('SSE 连接错误', error);
eventSource.close();
setIsStreaming(false);
onError?.(error);
};
}, []);
const stopStream = useCallback(() => {
if (eventSourceRef.current) {
eventSourceRef.current.close();
setIsStreaming(false);
}
}, []);
return { isStreaming, startStream, stopStream };
}
2.3 在 React 组件中使用
tsx
// components/ChatWithSSE.tsx
import { useState } from 'react';
import { useSSE } from '@/hooks/useSSE';
export function ChatWithSSE() {
const [input, setInput] = useState('');
const [answer, setAnswer] = useState('');
const { isStreaming, startStream, stopStream } = useSSE();
const handleSend = () => {
if (!input.trim() || isStreaming) return;
setAnswer('');
startStream(
input,
(msg) => {
if (msg.type === 'text') {
setAnswer(prev => prev + (msg.content || ''));
}
},
() => console.log('完成'),
(err) => console.error('错误', err)
);
setInput('');
};
return (
<div className="p-4">
<div className="border rounded-lg p-4 min-h-[200px] mb-4">
{answer || (isStreaming && '正在思考...')}
</div>
<div className="flex gap-2">
<input
value={input}
onChange={(e) => setInput(e.target.value)}
className="flex-1 border rounded px-2 py-1"
disabled={isStreaming}
/>
<button onClick={handleSend} disabled={isStreaming}>发送</button>
{isStreaming && <button onClick={stopStream}>停止</button>}
</div>
</div>
);
}
三、方案二:Fetch API + ReadableStream 实现流式输出
3.1 后端实现(支持认证和更灵活的请求)
后端同样需要返回 Transfer-Encoding: chunked,但不需要特殊的 text/event-stream 类型,普通 application/json 也可以,只要数据是分块发送的。
python
from fastapi import FastAPI
from fastapi.responses import StreamingResponse
import asyncio
import json
app = FastAPI()
async def generate_chunks(prompt: str):
# 模拟逐字生成
full = f"你问:{prompt}。答案是......"
for ch in full:
yield json.dumps({'content': ch}) + '\n'
await asyncio.sleep(0.05)
yield json.dumps({'done': True}) + '\n'
@app.post("/api/agent/chat")
async def agent_chat(request: dict):
prompt = request.get('message', '')
return StreamingResponse(
generate_chunks(prompt),
media_type="application/x-ndjson" # 或者 text/plain
)
这里使用了 NDJSON (Newline Delimited JSON) 格式,每个 chunk 是一个 JSON 字符串,以换行符分隔,解析非常方便。
3.2 前端 React Hook:useStreamingChat
这个 Hook 支持请求头认证、用户主动打断、错误重试等功能。
typescript
// hooks/useStreamingChat.ts
import { useState, useRef, useCallback } from 'react';
export function useStreamingChat() {
const [answer, setAnswer] = useState('');
const [isLoading, setIsLoading] = useState(false);
const abortControllerRef = useRef<AbortController | null>(null);
const sendMessage = useCallback(async (message: string, onChunk?: (chunk: string) => void) => {
// 取消之前的请求
if (abortControllerRef.current) {
abortControllerRef.current.abort();
}
const controller = new AbortController();
abortControllerRef.current = controller;
setAnswer('');
setIsLoading(true);
try {
const response = await fetch('/api/agent/chat', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${localStorage.getItem('token')}`,
},
body: JSON.stringify({ message }),
signal: controller.signal,
});
if (!response.ok) throw new Error(`HTTP ${response.status}`);
if (!response.body) throw new Error('No response body');
const reader = response.body.getReader();
const decoder = new TextDecoder('utf-8');
let buffer = '';
while (true) {
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
const lines = buffer.split('\n');
buffer = lines.pop() || '';
for (const line of lines) {
if (line.trim()) {
try {
const data = JSON.parse(line);
if (data.content) {
const chunk = data.content;
setAnswer(prev => prev + chunk);
onChunk?.(chunk);
}
if (data.done) {
// 完成
}
} catch (e) {
console.warn('JSON 解析失败', line);
}
}
}
}
} catch (error: any) {
if (error.name === 'AbortError') {
console.log('请求已取消');
} else {
console.error('流式请求失败', error);
setAnswer('抱歉,网络出错了,请稍后重试。');
}
} finally {
setIsLoading(false);
abortControllerRef.current = null;
}
}, []);
const stopGeneration = useCallback(() => {
if (abortControllerRef.current) {
abortControllerRef.current.abort();
}
}, []);
return { answer, isLoading, sendMessage, stopGeneration };
}
3.3 React 组件中使用(带打断功能)
tsx
// components/ChatWithFetch.tsx
import { useState } from 'react';
import { useStreamingChat } from '@/hooks/useStreamingChat';
export function ChatWithFetch() {
const [input, setInput] = useState('');
const { answer, isLoading, sendMessage, stopGeneration } = useStreamingChat();
const handleSend = async () => {
if (!input.trim() || isLoading) return;
await sendMessage(input);
setInput('');
};
return (
<div className="flex flex-col h-screen p-4">
<div className="flex-1 overflow-auto border rounded p-4 mb-4">
{answer || (isLoading && '正在接收回答...')}
</div>
<div className="flex gap-2">
<input
value={input}
onChange={(e) => setInput(e.target.value)}
className="flex-1 border rounded px-3 py-2"
disabled={isLoading}
onKeyDown={(e) => e.key === 'Enter' && handleSend()}
/>
<button onClick={handleSend} disabled={isLoading}>发送</button>
{isLoading && <button onClick={stopGeneration}>停止</button>}
</div>
</div>
);
}
四、错误处理与重连机制
SSE 原生支持自动重连(间隔约 3 秒),但 fetch 方案需要手动实现。我们可以封装一个带指数退避的重连逻辑:
typescript
class RobustStreamClient {
private retryCount = 0;
private maxRetries = 5;
private baseDelay = 1000;
private isActive = false;
async connect(url: string, onMessage: (data: any) => void) {
this.isActive = true;
while (this.retryCount < this.maxRetries && this.isActive) {
try {
await this._doConnect(url, onMessage);
this.retryCount = 0; // 成功后重置
break;
} catch (err) {
this.retryCount++;
const delay = this.baseDelay * Math.pow(2, this.retryCount - 1);
console.log(`${delay}ms 后重试...`);
await this.sleep(delay);
}
}
}
private async _doConnect(url: string, onMessage: (data: any) => void) {
const response = await fetch(url);
const reader = response.body!.getReader();
// ... 读取流,调用 onMessage
}
private sleep(ms: number) {
return new Promise(resolve => setTimeout(resolve, ms));
}
disconnect() {
this.isActive = false;
}
}
另外,对于网络超时(如移动端信号差),可以在前端设置一个定时器:如果 10 秒内没有收到任何数据,主动中断并提示用户。
五、性能优化与用户体验细节
5.1 节流渲染
流式输出时,高频调用 setAnswer 会导致组件频繁重绘。React 18 的自动批处理已经能合并大部分更新,但如果仍然感觉卡顿,可以使用 useDeferredValue 或手动节流:
typescript
const [answer, setAnswer] = useState('');
const deferredAnswer = useDeferredValue(answer);
// 渲染时使用 deferredAnswer,避免高频渲染阻塞用户输入
或者使用 throttle:
typescript
const throttledAppend = useCallback(
throttle((chunk: string) => {
setAnswer(prev => prev + chunk);
}, 50),
[]
);
5.2 显示"正在输入"指示器
在第一个 chunk 到达之前,显示一个闪烁光标或三个点动画,提升等待体验。
tsx
{isLoading && answer.length === 0 && (
<div className="typing-indicator">...</div>
)}
5.3 移动端适配
- 使用
-webkit-overflow-scrolling: touch让滚动更流畅。 - 在页面可见性变化时暂停渲染(
document.visibilityState),节省流量。
typescript
useEffect(() => {
const handleVisibilityChange = () => {
if (document.hidden) {
// 暂停流式更新或降低频率
}
};
document.addEventListener('visibilitychange', handleVisibilityChange);
return () => document.removeEventListener('visibilitychange', handleVisibilityChange);
}, []);
5.4 自动滚动到最新消息
配合消息列表的滚动容器,每次收到新 chunk 时滚动到底部(前提是用户未主动上滚)。
typescript
const messagesEndRef = useRef<HTMLDivElement>(null);
useEffect(() => {
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' });
}, [answer]);
六、生产环境注意事项
6.1 Nginx 代理配置
如果前端通过 Nginx 反向代理后端,必须关闭缓冲,否则 SSE 会被缓存到一定大小才发送,失去流式效果。
nginx
location /api/agent/ {
proxy_pass http://backend;
proxy_buffering off;
proxy_cache off;
proxy_set_header X-Accel-Buffering no;
proxy_http_version 1.1;
chunked_transfer_encoding off;
}
6.2 浏览器兼容性
SSE 在所有现代浏览器(Chrome、Firefox、Safari、Edge)中均受支持。fetch 流式读取需要 ReadableStream,同样被广泛支持(IE 除外)。移动端表现良好。
6.3 大流量下的资源管理
每个 SSE 连接都会占用一个文件描述符和内存。当并发连接数很高时(例如上千),需要考虑使用 HTTP/2 多路复用,或者改用 WebSocket + 消息队列。对于大多数企业内部智能体,SSE 完全够用。
6.4 安全与认证
- SSE 方案:由于无法添加自定义请求头,推荐在 URL 中使用短期有效的 token,并配合
HttpOnlyCookie 做辅助认证。 - Fetch 方案:可以直接在
Authorization头中携带 Bearer Token,更安全。
七、总结
流式输出是智能体前端体验的"基本盘"。用户不会因为你用了多先进的 Agent 框架而赞叹,但会因为回答是一个字一个字蹦出来而觉得"好快"。
两种方案的选择:
| 场景 | 推荐方案 |
|---|---|
| 快速原型、内部工具、不需要用户认证 | SSE (EventSource) |
| 生产环境、需要 Token 认证、用户可打断生成 | Fetch + ReadableStream |
本文提供了完整的后端(FastAPI)和前端(React + TypeScript)代码示例,以及错误处理、重连、性能优化等生产级细节。你可以根据自己的项目需求,直接复制代码进行修改。
最后送上一张完整的时序图,帮助你从整体上理解流式交互的全过程:
