SSE流式输出详解:大模型打字机效果底层原理

文章目录

    • 前言
    • 一、为什么需要流式输出?
      • [1. 传统 HTTP 响应到底有多折磨人?](#1. 传统 HTTP 响应到底有多折磨人?)
      • [2. 流式输出:把"一整盘"改成"一道一道上"](#2. 流式输出:把"一整盘"改成"一道一道上")
      • [3. 一个经典比喻:水管](#3. 一个经典比喻:水管)
    • [二、SSE 是啥?一句话给你讲明白](#二、SSE 是啥?一句话给你讲明白)
      • [1. 定义](#1. 定义)
      • [2. SSE 长什么样?先看三个响应头](#2. SSE 长什么样?先看三个响应头)
      • [3. SSE 数据的格式:`data:` 前缀](#3. SSE 数据的格式:data: 前缀)
    • [三、后端怎么搞一个 SSE 接口?看 server.js](#三、后端怎么搞一个 SSE 接口?看 server.js)
      • [1. 回顾:Node 写 HTTP 服务器](#1. 回顾:Node 写 HTTP 服务器)
      • [2. 核心:异步生成器 + 逐个吐字](#2. 核心:异步生成器 + 逐个吐字)
      • [3. 把生成器接到 SSE 上](#3. 把生成器接到 SSE 上)
    • [四、前端怎么收?看 index.html 和 EventSource](#四、前端怎么收?看 index.html 和 EventSource)
      • [1. `new EventSource(url)`](#1. new EventSource(url))
      • [2. `onmessage` 事件](#2. onmessage 事件)
    • 五、把流式接上大模型:stream-normal.mjs
      • [1. `invoke` 和 `stream` 的对比](#1. invokestream 的对比)
      • [2. 用 `for await` 逐块读取](#2. 用 for await 逐块读取)
    • [六、会流式了,怎么让大模型输出结构化 JSON?](#六、会流式了,怎么让大模型输出结构化 JSON?)
      • [1. 用 prompt 约束大模型](#1. 用 prompt 约束大模型)
      • [2. 大模型输出的"坑":JSON 被 markdown 包住了](#2. 大模型输出的"坑":JSON 被 markdown 包住了)
    • [七、SSE 和其他方案的对比,以及它的局限](#七、SSE 和其他方案的对比,以及它的局限)
      • [1. SSE vs WebSocket](#1. SSE vs WebSocket)
      • [2. 为什么 AI 场景常用 SSE?](#2. 为什么 AI 场景常用 SSE?)
      • [3. SSE 的局限](#3. SSE 的局限)


P.S. 无意间发现了一个巨牛的人工智能教程,非常通俗易懂,对AI感兴趣的朋友强烈推荐去看看, 传送门https://blog.csdn.net/HHX_01

前言

先别急着往下划,我有个灵魂问题想问你:你给 ChatGPT 发了一句话,屏幕上一个字都没有,光标在那儿闪了八秒------这八秒你脑子里在想什么?

反正我想的是:完了,是不是欠费了。

这种"等了半天,啥都没有,最后哗啦一下全出来"的体验,就是传统 HTTP 的优良传统。今天咱们就聊聊,怎么把这种"便秘式"输出,改成"涓涓细流"式的输出------也就是 SSE。

一、为什么需要流式输出?

1. 传统 HTTP 响应到底有多折磨人?

传统的"请求---响应"模型是这样的:你发请求,服务器吭哧吭哧算完,把完整的结果 打包好,一次性 response 给你,然后连接咔嚓一断。

像极了你去餐厅点菜:点完单,厨房开火,你在座位上干瞪眼,等十五分钟,服务员端上来一整盘。菜是好菜,问题是等待的过程里你连个响都听不到,只能反复刷手机,怀疑厨师是不是在厨房里睡着了。

同步等待(也叫阻塞)最大的问题就俩字:干等。你要是拿它去问大模型,模型思考 8 秒才把答案一次性甩出来,这 8 秒屏幕一动不动,你大概率会做出三件事:刷新页面、检查网络、问候产品经理。

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

流式输出的思路特别朴素:服务器别憋大招了,算出来一点就发一点。

这就好比餐厅改成了流水席------厨师炒好一小碟就端上来,你边吃边等,嘴不闲着,心里也踏实。你看屏幕上的文字一个一个往外蹦,非但不着急,反而觉得:"哦,它在干活,行,我不慌。"

这个思路其实特别顺理成章:大模型生成回答,本来就是一个 Token 一个 Token 往外蹦的,它自己都是一字一字"说"的,你非让它攒成一大坨再一次性交给你,这不是硬逼着哪吒憋大招吗?既然它天生是流式的,服务器为什么不边收边推?

3. 一个经典比喻:水管

把大模型想象成一个水龙头stream 就是一根水管,token 就是里面的"水珠",一滴一滴往客户端流。而普通的一次性响应,相当于拿整桶水兜头浇下来------爽是爽,就是容易呛着。

(我第一次看到这个比喻的时候,就感觉写这个 demo 的人,一定是在修水管的时候顿悟的。)

好,画面有了。那问题来了:流式数据在网络上跑,到底用的什么协议、什么格式?答案就是今天的主角------SSE。

二、SSE 是啥?一句话给你讲明白

1. 定义

SSE(Server-Sent Events,服务器推送事件),基于 HTTP,服务器单向地、持续地往浏览器推消息。三个特点:

  • 服务器主动推,浏览器不用一遍遍问。
  • 只发不收,单向流,浏览器不往回发。
  • 长连接,建立了就不断开,服务器想塞多少塞多少。

你品品,"只发不收"这个设定,像不像你那个只会在群里发通知、从来不回消息的部门群?

2. SSE 长什么样?先看三个响应头

SSE 表面上就是个普通 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 是实时推送,你缓存一个过期版本有啥意义?总不能 AI 都讲到第三句了,你还在循环第一句吧。
  • 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,多个帧拼在一起,就成了源源不断的流。你就把它理解成摩斯电码的 HTTP 版,只不过不是"滴滴答",是"data data 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. 核心:异步生成器 + 逐个吐字

/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 格式写出去:

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 响应,但不断开,持续写数据"。说穿了,比某些同事的周报还简单。

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

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

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 聊天到底怎么连上?别急,核心业务来了------让大模型流式输出,并把结果解析成结构化数据。

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 地读。就像"一道道菜端上来"。

一句话总结:invoke 是自助餐结账后一次性端齐,stream 是吃回转寿司,转过来一盘拿一盘。

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 立刻打印。

这样终端里莫扎特的介绍就是一句一句蹦出来的,而不是卡了半天哗啦全出来。我第一次跑这段代码的时候盯着终端看了半天,觉得莫扎特比我还话痨。

六、会流式了,怎么让大模型输出结构化 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 约束------靠语言把大模型框进你要的格式里。说好听点叫提示工程,说难听点叫跟 AI 反复确认"你到底听懂了没"。

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

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

json 复制代码
```json
{
  "name": "爱因斯坦",
  "birth_year": 1879
}
复制代码
注意,它是被 ```json ... ```(markdown 代码块)**包裹**的。为什么?因为大模型默认是"给人看"的,所以会顺手用 markdown 美化一下。这就像你去餐厅点菜,菜本身没问题,结果服务员非要给你盖个盖子、系个蝴蝶结------你拆包装的时间比吃的时间还长。

但对程序来说,`JSON.parse()` 解析不了带 ```json 前缀的东西,会直接报错。程序看到带代码块的 JSON,大概率和看到带注释的 JSON 一样崩溃。

### 3. 用正则"剥掉"包装
所以 output_parser 的职责之一,就是把 markdown 包裹去掉,只留下纯 JSON。`normal.mjs` 里用正则实现了这一点:
```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 聊天主要是"机器单方面往用户输出文字",用户不需要频繁往服务器发消息(发请求另用普通 POST 就行)。这种"服务器往客户端单向发"的场景,SSE 正好最合适:

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

说白了,SSE 就像公司里那种只负责群发消息的机器人,而 WebSocket 是两个人面对面唠嗑------AI 聊天这个场景,本来就是你听 AI 说,用 SSE 就够了。

3. SSE 的局限

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

选哪个,取决于你的场景是"单向发"还是"双向聊"。别问哪种更好,问你的需求是听广播还是打电话------广播用 SSE,电话用 WebSocket,你要是拿广播的方式打电话,对方只会以为你在演独角戏。

P.S. 无意间发现了一个巨牛的人工智能教程,非常通俗易懂,对AI感兴趣的朋友强烈推荐去看看,传送门https://blog.csdn.net/HHX_01

相关推荐
李兆龙的博客1 小时前
从一到无穷大 #91:从 Habitat 看存储平台的整合与分工
数据库·人工智能·架构
阿文和她的Key1 小时前
OpenAI 关 Pro 入口事件复盘:企业 AI 架构的稳定性问题,不只是故障应急
人工智能·架构
sdzhyt1 小时前
从“台账分散”到“智能调度”,AI如何走进应急避难场所管理一线?
人工智能
. . . . .1 小时前
comfyUI原理
人工智能·算法·机器学习
在所不辞兄1 小时前
【零基础学智能仿真-16】循环神经网络与LSTM/GRU——学习力学响应的历史记忆
人工智能·rnn·神经网络·gru·lstm·工程技术·工程仿真
愚公搬代码1 小时前
【愚公系列】《造浪者:AI创业实战地图》002-AI创业的六个本质差异
人工智能
Dawson Zhu1 小时前
从“会回答“到“能执行“:AI Agent 的系统构成、运行机制与工程实践
人工智能·语言模型·架构·aigc·agi
七夜zippoe1 小时前
AI Agent 的核心能力循环:感知→规划→执行→反思→记
人工智能·ai·agent·循环·核心能力
loser.with.m1 小时前
【AgentScope 2.0】8-MCP 集成:Agent 的「USB-C 接口」是怎么接的
人工智能·spring boot·agentscope