node egg流式调用 OpenAI 的 gpt-3.5-turbo 模型接口,前端VUE实时显示

943 阅读3分钟
流式接收 OpenAI 的返回数据

node端 getChatGPT.js 中用 openai.createChatCompletion 向 OpenAI 发起请求,同时请求参数里 stream: true 告诉 OpenAI 开启流式传输。

const completion = await openai.createChatCompletion({
            messages:messages.messages,
            stream: true,
            model: 'gpt-3.5-turbo',

        },  { responseType: 'stream', timeout: 600000, });

OpenAI 会在模型每次输出时返回 data: {"id":"","object":"","created":1679616251,"model":"","choices":[{"delta":{"content":""},"index":0,"finish_reason":null}]} 格式字符串,其中我们需要的回答就在 choices[0]['delta']['content'] 里,但是不能直接这样获取数据。

// 监听事件
completion.data.on('data', (data) => {
    try {   
        const lines = data
            .toString()
            .split("\\n")
            .filter((line) => line.trim() !== "");
            for (const line of lines) {
                const message = line.replace(/^data: /, "");

                if (message.includes('"finish_reason":"stop"')) {
                    // 通信结束
                    stream.end();
                    return
                } else if (message == "[DONE]") {
                    stream.end();
                    return;
                } else {
                    stream.write(message);
                }
            }
    } catch (error) {
        stream = null
        ctx.logger.info('~~~~~~~~~~~~~~~~~~~~~~~~~~~~处理失败啦', error)
    }
});
 data: {"id":"chatcmpl-6wimHHBt4hKFHEpFnNT2ryUeuRRJC","object":"chat.completion.chunk","created":1679453169,"model":"gpt-3.5-turbo-0301","choices":[{"delta":{"role":"assistant"},"index":0,"finish_reason":null}]}\n\ndata: {"id":"chatcmpl-6wimHHBt4hKFHEpFnNT2ryUeuRRJC","object":"chat.completion.chunk","created":1679453169,"model":"gpt-3.5-turbo-0301","choices":[{"delta":{"content":"以下"},"index":0,"finish_reason":null}]}\n\ndata: {"id":"chatcmpl-6wimHHBt4hKFHEpFnNT2ryUeuRRJC","object":"chat.completion.chunk","created":1679453169,"model":"gpt-3.5-turbo-0301","choices":[{"delta":{"content":"是"},"index":0,"finish_reason":null}]}\n\ndata: {"id":"chatcmpl-6wimHHBt4hKFHEpFnNT2ryUeuRRJC","object":"chat.completion.chunk","created":1679453169,"model":"gpt-3.5-turbo-0301","choices":[{"delta":{"content":"使用"},"index":0,"finish_reason":null}]}

最后两条一般是这样的:
 data: {"id":"chatcmpl-6wimHHBt4hKFHEpFnNT2ryUeuRRJC","object":"chat.completion.chunk","created":1679453169,"model":"gpt-3.5-turbo-0301","choices":[{"delta":{},"index":0,"finish_reason":"stop"}]}\n\ndata: [DONE]

        

测试了很多次,发现每次 callback 函数收到的数据并不一定只有一条 data: {"key":"value"} 格式的数据,有可能只有半条,也有可能有多条,最后一条也有所不同。

我这边是在view层,也就是在vue中处理的,具体代码如下。

// chat接口的返回值处理
async handleResChat(message){
// 如果控制器存在,说明有上个请求,就它取消并设置为空
  if (controller) {
    controller.abort()
    controller = null
  }
  controller = new AbortController()

    // 1. 请求接口
    if(this.url == '/text/chat'){
      this.result.push({
        content: message.prompt, // || this.$refs['inputCondition'].$data.form.prompt,
        role: "user"
      });
    }else{
      this.result = ''
    }

    const response = await fetch(`/gpt-api${this.url}`,{
      method: "POST",
      body: JSON.stringify(message),
      redirect:"follow",
      headers: {
        "Content-Type":"application/json"
      },
      signal: controller.signal,
    });

    if(response.status != 200){
      this.handleActionStop()
      controller = null
      return 
    }

      const reader = response.body.getReader(); // 获取reader
      let tempId = null // 临时id
      let answerArr = []
      let done = false
      while (!done) {
        const { value, done: streamDone } = await reader.read()
        if (streamDone) {
          this.isCanHandleActionChange(true)

          done = true;
          break
        }

        if (value) {
          const decoder = new TextDecoder("utf-8")
          let answerResult = decoder.decode(value,{ stream: true })

          let str = null
          // 1、把所有的 'data: {' 替换为 '{' ,'data: [' 换成 '['
          str = answerResult.replaceAll('data: {','{') 
          str = str.replaceAll('data: [','[') 
          // 2、把所有的 '}\n\n{' 替换维 '}[br]{' , '}\n\n[' 替换为 '}[br]['
          str = str.replaceAll('}\n\n{','}[br]{')  
          str = str.replaceAll('}\n\n[','}[br][')
          // 3、用 '[br]' 分割成多行数组
          let arr = str.split('[br]');

          //  arr.length && JSON.parse(arr[0])?.id;
          let content = ''
          // 4、循环处理每一行,转成json
          arr.map((item)=>{
            if(item.trim() == '[DONE]'){
              done = true
              return 
            }
            try {
              let json = JSON.parse(item) 
              if (json?.choices && json.choices[0].finish_reason === 'stop') {
                done = true
                return 
              }
              if (json?.choices && json.choices.length > 0) {
                content = json.choices[0].delta.content
              }
              // 如果返回的结果 ID 与当前对话 ID 相同,则将聊天机器人的回复拼接到当前对话中
              if(this.url == '/text/chat'){
                if (tempId === json?.id) {
                  const index = this.result.findIndex(item => item.name === tempId)
                  const dialog = this.result[index]
                  dialog.content += content
                } else {
                  tempId = json.id
                  this.result.push({
                    role: "assistant",
                    content: content,
                    name: json.id
                  })
                }
              }else{
                // 如果是其他模块则直接显示
                this.result += content
              }
            } catch (error) {
              console.log(error);
            }
          })

        }
      }
      return answerArr

},

message即是每次需要给openai的参数,根据业务自己组装。可以使用原生的XMLHttpRequest或者fetch,我是使用了fetch,但是后面的【Stop当前返回值】的功能上也踩了不少坑。后续再说... 主要的js处理步骤就是如下:

1.把所有的 'data: {' 替换为 '{' ,'data: [' 换成 '['

2.把所有的 '}\n\n{' 替换维 '}[br]{' , '}\n\n[' 替换为 '}[br]['

3.用 '[br]' 分割成多行数组

注意: 做完这几个步骤再去JSON.parse,还有一点要注意的是条件的判断 tempId === json?.id,这个是判断如果返回的结果 ID 与当前对话 ID 相同,则将聊天机器人的回复拼接到当前对话中,如果不一致则往list中新插入一条。

最后就是页面的显示了,像微信聊天一样,分成左右两列,具体代码如下:

<div v-for="(jj, ii) in result" :key="ii" :class="jj.role == 'user' ? 'output-chat-left' : 'output-chat-right'">
        <img :src="jj.role == 'user' ? require('../assets/avatar_user.svg') : require('../assets/avatar_gpt.svg')" width="32px" />
        <div v-if="jj.role == 'user'">
          <pre v-html="handleMarkDown(jj.content)" class="markdown-define">
          </pre>
          <!-- {{ jj.content }} -->
        </div>
        <div v-else class="right-answer">
          <pre v-html="handleMarkDown(jj.content)" class="markdown-define">
          </pre>
          <div v-if="jj.role != 'user'" class="operation" @click="(e) => handleCopyContent(jj.content, e)">复制此条</div>
        </div>
      </div>

主要的思路都已经供述了。 还有一点是Stop 流的返回的功能,也是fetch带了一个abort的方法,

// 停止
handleActionStop(){
  if (controller) { 
    controller.abort()
    this.isCanHandleActionChange(true) // 本项目自己的代码,具体可自定义
  }

},

node 层的则是需要引入即可。

const { PassThrough } = require('stream')
let stream;

如此一来,便是可以实现了。 感谢这两篇文章的帮助:

vue页面实现逻辑参考:blog.csdn.net/weixin_6871…
node层数据处理参考:www.swvq.com/boutique/de…

为何掘金上,效果视频为何无法上传哎?