大模型回答问题时,如果等整段内容全部生成完再返回,用户会经历明显的等待。
因此聊天应用通常采用流式输出:
模型生成一点
→ 返回一点
→ 页面显示一点
现在使用 LangChain,实现已经非常简单:
arduino
const stream = await model.stream(prompt);
for await (const chunk of stream) {
process.stdout.write(chunk.content);
}
但要真正理解这几行代码,还得知道底层发生了什么。
一、流式输出最底层是什么
以前直接使用 fetch 请求模型接口时,开启:
vbnet
stream: true
不能再简单地:
csharp
await response.json();
而是读取:
vbscript
response.body
大概过程是:
ini
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));
}
数据经历:
javascript
HTTP Response
↓
ReadableStream
↓
Uint8Array
↓
TextDecoder
↓
字符串
这里要明确一点:
read()每次得到的是一个数据块 chunk,不保证刚好是一条完整消息。
例如:
css
data: {"content":"hello"}
可能被拆成:
css
data: {"cont
和:
css
ent":"hello"}
所以以前还需要 buffer 保存残缺内容,再和下一块数据拼起来。
这也是为什么自己处理模型原始流会比较麻烦。
二、SSE 到底是什么
SSE,全称:
arduino
Server-Sent Events
它是一种基于 HTTP 的服务器单向推送机制。
最小 Node 服务:
javascript
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 最重要的是两个东西。
响应头:
vbnet
Content-Type: text/event-stream
数据格式:
kotlin
data: 你
data: 好
其中:
kotlin
data:
表示事件的数据。
而:
\n\n
表示一个事件结束。
三、浏览器如何接收 SSE
浏览器原生提供了:
EventSource
使用方式:
ini
const eventSource =
new EventSource('/stream');
eventSource.onmessage = event => {
console.log(event.data);
};
服务器:
lua
res.write('data: hello\n\n');
浏览器最终得到:
ini
event.data === 'hello';
所以完整过程是:
vbnet
Browser
↓
GET /stream
↓
Node Server
↓
Content-Type: text/event-stream
↓
data: hello\n\n
↓
EventSource
↓
onmessage
注意,SSE 并不是服务器返回很多个 HTTP Response。
仍然是:
vbscript
1 个 Request
+
1 个 Response
只不过这个 Response 没有立即结束,而是不断:
ini
res.write();
向其中追加内容。
四、Stream 和 SSE 不是一回事
Node 中读取文件也可以:
scss
fs.createReadStream('./index.html')
.pipe(res);
这同样叫 Stream。
所以:
arduino
Stream
是一个更大的概念:
数据可以边产生、边读取、边处理。
而 SSE 是:
建立在 HTTP 流式响应之上的一种事件通信格式。
可以这样记:
arduino
Stream
→ 数据怎么持续流动
SSE
→ 流动的数据怎么组织成事件
因此:
arduino
Stream ≠ SSE
但 SSE 会利用 Stream。
五、为什么后来又需要 BFF
浏览器直接调用大模型接口会出现几个问题:
vbnet
API Key 暴露
跨域
不同模型接口格式不同
流式解析逻辑堆在前端
因此真实项目通常变成:
Browser
↓
Node / BFF
↓
LLM
BFF 负责:
vbnet
保存 API Key
请求大模型
处理跨域
统一模型接口
转发流式响应
本质还是:
markdown
上游模型流
↓
Node
↓
下游浏览器流
六、现在为什么只需要 model.stream()
理解完底层,再回来看 LangChain:
arduino
const stream =
await model.stream('介绍一下莫扎特');
for await (const chunk of stream) {
process.stdout.write(chunk.content);
}
以前自己处理的是:
javascript
HTTP Response
↓
ReadableStream
↓
Uint8Array
↓
TextDecoder
↓
buffer
↓
SSE / JSON
↓
模型增量内容
现在 LangChain 给我们的已经是:
AIMessageChunk
所以只需要:
chunk.content
这里的 chunk 和以前网络层的 chunk 已经不是一个层级。
以前:
ini
chunk = 字节数据块
现在:
ini
chunk = AI 消息增量
LangChain 把不同模型供应商底层的流式协议进行了统一封装。
七、最终只需要记住三层
markdown
HTTP Stream
↓
数据可以持续传输
SSE
↓
在 HTTP Stream 上组织事件
LangChain
↓
model.stream()
↓
AIMessageChunk
真正写业务时:
arduino
const stream = await model.stream(prompt);
for await (const chunk of stream) {
process.stdout.write(chunk.content);
}
就够了。
但理解 SSE 和 Stream 的意义在于:当以后遇到代理缓存、流式中断、BFF 转发等问题时,不会把 model.stream() 当成一个完全无法解释的黑盒。