流式输出SSE

引言

在现代 Web 应用中,用户越来越期待即时反馈。当集成大语言模型(LLM)如 DeepSeek、ChatGPT 等时,传统的"发送请求 → 等待完整响应 → 显示结果"模式已无法满足体验需求。理想情况是:服务器每生成一个词,客户端就能立即看到

为此,许多 LLM 提供商支持 流式输出(Streaming Response) ,通过 HTTP 分块传输编码(chunked transfer encoding)持续推送数据。然而,直接在前端调用这些流式 API 存在严重安全隐患和功能限制。本文介绍一种更优雅、安全且可维护的方案:由后端服务作为代理,接收远程流式响应,并通过 Server-Sent Events(SSE)推送给前端

SSE是一种基于HTTP的服务器向客户端单向推送数据的标准化通信机制。在Web应用中,当需要实现服务端持续向浏览器传输更新时,SSE提供了一种轻量且高效的解决方案。相较于传统的轮询或长轮询,SSE通过建立持久化的HTTP连接,允许服务器在数据生成后立即推送给客户端,从而显著降低延迟并减少不必要的网络开销。

一、为什么不能在前端直接处理流式请求?

我们先来看一段典型的前端流式请求代码:

js 复制代码
const update = async () => {
    // 检查输入是否为空,若为空则直接返回
    if(!input){
      return
    }
    // 设置输出状态为"思考中...",表示正在处理请求
    setOutput('思考中。。。')
    // 定义API端点URL
    const endpoint = 'https://api.deepseek.com/chat/completions'
    // 设置请求头,包括内容类型和认证信息
    const headers = {
      'Content-Type': 'application/json',  // 设置请求内容类型为JSON
      'Authorization': `Bearer ${import.meta.env.VITE_DEEPSEEK_API_KEY}`  // 使用环境变量中的API密钥进行认证
    }
    // 发送POST请求到API
    const response = await fetch(endpoint, {
      method: 'POST',  // 使用POST方法发送请求
      headers: headers,  // 设置请求头
      body: JSON.stringify({  // 将请求体转换为JSON字符串
        model: "deepseek-chat",  // 指定使用的模型为deepseek-chat
        messages: [{role: "user", content: input}],  // 设置消息内容,角色为user,内容为用户输入
        stream:stream  // 设置是否使用流式传输
      })
    })
    // 解析响应数据
    if (stream) {
      clearOutput()  // 清空之前的输出内容
     const reader = response.body.getReader()
     const decoder = new TextDecoder('utf-8')  // 创建UTF-8解码器
     let done = false  // 标记流是否读取完成
     let buffer = ''  // 用于存储不完整的块数据
     while (!done) {
      // 从读取器中读取数据块,并解构出值和完成状态
      const { value, done: readerDone } = await reader.read()
      // 更新完成状态
      done = readerDone
      // 将缓冲区中的数据与当前解码后的值合并
      const chunkValue = buffer +decoder.decode(value)
      // 清空缓冲区
      buffer = ''
      // 按行分割数据,并过滤出以 'data: ' 开头的行
      const lines = chunkValue.split('\n').filter(line => line.startsWith('data: '))
      console.log(lines) // 打印处理后的行数据
      // 遍历所有行
      for(const line of lines){
        // 去除 'data: ' 前缀,获取实际数据
        const incoming = line.slice(6)
        // 检查是否为结束标记
        if(incoming === '[DONE]'){
          done = true
          break // 遇到结束标记,退出循环
        }
        // 解析 JSON 数据
        const data =JSON.parse(incoming)
        // 更新输出,将新的内容追加到之前的输出中
        setOutput(prevOutput => prevOutput + data.choices[0].delta.content)
      }
    }
    }else{
       const data = await response.json()
    // 更新输出状态为API返回的消息内容
    setOutput(data.choices[0].message.content)
    // 在控制台打印响应数据,用于调试
    console.log(data)
    }

❌ 存在的问题:

  1. API 密钥暴露风险

    VITE_DEEPSEEK_API_KEY 放在前端环境变量中,最终仍会打包进 JS 文件,任何用户都可以通过 DevTools 查看或抓包获取密钥。

  2. 无法设置某些请求头

    浏览器出于安全考虑,禁止 JavaScript 设置部分敏感请求头(如 Connection, Transfer-Encoding 等),可能导致与某些 API 不兼容。

  3. 跨域问题复杂化

    第三方 API 可能未开启 CORS 或不允许 fetch 的特定配置,导致预检失败。

  4. 连接管理困难

    需手动管理 ReadableStream、解码、缓冲区拼接、错误重试等,逻辑复杂易出错。

  5. 不利于统一鉴权与限流

    若未来需要做用户级访问控制、频率限制、日志记录等,必须依赖后端介入。

✅ 结论:流式请求应由后端发起,前端只负责展示结果。


二、解决方案:后端代理 + SSE

我们将采用如下架构:

scss 复制代码
[前端] 
   ↓ (HTTP 请求)
[Node.js 后端]
   ↓ (流式请求 + 认证)
[DeepSeek API]

后端收到 DeepSeek 的流式响应后,边读取边解析 ,然后通过 SSE(Server-Sent Events) 将每一个增量文本实时推送给前端。

✅ 优势:

  • 安全:API Key 保存在服务端环境变量中
  • 灵活:可在代理层添加鉴权、缓存、日志、重试等逻辑
  • 实时:前端通过 SSE 自动接收更新,无需轮询
  • 标准化:SSE 是 W3C 标准,浏览器原生支持自动重连、事件类型区分等特性

三、服务端实现(Node.js + Express)

1. 创建 SSE 路由

js 复制代码
// routes/sse.js
import express from 'express';
import axios from 'axios'; // 使用 axios 支持流式响应
import { TextDecoder } from 'util'; // Node.js 内置

const router = express.Router();

router.post('/chat-stream', async (req, res) => {
  const { input } = req.body;

  if (!input) {
    return res.status(400).json({ error: '输入不能为空' });
  }

  // 设置 SSE 响应头
  res.writeHead(200, {
    'Content-Type': 'text/event-stream',
    'Cache-Control': 'no-cache',
    'Connection': 'keep-alive',
    'X-Accel-Buffering': 'no' // Nginx 关键配置:禁用缓冲
  });

  const decoder = new TextDecoder('utf-8');
  let buffer = ''; // 缓冲未完整解析的数据

  try {
    const upstreamResponse = await axios({
      method: 'POST',
      url: 'https://api.deepseek.com/chat/completions',
      headers: {
        'Authorization': `Bearer ${process.env.DEEPSEEK_API_KEY}`,
        'Content-Type': 'application/json'
      },
      data: {
        model: 'deepseek-chat',
        messages: [{ role: 'user', content: input }],
        stream: true
      },
      responseType: 'stream' // 关键:以流的形式接收
    });

    // 监听数据流
    upstreamResponse.data.on('data', chunk => {
      buffer += decoder.decode(chunk, { stream: true });

      // 按行分割,注意可能有多条或半条
      const lines = buffer.split('\n');
      buffer = ''; // 清空缓冲(实际应保留不完整的部分)

      for (const line of lines) {
        if (line.trim() === '') continue;
        if (!line.startsWith('data:')) continue;

        const dataStr = line.slice(5).trim(); // 去掉 "data:"
        if (dataStr === '[DONE]') {
          res.write('data: [DONE]\n\n'); // 转发结束信号
          res.end();
          return;
        }

        try {
          const json = JSON.parse(dataStr);
          const content = json.choices[0]?.delta?.content;
          if (content) {
            res.write(`data: ${content}\n\n`); // 符合 SSE 协议格式
          }
        } catch (err) {
          console.error('解析失败:', err);
        }
      }
    });

    upstreamResponse.data.on('end', () => {
      res.end();
    });

    upstreamResponse.data.on('error', (err) => {
      console.error('上游流错误:', err);
      res.write(`data: [ERROR] ${err.message}\n\n`);
      res.end();
    });

  } catch (error) {
    console.error('请求失败:', error);
    res.write(`data: [ERROR] 请求异常,请稍后再试。\n\n`);
    res.end();
  }
});

export default router;

⚠️ 注意:

  • buffer 处理需谨慎,若一行被截断,应保留到下次再处理。
  • 生产环境中应使用更健壮的行解析器(如逐字节扫描 \n)。
  • X-Accel-Buffering: no 是 Nginx 必须设置的,否则会缓冲整个响应。

四、前端实现(EventSource + 动态渲染)

1. 发起 SSE 连接

js 复制代码
function startStream(input) {
  // 清空旧输出
  setOutput('');

  // 创建 EventSource 实例
  const eventSource = new EventSource(`/api/chat-stream`);

  // 接收消息
  eventSource.onmessage = (event) => {
    const data = event.data;

    if (data === '[DONE]') {
      eventSource.close();
      return;
    }

    if (data.startsWith('[ERROR]')) {
      setOutput(prev => prev + '\n\n系统错误:' + data);
      eventSource.close();
      return;
    }

    // 正常文本增量
    setOutput(prev => prev + data);
  };

  eventSource.onerror = (err) => {
    console.error('SSE 错误:', err);
    eventSource.close();
  };

  // 发送输入内容(可通过其他方式传递,例如先 POST 触发)
  fetch('/api/start-chat', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({ input })
  }).catch(console.error);
}

💡 提示:由于 EventSource 不支持自定义请求头,通常先用普通 POST 请求触发对话,再开启 SSE 连接拉取流。


五、关键技术点详解

1. UTF-8 解码中断问题

UTF-8 字符可能是 1~4 字节,网络传输中可能在一个字节中间切断。因此:

js 复制代码
buffer += decoder.decode(chunk, { stream: true }); // 使用 { stream: true } 告知解码器后续还有数据

这能确保多字节字符不会被错误解析。

2. 行切割与缓冲策略

不要简单地 split('\n') 后清空 buffer,而应判断最后一行是否完整:

js 复制代码
const lines = buffer.split('\n');
buffer = lines.pop(); // 最后一行可能不完整,留作下一次处理

for (const line of lines) {
  // 处理完整行
}

3. SSE 协议格式要求

每条消息必须以 data: 开头,末尾两个换行符结束:

kotlin 复制代码
data: Hello world

data: How are you?

data: [DONE]

浏览器会自动合并同一事件的多行 data: 字段。

4. 自动重连机制

EventSource 默认会在连接断开后自动尝试重连(间隔几秒)。可通过监听 onopenonerror 控制行为。

相关推荐
小噔小咚什么东东2 小时前
Vue开发H5项目中基于栈的弹窗管理
前端·vue.js·vant
OpenTiny社区2 小时前
基于华为云大模型服务MaaS和OpenTiny框架实现商城商品智能化管理
前端·agent·mcp
云枫晖2 小时前
JS核心知识-原型和原型链
前端·javascript
小卓笔记3 小时前
第1章 Web服务-nginx
前端·网络·nginx
华仔啊3 小时前
Vue+CSS 做出的LED时钟太酷了!还能倒计时,代码全开源
前端·css·vue.js
m0_564914924 小时前
点击EDGE浏览器下载的PDF文件总在EDGE中打开
前端·edge·pdf
@大迁世界4 小时前
JavaScript 2.0?当 Bun、Deno 与 Edge 运行时重写执行范式
开发语言·前端·javascript·ecmascript
red润4 小时前
Day.js 是一个轻量级的 JavaScript 日期处理库,以下是常用用法:
前端·javascript
Ting-yu4 小时前
Nginx快速入门
java·服务器·前端·nginx