乐趣区

关于node.js:如何丝滑实现-ChatGPT-打字机流式回复ServerSent-Events

我的项目地址:github.com/ltyzzzxxx/g…

欢送大家 Star、提出 PR,一起高兴地用 GPT Terminal 游玩吧~

前言

明天来给大家整点 ChatGPT 的干货!想必大家用过 ChatGPT 都晓得,它是一个练习时长两年半,喜爱唱 …(油饼食不食!)

它在响应咱们给它发送音讯的时候,并不是将一整个音讯间接返回给咱们,而是流式传输,如同打字机成果个别,逐步地将整个内容出现给咱们。(市面上的 GPT 个别都是如此)

这样的益处有两个,一方面是 GPT 一边响应一边返回后果,流式输入,响应效率大大晋升;另一方面是显著晋升了用户体验,给咱们的感觉就像是实在的对话一样,GPT 仿佛在思考问题的答案。

说到这里,不得不拜服 Open AI 这家公司。不仅仅实现了人工智能的冲破,掀起了第四次科技反动,而且它在做产品方面,也有很多值得咱们深刻学习与思考的中央。正如陆奇传授在前段时间一次演讲上说的个别:

OpenAI 所代表的是全新的组织、全新的能力,他们所做的所有是要既能做科研、又能写代码、又能做产品,这些能力是分不开的。

心愿能给大家在将来的学习与工作中带来一些新的思考维度~

啊仿佛又跑题了,话不多说,咱们迅速进入正题!

Server-Sent Events

要想揭开 ChatGPT 实现流式传输的秘诀,那么肯定离不开这个技术 —— Server-Sent Events

它是一种服务端被动向客户端推送的技术,这一点是不是与 Websocket 有些相似,然而 SSE 并不反对客户端向服务端发送音讯,即 SSE 为单工通信。

通俗易懂一些了解就是,服务端与客户端建设了 长连贯 ,服务端源源不断地向客户端推送音讯。服务端就相当于河流的上游,客户端就相当于河流的上游,水往低处流,这就是 SSE 的流式传输。

大家简略理解一下即可,咱们还是须要在实战中深刻理解其具体如何应用。

GPT Terminal 调用流程

  1. 用户输出 GPT Terminal 中的 GPT 相干命令
gpt chat -r ikun 请给我表演一下《只因你太美》!
  1. 前端失去用户输出的命令并解析,将解析后果作为参数,申请后端。
  2. 后端拿到参数后,渲染对应的角色模板(如:ikun),申请 GPT 服务。
  3. GPT 服务响应用户传入的音讯,并以 Stream 流模式返回给后端;后端也以 Stream 流模式返回给前端。

咱们要害的点在于拆解 2/3/4 步,看看两次数据传输是如何用 Server-Sent Events 实现的!

至于前端是如何解析命令的,请大家移步 GPT Terminal 我的项目中寻找答案。

至于我为什么不必前端间接去申请 GPT 服务,思考有一下几点,供大家参考:

  1. 职责拆散。GPT 服务属于第三方库,依照个别设计理念来看,须要交由后端解决,前端只须要负责申请后端。
  2. 便于扩大。之后在 GPT Terminal 中可能会引入用户服务以及 GPT 图片生成性能,为了防止性能都耦合到前端,导致前端臃肿,因而我抉择将 GPT 服务抽取到后端。

前端申请后端

如下局部代码对应我的项目门路为:src/core/commands/gpt/subCommands/ChatBox.vue

const response = await fetch('http://127.0.0.1:7345/api/gpt/get', {
    method: "POST",
    headers: {"Content-Type": "application/json",},
    body: JSON.stringify({
      message: message.value,
      role: role.value,
    }),
  });

  if (!response.body) return;
  const reader = response.body.pipeThrough(new TextDecoderStream()).getReader();
  while (true) {var { value, done} = await reader.read();
    if (done) break;
    value = value?.replace('undefined', '')
    console.log("received data -", value)
    output.value += value?.replace('undefined', '')
  }

前端向后端发动申请,失去申请响应体,而后通过 pipeThrough() 办法将其转换为一个文本解码器流(TextDecoderStream),这个流能够将字节流(如网络申请的响应)转换为 Unicode 字符串,最初调用 getReader() 办法返回一个 reader 对象,用于读取响应体数据。

读取时循环读取,并对数据做一些解决(数据流结尾、结尾为 undefined),而后拼接到 output.value 中,渲染到页面中。这样的话 output.value 就是动静浮现的,给用户视觉效果即为 打字机

在这里大家很容易发现,后端与 GPT 服务的交互对于前端而言就是通明的,前端仅晓得其响应是一个流式数据,其它一概不知。

说到这里,大家可能还有些纳闷,Server-Sent Events 仿佛什么都还没配,前端不就是发了一个惯例的 POST 申请嘛!我晓得你很急,但你先别急,跟我缓缓往下看~重头戏是在后端与 GPT 服务的交互!

后端申请 GPT 服务

如下局部代码对应我的项目门路为:server/src/thirdpart/gptApi/gptApi.js

async function createChatCompletion(messages) {
  // 如下为 流式数据传输 写法
  const res = openai.createChatCompletion(
    {
      model: "gpt-3.5-turbo",
      messages,
      stream: true,
    },
    {responseType: "stream",}
  );
  return res
}

后端拿到前端传递的参数后,对角色进行简略的模板渲染,失去音讯数组后,调用 GPT 服务。

其参数如下所示,设置 GPT 模型类型,传入音讯数组。

关键在于,设置 stream 参数为 true。这一步就是通知 GPT 服务,我须要获取流式响应!

而如果你只想要 GPT 给你回复整个音讯内容,能够不设置 stream,即为一般响应。

接下来,关键在于后端是如何解析 GPT 返回的响应。

如下局部代码对应我的项目门路为:server/src/handler/gptStreamHandler.js

我将这部分的解决独自抽取到了 gptStreamHandler.js 中,将其与其它一般申请的解决辨别开,便于之后扩大

res.setHeader("Cache-Control", "no-cache");
res.setHeader("Content-Type", "text/event-stream");
res.setHeader("Connection", "keep-alive");
res.flushHeaders();
const response = handlerFunction(req.body, req, res);
response.then((resp) => {resp.data.on("data", (data) => {console.log("stream data -", data.toString());
    const lines = data
      .toString()
      .split("\n\n")
      .filter((line) => line.trim() !== "");
    for (const line of lines) {const message = line.replace("data:", "");
      if (message === "[DONE]") {res.end();
        return;
      }
      const parsed = JSON.parse(message);
      console.log("parsed content -", parsed.choices[0].delta.content);
      res.write(`${parsed.choices[0].delta.content}`);
    }
  });
});

该响应是一个流式响应,因而须要用事件回调函数来解决。具体来说,response.then() 是一个 Promise 对象的办法,用于解决异步操作的后果。其中 resp.data 是一个可读流对象,通过订阅 data 事件,能够在每次获取到数据时触发回调函数。

回调函数须要做的很简略,先将数据转换为字符串,而后应用 split() 和 filter() 办法将其拆散为一个个独立的音讯行。每一行都是以 data: 结尾,如下所示:

data: {  
    "id":"chatcmpl-7RNOsBXERLBhETQxgg5RpF2EGDSpi",  
    "object":"chat.completion.chunk",  
    "created":1686759162,  
    "model":"gpt-3.5-turbo-0301",  
    "choices":[  
        {  
            "delta":{"content":"你"},  
            "index":0,  
            "finish_reason":null  
        }  
    ]  
}

数据看起来比较复杂,然而咱们重点只须要关注 choices.delta.content,这是咱们真正须要的数据!

后端须要做的事件就是把这个数据返回给前端即可。当其读到 message === "[DONE]",这也就是 GPT 服务给咱们提供的信号,通知这个时候曾经没有内容给你啦,你能够进行接管了。这样就实现了一次音讯的回复!

置信粗疏的大家曾经发现了,我还没有提到代码一开始响应的 header 设置,这正是 Server-Sent Events 的外围配置,是不是很简略?

res.setHeader("Cache-Control", "no-cache");
res.setHeader("Content-Type", "text/event-stream");
res.setHeader("Connection", "keep-alive");

只须要简略设置一下 header,即可实现服务端到客户端的流式传输!

然而很粗疏的大家又发现了,为什么 GPT 服务向后端传输数据的时候,并没有设置 header 呢?起因我认为很简略,因为咱们调用的是 Open AI 提供的 SDK 包。其对于响应的封装对于咱们而言是通明的,也就是说,咱们无需去设置,这些繁琐的操作 SDK 都帮咱们做好啦!

成绩

来吧,展现!让咱们看看,通过这一系列骚操作之后,GPT Terminal 会为咱们出现什么样的成果?

总结

明天带着大家通过我的项目实战的形式,理解了 Server-Sent Events 的根本实现原理。

在此,我也有一点心得想与大家分享,在学习新技术的时候,肯定不要畏手畏脚,总想着先把原理看会再去做,这其实是一种 夸夸其谈 。只有真正地去实际,去动手做,能力更加粗浅地了解其原理!在做与踩坑的过程中,去学习与了解,并及时地补充相干常识,这样最初学到的货色才是本人的!

好啦,明天就临时告一段落啦!如果大家想要理解 GPT Terminal 我的项目的更多细节与解锁更多玩法的话,请到其主页查看哦。

看在我这么认真的份上,大家点个 Star、点个赞不过分吧(磕头!)下期再见!

退出移动版