目前前端开发的大环境变更频率没有过去几年那么快了,感觉此时正是技术选型固定,沉淀 最佳实践 或 最佳方向的好时机,后续会在这个专栏中:
持续更新真实项目 分析、选型、调研等相关内容,根本目的在于持续强化个人的产品分析能力,及技术广度,同时分享给有兴趣参考的各位。
前情提要
在 上一篇 中完成了开发框架选项,生成了基本开发环境,是时候开始验证一些重点功能的实现了。
首先完成 Chatgpt 最核心的流式对话,也就是那个返回结果被一个一个字打出来的效果。
之前始终认为,肯定是通过 WebSocket 这样的长连接通信机制实现的,但看了 network 中接口的信息,就是一般的 HTTP POST 请求,唯一特殊的是 Content-Type 类型为 text/event-stream:
了解了下这种实现机制被称为 SSE(server-sent-event),对于该技术的解释,此处可参考阮一峰老师的文章:www.ruanyifeng.com/blog/2017/0…
SSE 与 WebSocket 作用相似,都是建立浏览器与服务器之间的通信渠道,然后服务器向浏览器推送信息。
总体来说,WebSocket 更强大和灵活。因为它是全双工通道,可以双向通信;SSE 是单向通道,只能服务器向浏览器发送,因为流信息本质上就是下载。如果浏览器向服务器发送信息,就变成了另一次 HTTP 请求。
SSE 也有自己的优点。
- SSE 使用 HTTP 协议,现有的服务器软件都支持。WebSocket 是一个独立协议。
- SSE 属于轻量级,使用简单;WebSocket 协议相对复杂。
- SSE 默认支持断线重连,WebSocket 需要自己实现。
- SSE 一般只用来传送文本,二进制数据需要编码后传送,WebSocket 默认支持传送二进制数据。
- SSE 支持自定义发送的消息类型。
因此,两者各有特点,适合不同的场合。
所以通过 SSE 机制传递的也就是一般的字符串文本,每次传递一份,而这每一份的文本当然也可以是 json 格式的。
既然接口是普通的 http 类型请求,就想使用 aixos 开发实现,问了 ChatGPT,他也告诉我可以。没想到踩了坑,提供的示范用法提示无 data.on 方法?
function connectSSE() {
axios({
method: 'get',
url: 'https://example.com/sse',
responseType: 'stream'
}).then(response => {
// ? 实际没有
response.data.on('data', chunk => {
const data = chunk.toString().trim();
console.log(data);
});
response.data.on('error', () => {
setTimeout(() => {
connectSSE(); // 断开连接后重新连接
}, 1000);
});
});
}
于是去翻了下 axios 的 issure 找到了这条:github.com/axios/axios…
从中得到信息,旧版 0.x axios 不支持 web 端的流式请求获取,目前虽然在 1.x 中能获取了,但也只能获取最终的所有结果,中间的分片实时是获取不到的 ... 也就是兼容了流式,把它当普通请求来处理。
然后上面那个写法,实际是 axios 在 node 端使用时调用流式的方式,axios 在 client 和 server 的表现是不同构的🤦♂️(所以 ChatGPT 一般也只能提供参考,不能全信,最好多验证)。
最终得出结论,axios 能处理但在 web 端只能最终返回结果字符(猜测是考虑中间依赖的部分功能浏览器版本要求高,毕竟 aixos 也以高兼容性为特点),不支持 ReadableStream 返回体(node端支持),遂绕了个弯子还是决定先用原生 fetch 实现起来。
直接查看 MDN 可看到 fetch 与 ReadableStream 共同使用的例子:
developer.mozilla.org/en-US/docs/…
其实就是在 fetch 的结果中,使用 ReadableStream 读取 body,然后经过 reader 读取后的结果是 Uint8Array 类型的:
至于为啥要读成 Uint8Array,而不是纯字符串,ChatGPT 是这么解释的,我觉着合理:
ReadableStream 统一返回 Uint8Array 是因为它是最常见的数据类型,特别是在处理二进制数据时。
Uint8Array 是一个固定长度的数组,其中每个元素都是 8 位无符号整数,可以表示从 0 到 255 的值。这使得它非常适合处理像图像、音频和视频等二进制数据。
此外,使用统一的数据类型可以简化代码,减少错误,并提高可读性。如果每个流返回不同的数据类型,那么消费者就需要编写更多的代码来处理它们,这会增加复杂性并可能导致错误。因此,将所有流都返回 Uint8Array 可以使代码更加简洁和易于维护。
之后我们需要通过 TextDecoder 再重新转为字符串:
data: {"id":"chatcmpl-7OoVtlU9hqleHiMK2y3Spxs9sMykg","object":"chat.completion.chunk","created":1686148401,"model":"gpt-3.5-turbo-0301","choices":[{"delta":{"content":"。"},"index":0,"finish_reason":null}]}
其实就是 data: xxxx \n\n这样的固定结构,中间是个json体。
最终实现全量代码如下:
interface VoidFn {
(): void;
}
const paramsBase = {
model: "gpt-3.5-turbo",
temperature: 0.5,
top_p: 0.8,
presence_penalty: 1.0,
};
const API_URL = "https://api.openai.com/v1/chat/completions";
const reqParams = (content: string, stream: boolean = true) => ({
...paramsBase,
messages: [{ role: "user", content }],
stream,
});
const D = new TextDecoder();
const parseData = (v: Uint8Array): Record<string, any>[] => {
const dataObjs = D.decode(v).split("\n\n");
return dataObjs.reduce<Record<string, any>[]>((res, i) => {
const str = i.replace(/^data: /, "");
if (str === "[DONE]") {
return res;
} else if (!str) {
return res;
}
res.push(JSON.parse(str));
return res;
}, []);
};
const chatFetch = async (
message = "make a joke in ten word",
onData = (chunks: any[]) => {
console.log("dataChunks", chunks);
},
openaiKey: string,
options: {
onStart?: VoidFn;
onSteaming?: VoidFn;
onDone?: (res: string) => void;
onFinally?: VoidFn;
onError?: (err: Error) => void;
} = {}
) => {
options.onStart?.();
return fetch(API_URL, {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${openaiKey}`,
},
body: JSON.stringify(reqParams(message)),
})
.then((response) => response.body)
.then((rb) => {
if (!rb) throw new Error("no body");
const reader = rb.getReader();
return new ReadableStream({
start: (controller) => {
options.onSteaming?.();
const push = () => {
reader.read().then(({ done, value }) => {
// * 请求结束 done 状态为 true,不再递归获取
if (done) {
controller.close();
return;
}
// 收集所有流式请求字符拼接
controller.enqueue(value);
onData(parseData(value));
// 继续读取
push();
});
};
// 只要请求不结束,递归调用 reader.read 读取流中数据
push();
},
});
})
.then((stream) => {
// 返回收集后的文本,仅用于测试
return new Response(stream, {
headers: { "Content-Type": "text/html" },
}).text();
})
.then((res) => {
// 请求完成,最终返回
options.onDone?.(res);
return res;
})
.catch((err) => {
options.onError?.(err);
})
.finally(() => {
options.onFinally?.();
});
};
之所以会呈现一个字一个字出现的效果,其实就是接口一直在返回这样结构的字符串:
然后我们经过上面实现的 parseData 方法就能解析出如下的 json 结构:
[
{
"id": "chatcmpl-7OnilSEfQvBxMqpvFowwf61H1wIjD",
"object": "chat.completion.chunk",
"created": 1686145355,
"model": "gpt-3.5-turbo-0301",
"choices": [
{
"delta": {
"content": "和"
},
"index": 0,
"finish_reason": null
}
]
}
]
当然除了自己通过原生 fetch去做,也有@microsoft/fetch-event-source这个包封装了 SSE 的调用方式,具体实操可以参考这篇:
blog.logrocket.com/using-fetch…
之后发现一些社区的 ChatGPT 项目也有类似的流式请求实现:
下一篇预测:ChatGPT对话类项目开发 - MarkDown 解析及编辑
本系列中间过程的记录:《chat 对话类项目开发实践》比较杂,仅供参考;
另外还有个人的每日学习专栏,感兴趣可以参考 www.yuque.com/jhonxy/note