大模型流式输出完全指南(上):从 HTTP 长连接到手写 SSE

文章目录

    • 一、流式输出:大模型为什么要"一个字一个字"地返回?
      • [1.1 先理解一个痛点](#1.1 先理解一个痛点)
      • [1.2 一个直观的比喻:水管与水流](#1.2 一个直观的比喻:水管与水流)
      • [1.3 代码实战:invoke 与 stream 的区别](#1.3 代码实战:invoke 与 stream 的区别)
    • [二、流式输出的服务器端本质:HTTP 也能"边算边发"](#二、流式输出的服务器端本质:HTTP 也能"边算边发")
      • [2.1 传统 HTTP:请求 → 响应 → 断开](#2.1 传统 HTTP:请求 → 响应 → 断开)
      • [2.2 流式响应:连接不断,数据分块到达](#2.2 流式响应:连接不断,数据分块到达)
      • [2.3 响应头决定了传输方式](#2.3 响应头决定了传输方式)
    • 三、SSE:服务器单向推送的事实标准
      • [3.1 SSE 是什么](#3.1 SSE 是什么)
      • [3.2 SSE 的三个关键响应头](#3.2 SSE 的三个关键响应头)
      • [3.3 SSE 的数据帧格式](#3.3 SSE 的数据帧格式)
      • [3.4 手写一个 SSE 服务器(Node.js 实战)](#3.4 手写一个 SSE 服务器(Node.js 实战))
      • [3.5 SSE 与传统 HTTP 对比](#3.5 SSE 与传统 HTTP 对比)
    • [四、EventSource:浏览器端怎么接收 SSE](#四、EventSource:浏览器端怎么接收 SSE)
      • [4.1 EventSource 类](#4.1 EventSource 类)
      • [4.2 运行与验证](#4.2 运行与验证)
    • 五、全文总结
    • 六、核心知识点复盘
    • [七、常见问题 / 避坑指南](#七、常见问题 / 避坑指南)

你有没有好奇过:为什么大模型回答问题时,文字是一个一个"蹦"出来的,而不是等半天一次性出现?这背后就是流式输出。本文从最基础的 HTTP 讲起,带你搞懂流式的本质、SSE 协议、以及浏览器如何用 EventSource 接收,最后不依赖任何框架手写一个 SSE 服务。基础薄弱也能跟着读下来。

配套依赖如下:

bash 复制代码
pnpm add @langchain/core @langchain/openai dotenv

项目根目录准备一个 .env 文件:

bash 复制代码
MODEL_NAME=qwen-plus              # 或 gpt-4o-mini 等你用的模型名
OPENAI_API_KEY=sk-xxxx            # 你的 API Key
OPENAI_BASE_URL=https://dashscope.aliyuncs.com/compatible-mode/v1  # 兼容 OpenAI 协议的网关地址

一、流式输出:大模型为什么要"一个字一个字"地返回?

1.1 先理解一个痛点

大模型生成回答是一个 token 一个 token 地"想"出来的 。如果用普通的同步调用(invoke),你必须等模型把整段话全部生成完,才能看到第一个字------生成一篇 2000 字的文章可能要等十几秒,期间页面一片空白,用户体验极差。

流式输出(stream)就是解决这个问题的:模型每生成一小段,就立刻往客户端推一小段,用户看到的效果就像打字机一样,文字逐个浮现。

在调用 API 时,对应一个参数:

js 复制代码
stream: true   // 开启流式输出

1.2 一个直观的比喻:水管与水流

可以把流式输出想象成一根水管

  • 水管一头接在 LLM Server(水源),一头接在客户端(你家水龙头);
  • 模型生成的 token 就像水流,源源不断、一点一点地流向客户端;
  • 每一次流过来的一小包数据,叫做一个 chunk(数据块)
  • 数据在传输过程中会经过 buffer(缓冲区),攒一点、发一点。

所以在代码里,你拿到的不是一个"完整答案字符串",而是一条"数据流",需要循环着把每个 chunk 拼起来。

1.3 代码实战:invoke 与 stream 的区别

js 复制代码
// stream-normal.mjs ------ 普通流式输出演示(无结构化)
import 'dotenv/config';
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 = '详细介绍莫扎特的生平';

try {
  // invoke:同步调用,等模型全部生成完,一次性返回完整结果
  // stream:流式调用,立刻返回一个"数据流",后面循环读取
  const stream = await model.stream(prompt);

  let fullContent = '';   // 用来拼接完整内容
  let chunkCount = 0;     // 统计收到了多少个数据块

  // for await...of:异步迭代器,每来一个 chunk 就执行一次循环体
  for await (const chunk of stream) {
    chunkCount++;
    const content = chunk.content;   // chunk.content 是这一小段文本
    fullContent += content;          // 拼接到完整内容里
    process.stdout.write(content);   // 实时打印到终端,形成"打字机"效果
  }

  console.log(`\n\n共接收 ${chunkCount} 个数据块`);
  console.log(`完整内容:\n${fullContent}`);
} catch (error) {
  console.error('流式输出演示失败:', error);
}

关键步骤说明:

写法 返回值 行为
model.invoke(prompt) 一条完整的 AIMessage 阻塞等待,全部生成完才返回
model.stream(prompt) 一个异步数据流(AsyncIterable) 立即返回,用 for await...of 逐块读取
chunk.content 字符串 当前数据块里的文本片段,把它们 += 拼起来就是全文

易错点:stream 拿到的不是结果本身,而是一个"流对象"。必须用 for await...of 遍历才能拿到数据,直接 console.log(stream) 只会打印一个流对象,看不到内容。


二、流式输出的服务器端本质:HTTP 也能"边算边发"

2.1 传统 HTTP:请求 → 响应 → 断开

我们平时写的 HTTP 服务,是基于「请求-响应」模型的简单协议:

  1. 浏览器发请求;
  2. 服务器处理(可能要查数据库、调大模型);
  3. 服务器把完整结果一次性返回;
  4. 连接断开。

响应头里的 Content-Type 表明返回的是什么:

http 复制代码
Content-Type: text/html     # 返回网页
Content-Type: text/plain    # 返回纯文本
Content-Type: application/json  # 返回 JSON 数据

这种模式下,服务器"憋半天憋个大的",客户端在响应完成前什么也拿不到。

2.2 流式响应:连接不断,数据分块到达

但 HTTP 协议本身并没有规定"响应必须一次性发完"。服务器完全可以:

  1. 先把响应头发出去,告诉客户端"我要开始流式传输了";
  2. 然后保持连接不断开,生成一点、发一点(发一个 chunk)
  3. 全部发完后,再关闭连接。

所以"流式"不是什么新协议,它就是 HTTP,只是响应体被分成了很多个 chunk 陆续发送。大模型的流式输出、股票行情推送、日志实时滚动,底层用的都是这套机制。

2.3 响应头决定了传输方式

服务器只要在响应头里声明特定的 Content-Type,浏览器就会按流式方式处理。大模型流式输出最常用的就是下一节讲的 SSE


三、SSE:服务器单向推送的事实标准

3.1 SSE 是什么

SSE 全称 Server-Sent Events(服务器发送事件),它的工作模式是:

  • 浏览器与服务器建立一条长连接(连接建立后不断开);
  • 服务器单向地、不停地往浏览器推送消息,可以发送很多次;
  • 浏览器每收到一个数据块(chunk),就触发一次事件。

注意方向:SSE 是服务器 → 浏览器的单向推送。大模型流式输出天然就是这种方向(服务器生成、客户端接收),所以各大模型 API 的流式接口基本都基于 SSE。

3.2 SSE 的三个关键响应头

一个 SSE 响应必须带上这三个头:

http 复制代码
Content-Type: text/event-stream;   // 声明:这是 SSE 事件流,浏览器按流处理
Cache-Control: no-cache;           // 不缓存,保证每条消息都是实时的
Connection: keep-alive;            // 保持连接,不要发完就断

3.3 SSE 的数据帧格式

SSE 推送的每条消息有固定的文本格式:

复制代码
data: 你\n\n
  • data: 开头,后面跟消息内容;
  • 结尾必须是两个换行符 \n\n ,表示"这一条消息结束了",浏览器收到 \n\n 才会触发一次 onmessage

少一个换行符,浏览器就会以为消息还没发完,事件不会触发------这是手写 SSE 最常见的坑。

3.4 手写一个 SSE 服务器(Node.js 实战)

不依赖任何框架,用 Node 内置模块就能写一个 SSE 服务,代码对应 sse-demo/server.js

js 复制代码
// server.js ------ 用 node server.js 启动(CommonJS 写法)
const http = require('http'); // Node 内置 http 模块,用来起 HTTP 服务
const fs = require('fs');     // Node 内置文件模块,用来读取 html 文件

const server = http.createServer((req, res) => {
  // 路由 1:访问首页,返回 index.html
  if (req.url === '/') {
    const readStream = fs.createReadStream('./index.html'); // 创建文件可读流
    readStream.on('error', () => {
      res.writeHead(500, { 'Content-Type': 'text/plain' });
      res.end('Internal Server Error');
    });
    res.writeHead(200, { 'Content-Type': 'text/html' });
    // pipe:把文件流"管道"接到响应流上,文件读一点、往响应写一点
    readStream.pipe(res);
  }
  // 路由 2:/stream,SSE 流式接口
  else if (req.url === '/stream') {
    // 关键:三个 SSE 响应头
    res.writeHead(200, {
      'Content-Type': 'text/event-stream',
      'Cache-Control': 'no-cache',
      'Connection': 'keep-alive',
    });

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

    // 每隔 1 秒推送一个字,模拟大模型"逐个 token 生成"
    const timer = setInterval(() => {
      if (index >= words.length) {
        clearInterval(timer);
        res.end(); // 全部发完,关闭连接
        return;
      }
      // 严格按 SSE 帧格式:data: 内容 + 两个换行
      res.write(`data: ${words[index]}\n\n`);
      index++;
    }, 1000);
  }
});

server.listen(3000, () => {
  console.log('server is running on http://localhost:3000');
});

这里藏着一个重要的底层认知:stream.pipe()

fs.createReadStream 创建的是一个"文件可读流",res 是一个"可写流"。readStream.pipe(res) 的意思是:把两根水管接起来 ,文件内容读一块就自动写一块到响应里,不用手动监听 data 事件再 res.write。这就是"stream fs 流 pipe 一下"------流的价值就在于可以像水管一样串联、转接。

3.5 SSE 与传统 HTTP 对比

对比项 传统 HTTP 响应 SSE 流式响应
连接方式 请求→响应→断开 建立长连接,保持不断
发送次数 一次返回完整响应 服务器可推送多次(多个 chunk)
Content-Type text/html、application/json 等 text/event-stream
客户端体验 等待 loading,完成后一次性看到 打字机效果,逐字实时显示
典型场景 网页、普通接口 大模型流式输出、行情、实时通知

四、EventSource:浏览器端怎么接收 SSE

4.1 EventSource 类

浏览器原生提供了 EventSource 类,专门用来连接 SSE 服务。用法非常简单:给它一个 URL,它会自动建立长连接;之后服务器每推一条消息,就触发一次 onmessage 事件。

对应 sse-demo/index.html

html 复制代码
<!DOCTYPE html>
<html>
<head><meta charset="UTF-8"><title>SSE Demo Client</title></head>
<body>
  <div id="result"></div>
  <script>
    const resultEle = document.getElementById('result');

    // 1. 传入 SSE 接口地址,建立长连接
    const eventSource = new EventSource('http://localhost:3000/stream');

    // 2. 每收到一个 chunk(服务器 res.write 一次),触发一次 onmessage
    eventSource.onmessage = (event) => {
      console.log(event.data);          // event.data 就是 data: 后面的内容
      resultEle.innerText += event.data; // 拼接到页面上,逐字显示
    };
  </script>
</body>
</html>

4.2 运行与验证

bash 复制代码
cd sse-demo
node server.js
# 浏览器打开 http://localhost:3000

你会看到页面上每隔 1 秒多出一个字:「你好,欢迎了解SSE」。打开浏览器 DevTools 的 Network 面板,点中 stream 请求,会看到它一直处于 pending(进行中)状态,Response 里数据一行行增加------这就是长连接 + chunk 推送的真实样子。

补充几个 EventSource 的特点(了解即可):

  • 它是浏览器原生 API,不用装任何库;
  • 连接意外断开时,浏览器会自动重连
  • 只能发 GET 请求、只能传文本(所以大模型接口用 POST 时,前端一般用 fetch + ReadableStream 来读 SSE,原理相同)。

到这里,流式输出的完整链路就通了:服务器保持连接、按 data: ...\n\n 格式分块推送 → 浏览器 EventSource 逐块接收 → 拼起来就是完整文本。


五、全文总结

流式输出的核心链路可以一句话概括:

大模型逐 token 生成 → 服务器保持 HTTP 长连接、按 SSE 格式(Content-Type: text/event-stream + data: ...\n\n)分块推送 → 浏览器用 EventSourceonmessage 逐块接收拼接。

几个关键认知再强调一遍:

  1. 流式不是新协议,它就是 HTTP------只是响应体没有一次性发完,而是"生成一点、发一点",连接保持不断开;
  2. SSE 是服务器 → 浏览器的单向推送 ,三个响应头 + data: ...\n\n 帧格式是它的全部核心约定;
  3. 流(stream)就像水管 ,可以用 pipe() 把可读流和可写流对接,数据自动从一端流向另一端;
  4. 代码层面,model.stream() 返回的是数据流,用 for await...of 遍历,每个 chunk.content 是一小段文本,自己拼起来就是全文。

至于流式场景下如何让模型返回的是结构化 JSON(而不是纯文本),属于"结构化输出"话题,在下篇展开。


六、核心知识点复盘

知识点 一句话记忆
流式输出 stream: true,模型逐 token 推送,客户端逐块接收,体验像打字机
chunk / buffer chunk 是一次到达的数据块;buffer 是传输中的缓冲区,攒一点发一点
流式本质 不是新协议,就是 HTTP 响应被分成多个 chunk 陆续发送、连接保持
invoke vs stream invoke 阻塞等完整结果;stream 立即返回数据流,用 for await 逐块读
SSE Server-Sent Events,服务器→浏览器单向推送,基于 HTTP 长连接
SSE 三响应头 Content-Type: text/event-streamCache-Control: no-cacheConnection: keep-alive
SSE 数据帧 data: 内容\n\n两个换行标志一条消息结束,少一个都不触发
EventSource 浏览器原生 API,new EventSource(url) 建连,onmessage 收消息,断线自动重连
pipe 可读流 .pipe(可写流),像接水管一样把数据自动导过去
SSE vs 传统HTTP 传统:请求→响应→断开;SSE:长连接 + 多次推送 + 实时显示

七、常见问题 / 避坑指南

1. model.stream() 返回的东西打印出来看不到内容?

stream 返回的是流对象不是结果,必须用 for await (const chunk of stream) 遍历,文本在 chunk.content 里,需要自己 += 拼接。

2. 手写 SSE,浏览器一直不触发 onmessage?

重点排查三处:① 每条消息是否以 \n\n 两个换行 结尾(写成一个 \n 浏览器会认为消息没结束);② 响应头是否设置了 Content-Type: text/event-stream;③ 连接是否被提前 res.end() 关掉了。

3. 页面上中文显示正常,但 DevTools 里看到请求一直 pending,是报错了吗?

不是。SSE 长连接在数据传输期间本来就一直是 pending(进行中)状态,直到服务器 res.end() 才结束。Response 里能看到数据一行行增加就是正常的。

4. EventSource 连上后过一会儿断了怎么办?

EventSource 自带断线重连机制,浏览器会自动重新发起连接。如果是服务器主动 res.end()(数据发完了)则不会重连,这是正常结束。

5. 大模型接口是 POST 请求,EventSource 只能发 GET,怎么接收流式?

EventSource 确实只支持 GET。POST 场景下前端用 fetch 请求 + response.body(ReadableStream)手动读取 SSE 数据帧,自己按 \n\n 切分消息,原理和 EventSource 完全一样。

6. SSE 和 WebSocket 怎么选?

SSE 是服务器→浏览器的单向 推送、基于 HTTP、自带重连,适合大模型流式、通知、行情;WebSocket 是双向全双工,适合聊天、协同编辑等需要客户端频繁发消息的场景。大模型流式输出用 SSE 就够了。

7. 为什么不直接用一次性响应,自己在前端做"逐字显示"动画?

一次性响应必须等模型全部生成完才能拿到数据,等待期间用户什么都看不到;流式是服务端边生成边推送,首字延迟极低。打字机动画只是流式数据到达后的自然呈现,关键是数据本身就是分批到达的。

相关推荐
牛奶yu茶1 小时前
数据链路层的MTU
网络·网络通信·通信·通信协议·通信网络
keyipatience2 小时前
5种IO模型与阻塞IO,select,poll,epoll,LT和ET模式
linux·服务器·网络·数据结构·c++·算法
DFT计算杂谈2 小时前
无图形界面服务器用 Codex 终端连接本地部署的 DeepSeek
运维·服务器·网络
GLAB-Mary4 小时前
90%的网络工程师,根本没必要考HCIE!
网络·华为·华为认证·hcie·hcia·hcip
wuyk5555 小时前
【Socket 进阶之路】第 3 章 TCP 三次握手 & 四次挥手深度剖析|连接建立、断开、状态机、TIME_WAIT 核心工程问题
服务器·开发语言·网络·物联网·网络协议·tcp/ip
QYRdata11 小时前
权威数据披露:2026至2032年边缘云服务CAGR达15.6%,赛道发展驶入快车道
网络·人工智能·云计算·服务发现·边缘计算
Yang961114 小时前
鼎讯信通DXB-2000S OTDR光时域反射仪模块技术拆解:0.8m盲区
网络
中科三方14 小时前
两家域名注册商资质被ICANN终止:企业域名资产安全再受关注
前端·网络·安全·域名
超智算科技14 小时前
2026服贸会现场直击|Net Zero Hub净零算力枢纽全球首发! 超智算受邀深度参与服贸会全球OPC共创节
网络·人工智能·科技·物联网·gpu算力