前言
在当今快速发展的技术世界中,与大模型进行实时对话已经成为一种令人兴奋且实用的功能。想象一下,仅用几行JavaScript和HTML代码,你就可以构建一个简单的界面,与强大的AI模型进行无缝的、实时的互动。这种体验不仅能够提升用户体验,还能为你的项目增添智能化的元素。
在这篇文章中,我们将深入探讨如何使用Node.js和Express创建一个支持流式输出的服务器端应用,并结合DeepSeek来处理和返回数据。通过流式输出,我们可以实现即时的数据传输,使用户能够在数据生成的过程中实时看到结果,从而提供更加流畅和响应迅速的用户体验。
什么是流式输出?
流式输出是一种数据传输技术,它允许数据在生成时逐步发送到客户端,而不是等待所有数据完全生成后再一次性发送。这种技术在处理大量数据或长时间运行的任务时特别有用,因为它可以显著提高用户体验和系统性能。
它的最直观的体现就是:在你和大模型对话的时候,大模型的结果一点一点的输出,而不是一下全部生成内容。
主要特点
实时性:
- 流式输出使用户能够即时看到部分结果。例如,在文本生成任务中,用户可以逐字逐句地看到生成的内容,而不需要等待整个文档生成完毕。
高效性:
- 通过逐步发送数据,流式输出减少了服务器和客户端之间的内存使用和带宽消耗。这对于大数据集尤其重要,因为它避免了在内存中累积大量数据的风险。
响应性:
- 用户界面可以更快地响应用户的输入,因为数据是逐步显示的。这使得应用程序感觉更加流畅和互动。
灵活性:
- 流式输出适用于各种应用场景,包括但不限于文本生成、视频流、音频流和实时数据分析。无论数据的类型和大小如何,流式输出都能提供出色的性能和用户体验。
应用场景
- 聊天机器人:当用户与聊天机器人对话时,机器人的回复可以逐字逐句地显示出来,提供更自然的对话体验。
- 文件下载:大文件可以分段下载,用户可以在文件完全下载完成之前开始查看或使用已下载的部分。
- 视频播放:在线视频平台可以边下载边播放,用户不需要等待整个视频下载完成就可以开始观看。
- 日志监控:在监控系统日志时,新的日志条目可以实时显示在界面上,帮助运维人员及时发现和解决问题。
流式输出虽然只是将数据结果一点点展示给用户,表面上看起来没多厉害,实际上它是我们前端体验很重要的一环!
拿个场景来看吧,如果没有流式输出,当你对DeepSeek-R1提出一个问题后,它会进行思考,用过的都知道,它的思维链有的时候会很长,有的时候甚至可以思考几分钟,如果这个时候它不把思维链展现给你的话,你会不会觉得它卡了呢?你会不会觉得它比较拉跨,做事情太慢了?
所以说,快速展现结果是前端用户体验重要的一环,在当下LLM应用生成结果较慢的时候,我们就需要利用流式输出来告诉用户:客官别走!它还在工作!你的网络没有问题!
如何进行流式输出?
在这里我们选用node.js和HTML来实现流式输出,其中我们会利用express框架和axios来创建web服务器和进行HTTP请求。
(如果你是小白,接下来你可能见过很多没见过的api,比如app.get()、res.sendFile()......没事的,你就先看看就行,以后你可以自己学,现在你可以先了解一下,它不难,就是因为你之前没用过没见过而已,我尽量把这些api也给你们将清楚哈~)
1.创建文件/安装依赖
OK,第一步,创建一个新的文件夹TestforStream(啥名字都行)。
下一步骤,在此文件夹下安装express和axios的依赖:
npm install express axios
如果你安装成功了应该会出现package-lock.json、package.json以及node_modules文件。
OK,接下来我们来建立一个Web的服务器吧!
创建一个server.js文件和index.html文件
我们将用server.js作为Web的服务器端,利用index.html来利用流式输出展示我们从大模型那里拿到的结果。
2.HTML文件撰写
关于HTML,我们用它就是为了接收信息,展示信息的,本着这个原则,我们来设计它:
它应该有一个标题,来展现它的作用,还应该有一个挂载点,来让接收到的数据挂载在上面进行展示。
所以我们简单设计一下~
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>SSE with LLM Example</title>
</head>
<body>
<h1>Server-Sent Events with LLM Example</h1>
<div id="reply"></div>
</body>
</html>
OK,简单设计完了,我们现在要解决的就是如何拿到数据和进行流式输出了。
3.Express框架下的服务器构建
接下来我们来构建express框架下的简单的Web服务器:
首先我们初始化一下,创建一个实例app
const express = require('express');// 引入 `express` 模块,用于快速构建 Web 应用程序。
const axios = require('axios'); // 引入 `axios`,用于和`DeepSeek`进行通信,发送请求
const app = express();// 实例化一个 `express` 的app
const port = 3000; // 设置一个端口,最后让服务器跑在这个端口上
接下来,我们要让之前写的index.html与这个服务器相关联,我们来设置个路由:
// 创建一个路由
app.get('/', (req, res) => {
res.sendFile(__dirname + '/index.html'); // __dirname 是当前server.js所在的文件目录,
// 由于index.html与他同目录,所以这么写把index.html的路径补全,使其能够正确访问
}); // 通过这个服务器下的端口访问,默认访问到index.html
现在,页面展示功能和我们的服务器关联起来了,只要我们的服务器在3000端口运行,如果默认访问localhost:3000则浏览器就会收到index.html这个文件并展示给我们。
既然我们页面展示数据有了,给DeepSeek发送信息的服务器有了,那就该发送和接收信息了!
接下来我们再添加一个路由进行信息的发送和获取:
app.get('/stream', (req, res) => {
// app.get 处理 get请求,当get请求到 /stream 时会触发后面的回调函数
});
req & res
req 和 res 是 Express.js 中处理 HTTP 请求和响应的对象。具体来说:
req(请求对象) :代表客户端发送的 HTTP 请求。它包含了请求的头部、查询参数、请求体等信息。res(响应对象) :用于向客户端发送 HTTP 响应。可以使用它来设置响应头、发送数据、设置状态码等。
两者都是对客户端所服务的,也就是对访问这个服务器的浏览器服务。
向DeepSeek出击!
海的那边....是敌人吗.......接下来我们要尝试越过那片大海,向DeepSeek发起请求了,请求它的回答:
(但是接下来让我们先设置一下响应头,告诉客户端信息都是怎么样的,长什么样啊,芳龄多大呀~)
app.get('/stream', (req, res) => {
res.setHeader('Content-Type', 'text/event-stream');
res.setHeader('Cache-Control', 'no-cache');
res.setHeader('Connection', 'keep-alive');
});
Headers
Content-Type: text/event-stream
- 作用:告诉客户端这是一个 SSE 连接。
- 解释:
text/event-stream是一种 MIME 类型,专门用于 SSE。客户端(如浏览器)会识别这种类型并以特定的方式处理数据流,例如自动重连、处理事件等。
Cache-Control: no-cache
- 作用:防止浏览器缓存响应。
- 解释:SSE 是一个持续的数据流,不应该被缓存。设置
Cache-Control: no-cache可以确保每次请求都从服务器获取最新的数据,而不是从缓存中读取。
Connection: keep-alive
- 作用:保持连接打开,以便服务器可以持续发送数据。
- 解释:默认情况下,HTTP 连接在请求和响应完成后会关闭。设置
Connection: keep-alive可以让连接保持打开状态,从而使服务器能够持续向客户端发送事件。
app.get('/stream', (req, res) => {
res.setHeader('Content-Type', 'text/event-stream');
res.setHeader('Cache-Control', 'no-cache');
res.setHeader('Connection', 'keep-alive');
// 代理请求到 LLM API
const endpoint = "https://api.deepseek.com/chat/completions";
const headers = {
"Content-Type": "application/json",
Authorization: `Bearer ${api_key}`, // 把${api_key}换成你自己的api_key,可以到DeepSeek官网申请
"deepseek-organization": "deepseek-ai",
};
const payload = {
model: 'deepseek-chat',
messages: [
{ role: 'system', content: '你是一个非常有帮助的助手' },
{ role: 'user', content: '你好,deepseek' }, // 我们要问的问题
],
stream: true // 启用流式响应
};
});
请求头/payload
这些语法都是官网规定的,最开始由openai制定,因为它们最开始搞得很牛嘛,在这个领域比较权威,于是就制定了一套规则,规定我们这么用:
用一个请求头,表示向哪里发送请求,请求的内容格式如何 用一个payload,也就是负载,细致规定和谁对话,拥有系统提示词和用户提示词,(不理解的可以看看我之前的调教LLM的文章,在我主页AI的合集中),用户提示词是我们想问的问题~ 最后利用 stream:true,启动流式响应
axios发送请求
OK,接下来如何发送请求呢?我们利用axios来发送请求:
app.get('/stream', (req, res) => {
// .......
axios.post(endpoint, payload, { headers, responseType: 'stream' })
});
endpoint:
- 这是你要发送 POST 请求的目标 URL。
payload:
- 这是你想要发送到服务器的数据。
配置对象 { headers, responseType: 'stream' } :
-
这个对象包含了额外的配置选项,用于自定义请求的行为。
-
headers:-
这是一个对象,包含你想要设置的 HTTP 请求头。
-
头部信息可以用于指定内容类型、认证令牌等。
-
-
responseType: 'stream':- 这个选项指定了响应数据的类型。
'stream'表示你希望将响应作为一个流来处理,而不是默认的 JSON 或其他格式。 - 使用流的好处是可以处理大文件或持续的数据流,而不需要一次性加载整个响应体到内存中。这对于下载大文件或实时数据流非常有用。
- 这个选项指定了响应数据的类型。
对得到的数据进行流式输出
axios是一个异步操作,它发送完请求会得到返回值,为一个Promise对象,接下来我们就要对它进行处理,让它流式输出到HTML中
let buffer = '';
axios.post(endpoint, payload, { headers, responseType: 'stream' })
.then(response => {
response.data.on('data', chunk => {
buffer += chunk.toString();
// 尝试解析缓冲区中的数据
while (true) {
try {
const startIndex = buffer.indexOf('{');
const endIndex = buffer.indexOf('}\n\n') + 3;
if (startIndex === -1 || endIndex === -1) {
break; // 没有完整的 JSON 对象
}
const jsonStr = buffer.slice(startIndex, endIndex);
const parsedChunk = JSON.parse(jsonStr);
if (parsedChunk.choices && parsedChunk.choices.length > 0) {
const choice = parsedChunk.choices[0];
const message = choice.delta.content;
if (message) {
res.write(`data: ${JSON.stringify({ content: message })}\n\n`);
}
}
buffer = buffer.slice(endIndex); // 移除已处理的数据
} catch (e) {
break; // 不是完整的 JSON 对象
}
}
});
得到Promise对象我们可以拿到它的请求结果response,之后执行下面的函数,获取数据实现流式输出。
response.data.on()
当你使用 axios 发送一个请求并将 responseType 设置为 'stream' 时,response.data 会是一个可读流(ReadableStream)。这个流对象提供了多种事件处理方法,其中最常用的是 on 方法。on 方法允许你监听流的不同事件,并在事件触发时执行相应的回调函数。
data:
- 当有新的数据块可用时触发。
- 回调函数接收一个参数
chunk,表示当前数据块。
默认情况下,chunk 是一个 Buffer 对象,它长这个样子:
const buffer = Buffer.from('Hello, World!', 'utf8');
console.log(buffer); // 输出: <Buffer 48 65 6c 6c 6f 2c 20 57 6f 72 6c 64 21>
// 将 Buffer 转换为字符串
const str = buffer.toString();
console.log(str); // 输出: Hello, World!
很显然在我们进行toString()处理后,它将是一个json格式信息,因为我们之前在向DeepSeek的请求头中,请求格式为application/json.
为什么能够流式输出?
上面我刚刚介绍了,每次数据更新时我们可以拿到数据块,数据块长什么样子呢? 长这个样子:
{"id":"cmpl-1234567890","object":"text_completion","choices":[{"text":"Hello,","index":0,"logprobs":null,"finish_reason":null}]}
\n\n
{"id":"cmpl-1234567890","object":"text_completion","choices":[{"text":" World!","index":0,"logprobs":null,"finish_reason":null}]}
\n\n
DeepSeek每生产一点数据就会传输给我们,而形式就是这样的数据块,而相邻数据块的传输可能就只有几十毫秒的差距,这就构成了流式输出的前提:数据一点一点被传递过来解析
流式输出逻辑
response.data.on('data', chunk => {
buffer += chunk.toString();
// 尝试解析缓冲区中的数据
while (true) {
try {
const startIndex = buffer.indexOf('{');
const endIndex = buffer.indexOf('}\n\n') + 3; // 解释在下面
--------------------------------------------------------------
if (startIndex === -1 || endIndex === -1) {
break; // 没有完整的 JSON 对象
}
const jsonStr = buffer.slice(startIndex, endIndex);
const parsedChunk = JSON.parse(jsonStr);
// 保留一整条json,此时还是字符串json,利用JSON.parse,将其json化
if (parsedChunk.choices && parsedChunk.choices.length > 0) {
const choice = parsedChunk.choices[0];
const message = choice.delta.content;
if (message) {
res.write(`data: ${JSON.stringify({ content: message })}\n\n`);
}
} // 这里的目的就是提取它的答案,这一段逻辑是根据返回结果来的,
// 返回结果是一个很大的数组,choices[0].content 是在chatbot中显示的结果
// 所以我们去这个就好了
buffer = buffer.slice(endIndex); // 移除已处理的数据
} catch (e) {
break; // 不是完整的 JSON 对象
}
}
app.listen(port, () => {
console.log(`Server running at http://localhost:${port}`);// 在3000端口启动服务器
});
关于这段函数,我们利用了 response.data.on(),只要有数据块传递来,就会执行这个函数,buffer为临时存储的答案,我们进入一个永真循环,找到json数据的开始位置,即{,记录下标,最后找到结束位置}\n\n,记录下标,并+3,因为最后记录的是}的下标,当其下标+3时,就到了最后一个\n的后面,这样就能包括整一条json数据,保证不漏。
res.write(`data: ${JSON.stringify({ content: message })}\n\n`);
// 将 `message` 包装在一个 JSON 对象中,并将其作为 `data` 字段发送。
res.write(),将数据写入响应体,以data传入,HTML利用data就能找到数据
注意: 每条消息必须以 data: 开头,并以 两个换行符 \n\n 结束。
如果服务器返回的数据格式不正确,浏览器不会触发 onmessage。
onmessage用于更新页面内容,只要data改变就能更新内容。
一个小bug
目前我们的函数已经够完全了,只是缺少一个表示结束状态的表达式,当我们的大模型数据传输全部完毕的时候,要让我们的接收数据的客户端知道数据已经完毕了,不需要继续请求了。
于是加上一些防护措施:
response.data.on('end', () => {
res.write(`event: end\ndata: {}\n\n`);
res.end();
// LLM传输结束后,流中没有更多数据可读取,监听流的监听器触发`end`事件
});
})
.catch(error => {
console.error('Error fetching LLM response:', error);
res.write(`event: error\ndata: ${JSON.stringify({ error: error.message })}\n\n`);
res.end();
});
4. HTML 客户端接收数据
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>SSE with LLM Example</title>
</head>
<body>
<h1>Server-Sent Events with LLM Example</h1>
<div id="reply"></div>
<script>
const eventSource = new EventSource('/stream');
const replyDiv = document.querySelector('#reply');
// 处理普通消息
eventSource.onmessage = function (event) {
const data = JSON.parse(event.data);
if (data.error) {
console.error('Error from server:', data.error);
} else {
replyDiv.innerHTML += data.content; // 不断更新内容
}
};
// 处理结束事件
eventSource.addEventListener('end', function () {
console.log('Stream ended normally');
replyDiv.innerHTML += '<p><strong>对话结束</strong></p>';
eventSource.close();
});
// 处理错误事件
eventSource.addEventListener('error', function (event) {
console.error('Error event received:', event);
replyDiv.innerHTML += '<p style="color:red;"><strong>发生错误: ' + event.data + '</strong></p>';
eventSource.close();
});
// 处理连接错误
eventSource.onerror = function (error) {
console.error('EventSource failed:', error);
eventSource.close();
};
</script>
</body>
</html>
EventSource
EventSource 是一种浏览器内置的对象,用于处理 Server-Sent Events (SSE),
在这里我们新建了一个实例,连接到/stream端点。这个端点是服务器发送实时更新数据的 URL。
为什么客户端传输要用字符串?
相信你看到传输要用字符串,并在html中将字符串换为json后有些不解,其实是因为:
HTTP 协议的限制
HTTP 协议本身是基于文本的协议,它要求所有传输的数据都是文本格式。 这意味着任何数据在通过 HTTP 传输时,都需要被转换为字符串形式。
最后我们利用 node server.js启动服务器即可。
源码 & 总结
HTML 的源码如上 👆
JS的源码:
const express = require('express');
const axios = require('axios');
const { log } = require('console') // 输出用,console.log不能直接使用,要这么用
const app = express();
const port = 3000;
app.get('/', (req, res) => {
res.sendFile(__dirname + '/index.html');
});
// 设置 SSE 路由
app.get('/stream', (req, res) => {
res.setHeader('Content-Type', 'text/event-stream');
res.setHeader('Cache-Control', 'no-cache');
res.setHeader('Connection', 'keep-alive');
// 代理请求到 LLM API
const endpoint = "https://api.deepseek.com/chat/completions";
const headers = {
"Content-Type": "application/json",
Authorization: `Bearer ${API_KEY}`,
"deepseek-organization": "deepseek-ai",
};
const payload = {
model: 'deepseek-chat',
messages: [
{ role: 'system', content: '你是一个非常有帮助的助手' },
{ role: 'user', content: '你好,deepseek' },
],
stream: true // 启用流式响应
};
let buffer = '';
axios.post(endpoint, payload, { headers, responseType: 'stream' })
.then(response => {
response.data.on('data', chunk => {
buffer += chunk.toString();
// 尝试解析缓冲区中的数据
while (true) {
try {
const startIndex = buffer.indexOf('{');
const endIndex = buffer.indexOf('}\n\n') + 3;
if (startIndex === -1 || endIndex === -1) {
break; // 没有完整的 JSON 对象
}
const jsonStr = buffer.slice(startIndex, endIndex);
const parsedChunk = JSON.parse(jsonStr);
// 检查是否为结束标记
if (parsedChunk.choices && parsedChunk.choices.length > 0) {
const choice = parsedChunk.choices[0];
// 检测结束标记
if (choice.finish_reason === "stop") {
res.write(`event: end\ndata: {}\n\n`);
res.end();
return;
}
// 处理正常消息
const message = choice.delta.content;
// log(message); 输出日志
if (message) {
res.write(`data: ${JSON.stringify({ content: message })}\n\n`);
}
}
buffer = buffer.slice(endIndex); // 移除已处理的数据
} catch (e) {
break; // 不是完整的 JSON 对象
}
}
});
response.data.on('end', () => {
res.write(`event: end\ndata: {}\n\n`);
res.end();
});
})
.catch(error => {
console.error('Error fetching LLM response:', error);
res.write(`event: error\ndata: ${JSON.stringify({ error: error.message })}\n\n`);
res.end();
});
});
app.listen(port, () => {
console.log(`Server running at http://localhost:${port}`);
});
哎呀呀,累死我辽!应该讲清楚了吧!我希望你们能够听懂哈哈哈哈哈哈哈,如果不懂,还劳烦各位大人借助AI的力量~好了!今天就这样吧,讲完了这个SSE,可把我累坏了,拜拜!