讲透 SSE:从流式响应到 LangChain model.stream()

9 阅读3分钟

大模型回答问题时,如果等整段内容全部生成完再返回,用户会经历明显的等待。

因此聊天应用通常采用流式输出

模型生成一点
→ 返回一点
→ 页面显示一点

现在使用 LangChain,实现已经非常简单:

const stream = await model.stream(prompt);

for await (const chunk of stream) {
  process.stdout.write(chunk.content);
}

但要真正理解这几行代码,还得知道底层发生了什么。


一、流式输出最底层是什么

以前直接使用 fetch 请求模型接口时,开启:

stream: true

不能再简单地:

await response.json();

而是读取:

response.body

大概过程是:

const reader = response.body.getReader();
const decoder = new TextDecoder();

while (true) {
  const { value, done } = await reader.read();

  if (done) break;

  console.log(decoder.decode(value));
}

数据经历:

HTTP ResponseReadableStreamUint8ArrayTextDecoder
    ↓
字符串

这里要明确一点:

read() 每次得到的是一个数据块 chunk,不保证刚好是一条完整消息。

例如:

data: {"content":"hello"}

可能被拆成:

data: {"cont

和:

ent":"hello"}

所以以前还需要 buffer 保存残缺内容,再和下一块数据拼起来。

这也是为什么自己处理模型原始流会比较麻烦。


二、SSE 到底是什么

SSE,全称:

Server-Sent Events

它是一种基于 HTTP 的服务器单向推送机制。

最小 Node 服务:

const http = require('http');

const server = http.createServer((req, res) => {
  if (req.url === '/stream') {
    res.writeHead(200, {
      'Content-Type': 'text/event-stream',
      'Cache-Control': 'no-cache',
      Connection: 'keep-alive'
    });

    res.write('data: 你\n\n');

    setTimeout(() => {
      res.write('data: 好\n\n');
    }, 1000);

    setTimeout(() => {
      res.write('data: SSE\n\n');
      res.end();
    }, 2000);
  }
});

server.listen(3000);

SSE 最重要的是两个东西。

响应头:

Content-Type: text/event-stream

数据格式:

data: 你

data: 好

其中:

data:

表示事件的数据。

而:

\n\n

表示一个事件结束。


三、浏览器如何接收 SSE

浏览器原生提供了:

EventSource

使用方式:

const eventSource =
  new EventSource('/stream');

eventSource.onmessage = event => {
  console.log(event.data);
};

服务器:

res.write('data: hello\n\n');

浏览器最终得到:

event.data === 'hello';

所以完整过程是:

Browser
   ↓
GET /stream
   ↓
Node Server
   ↓
Content-Type: text/event-stream
   ↓
data: hello\n\n
   ↓
EventSource
   ↓
onmessage

注意,SSE 并不是服务器返回很多个 HTTP Response。

仍然是:

1Request
+
1Response

只不过这个 Response 没有立即结束,而是不断:

res.write();

向其中追加内容。


四、Stream 和 SSE 不是一回事

Node 中读取文件也可以:

fs.createReadStream('./index.html')
  .pipe(res);

这同样叫 Stream。

所以:

Stream

是一个更大的概念:

数据可以边产生、边读取、边处理。

而 SSE 是:

建立在 HTTP 流式响应之上的一种事件通信格式。

可以这样记:

Stream
→ 数据怎么持续流动

SSE
→ 流动的数据怎么组织成事件

因此:

Stream ≠ SSE

但 SSE 会利用 Stream。


五、为什么后来又需要 BFF

浏览器直接调用大模型接口会出现几个问题:

API Key 暴露
跨域
不同模型接口格式不同
流式解析逻辑堆在前端

因此真实项目通常变成:

Browser
   ↓
Node / BFF
   ↓
LLM

BFF 负责:

保存 API Key
请求大模型
处理跨域
统一模型接口
转发流式响应

本质还是:

上游模型流
    ↓
Node
    ↓
下游浏览器流

六、现在为什么只需要 model.stream()

理解完底层,再回来看 LangChain:

const stream =
  await model.stream('介绍一下莫扎特');

for await (const chunk of stream) {
  process.stdout.write(chunk.content);
}

以前自己处理的是:

HTTP ResponseReadableStreamUint8ArrayTextDecoder
↓
buffer
↓
SSE / JSON
↓
模型增量内容

现在 LangChain 给我们的已经是:

AIMessageChunk

所以只需要:

chunk.content

这里的 chunk 和以前网络层的 chunk 已经不是一个层级。

以前:

chunk = 字节数据块

现在:

chunk = AI 消息增量

LangChain 把不同模型供应商底层的流式协议进行了统一封装。


七、最终只需要记住三层

HTTP Stream
    ↓
数据可以持续传输

SSE
    ↓
在 HTTP Stream 上组织事件

LangChain
    ↓
model.stream()
    ↓
AIMessageChunk

真正写业务时:

const stream = await model.stream(prompt);

for await (const chunk of stream) {
  process.stdout.write(chunk.content);
}

就够了。

但理解 SSE 和 Stream 的意义在于:当以后遇到代理缓存、流式中断、BFF 转发等问题时,不会把 model.stream() 当成一个完全无法解释的黑盒。