专栏目录

耗时一下午,我完成了 GPT Terminal,真实拥有了专归于我的 GPT 终端!

怎么用 GPT 在 5 分钟内 ”调教“ 出一个专归于你的 ”小黑子“?

怎么丝滑完成 GPT 打字机流式回复?Server-Sent Events!

我是怎么让我的 GPT Terminal “长记忆” 的?还是老配方!

一个合格的类 GPT 应用需求具备什么?一文带你打通 GPT 产品功能!

项目地址: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 调用流程

如何丝滑实现 ChatGPT 打字机流式回复?Server-Sent Events!

  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 会为咱们出现什么样的作用?

如何丝滑实现 ChatGPT 打字机流式回复?Server-Sent Events!

总结

今日带着咱们通过项目实战的方式,了解了 Server-Sent Events 的根本完成原理。

在此,我也有一点心得想与咱们分享,在学习新技能的时分,必定不要畏手畏脚,总想着先把原理看会再去做,这其实是一种 坐而论道。只有真实地去实践,去动手做,才干更加深刻地了解其原理!在做与踩坑的过程中,去学习与了解,并及时地弥补相关常识,这样最终学到的东西才是自己的!

好啦,今日就暂时告一段落啦!假如咱们想要了解 GPT Terminal 项目的更多细节与解锁更多玩法的话,请到其主页检查哦。

看在我这么认真的份上,咱们点个Star、点个赞不过火吧(磕头!)下期再会!