一文搞懂 LLM 流式输出与 SSE

21 阅读5分钟

为什么 ChatGPT 的回答是一个字一个字蹦出来的?这背后是 SSE 协议在撑腰。本文从 Node.js 手写 SSE 服务开始,逐层拆解流式输出的底层原理。


你会收获什么

  • 理解 SSE(Server-Sent Events)协议的完整机制
  • 用 Node.js 原生代码手写一个 SSE 服务
  • 搞懂 LLM 流式输出的底层传输原理
  • 对比同步请求与流式请求的本质区别

一、流式输出是什么?

你用 ChatGPT 时,回答不是一次性全出来的,而是一个字一个字往外蹦。这就是流式输出。

用一个比喻:

同步请求:你点了一桌菜,厨师全部做完,服务员一次性端上来。
流式输出:厨师每做完一道菜,服务员就端一道上来,你边吃边等。

技术上说,就是服务端不等数据全部生成,边生成边发给客户端。


二、SSE 协议详解

2.1 什么是 SSE?

SSE(Server-Sent Events)是一种服务器向客户端单向推送的协议。

传统 HTTP:请求 → 响应 → 断开连接
SSE:      请求 → 响应 → 保持连接 → 持续推送 → 关闭

关键特点:

  • 单向:只有服务器→客户端,客户端不能通过 SSE 发消息
  • 基于 HTTP:不需要 WebSocket 那样的协议升级
  • 自动重连:浏览器的 EventSource API 内置重连机制

2.2 SSE 响应头

res.writeHead(200, {
  'Content-Type': 'text/event-stream',   // 告诉浏览器:这是事件流
  'Cache-Control': 'no-cache',           // 禁止缓存,保证实时
  'Connection': 'keep-alive',            // 保持 TCP 长连接
});

对比普通 HTTP 响应:

普通 HTTPSSE
Content-Typetext/html / text/plaintext/event-stream
连接发完就断保持不断
数据一次性分批推送

2.3 SSE 消息格式

data: 你好\n\n
data: 世界\n\n
data: [DONE]\n\n

规则很简单:

  • 每条消息以 data: 开头(注意有空格)
  • 消息之间用 \n\n(双换行)分隔
  • data: [DONE] 是结束信号(OpenAI 约定,非 SSE 标准)

为什么需要 data: 前缀? 因为 SSE 协议还支持 event:、id:、retry: 等字段,data: 标识"这是消息体"。浏览器收到后会自动剥掉前缀,只把内容放到 e.data 里。


三、Node.js 手写 SSE 服务

3.1 完整代码

const http = require('http');
const fs = require('fs');
const path = require('path');

const server = http.createServer((req, res) => {
  // 路由1:返回 HTML 页面
  if (req.url === '/') {
    const filePath = path.join(__dirname, 'index.html');
    fs.readFile(filePath, (err, data) => {
      if (err) {
        res.writeHead(500, { 'Content-Type': 'text/plain' });
        res.end('Internal Server Error');
        return;
      }
      res.writeHead(200, { 'Content-Type': 'text/html' });
      res.end(data);
    });

  // 路由2:SSE 流式推送
  } else if (req.url === '/stream') {
    res.writeHead(200, {
      'Content-Type': 'text/event-stream',
      'Cache-Control': 'no-cache',
      'Connection': 'keep-alive',
    });

    let words = ['你', '好', ', ', '欢', '迎', '了', '解', 'sse'];
    let index = 0;

    const timer = setInterval(() => {
      if (index >= words.length) {
        clearInterval(timer);
        res.end();  // 关闭连接
        return;
      }
      // SSE 格式发送
      res.write(`data: ${words[index]}\n\n`);
      index++;
    }, 1000);
  }
});

server.listen(3000, () => {
  console.log('server is running on port 3000');
});

3.2 逐段解析

路由 /:静态文件服务

const filePath = path.join(__dirname, 'index.html');
fs.readFile(filePath, (err, data) => { ... });
  • path.join(__dirname, 'index.html') 拼出绝对路径,避免相对路径找不到文件
  • fs.readFile 回调式读取文件,读完后 res.end(data) 一次性返回

路由 /stream:SSE 推送

let words = ['你', '好', ', ', '欢', '迎', '了', '解', 'sse'];
let index = 0;

const timer = setInterval(() => {
  if (index >= words.length) {
    clearInterval(timer);
    res.end();
    return;
  }
  res.write(`data: ${words[index]}\n\n`);
  index++;
}, 1000);

执行流程:

第 1 秒 → res.write("data: 你\n\n")
第 2 秒 → res.write("data: 好\n\n")
第 3 秒 → res.write("data: , \n\n")
...
第 8 秒 → res.write("data: sse\n\n")
第 9 秒 → clearInterval + res.end()  // 关闭连接

关键点:

  • res.write() 是追加发送,不关闭连接
  • res.end() 才关闭连接
  • \n\n 是 SSE 消息分隔符,必须有

3.3 前端接收

<div id="result"></div>
<script>
  const resultEle = document.getElementById('result');
  const eventSource = new EventSource('http://localhost:3000/stream');

  eventSource.onmessage = (e) => {
    console.log(e.data);        // "你"、"好"、", " ...
    resultEle.innerText += e.data;
  };
</script>

EventSource 做了什么:

  1. 向 /stream 发起 HTTP 请求
  2. 保持连接不断开
  3. 收到 data: xxx\n\n 后,自动剥掉 data: 前缀
  4. 触发 onmessage,把内容放到 e.data

四、LLM 流式输出的本质

4.1 请求参数

body: JSON.stringify({
  model: 'deepseek-v4-flash',
  messages: [{ role: 'user', content: '介绍一下莫扎特' }],
  stream: true    // 关键:开启流式
})

一个 stream: true 参数,决定了服务端用哪种方式返回数据。

4.2 同步 vs 流式

同步(stream: false):
浏览器 ──请求──→ LLM Server ──生成5秒──→ 一次性返回完整JSON ──→ 页面显示
                                                    ↑
                                     用户盯着"思考中..."等了5秒

流式(stream: true):
浏览器 ──请求──→ LLM Server ──吐一个token──→ 客户端收到
                            ──吐一个token──→ 客户端收到
                            ──吐一个token──→ 客户端收到
                            ──...
                                     ↑
                        用户看到一个字一个字往外蹦

4.3 响应格式对比

同步返回一个完整的 message:

{
  "choices": [{
    "message": {
      "content": "莫扎特是奥地利作曲家..."
    }
  }]
}

流式每次返回一个 delta(增量):

data: {"choices":[{"delta":{"content":"莫"}}]}
data: {"choices":[{"delta":{"content":"扎"}}]}
data: {"choices":[{"delta":{"content":"特"}}]}
data: {"choices":[{"delta":{"content":"是"}}]}
data: [DONE]

几十个 delta 拼起来 = 一个完整的 message。

4.4 LangChain 流式调用

import { ChatOpenAI } from "@langchain/openai";

const model = new ChatOpenAI({
  modelName: process.env.MODEL_NAME,
  apiKey: process.env.OPENAI_API_KEY,
  configuration: { baseURL: process.env.OPENAI_BASE_URL },
});

const stream = await model.stream('详细介绍莫扎特');

for await (const chunk of stream) {
  process.stdout.write(chunk.content);  // 实时输出,不换行
}

model.stream() 返回的是一个 AsyncIterator,每次 yield 一个 chunk,每个 chunk 的 content 就是一个或几个 token。


五、完整数据流全景图

用户输入 "介绍一下莫扎特"
    │
    ▼
┌──────────────┐
│  前端 fetch   │  stream: true
└──────┬───────┘
       │
       ▼
┌──────────────────────────────────────────────┐
│           LLM Server(DeepSeek / OpenAI)     │
│                                              │
│  token 1 生成完 → 写入 HTTP 响应流            │
│  token 2 生成完 → 写入 HTTP 响应流            │
│  token 3 生成完 → 写入 HTTP 响应流            │
│  ...                                         │
│  全部生成完   → 发送 [DONE] → 关闭连接        │
└──────────────────┬───────────────────────────┘
                   │
                   │ SSE 格式:data: {json}\n\n
                   ▼
┌──────────────────────────────────────────────┐
│           前端接收                             │
│                                              │
│  ReadableStream → TextDecoder → Buffer       │
│  → split('\n') → JSON.parse → delta.content  │
│  → 拼接到页面                                 │
└──────────────────────────────────────────────┘

六、SSE 不只用于 LLM

SSE 是通用的服务器推送协议,LLM 流式输出只是其中一个应用场景:

场景说明
LLM 聊天ChatGPT、DeepSeek 的打字机效果
股票行情实时推送价格变动
消息通知站内信、系统告警
日志流实时查看构建/部署日志
进度条长任务的实时进度更新

七、SSE vs WebSocket

SSEWebSocket
方向单向(服务器→客户端)双向
协议基于 HTTP独立协议(ws://)
自动重连浏览器内置需手动实现
数据格式纯文本文本 + 二进制
适用场景通知、流式输出聊天、游戏、协同编辑

选择建议:如果你只需要服务器推数据给客户端(如 LLM 输出),用 SSE 就够了,更简单。如果需要双向通信(如实时聊天),用 WebSocket。


总结

stream: true
    │
    ▼
LLM Server 用 SSE 协议返回数据
    │
    ▼
每生成一个 token → 发送一条 data: {json}\n\n
    │
    ▼
前端 EventSource / ReadableStream 接收
    │
    ▼
逐字拼接到页面 → 打字机效果

核心就一句话:LLM 流式输出 = SSE + 逐 token 推送。stream: true 这个参数背后,是服务端把 HTTP 响应变成了一条"水管",token 像水一样源源不断地流向客户端。