一文搞懂 LLM 流式输出与 SSE

为什么 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 响应头

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

对比普通 HTTP 响应:

普通 HTTP SSE
Content-Type text/html / text/plain text/event-stream
连接 发完就断 保持不断
数据 一次性 分批推送

2.3 SSE 消息格式

kotlin 复制代码
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 完整代码

js 复制代码
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 逐段解析

路由 /:静态文件服务

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

路由 /stream:SSE 推送

js 复制代码
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);

执行流程:

swift 复制代码
第 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 前端接收

html 复制代码
<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 请求参数

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

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

4.2 同步 vs 流式

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

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

4.3 响应格式对比

同步返回一个完整的 message

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

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

css 复制代码
data: {"choices":[{"delta":{"content":"莫"}}]}
data: {"choices":[{"delta":{"content":"扎"}}]}
data: {"choices":[{"delta":{"content":"特"}}]}
data: {"choices":[{"delta":{"content":"是"}}]}
data: [DONE]

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

4.4 LangChain 流式调用

js 复制代码
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。


五、完整数据流全景图

arduino 复制代码
用户输入 "介绍一下莫扎特"
    │
    ▼
┌──────────────┐
│  前端 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

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

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


总结

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

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

相关推荐
Rain的Java大神之路2 小时前
JavaWeb开发如何解决跨域问题
java·前端·后端·nginx·web安全·面试·运维开发
职场的momo3 小时前
字节生活服务海量内推,挑战亿级订单与AI交易中台
人工智能·程序人生·面试·职场和发展·跳槽·生活·业界资讯
黄敬峰8 小时前
一文搞懂 AI 聊天应用的 Memory 模块:Milvus 向量数据库实战
面试
CoderYanger8 小时前
前端基础——JavaScript(基础语法)(下篇)
java·开发语言·前端·javascript·程序人生·面试·职场和发展
~木雨13 小时前
Java 线程池七问七答:参数、执行流程、拒绝策略到 ThreadLocal 内存泄漏,面试必背
java·面试·线程池·threadlocal·threadpool·executor
ocean210314 小时前
2025-2026年计算机网络面试高频知识点洞察
计算机网络·面试·职场和发展·https·tcp·面试真题·秋招春招
lee_curry1 天前
AI Agent 工程师完整学习路线(面向生产级项目与面试)
人工智能·学习·ai·面试·agent
江湖十年1 天前
在 Go 中使用 dyno 包处理动态对象
后端·面试·go
数智启示录1 天前
PostgreSQL 内存调优实战(第 12 篇):work_mem 只调大 64 倍,峰值为什么远不止 64 倍
运维·数据库·经验分享·postgresql·面试