🔥智能前端:Node.js+Express下的流式输出技术及其与DeepSeek的完美结合

169 阅读11分钟

前言

在当今快速发展的技术世界中,与大模型进行实时对话已经成为一种令人兴奋且实用的功能。想象一下,仅用几行JavaScript和HTML代码,你就可以构建一个简单的界面,与强大的AI模型进行无缝的、实时的互动。这种体验不仅能够提升用户体验,还能为你的项目增添智能化的元素。 image.png

在这篇文章中,我们将深入探讨如何使用Node.js和Express创建一个支持流式输出的服务器端应用,并结合DeepSeek来处理和返回数据。通过流式输出,我们可以实现即时的数据传输,使用户能够在数据生成的过程中实时看到结果,从而提供更加流畅和响应迅速的用户体验。

什么是流式输出?

流式输出是一种数据传输技术,它允许数据在生成时逐步发送到客户端,而不是等待所有数据完全生成后再一次性发送。这种技术在处理大量数据或长时间运行的任务时特别有用,因为它可以显著提高用户体验和系统性能。

它的最直观的体现就是:在你和大模型对话的时候,大模型的结果一点一点的输出,而不是一下全部生成内容。

主要特点

实时性

  • 流式输出使用户能够即时看到部分结果。例如,在文本生成任务中,用户可以逐字逐句地看到生成的内容,而不需要等待整个文档生成完毕。

高效性

  • 通过逐步发送数据,流式输出减少了服务器和客户端之间的内存使用和带宽消耗。这对于大数据集尤其重要,因为它避免了在内存中累积大量数据的风险。

响应性

  • 用户界面可以更快地响应用户的输入,因为数据是逐步显示的。这使得应用程序感觉更加流畅和互动。

灵活性

  • 流式输出适用于各种应用场景,包括但不限于文本生成、视频流、音频流和实时数据分析。无论数据的类型和大小如何,流式输出都能提供出色的性能和用户体验。

应用场景

  • 聊天机器人:当用户与聊天机器人对话时,机器人的回复可以逐字逐句地显示出来,提供更自然的对话体验。
  • 文件下载:大文件可以分段下载,用户可以在文件完全下载完成之前开始查看或使用已下载的部分。
  • 视频播放:在线视频平台可以边下载边播放,用户不需要等待整个视频下载完成就可以开始观看。
  • 日志监控:在监控系统日志时,新的日志条目可以实时显示在界面上,帮助运维人员及时发现和解决问题。

流式输出虽然只是将数据结果一点点展示给用户,表面上看起来没多厉害,实际上它是我们前端体验很重要的一环

拿个场景来看吧,如果没有流式输出,当你对DeepSeek-R1提出一个问题后,它会进行思考,用过的都知道,它的思维链有的时候会很长,有的时候甚至可以思考几分钟,如果这个时候它不把思维链展现给你的话,你会不会觉得它卡了呢?你会不会觉得它比较拉跨,做事情太慢了?

所以说,快速展现结果是前端用户体验重要的一环,在当下LLM应用生成结果较慢的时候,我们就需要利用流式输出来告诉用户:客官别走!它还在工作!你的网络没有问题!

如何进行流式输出?

在这里我们选用node.js和HTML来实现流式输出,其中我们会利用express框架和axios来创建web服务器和进行HTTP请求。

(如果你是小白,接下来你可能见过很多没见过的api,比如app.get()res.sendFile()......没事的,你就先看看就行,以后你可以自己学,现在你可以先了解一下,它不难,就是因为你之前没用过没见过而已,我尽量把这些api也给你们将清楚哈~)

1.创建文件/安装依赖

OK,第一步,创建一个新的文件夹TestforStream(啥名字都行)。

下一步骤,在此文件夹下安装expressaxios的依赖:

npm install express axios

如果你安装成功了应该会出现package-lock.jsonpackage.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

reqres 是 Express.js 中处理 HTTP 请求和响应的对象。具体来说:

  • req (请求对象) :代表客户端发送的 HTTP 请求。它包含了请求的头部、查询参数、请求体等信息。
  • res (响应对象) :用于向客户端发送 HTTP 响应。可以使用它来设置响应头、发送数据、设置状态码等。

两者都是对客户端所服务的,也就是对访问这个服务器的浏览器服务。

image.png

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,可把我累坏了,拜拜!

6128-b0868578421793c38d18b1e229624512.jpg