为什么 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 那样的协议升级
- 自动重连 :浏览器的
EventSourceAPI 内置重连机制
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 做了什么:
- 向
/stream发起 HTTP 请求 - 保持连接不断开
- 收到
data: xxx\n\n后,自动剥掉data:前缀 - 触发
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 像水一样源源不断地流向客户端。