SSE 流式输出:让大模型回答像打字机一样「蹦字」的全栈魔法

2,183 阅读5分钟

在做一个AI交互的小项目时,遇到个头疼的问题 —— 大模型生成回答太慢了。用户点了发送之后,要等好几秒才能看到完整回复,期间界面一动不动,很容易让人以为卡了。查了很多资料,发现普遍用 SSE 来解决这个问题,试了一下果然好用,今天就把这个过程拆解开来讲讲。

为什么需要流式输出?

先说说为啥要搞流式输出。普通的 HTTP 请求就像寄快递,服务器得把所有东西打包好才发货,用户只能等着。但大模型生成内容不一样,它是一个词一个词 "想" 出来的(业内叫 token),就像人打字一样。如果等它全想完再返回,用户得盯着空白屏幕等半天,体验特别差。

流式输出就是让服务器 "边想边说",生成一个词就立刻发给前端,前端收到就显示一个词。虽然总耗时可能差不多,但用户能实时看到内容在变化,心理上会觉得快很多。这招在 AI 聊天机器人里几乎是标配毕竟用户体验太重要了。

什么是 SSE?

一开始我以为要搞 WebSocket,后来发现根本没必要。WebSocket 是双向通信的,就像打电话,两边都能说。但流式输出只需要服务器往前端发数据,前端不用给服务器回消息,这时候 SSE 就够用了。

SSE 全称是 Server-Sent Events,翻译过来就是服务器发送事件。它本质上还是 HTTP 协议,但做了点改造 —— 让连接保持打开状态,服务器可以随时往里面塞数据。你可以把它理解成广播,前端一旦 "调频" 到这个频道,就能一直收到服务器发的内容。

对比一下 SSE 和 WebSocket:

特性WebSocketSSE
通信方向双向(全双工)单向(服务器到客户端)
协议ws:// 或 wss://http:// 或 https://
复杂度较高(需要处理连接状态)较低(类似普通 HTTP)
浏览器支持现代浏览器均支持除 IE 外均支持
断线重连需要手动实现原生支持自动重连
二进制支持支持仅支持文本

可以看到,SSE 在以下场景中具有明显优势:

  • 服务器主动推送数据(如股票行情、实时通知)
  • 不需要客户端向服务器发送数据
  • 希望实现简单且稳定的实时通信

对咱们做聊天机器人来说,SSE 简直是量身定做的。

前端怎么收 SSE 数据?

前端实现特别简单,浏览器自带了 EventSource 对象,专门用来处理 SSE。就像打开一个水龙头,打开之后数据就会源源不断流过来。

// 连接到服务器的SSE接口
const source = new EventSource('/sse'); 

// 监听消息事件,服务器发数据就会触发
source.onmessage = function(event) {
  // event.data就是服务器发过来的内容
  const messages = document.getElementById('messages');
  messages.innerHTML += event.data + '<br>';
}

// 连接成功时触发
source.onopen = function() {
  console.log('连接上了,准备接收数据~');
}

// 出错时触发
source.onerror = function(error) {
  console.error('出问题了:', error);
}

这里有几个细节要注意:

  • EventSource 默认会自动重连,如果连接断了,它会每隔几秒重试一次,省了我们好多事
  • 可以通过 source.readyState 查看连接状态:0 是正在连接,1 是已连接,2 是已关闭
  • 不需要的时候记得调用 source.close () 关掉连接,不然会一直占着资源 我一开始犯了个傻,把接口地址写错了,结果控制台一直报错,后来才发现 EventSource 的 url 必须和当前页面同源,或者服务器配置了 CORS 才行。

后端怎么发 SSE 数据?

后端稍微复杂点,得改造一下 HTTP 响应,让它变成一个持续输出的流。我用的是 Express 框架,核心就是设置几个特殊的响应头。

app.get('/sse', (req, res) => {
 
  res.set({
    'Content-Type': 'text/event-stream', 
    'Cache-Control': 'no-cache', 
    'Connection': 'keep-alive', 
  });
  //强制立即发送已设置的响应头
  res.flushHeaders(); 
  
  res.write('data: 开始接收数据啦\n\n');
  
  // 模拟每秒发一次数据
  const timer = setInterval(() => {
    res.write(`data: 当前时间 ${new Date().toLocaleTimeString()}\n\n`);
  }, 1000);
  
  // 当客户端断开连接时,清理定时器
  req.on('close', () => {
    clearInterval(timer);
    res.end();
  });
});

这里的坑特别多:

  1. 数据格式必须严格按照data: 内容\n\n来,少个换行都不行。如果内容有多行,每行都得加data: 前缀
  2. 服务器不能调用 res.end (),不然连接就断了,得一直保持打开
  3. 要监听 req 的 close 事件,用户关掉页面时及时清理资源,不然会内存泄漏
  4. 本地测试没问题,部署到服务器时可能被 nginx 之类的反向代理拦截,需要配置 proxy_buffering off

我一开始就是因为没处理 close 事件,结果开着页面刷新几次,服务器上就挂着一堆定时器,后来看日志才发现这个问题。

怎么在 LLM 聊天机器人里用?

把 SSE 用到聊天机器人里其实很简单,核心就是把大模型生成的内容实时推给前端。

后端调用大模型 API 的时候,一般可以拿到一个流对象,我们只需要监听这个流的 data 事件,收到一点内容就立刻通过 SSE 发出去:

app.get('/chat', async (req, res) => {
  res.set({
    'Content-Type': 'text/event-stream',
    'Cache-Control': 'no-cache',
    'Connection': 'keep-alive',
    'Access-Control-Allow-Origin': '*'
  });
  
  const response = await axios.post(
      DEEPSEEK_API_URL,
      {
        model: "deepseek-chat", 
        messages: [
          { role: "user", content: userPrompt } // 用户问题
        ],
        stream: true, 
        max_tokens: 1024 
      },
      {
        headers: {
          'Content-Type': 'application/json',
          'Authorization': `Bearer ${DEEPSEEK_API_KEY}` 
        },
        responseType: 'stream' // 声明响应为流
      }
    );
  
  // 监听流的data事件
   response.data.on('data', (chunk) => {
    res.write(`data: ${chunk}\n\n`);
  });
  
  response.data.on('end', () => {
    res.end();
  });
});

前端收到数据后,除了显示出来,还可以加点打字机效果,体验更好:

// 接收 SSE 流式数据
eventSource.onmessage = (event) => {
if (event.data === '[STREAM_END]') {
  // 流结束,关闭连接并恢复按钮
  eventSource.close();
  sendBtn.disabled = false;
  return;
}
// 拼接并更新 AI 回复内容
aiMessageDiv.innerHTML += event.data;
answerEl.scrollTop = answerEl.scrollHeight;
};

我还加了个小优化,就是当收到的内容包含换行符时,自动分段,看起来更舒服。

下面附上简单聊天机器人的完整代码:

<!DOCTYPE html>
<html lang="zh-CN">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>DeepSeek + SSE 流式聊天示例</title>
  <style>
    .chat-container {
      width: 800px;
      margin: 20px auto;
      border: 1px solid #e0e0e0;
      border-radius: 8px;
      padding: 20px;
    }
    #chat-messages {
      height: 400px;
      overflow-y: auto;
      margin-bottom: 20px;
      padding: 10px;
      border: 1px solid #f0f0f0;
      border-radius: 4px;
    }
    .user-message {
      text-align: right;
      margin: 5px 0;
      padding: 8px 12px;
      background: #e6f7ff;
      border-radius: 4px;
    }
    .ai-message {
      text-align: left;
      margin: 5px 0;
      padding: 8px 12px;
      background: #f5f5f5;
      border-radius: 4px;
    }
    .input-area {
      display: flex;
      gap: 10px;
    }
    #prompt {
      flex: 1;
      padding: 10px;
      border: 1px solid #ddd;
      border-radius: 4px;
      font-size: 14px;
    }
    button {
      padding: 10px 20px;
      background: #1890ff;
      color: white;
      border: none;
      border-radius: 4px;
      cursor: pointer;
    }
    button:disabled {
      background: #ccc;
      cursor: not-allowed;
    }
  </style>
</head>
<body>
  <div class="chat-container">
    <h2>DeepSeek 流式聊天(SSE 实现)</h2>
    <div id="chat-messages"></div>
    <div class="input-area">
      <input type="text" id="prompt" placeholder="请输入你的问题...">
      <button onclick="sendMessage()" id="send-btn">发送</button>
    </div>
  </div>

  <script>
    let eventSource = null;
    const messagesDiv = document.getElementById('chat-messages');
    const promptInput = document.getElementById('prompt');
    const sendBtn = document.getElementById('send-btn');

    // 发送消息并建立 SSE 连接
    function sendMessage() {
      const prompt = promptInput.value.trim();
      if (!prompt) return;

      sendBtn.disabled = true;
      messagesDiv.innerHTML += `<div class="user-message">你:${prompt}</div>`;
      promptInput.value = '';
      scrollToBottom();

      if (eventSource) eventSource.close();

      eventSource = new EventSource(`/stream?prompt=${encodeURIComponent(prompt)}`);

      const aiMessageDiv = document.createElement('div');
      aiMessageDiv.className = 'ai-message';
      aiMessageDiv.innerHTML = 'DeepSeek:';
      messagesDiv.appendChild(aiMessageDiv);

      // 接收 SSE 流式数据
      eventSource.onmessage = (event) => {
        if (event.data === '[STREAM_END]') {
          eventSource.close();
          sendBtn.disabled = false;
          return;
        }
        aiMessageDiv.innerHTML += event.data;
        scrollToBottom();
      };

      eventSource.onerror = (error) => {
        console.error('SSE 连接错误:', error);
        aiMessageDiv.innerHTML += '<br>(连接出错,请重试)';
        eventSource.close();
        sendBtn.disabled = false;
      };
    }

    // 滚动到最新消息
    function scrollToBottom() {
      messagesDiv.scrollTop = messagesDiv.scrollHeight;
    }
  </script>
</body>
</html>
const express = require('express');
const http = require('http');
const axios = require('axios'); // 用于调用 DeepSeek API
const app = express();
const server = http.createServer(app);

// 配置 DeepSeek API(需替换为你的 API Key)
const DEEPSEEK_API_KEY = ''; 
const DEEPSEEK_API_URL = 'https://api.deepseek.com/v1/chat/completions'; 

app.use(express.static(__dirname));

app.get('/', (req, res) => {
  res.sendFile(__dirname + '/chat.html');
});

// SSE 流式接口
app.get('/stream', async (req, res) => {
  const userPrompt = req.query.prompt;
  if (!userPrompt) {
    return res.status(400).send('请输入问题');
  }

  res.set({
    'Content-Type': 'text/event-stream',
    'Cache-Control': 'no-cache',
    'Connection': 'keep-alive',
    'Access-Control-Allow-Origin': '*' // 跨域配置(生产环境需限制域名)
  });

  try {
    // 调用 DeepSeek API(流式模式)
    const response = await axios.post(
      DEEPSEEK_API_URL,
      {
        model: "deepseek-chat", 
        messages: [
          { role: "user", content: userPrompt } 
        ],
        stream: true, 
        max_tokens: 1024 
      },
      {
        headers: {
          'Content-Type': 'application/json',
          'Authorization': `Bearer ${DEEPSEEK_API_KEY}` 
        },
        responseType: 'stream' 
      }
    );

    // 监听 DeepSeek 流数据,通过 SSE 推送给前端
    response.data.on('data', (chunk) => {
      // DeepSeek 流式返回格式为:data: { ... }\n\n(需解析)
      const chunkStr = chunk.toString().trim();
      const lines = chunkStr.split('\n');

      for (const line of lines) {
        if (line.startsWith('data:')) {
          const jsonStr = line.slice(5).trim(); // 去除 "data: " 前缀
          if (jsonStr === '[DONE]') {
            res.write('data: [STREAM_END]\n\n');
            return;
          }
          try {
            const data = JSON.parse(jsonStr);
            const content = data.choices[0]?.delta?.content;
            if (content) {
              res.write(`data: ${content}\n\n`);
            }
          } catch (e) {
            console.error('解析 DeepSeek 响应失败:', e);
          }
        }
      }
    });

    // 流结束时关闭响应
    response.data.on('end', () => {
      res.end();
    });

    // 监听客户端断开连接,清理资源
    req.on('close', () => {
      response.data.destroy(); // 终止 DeepSeek 流
      res.end();
    });

  } catch (error) {
    console.error('调用 DeepSeek API 失败:', error.message);
    res.write(`data: 调用失败:${error.message}\n\n`);
    res.write('data: [STREAM_END]\n\n');
    res.end();
  }
});

const PORT = 3000;
server.listen(PORT, () => {
  console.log(`服务已启动,访问 http://localhost:${PORT} 体验`);
});

效果如下:

image.png

最后想说的

学 SSE 的过程其实也是在理解 HTTP 协议的本质。以前总觉得 HTTP 就是 "请求 - 响应" 一次就完,没想到还能这么玩 —— 保持连接不断,持续发送数据。

对前端来说,掌握 SSE 不只是多了个技能,更重要的是理解 "用户体验" 的真正含义。有时候技术不用搞得多复杂,像 SSE 这样简单直接的方案,反而能解决大问题。

如果你也在做聊天机器人或者需要实时展示数据的项目,不妨试试 SSE,代码不多,效果却很明显。有什么问题欢迎在评论区交流,我也是个刚学没多久的新手,大家一起进步~