🔥 AIGC 时代,别再用 EventSource 了!这个微软开源库才是 SSE 流式消息的正确打开方式

10 阅读7分钟

🔥 AIGC 时代,别再用 EventSource 了!这个微软开源库才是 SSE 流式消息的正确打开方式

用过 ChatGPT、文心一言、通义千问吗?那个"打字机效果"的背后,就是 SSE(Server-Sent Events)。但浏览器原生的 EventSource API 槽点满满,今天介绍一个微软出品的替代方案 —— @microsoft/fetch-event-source

前言

2024-2026 年是 AIGC 爆发的时代。几乎所有 AI 对话产品都采用了 流式输出:用户提问后,答案像打字一样逐字出现,而不是等待几秒后一次性返回。

这背后的核心技术就是 SSE(Server-Sent Events) —— 服务端主动向客户端推送事件流。

但当你真正上手开发时,你会发现浏览器原生的 EventSource API 简直是个"半成品"……

一、原生 EventSource 的致命缺陷

浏览器内置的 EventSource 构造函数用起来很简单:

const sse = new EventSource('/api/chat/stream');
sse.onmessage = (event) => {
  console.log(event.data);
};

但在 AIGC 场景下,它有 4 个致命问题

❌ 1. 只能 GET,不能 POST

EventSource 只支持 GET 请求。而 AI 对话接口通常需要 POST 一个复杂的 JSON body(包含上下文消息、模型参数、系统 prompt 等),你根本塞不进 URL 里。

URL 长度限制普遍为 2000 字符,一段对话历史就能轻松超过。

❌ 2. 无法自定义 Headers

不能传 Authorization、不能传自定义 Content-Type、不能传 locale……在任何需要鉴权的系统中,这基本是"不可用"。

❌ 3. 错误处理形同虚设

连接断了?EventSource 会默默重试几次然后放弃。你无法区分是 401/403/500,也无法自定义重试策略。

❌ 4. 页面隐藏时的行为不可控

用户切换浏览器 Tab 后,连接行为不受开发者控制。

一句话总结:EventSource 是为"简单通知推送"设计的,不是为"AI 流式对话"设计的。

二、fetch-event-source:微软的解法

@microsoft/fetch-event-source 是微软开源的 SSE 客户端库,基于 Fetch API 重新实现了 EventSource 协议,完美解决上述所有问题

  • ⭐ GitHub 2.8k+ Stars
  • 📦 MIT 协议
  • 🏷️ 当前版本 v2.0.1
  • 💻 TypeScript 编写,类型完备

安装

npm install @microsoft/fetch-event-source
# 或
pnpm add @microsoft/fetch-event-source

三、核心能力对比

能力原生 EventSourcefetch-event-source
POST 请求
自定义 Headers
自定义 Body
错误状态码处理
自定义重试策略
AbortController 取消
页面可见性管理不可控✅ 可配置
访问 Response 对象

四、快速上手

4.1 最简用法

import { fetchEventSource } from '@microsoft/fetch-event-source';

await fetchEventSource('/api/chat/stream', {
  method: 'POST',
  headers: {
    'Content-Type': 'application/json',
    'Authorization': 'Bearer your-token',
  },
  body: JSON.stringify({
    messages: [{ role: 'user', content: '你好' }],
    model: 'gpt-4',
    stream: true,
  }),
  onmessage(event) {
    const data = JSON.parse(event.data);
    console.log(data.choices?.[0]?.delta?.content);
  },
});

对比原生 EventSource,你可以:

  • 发 POST 请求 ✅
  • 带上 Authorization ✅
  • 传 JSON Body ✅

4.2 优雅地取消请求

AI 生成到一半,用户点了"停止生成"?用 AbortController 即可:

const controller = new AbortController();

fetchEventSource('/api/chat/stream', {
  method: 'POST',
  body: JSON.stringify({ message: '写一篇 1000 字的文章' }),
  signal: controller.signal,
  onmessage(event) {
    // 处理流式消息
  },
});

// 用户点击「停止生成」
stopButton.onclick = () => controller.abort();

4.3 完善的错误处理

这是 AIGC 应用的重中之重。网络抖动、token 过期、服务端限流……都需要不同的处理策略:

class RetriableError extends Error {}
class FatalError extends Error {}

await fetchEventSource('/api/chat/stream', {
  method: 'POST',
  headers: { 'Content-Type': 'application/json' },
  body: JSON.stringify({ message: 'hello' }),

  async onopen(response) {
    if (response.ok) {
      return; // 连接成功
    }

    if (response.status === 401) {
      // Token 过期,跳转登录
      throw new FatalError('Unauthorized');
    }

    if (response.status === 429) {
      // 限流,可以重试
      throw new RetriableError('Rate limited');
    }

    if (response.status >= 400 && response.status < 500) {
      // 客户端错误,不重试
      throw new FatalError(`Client error: ${response.status}`);
    }

    // 服务端错误,重试
    throw new RetriableError(`Server error: ${response.status}`);
  },

  onmessage(msg) {
    if (msg.event === 'error') {
      throw new FatalError(msg.data);
    }
    // 正常处理消息
  },

  onclose() {
    // 服务端主动关闭连接
    console.log('Connection closed by server');
  },

  onerror(err) {
    if (err instanceof FatalError) {
      throw err; // 抛出错误,停止重试
    }
    // 返回重试间隔(毫秒),或不返回使用默认策略
    return 3000;
  },
});

关键设计:

  • onerrorthrow 错误 = 停止重试,彻底断开
  • onerror返回数字 = 等待指定毫秒后重试
  • onerror不返回 = 使用默认重试策略

4.4 页面可见性控制

默认行为:用户最小化窗口或切换 Tab 时,库会 自动关闭连接,回来时带上 Last-Event-ID 重连。这对服务端很友好,避免维护无意义的长连接。

但在 AIGC 场景中,用户切 Tab 查资料时 AI 还在生成,你肯定不希望连接断掉

await fetchEventSource('/api/chat/stream', {
  // 关键配置:页面隐藏时保持连接
  openWhenHidden: true,
  onmessage(event) {
    // 即使页面不可见也继续接收消息
  },
});

💡 这个参数在 AIGC 场景中几乎是必开的。

五、AIGC 场景最佳实践

结合实际项目经验,总结几个关键实践:

5.1 封装统一的 SSE 请求层

建议将 fetchEventSource 封装成统一的工具函数,统一处理:

interface SSEConfig {
  body?: any;
  headers?: Record<string, string>;
  method?: 'GET' | 'POST';
}

interface SSEHandlers {
  onMessage?: (data: any, event?: string) => void;
  onOpen?: (response: Response) => void;
  onClose?: () => void;
  onError?: (error: any) => void;
}

async function requestSSE(
  url: string,
  config: SSEConfig,
  handlers: SSEHandlers,
  abortController?: AbortController,
) {
  const { body, headers = {}, method = 'POST' } = config;

  await fetchEventSource(url, {
    method,
    headers: {
      'Content-Type': 'application/json',
      'Authorization': `Bearer ${getToken()}`,
      ...headers,
    },
    body: body ? JSON.stringify(body) : undefined,
    signal: abortController?.signal,
    openWhenHidden: true,

    async onopen(response) {
      if (response.ok) {
        handlers.onOpen?.(response);
        return;
      }
      // 统一的 HTTP 错误处理
      handleHttpError(response);
      throw new Error(`HTTP ${response.status}`);
    },

    onmessage(event) {
      if (!event.data) return;

      let data;
      try {
        data = JSON.parse(event.data);
      } catch {
        data = event.data; // 纯文本场景兜底
      }

      handlers.onMessage?.(data, event.event);
    },

    onclose() {
      handlers.onClose?.();
    },

    onerror(error) {
      handlers.onError?.(error);
      throw error; // AIGC 场景通常不自动重试
    },
  });
}

5.2 处理 FormData 上传 + 流式返回

多模态 AI 场景(上传图片/文件并流式返回分析结果)需要注意:

const formData = new FormData();
formData.append('file', file);
formData.append('question', '请分析这张图片');

await fetchEventSource('/api/multimodal/stream', {
  method: 'POST',
  headers: {
    'Authorization': `Bearer ${token}`,
    // ⚠️ 不要手动设置 Content-Type!
    // 浏览器会自动设置 multipart/form-data 并添加 boundary
  },
  body: formData, // 直接传 FormData,不要 JSON.stringify
  openWhenHidden: true,
  onmessage(event) {
    // 处理流式响应
  },
});

⚠️ 这是一个常见坑:FormData 不能 JSON.stringify,且不能手动设置 Content-Type

5.3 SSE 消息的结构化处理

后端通常会通过 event 字段区分不同类型的消息:

onmessage(event) {
  const data = JSON.parse(event.data);

  switch (event.event) {
    case 'message':
      // 正常的文本 token
      appendToConversation(data.content);
      break;
    case 'thinking':
      // 思考过程(DeepSeek R1 等模型)
      updateThinkingProcess(data.content);
      break;
    case 'tool_call':
      // 工具调用(Function Calling)
      showToolExecution(data);
      break;
    case 'done':
      // 生成完成
      finalizeResponse(data);
      break;
    case 'error':
      // 业务错误
      showErrorMessage(data.message);
      break;
  }
}

5.4 与 React 配合的 Hook 示例

function useSSEChat() {
  const [messages, setMessages] = useState<Message[]>([]);
  const [loading, setLoading] = useState(false);
  const controllerRef = useRef<AbortController | null>(null);

  const send = useCallback(async (content: string) => {
    // 取消上一次未完成的请求
    controllerRef.current?.abort();
    controllerRef.current = new AbortController();

    setLoading(true);

    try {
      await requestSSE(
        '/api/chat/stream',
        { body: { message: content } },
        {
          onMessage: (data) => {
            setMessages(prev => {
              // 追加 AI 回复内容
              const last = prev[prev.length - 1];
              return [
                ...prev.slice(0, -1),
                { ...last, content: last.content + data.content },
              ];
            });
          },
          onClose: () => setLoading(false),
          onError: () => setLoading(false),
        },
        controllerRef.current,
      );
    } catch {
      setLoading(false);
    }
  }, []);

  const stop = useCallback(() => {
    controllerRef.current?.abort();
    setLoading(false);
  }, []);

  return { messages, loading, send, stop };
}

六、原理简析

fetch-event-source 的核心原理并不复杂:

┌─────────────┐    fetch()     ┌──────────────┐
│   Client     │ ──────────▶  │   Server      │
│              │               │              │
│  fetchEvent  │  ◀──────────  │  text/event  │
│  Source()    │   SSE Stream  │  -stream     │
└─────────────┘               └──────────────┘
       │
       ▼
  ┌──────────────────────────┐
  │  ReadableStream Reader   │
  │  ──────────────────────  │
  │  1. 读取 response.body   │
  │  2. 按 \n\n 分割事件     │
  │  3. 解析 event/data/id   │
  │  4. 回调 onmessage       │
  └──────────────────────────┘

本质上就是:

  1. fetch() 发起请求(所以支持所有 fetch 参数)
  2. 拿到 ResponseReadableStream
  3. 逐块读取,按 SSE 协议(\n\n 分隔事件,data: / event: / id: 前缀)解析
  4. 解析后回调 onmessage

七、与其他方案的对比

方案适用场景优缺点
EventSource简单通知推送简单但功能严重受限
fetch-event-sourceAIGC 流式对话功能完整,API 优雅
WebSocket双向实时通信SSE 场景下过于重量级
原生 fetch + ReadableStream极致定制需要手动解析 SSE 协议

如果你的场景是服务端单向推送(AI 流式回答、实时日志、进度通知等),fetch-event-source 是最佳选择。 如果需要双向通信(协同编辑、实时聊天),请选择 WebSocket。

八、注意事项

  1. 该库已 3 年未更新(最后更新 2021 年),但 SSE 协议本身很稳定,功能上完全够用
  2. 打包体积很小,gzip 后仅约 2KB
  3. 不支持 IE,目标是 ES2017+ 的现代浏览器
  4. 如果用到旧版 Edge(< 79),需要 polyfill TextDecoder

总结

在 AIGC 时代,SSE 是实现流式对话体验的核心技术。@microsoft/fetch-event-source 以极小的体积,优雅地解决了原生 EventSource 的所有痛点:

  • ✅ 支持 POST + 自定义 Headers + Body
  • ✅ 完善的错误处理和重试控制
  • ✅ AbortController 取消支持
  • ✅ 页面可见性智能管理
  • ✅ TypeScript 友好

如果你正在做 AI 对话产品、流式数据展示、实时日志推送等功能,强烈推荐使用。


📌 GitHubgithub.com/Azure/fetch… 📦 NPMnpm install @microsoft/fetch-event-source 📄 协议:MIT

如果这篇文章对你有帮助,欢迎点赞 👍 收藏 ⭐ 关注,我们下篇再见!