从"堵车"到"水管":一文搞懂 SSE 流式输出与大模型结构化解析

一、为什么需要"流式输出"?从一次堵车说起

1. 传统 HTTP 响应是什么感觉?

我们先回忆一下传统的"请求---响应"模型。你打开一个网页,浏览器向服务器发出请求,服务器经过一顿计算,把完整的 结果打包好,一次性 response 返回给你,然后连接就断了

这就像你去餐厅点餐:你点完菜,厨房做好了,然后服务员把一整盘菜一次端上来,整个过程你可能要干等好几分钟。如果这道菜特别复杂,你等的时候只能干瞪眼,什么都看不到。

同步等待 (也叫阻塞)最大的问题就是:体验差、心理上很漫长。你用 ChatGPT 提问,如果它要思考 8 秒才一次性把答案甩给你,这 8 秒里屏幕一动不动,你会觉得"是不是卡死了?"

2. 流式输出:把"一整盘菜"变成"一道道上"

流式输出的思路是:服务器不再一次性把结果发完,而是边算边发,算出来一点就发给客户端一点。

这就像餐厅改成了"流水席"------服务员每炒好一小盘,就立刻端上来,你在等后面菜的时候,嘴边已经有的吃了。你看到文字一个个出现,不但不着急,反而觉得"哦,它在干活,我放心了"。

这个思考对不对?其实大模型(LLM,比如 GPT)在生成回答时,本身就是一个字一个字(更准确说是"一个 Token 一个 Token")往外蹦 的。既然如此,何不让服务器也一个字一个字地推给前端,而不是等它全部想完再一起发?这就产生了流式输出

3. 一个形象的比喻:水管

还记得 readme.md 里那句特别生动的比喻吗?

水管,一头接着 LLM server,一头连着客户端,不断地有 token 流向客户端。

把大模型想成一条水龙头stream 就是一条水管,token(文本的"水珠")在水管里一滴一滴地流到客户端。普通的一次性响应,则是把整桶水一次性倒过来。

好,现在你脑子里应该有画面了。那么问题来了:"流式"这个数据,具体是通过什么协议、什么格式在网络上跑的? 答案正是 SSE。


二、SSE 是什么?一句话讲明白

1. 定义

SSE(Server-Sent Events,服务器推送事件) 是一种基于 HTTP 的、服务器单向往浏览器持续推送消息的技术。它的核心特点是:

  • 服务器主动给浏览器推送数据,浏览器不用一直问。
  • 只发不还 :数据是服务器→浏览器单向流,浏览器不往回发。
  • 长连接 :建立之后,连接不断开,服务器可以一次、一次、又一次地往里面塞数据。

再看 readme.md 里对 SSE 的描述:

服务器单向不停地往浏览器推送消息,发送多次,不会断开链接。浏览器建立一条长链接,服务器一点一点(chunk)发数据,也就是流式输出。

这里的 chunk (数据块)是个关键词:流式传输不是把整块数据一发完,而是切成一个小块、一个小块地发,这一个个小块就叫 chunk。前面说的"水珠"、这里说的"数据块",都是它。

2. SSE 到底"长什么样"?看三个响应头

SSE 表面上和普通 HTTP 请求没什么两样,也是一个 HTTP 响应,但它响应头 与普通网页很不一样。看 sse-demo/server.js 里那段核心代码:

js 复制代码
res.writeHead(200, {
  "content-Type": "text/event-stream",  // ① 告诉浏览器:这是 SSE!
  "Cache-Control": "no-cache",           // ② 不要缓存,每次都要最新
  Connection: "keep-alive",              // ③ 保持长连接,别断
});

这三个响应头是 SSE 的"身份证":

  • Content-Type: text/event-stream :这是最关键的一个。浏览器一看到这个类型,就知道"哦,这是服务器推送事件",会触发我们后面要讲的 EventSource 的逻辑。对比一下,普通网页是 text/html、普通文本是 text/plain
  • Cache-Control: no-cache:告诉浏览器"别缓存这个响应",因为 SSE 是实时推送,你缓存一个过期的版本毫无意义。
  • Connection: keep-alive:这是 HTTP 的"长连接"开关,意思是"这个连接保持活着,别发完就断"。

小对比:传统 HTTP 是"请求 → 响应 → 断开连接",像打电话说完就挂。而 SSE 是"请求 → 响应 → 保持通话",服务器可以顺着这条线持续不断地说话。

3. SSE 数据的格式:data: 前缀

服务器在推数据时,每条消息都有约定好的格式。server.js 里是这样写的:

js 复制代码
res.write(`data: ${word}\n\n`);

这条消息的格式是:data: + 一个空格 + 数据内容 + 两个换行符\n\n)。

  • data: 是固定前缀,表示"这是一条数据"。
  • 末尾的两个 \n\n(连续两个换行)是整个 SSE 协议里非常重要的分隔符------它告诉浏览器"这一条消息发完了 ,可以触发一次 onmessage 了"。

一句话:SSE 的每一帧就是 data: 内容\n\n,多个 data 帧拼在一起,就形成了源源不断的流。


三、后端怎么写一个 SSE 接口?看 server.js

现在我们把上面的理论落到代码上。sse-demo/server.js 完整演示了"如何用 Node.js 原生代码搭一个 SSE 接口"。

1. 回顾:Node 写一个 HTTP 服务器

文件开头引入 Node 内置模块:

js 复制代码
const http = require("http");
const fs = require("fs");

然后 http.createServer 创建一个服务器,根据 req.url(请求的地址)做不同处理。本 demo 有两个路由:

  • /:返回 HTML 页面(前端页面 index.html)。
  • /stream:返回 SSE 流(核心内容)。

2. 核心:异步生成器 + 逐个吐字

SSE 接口 /stream 用了一个"异步生成器"(async function*),它每 yield 一个词,就发一个 chunk:

js 复制代码
async function* streamWords() {
  const words = ["你", "好", ",", "欢", "迎", "了", "解", "see"];
  for (const word of words) {
    await sleep(1000); // 模拟耗时,每 1 秒吐一个字
    yield word;
  }
}

这里最关键的两点:

  • async function*(异步生成器) :它像一个"流水线",你每次调用它,它就 yield(产出)一个值,然后暂停,等你下次再要。
  • await sleep(1000) :每生产一个字,故意停 1 秒。这是为了模拟"实时生成"的效果。真实的大模型也是这样,它算一个 token 需要几十毫秒,所以你会看到文字"蹦"出来有节奏。

3. 把生成器接到 SSE 上

最后把生成器产出的每个词,用 SSE 格式 data: xxx\n\n 写出去:

js 复制代码
(async () => {
  for await (const word of streamWords()) {
    res.write(`data: ${word}\n\n`); // 逐个以 SSE 帧发送
  }
  res.end(); // 全部发完,关闭连接
})();

这段代码等价于:服务器一边产词,一边 res.write 推给浏览器,最后 res.end() 收尾。注意:在整个过程中,HTTP 连接一直是开着的,只有所有词都发完了才真正结束。

到这一步,你已经用原生 Node 写出了一个可运行的 SSE 后端。是不是比想象中简单?它本质上就是"一个 HTTP 响应,但不断开,持续写数据"。


四、前端怎么接收 SSE?看 index.html 和 EventSource

后端会推了,前端怎么"收"呢?SSE 在浏览器里有一个专门的类叫 EventSource 。看 sse-demo/index.htmlscript 部分:

js 复制代码
const resultEle = document.getElementById("result");

// ① 用 EventSource 建立连接,传入 SSE 接口的 URL
const eventSource = new EventSource("http://localhost:3000/stream");

// ② 有数据到达时,触发 onmessage 事件
eventSource.onmessage = (e) => {
  console.log(e.data);
  resultEle.textContent += e.data + "\n"; // 把新内容追加到页面
};

1. new EventSource(url)

浏览器用 new EventSource(".../stream") 向服务器发起连接。注意两点:

  • 要传 SSE 接口的 URL (也就是后端那个 text/event-stream 的地址)。
  • 这个类只负责接收,它不会向服务器发数据------所以 SSE 是"单向"的。

2. onmessage 事件

当服务器每推过来一条 data: xxx\n\n,浏览器就自动触发一次 onmessage,并把数据塞进 e.data 里。

所以每次 onmessage 触发,我们就拿到一个 word(比如"你"、"好"......),然后用 resultEle.textContent += e.data + "\n" 把它追加 到页面上。这样你就看到页面上的字一个一个地出现,完美还原了"打字机"效果。

生活类比:EventSource 就像给你派了一个"永远在线"的快递员,他(服务器)一到时间就送来一个小包裹(chunk),每次送来你就拆开,把里面的东西贴在墙上(页面),一直贴到他说"送完了"。


五、把"流式"接上大模型:stream-normal.mjs

讲了半天 Node 和浏览器,可能有人会问:这和"AI 聊天"到底怎么连起来? 别忘了我们是在 output_parser 这个目录里,这里的核心业务就是"让大模型流式输出,并把结果解析成结构化数据"。

src/stream-normal.mjs

js 复制代码
import { ChatOpenAI } from "@langchain/openai";

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

const prompt = `详细介绍莫扎特的信息。`;
const stream = await model.stream(prompt); // 流式:拿到一个"数据流"

1. invokestream 的对比

这里有个非常重要的对比:

  • model.invoke(prompt)同步 。它会等大模型把完整答案都算完,然后一次性返回。就像前面说的"一整盘菜端上来"。
  • model.stream(prompt)流式 。它返回的是一个数据流(stream) ,你可以 for await 循环,一个 chunk 一个 chunk 地读。就像"一道道菜端上来"。

stream 返回的 stream 对象,本质上就是那个"水管",你往里抽水(for await),就不断拿到 token。

2. 用 for await 逐块读取

代码里演示了怎么消费这个流:

js 复制代码
for await (const chunk of stream) {
  const content = chunk.content;
  fullContent += content;
  process.stdout.write(content); // 实时打印
}
  • for await...of:就是一个一个地读流里的 chunk。
  • 每读到一个 chunk,就取出它的 content(文本内容),拼进 fullContent,同时 process.stdout.write 把它立刻打印出来

这样终端就会看到莫扎特的介绍一句一句蹦出来,而不是卡了半天一次性全出来。这就是"流式"在 AI 场景里的真实模样。


六、既然会流式了,怎么让大模型输出"结构化 JSON"?

现在来到我们目录的另一个核心概念------output_parser(输出解析器) 。大模型输出的是"自然语言",但程序往往希望拿到格式固定、能直接处理的数据(比如 JSON),好拿去存数据库、画图表、做后续逻辑。

1. 用 prompt 约束大模型

最直接的办法是在提示词里把格式要求写死 。看 src/normal.mjs

js 复制代码
const prompt = `
请介绍一下爱因斯坦的信息。 请以 JSON 格式返回,
包含以下字段: name(姓名)、birth_year(出生年份)、
nationality(国籍)、major_achievement(主要成就, 数组)、
famous_theory(著名理论)
`;

这句 prompt 明确告诉大模型:"请你以 JSON 格式返回,并且必须包含这几个字段。" 大模型很听话,就会按这个"模板"输出一个 JSON。这就是所谓 "prompt 约束"------靠语言把大模型"框"进你要的格式里。

2. 大模型输出的"坑":JSON 被 markdown 包住了

理想情况下大模型直接给纯 JSON,但现实中它经常会"好心"地加一层包装。比如它输出:

json 复制代码
```json
{
  "name": "爱因斯坦",
  "birth_year": 1879
}
```

注意,它是被 json ... (markdown 代码块)包裹 的。为什么?readme.md 里有句话点破了原因:

LLM 输出常是 markdown 格式,这是展示的需要。

大模型默认是"给人看"的,所以会顺手用 markdown 把内容美化一下。但对程序来说,JSON.parse() 解析不了带 ```````json```` 前缀的东西,会直接报错

3. 用正则"剥掉"包装

所以 output_parser 的职责之一,就是把 markdown 包裹去掉,只留下纯 JSONnormal.mjs 里用正则 replace / match 实现了这一点:

js 复制代码
// 用正则取出 markdown 中的 ```json ... ``` 代码块内容
const jsonMatch = response.content.match(/```json\s*([\s\S]*?)\s*```/);
const jsonStr = jsonMatch ? jsonMatch[1] : response.content;
const jsonResult = JSON.parse(jsonStr);
console.log(jsonResult);

拆开看这几行:

  • match(/```json\s*([\s\S]*?)\s*```/):这是一个正则,意思是"找到 ```````json```` 开头、``````````` 结尾的中间那段内容"。其中 ([\s\S]*?) 是非贪婪地"抓到中间的所有字符"(\s\S 表示匹配任意字符,包括换行)。
  • jsonMatch[1]:取正则捕获到的第一组(也就是真正的 JSON 文本)。
  • 如果正则没匹配到(jsonMatch 为 null),就退而求其次,直接拿原始 response.content
  • 最后 JSON.parse(jsonStr):把字符串解析成真正的 JS 对象。

之后你就可以 jsonResult.namejsonResult.birth_year 这样访问数据了。这就是"output_parser"的核心价值:把大模型的"自然语言输出",清洗成程序能直接用的"结构化 JSON"。


七、SSE 与其他方案的区别,以及它的局限

学到这里,你可能会想:SSE 和 WebSocket 什么区别?为什么大模型聊天不直接用 WebSocket 更"全能"? 简单说一下,帮你建立全局观。

1. SSE vs WebSocket

对比项 SSE(Server-Sent Events) WebSocket
方向 单向:服务器→浏览器 双向:服务器和浏览器互相收发
协议 基于普通的 HTTP 独立协议 ws:// / wss://
能否发数据回服务器 不能(另发请求)
断线重连 自动重连(EventSource 内置) 需要自己实现
使用难度 简单,纯 HTTP 就能跑 较复杂,需要专门的库

2. 为什么 AI 场景常用 SSE?

AI 聊天(像 ChatGPT)主要是"机器单方面往用户输出文字 ",用户不需要频繁往服务器发消息(发请求另用普通的 POST 就行)。这种"服务器往客户端单向发 "的场景,SSE 正好最合适

  • 直接用 HTTP,后端和前端的理解成本都低。
  • EventSource 浏览器内置,一行代码就能连。
  • 支持自动断线重连,体验更稳。

3. SSE 的局限

SSE 也有短板:只能单向、只适合"服务器发起"的推送 。如果需要"用户边打字,服务器边实时回应"这种双向互动 (比如在线协同编辑、游戏),SSE 就不够用了,得用 WebSocket。选哪个,取决于你的场景是"单向发"还是"双向聊"。

相关推荐
聚铭网络41 分钟前
【一周安全资讯】两项数据资产分类与登记国家标准9月1日实施;索尼、华纳联合起诉Anthropic,指控盗用版权音乐训练Claude模型
人工智能·安全·分类
自信人间三百年43 分钟前
STEPONMOON的人工智能之旅(五)
人工智能
阿尔法工场研究院43 分钟前
deepseek时刻到来前,洋河先重置了自己
人工智能
财复视界1 小时前
光智科技15.6亿存货背后的业务逻辑与风险管控
大数据·人工智能·科技
卷无止境1 小时前
除了写代码,AI智能体还能帮开发者做什么
人工智能·python
Carol06301 小时前
神经网络、激活函数:ReLU / SiLU / GELU
人工智能·深度学习·神经网络
揽秀亭长1 小时前
做视频时如何快速完成视频转文字?几种方法实测
人工智能·音视频
夏文强1 小时前
DeepSeek Harness 可追溯性实战:会话日志的 resume、fork 与 replay
人工智能·开源·大模型·agent·deepseek
爱吃苹果的梨叔1 小时前
AI 算力监控中心怎么建?分布式坐席 + 大屏联动 + 过程回放
人工智能·分布式·python