流式接收 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…
为何掘金上,效果视频为何无法上传哎?