【无标题】

实现流式输出: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,并配合 HttpOnly Cookie 做辅助认证。
  • Fetch 方案:可以直接在 Authorization 头中携带 Bearer Token,更安全。

七、总结

流式输出是智能体前端体验的"基本盘"。用户不会因为你用了多先进的 Agent 框架而赞叹,但会因为回答是一个字一个字蹦出来而觉得"好快"。

两种方案的选择:

场景 推荐方案
快速原型、内部工具、不需要用户认证 SSE (EventSource)
生产环境、需要 Token 认证、用户可打断生成 Fetch + ReadableStream

本文提供了完整的后端(FastAPI)和前端(React + TypeScript)代码示例,以及错误处理、重连、性能优化等生产级细节。你可以根据自己的项目需求,直接复制代码进行修改。

最后送上一张完整的时序图,帮助你从整体上理解流式交互的全过程:

相关推荐
tongluowan0076 小时前
@Autowired 和 @Resource 有什么区别?
java·spring·bean
数字供应链安全产品选型6 小时前
数字供应链安全治理体系研究:从软件供应链到AI原生安全的演进与实践
人工智能·安全·ai-native
iDao技术魔方6 小时前
GEO 生成式引擎优化完全指南:让你的内容成为 AI 的默认答案
人工智能
HIT_Weston6 小时前
87、【Agent】【OpenCode】read 工具提示词
人工智能·agent·opencode
墨北小七6 小时前
使用火山引擎 HiAgent 构建工业级设备智能运维智能体
运维·人工智能·火山引擎
晚霞的不甘6 小时前
CANN-ATB加速库:Transformer推理性能密码
人工智能·深度学习·transformer
Maiko Star6 小时前
* SpringBoot整合LangChain4j
java·spring boot·后端·langchain4j
创世宇图6 小时前
【AI入门知识点】Function Calling 是什么?为什么 AI 开始会“调用工具”了?
人工智能·ai·llm·functioncalling