基于sse模式ai对话

193 阅读10分钟

SSE模式核心概念

Server-Sent Events(SSE)是一种基于HTTP协议的单向服务器推送技术,允许服务器通过持久化连接主动向客户端(如浏览器)发送实时数据流。其核心特点包括:

  • 单向通信:仅支持服务器到客户端的数据推送,客户端无法直接发送数据。
  • 协议轻量:基于标准HTTP/1.1,无需额外端口或复杂握手流程。
  • 自动重连:浏览器内置机制,连接断开后自动尝试恢复。
  • 事件驱动:支持自定义事件类型(如dataeventidretry),便于客户端分类处理。

SSE的典型应用场景包括股票行情更新、新闻推送、实时监控仪表盘等服务端主导的实时数据流场景。例如,携程机票业务通过SSE实现航班动态的实时更新,避免了传统轮询的延迟问题。

SSE的优缺点分析

优势

  1. 实现简单:仅需设置HTTP响应头(Content-Type: text/event-stream)并持续写入数据流,无需复杂协议支持。
  2. 兼容性强:现代浏览器原生支持,无需插件或额外库(IE除外)。
  3. 资源占用低:相比WebSocket,SSE无需维护双向连接,服务器负载更小。
  4. 断线重连:内置自动恢复机制,适合网络不稳定环境。

局限性

  1. 单向通信:无法满足聊天、在线协作等双向交互需求。
  2. 文本传输:二进制数据需编码后传输,效率略低。
  3. 浏览器限制:IE全系列不支持,需Polyfill兼容。
  4. 连接数限制:部分浏览器对同一域名的SSE连接数有限制。

SSE与其他实时通信技术对比

SSE vs WebSocket

特性SSEWebSocket
通信方向单向(服务器→客户端)双向(全双工)
协议基础HTTP/1.1独立协议(ws:///wss://)
数据类型文本(需编码二进制)文本/二进制
复杂度低(标准HTTP头)高(需握手、心跳机制)
适用场景实时通知、数据监控在线聊天、游戏、协同编辑

典型案例

  • SSE:ChatGPT对话流推送、携程航班动态更新。
  • WebSocket:微信网页版聊天、腾讯会议实时协作。

SSE vs 轮询技术

特性SSE长轮询短轮询
实时性高(服务器主动推送)中(等待服务器响应)低(固定间隔请求)
服务器负载低(持久连接)中(保持连接等待数据)高(频繁请求)
实现复杂度中(需处理超时重试)极低(定时请求)
适用场景实时数据流准实时应用(如邮件通知)非实时数据更新(如天气预报)

性能对比

  • SSE:在携程机票业务中,SSE将数据更新延迟从轮询的3-5秒降低至200ms以内。
  • 长轮询:阿里云开发者社区案例显示,长轮询可减少80%的无效请求,但服务器仍需维护大量等待连接。

SSE的典型应用场景

  1. 实时数据仪表盘

    • 金融行业股票价格、交易量实时更新。
    • 物联网设备状态监控(如温度、湿度传感器数据流)。
  2. 通知与警报系统

    • 电商平台订单状态变更推送。
    • 安全系统入侵检测实时告警。
  3. AI交互场景

    • ChatGPT等大模型的对话流式响应。
    • 实时语音转文字结果推送。
  4. 内容更新

    • 新闻网站头条实时推送。
    • 社交媒体动态更新(如Twitter时间线)。

SSE语法

1、SSE数据格式结构

SSE数据流需遵循以下规范,每条消息由多行组成,以特定格式传输:

  1. 字段定义

    • data: (必需):消息内容,支持多行(每行以data:开头,末尾需用\n\n分隔消息)。
    • event: (可选):自定义事件类型,客户端可通过addEventListener监听。
    • id: (可选):消息ID,用于断线重连时恢复上下文。
    • retry: (可选):重连间隔(毫秒),默认3秒。
  2. 示例消息

    text
    event: update
    id: 123
    data: {"temperature": 25, "status": "normal"}
    data: Additional data line\n
    retry: 5000
    
    • 消息结束需用\n\n分隔。

下边的示例中,我们的数据格式是这样的,data中存放了业务代码,复制某一行内容解析出来如下:

image.png

image.png

2、HTTP协议配置

服务器需设置以下响应头以启用SSE:

http
HTTP/1.1 200 OK
Content-Type: text/event-stream          # 固定MIME类型
Cache-Control: no-cache                 # 禁用缓存
Connection: keep-alive                  # 保持长连接
Access-Control-Allow-Origin: *          # 跨域支持(可选)

3、前端实现:EventSource API

EventSource 是浏览器原生提供的 SSE 客户端接口,用于建立与服务器之间的持久化连接,接收服务器推送的实时数据流。其设计目标是通过 极简的 API 实现高效的单向通信,同时隐藏底层协议细节(如连接管理、重连机制、事件解析等)。

EventSource 对象触发的事件主要包括以下三种:

  • open 事件:当成功连接到服务端时触发。
  • message 事件:当接收到服务器发送的消息时触发。该事件对象的 data 属性包含了服务器发送的消息内容。
  • error 事件:当发生错误时触发。该事件对象的 event 属性包含了错误信息。
  1. 基础用法

    javascript
    const eventSource = new EventSource('/api/sse-endpoint');
     
    // 监听默认消息事件
    eventSource.onmessage = (event) => {
      console.log('Received:', event.data);
    };
     
    // 监听自定义事件
    eventSource.addEventListener('update', (event) => {
      console.log('Update event:', JSON.parse(event.data));
    });
     
    // 错误处理
    eventSource.onerror = (error) => {
      console.error('SSE error:', error);
      if (eventSource.readyState === EventSource.CLOSED) {
        console.log('Connection closed');
      }
    };
     
    // 手动关闭连接
    // eventSource.close();
    
  2. 关键属性与方法

    • readyState:连接状态(CONNECTING(0)OPEN(1)CLOSED(2))。
    • url:SSE连接的URL。
    • close() :主动关闭连接。
  3. 状态管理示例

    javascript
    switch (eventSource.readyState) {
      case EventSource.CONNECTING:
        console.log('Connecting...');
        break;
      case EventSource.OPEN:
        console.log('Connected');
        break;
      case EventSource.CLOSED:
        console.log('Connection closed');
        break;
    }
    

    虽然 XMLHttpRequest(XHR)支持长连接和流式响应,但是XHR 仅返回原始响应文本,需手动解析 SSE 格式(如拆分 \n\n、提取 event: 字段)。在最后也会有有基于fetch实现数据获取并处理数据的示例。

  4. @microsoft/fetch-event-source

    @microsoft/fetch-event-source 是微软开发的 JavaScript 库,用于在浏览器或 Node.js 环境中实现 Server-Sent Events (SSE)  协议。它基于浏览器原生的 Fetch API 扩展,提供了比传统 EventSource 更灵活、功能更强大的 SSE 客户端实现,尤其适合需要自定义请求、复杂错误处理或流式数据解析的场景。在实际项目中,我们借助工具包实现快速开发。

    与原生 EventSource 的对比

    特性@microsoft/fetch-event-source原生 EventSource
    请求方法支持 GET/POST 等任意方法仅支持 GET
    请求头/体可自定义请求头和请求体无法设置请求头或请求体
    错误处理提供细粒度错误分类和重试策略仅支持简单重连,无法自定义逻辑
    中断控制支持 AbortController 主动中断需手动关闭连接,无标准化中断机制
    环境兼容性浏览器 + Node.js仅浏览器
    TypeScript 支持

基于SSE实现AI对话流式输出,vue2举例

image.png

一个完整的对话过程包括开场白,提示集,输入框,对话内容等几个要素组成。开场白和提示集可以依赖接口直接返回。当用户输入问题,获取回答的过程就可以基于SSE实现。

1、前端代码,vue2

1、安装@microsoft/fetch-event-source

npm i @microsoft/fetch-event-source --save

2、定义可能需要的数据

export default {
    data() {
        return {
            recommendedQuestions: [], // 示例图片的猜你想问
            welcome: '', // 示例图片的开场白
            chatList: [], // 示例图片的对话内容
            suggestedQuestions: [], // 在ai回复结束以后会有相关问题,类似猜你想问功能
        }
    }
}

3、在文本框输入内容,点击发送

methods: {
    // 用户点击文本框发送消息
    onSend() {
        const questionId = `question-${Date.now()}` // 定义当前轮次问题的唯一id
        const questionItem = {
            id: questionId, // 当前轮次问题的唯一id
            content: this.msg, // 要发送的内容,也就是文本框输入的内容
            isAnswer: false, // 用来标识是用户还是机器人
            message_files: null // 用户发送的文件,当前只做文本的回答,根据实际业务调整,这里设置成null
        }
        
        const placeholderAnswerId = `answer-placeholder-${Date.now()}` // 定义当前轮次回答的唯一id
        const placeholderAnswerItem = {
            id: placeholderAnswerId, // 当前轮次回答的唯一id
            content: '', // 默认为空,可以根据这个字段切换思考中和机器人回复内容等业务需求
            isAnswer: true // 用来标识是用户还是机器人
        }
        
        // 用户点击发送,直接把内容回显到问答列表上
        this.handleUpdateChatList([questionItem, placeholderAnswerItem])
        
        // 再定义一个取消请求的控制器,比如用户点击停止回答,或者有业务需求要终止当前响应
        const abortController = new AbortController()
        this.abortControllerRef = abortController
    },
    // 更新页面问答列表
    handleUpdateChatList(newChatList) {
      this.chatList = [...this.chatList, ...newChatList]
    },
}

4、封装请求

// 导入安装的三方包
import { fetchEventSource } from '@microsoft/fetch-event-source'

// requesOption:配置一些请求信息,比如请求头的一些配置,根据业务自定义,不要的话删除就可以
// otherOptions:业务代码,监听数据流的状态的回调,比如开始响应,响应中,响应完成,报错等事件的回调
const AicsChatStream = (requesOption, otherOptions) => {
    const req = new fetchEventSource(请求地址, {
        method: 'POST',
        signal: abortController.signal,
        headers, // 如果需要就传,不需要就不传
        body: JSON.stringify(data), // 业务数据,根据后端实际需要处理
        onMessage: 接收数据流的回调
        onerror: 请求错误的回调,
        onclose: 请求关闭的回调
    }) 
}

// 以下是业务代码
// 接收数据流的回调
let isFirstMessage = true; // 是不是第一条数据,可用可不用
const onmessage = (response) => {
    handleStream(isFirstMessage, response)
}
// 处理数据流
const handleStream = (isFirstMessage, response) => {
    let r = ''
    try {
        // 此处需要json格式化一下
        r = JSON.parse(response.data)
    } catch (err) {
        // 主动调onData方法,把错误信息当成机器人回复返回去
        onData('', false, {
          conversationId: undefined,
          messageId: '',
          errorMessage: `${e}`,
        })
    }
    // 开始解析业务数据
    // r的数据结构就是上边截图所示存放的业务代码
    // 处理接口返回的数据,防止有特殊编码格式的字符
    function unicodeToChar(text) {
      if (!text)
        return ''

      return text.replace(/\\u[0-9a-f]{4}/g, (_match, p1) => {
        return String.fromCharCode(parseInt(p1, 16))
      })
    }
    let bufferObj = r
    if (bufferObj.event === 'message') {
        onData(unicodeToChar(bufferObj.answer), isFirstMessage, {
          conversationId: bufferObj.conversation_id,
          taskId: bufferObj.task_id,
          messageId: bufferObj.id,
        })
    }
    else if (bufferObj.event === 'message_end') {
        onMessageEnd?.(bufferObj)
    }
    // 其他状态判断
}
// 请求错误
const onerror = () => abortController.abort()
// 请求完成
const onclose = () => onCompleted()

5、回显数据 在问答列表上,可以将问和答拆分出来成单独组件,回显markdown语法。这里用的是markdown-it,高亮用的是highlight.js,相关文档都能搜到。

npm i markdown-it highlight.js --save

封装markdown组件回显内容

// 导入相关的包和样式文件,样式文件根据实际需要自动选择
import hljs from 'highlight.js'
import markdownit from 'markdown-it'
import 'github-markdown-css/github-markdown-light.css'
import 'highlight.js/styles/github.css'

// 初始化markdown,如果需要特殊处理可以参考文档
const md = markdownit({
  langPrefix: 'language-',
  breaks: true,
  highlight: function(str, lang) {
    if (lang && hljs.getLanguage(lang)) {
      const c = hljs.highlight(str, { language: lang, ignoreIllegals: true })
        .value
      try {
        return (
          '<pre><code class="hljs">' +
          c +
          '</code></pre>'
        )
      } catch (__) {}
    }

    return (
      '<pre><code class="hljs">' + md.utils.escapeHtml(str) + '</code></pre>'
    )
  }
})

<template>
  <div class="markdown-body" v-html="markdownText"></div>
</template>
export default {
    computed: {
        markdownText() {
          //生成html
          return md.render(this.content || '')
        },
      }
}

2、服务端代码,koa2

const Koa = require('koa');
const Router = require('koa-router');
const cors = require('@koa/cors');

// 创建Koa应用
const app = new Koa();
// 创建路由
const router = new Router();

// 启用CORS
app.use(cors());

// 模拟AI回复的函数
function generateAIResponse(userMessage) {
  // 简单的关键词匹配模拟AI回复
  const responses = {
    '你好': '你好!我是AI助手,有什么可以帮助你的吗?',
    '你是谁': '我是一个AI对话助手,能够通过SSE提供实时回复。',
    '天气': '今天天气不错,阳光明媚,适合外出活动。',
    '再见': '再见!祝你有美好的一天!'
  };

  // 如果没有匹配的关键词,返回默认回复
  return responses[userMessage] || '抱歉,我不太理解你的意思。请尝试其他问题。';
}

// SSE端点实现AI对话
router.get('/api/ai-chat', async (ctx) => {
  // 设置SSE响应头
  ctx.set({
    'Content-Type': 'text/event-stream',
    'Cache-Control': 'no-cache',
    'Connection': 'keep-alive'
  });

  // 获取用户输入的消息
  const userMessage = ctx.query.message || '你好';

  // 模拟思考时间
  await new Promise(resolve => setTimeout(resolve, 500));

  // 生成AI回复
  const aiResponse = generateAIResponse(userMessage);

  // 逐字发送AI回复
  for (let i = 0; i < aiResponse.length; i++) {
    // 发送单个字符
    ctx.res.write(`data: ${aiResponse[i]}\n\n`);
    // 模拟打字效果
    await new Promise(resolve => setTimeout(resolve, 100));
  }

  // 发送结束信号
  ctx.res.write('data: [DONE]\n\n');
  ctx.res.end();
});

// 应用路由
app.use(router.routes()).use(router.allowedMethods());

// 启动服务器
const PORT = 3000;
app.listen(PORT, () => {
  console.log(`Koa server running on http://localhost:${PORT}`);
  console.log(`AI chat SSE endpoint: http://localhost:${PORT}/api/ai-chat?message=你好`);
});

// 说明:
// 1. 安装依赖: npm install koa koa-router @koa/cors
// 2. 运行服务器: node server.js
// 3. 在前端使用EventSource或@microsoft/fetch-event-source连接SSE端点

以上就是实现基于sse模式ai对话的全过程。


3、基于fetch请求的手动处理response.body

1. fetch 默认返回的是 Response 对象,而不是直接解析 SSE 数据

  • fetch 是一个通用的 HTTP 请求 API,它默认返回一个 Response 对象,该对象包含原始的 HTTP 响应数据(如 statusheadersbody 等)。
  • SSE 是一种特殊的流式协议,服务器会持续发送 event: 格式的数据,而 fetch 不会自动解析这种格式,而是返回原始的 ReadableStream(二进制流)。
  • 因此,我们需要手动读取 response.body(即 ReadableStream),并解析 SSE 格式的数据。

2. response.body 是一个 ReadableStream,需要 getReader() 逐块读取

  • response.body 是一个 二进制流(ReadableStream ,而不是直接可读的文本或 JSON。
  • 要读取这个流,必须使用 getReader() 获取一个 TextDecoder 或 Reader 对象,然后逐块读取数据。

3. SSE 数据是分块发送的,需要手动拼接

  • SSE 服务器可能会分多次发送数据,每次发送一个 event: 或 data: 块。

  • fetch 不会自动拼接这些数据,而是返回原始的流,因此我们需要:

    1. 逐块读取数据。
    2. 手动拼接 SSE 格式的字符串(如 data: ...\n\n)。
    3. 解析 event 和 data 字段

4. 对比 EventSource(原生 SSE API)

  • 浏览器提供了 EventSource API,它专门用于处理 SSE,自动解析 event: 和 data: 格式,并触发 onmessage 事件。
  • 但 EventSource 不支持 CORS 自定义头,且 无法手动控制流(如取消请求、重试等)。
  • 因此,在需要更灵活控制时,fetch + ReadableStream 是更好的选择。
fetch(url, options).then((response) => {
    if (!/^(2|3)\d{2}$/.test(String(response.status))) {
      onError?.('Server Error')
      return
    }
    onst reader = response.body?.getReader()
    reader?.read().then((result) => {
      if (result.done) {
        onCompleted && onCompleted()
        return
      }
      buffer += decoder.decode(result.value, { stream: true })
      const lines = buffer.split('\n')
      try {
        lines.forEach((message) => {
            if (message.startsWith('data:')) {
                 bufferObj = JSON.parse(message.substring(5))
                 // 接下来的代码和上边一堆if判断一样
            }
        }) 
      } catch (err) {
        
      }
}

其他

上边介绍和代码中,手动进行了完整的从输入到输出的全过程,方便定制化开发和维护。element-plus-xant-design-x是vue爱好者和react爱好者分别维护的开箱即用的企业级AI交互组件,类似于使用的element-uiant-designui组件库,它们的底层已经实现请求和回显数据的完美封装,通过简单的配置和使用,可以快速帮助开发者完成功能开发。