前提
Chatgpt支持stream的response,因此前端可以展示实时打字的效果, 这里涉及到SSE的概念。具体参考juejin.cn/post/684490…
前端接收SSE
一般要使用没有封装底层的请求lib,即,可以支持streaming的chunk模式的。
Axios抽象了底层的HTTP基础架构,提供了一个基于promise的API来简化HTTP/HTTPS请求和响应。整个响应作为一个Promise由Axios的request()方法返回,它解析为一个Response对象。 Axios无法返回streaming的chunk,因为他封装后一次性返回。
前端的可以用fetch(浏览器原生),实现代码
import { createParser, ParsedEvent, ReconnectInterval } from 'eventsource-parser';
import getOpenAIBaseUrl from './getOpenAIBaseUrl';
export const OpenAIStream = async (prompt: string, apiKey: string) => {
const encoder = new TextEncoder();
const decoder = new TextDecoder();
const res = await fetch(`${getOpenAIBaseUrl()}/v1/chat/completions`, {
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${apiKey}`
},
method: 'POST',
body: JSON.stringify({
model: 'gpt-3.5-turbo',
messages: [
{
role: 'system',
content: `You are a helpful assistant that accurately answers queries using GitHub Privacy Statement. Use the text provided to form your answer, but avoid copying word-for-word from the context. Try to use your own words when possible. Keep your answer under 5 sentences. Be accurate, helpful, concise, and clear.`
},
{
role: 'user',
content: prompt
}
],
temperature: 0.1,
stream: true
})
});
if (res.status !== 200) {
throw new Error('OpenAI API returned an error');
}
const stream = new ReadableStream({
async start(controller) {
const onParse = (event: ParsedEvent | ReconnectInterval) => {
if (event.type === 'event') {
const data = event.data;
if (data === '[DONE]') {
controller.close();
return;
}
try {
const json = JSON.parse(data);
const text = json.choices[0].delta.content;
const queue = encoder.encode(text);
controller.enqueue(queue);
} catch (e) {
controller.error(e);
}
}
};
const parser = createParser(onParse);
for await (const chunk of res.body as any) {
parser.feed(decoder.decode(chunk));
}
}
});
return stream;
};
这段代码同样是流式传输的实现方式,但是它使用了浏览器的Fetch API中的流式传输。OpenAIStream函数接收多个参数,包括OpenAI模型、系统提示、温度、API密钥和消息数组。
为了开始流式传输过程,该函数首先使用Fetch API向OpenAI API发送POST请求。请求的主体包含OpenAI模型、消息和其他参数,如流参数设置为true。这就是使服务器能够以流式而不是完整响应发送数据的方法。
在Fetch API进行请求并接收到响应后,代码使用ReadableStream对象初始化读取流。它创建一个解析器对象,使用eventsource-parser监听已解析事件,指示新的数据块已到达。当新数据到来时,它将解析JSON数据以提取文本内容,并将其编码为Uint8Array,使用controller.enqueue()将其入队到流中。
这个过程将一直持续,直到触发close事件,表示流已被服务器关闭。
最后,OpenAIStream()函数返回一个ReadableStream对象。
Vuejs(TS)实现
模板部分:
<template>
<div class="openai-component">
<p>接收到的响应:</p>
<p v-if="content">{{ content }}</p>
<p v-if="error">错误:{{ error }}</p>
</div>
</template>
脚本部分:
<script lang="ts">
import { defineComponent, onMounted, ref } from 'vue';
interface OpenAIResponse {
choices: { delta: { content: string } }[];
}
export default defineComponent({
name: 'OpenAIComponent',
setup() {
const content = ref<string>('');
const error = ref<string | null>(null);
const apiKey = 'your-api-key'; // **把这个替换成你的实际API密钥**
const prompt = 'your-prompt';
onMounted(async () => {
try {
const response = await fetch('/api/stream-completion', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
apiKey,
prompt,
}),
});
if (!response.ok) {
throw new Error(`OpenAI API请求失败,状态码:${response.status}`);
}
const reader = response.body!.getReader();
const decoder = new TextDecoder();
let result = '';
while (true) {
const { done, value } = await reader.read();
if (done) {
break;
}
const chunk = decoder.decode(value);
const lines = chunk.split('\n');
for (const line of lines) {
if (line.trim() !== '') {
try {
const data: OpenAIResponse = JSON.parse(line);
result += data.choices[0].delta.content;
content.value = result;
} catch (parseError) {
console.error('解析JSON时出错:', parseError);
}
}
}
}
} catch (fetchError) {
error.value = (fetchError as Error).message;
}
});
return {
content,
error,
};
},
});
</script>
解释:
-
模板部分:
- 我们有一个简单的模板,用来显示从API流式传输过来的内容和任何可能的错误信息。
v-if
指令用于条件渲染内容和错误消息。
-
脚本部分:
- 导入必要的函数:
defineComponent
,onMounted
, 和ref
来自Vue。 - 组件定义:
name: 'OpenAIComponent'
定义了组件的名字。setup()
函数是我们定义组件逻辑的地方。
- 响应式变量:
content
: 一个ref
用来存储从API流式传输过来的内容。error
: 一个ref
用来存储任何可能的错误消息。
- API密钥和提示词:
- 替换
"your-api-key"
为你实际的OpenAI API密钥。 - 设置
prompt
变量为你想要给OpenAI模型的提示词。
- 替换
onMounted
生命周期钩子:- 这个函数在组件挂载到DOM后运行。
- API请求:
- 我们用
fetch
发起一个POST请求到你的/api/stream-completion
端点(你需要在后端实现这个端点)。 - 请求体包含
apiKey
和prompt
。
- 我们用
- 错误处理:
- 我们检查响应是否ok (
response.ok
),如果请求有问题就抛出一个错误。 - 使用一个
try...catch
块来处理流式传输过程中的潜在错误。
- 我们检查响应是否ok (
- 流式响应:
response.body!.getReader()
获取响应流的读取器。- 我们用一个
while
循环来持续读取流中的数据块。 - 数据处理:
decoder.decode(value)
将数据块从字节解码为字符串。- 我们用
chunk.split('\n')
将数据块分割成行。 - 对于每一行非空行,我们解析JSON响应并将
content
添加到result
变量中。 - 我们用累积的
result
更新content.value
。
- 返回值:
- 我们返回
content
和error
,这样它们可以在模板中被访问。
- 我们返回
- 导入必要的函数:
记住:
- 此代码假设你有一个后端端点(
/api/stream-completion
)处理与OpenAI API的通信并将响应流回客户端。 - 替换
"your-api-key"
为你实际的OpenAI API密钥。 - 根据你的需求调整
prompt
变量。
React实现
那我们来看看在React组件里如何使用这个API吧!
一开始,我们需要引入React的useEffect
和useState
。这两个都是React的Hooks,非常好用。
然后是我们的主角,StreamingComponent
组件。在这个组件里,我们用useState
来创建一个叫streamData
的状态。这个状态就是我们用来保存服务器发送过来的信息。
然后,我们进入useEffect
。在这里,我们会在组件加载好以后立刻发起一个请求。这个请求就是跟服务器进行交流的关键步骤了。
fetch
函数会向我们之前在服务器上设定好的"/stream-completion"这个地址发起请求。接着我们检查返回的response
,确认服务器有没有真的响应。
然后我们使用ReadableStream
来逐步读取服务器发送过来的数据,并且通过setStreamData
更新我们的streamData
状态。
React会在streamData
状态更新时重新渲染我们的组件,这样我们就可以在页面上看到新的数据了!
最后别忘了在卸载组件时停止请求,避免浪费资源。
以上就是一种简单的方式,可能需要根据你实际的API返回和数据处理需求进行调整。在真实的应用中,记得还要做好错误处理并保护好敏感数据哦。
import React, { useEffect, useState } from 'react';
function StreamingComponent() {
const [streamData, setStreamData] = useState("");
useEffect(() => {
const controller = new AbortController();
fetch("/stream-completion", { signal: controller.signal })
.then(response => {
if (!response.body) throw Error("No response from server");
const reader = response.body.getReader();
const stream = new ReadableStream({
start(controller) {
function push() {
reader.read().then(({done, value}) => {
if (done) {
controller.close();
return;
}
setStreamData(previous => previous + new TextDecoder("utf-8").decode(value));
controller.enqueue(value);
push();
});
}
push();
}
});
return new Response(stream);
})
.catch(error => console.error("Error:", error));
return () => {
controller.abort();
}
}, []);
return (
<div className="stream">
<p>Stream data:</p>
<p>{streamData}</p>
</div>
);
}
export default StreamingComponent;
import React, { useEffect, useState } from 'react';
function OpenAIComponent() {
const [content, setContent] = useState("");
const [error, setError] = useState(null);
const apiKey = "your-api-key";
const prompt = "your-prompt";
useEffect(() => {
const fetchStream = async () => {
const encoder = new TextEncoder();
const decoder = new TextDecoder();
const res = await fetch(`https://api.openai.com/v1/chat/completions`, {
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${apiKey}`
},
method: 'POST',
body: JSON.stringify({
model: 'gpt-3.5-turbo',
messages: [
{
role: 'system',
content: `You are a helpful assistant...`
},
{
role: 'user',
content: prompt
}
],
temperature: 0.1,
stream: true
})
});
if (res.status !== 200) {
throw new Error('OpenAI API returned an error');
}
const stream = new ReadableStream({
async start(controller) {
for await (const chunk of res.body as any) {
try {
// Parse the chunk into json
const json = JSON.parse(decoder.decode(chunk));
// Extract text content from the json
const text = json.choices[0].delta.content;
// Push the text into the stream to be consumed by the
// React component
controller.enqueue(encoder.encode(text));
} catch (e) {
controller.error(e);
}
}
controller.close();
}
});
const reader = stream.getReader();
let result = '';
while (true) {
const {done, value} = await reader.read();
if (done) {
break;
}
result += new TextDecoder("utf-8").decode(value);
}
setContent(result);
};
fetchStream().catch((error) => setError(error.message));
}, [apiKey, prompt]);
return (
<div className="openai-component">
<p>Received response:</p>
{content && <p>{content}</p>}
{error && <p>Error: {error}</p>}
</div>
);
}
export default OpenAIComponent;
后端接收SSE
当然openAi无法直接访问,可以搭建中间nodejs服务器proxy过去请求,同样为了保持streaming的功能;axios是无法使用的。可以使用nodejs的自带的https.request来实现。
代码
import { IncomingMessage } from "http";
import https from "https";
import { Message, truncateMessages, countTokens } from "./Message";
import { getModelInfo } from "./Model";
import axios from "axios";
export function assertIsError(e: any): asserts e is Error {
if (!(e instanceof Error)) {
throw new Error("Not an error");
}
}
async function fetchFromAPI(endpoint: string, key: string) {
try {
const res = await axios.get(endpoint, {
headers: {
Authorization: `Bearer ${key}`,
},
});
return res;
} catch (e) {
if (axios.isAxiosError(e)) {
console.error(e.response?.data);
}
throw e;
}
}
export async function testKey(key: string): Promise<boolean> {
try {
const res = await fetchFromAPI("https://api.openai.com/v1/models", key);
return res.status === 200;
} catch (e) {
if (axios.isAxiosError(e)) {
if (e.response!.status === 401) {
return false;
}
}
}
return false;
}
export async function fetchModels(key: string): Promise<string[]> {
try {
const res = await fetchFromAPI("https://api.openai.com/v1/models", key);
return res.data.data.map((model: any) => model.id);
} catch (e) {
return [];
}
}
export async function _streamCompletion(
payload: string,
apiKey: string,
abortController?: AbortController,
callback?: ((res: IncomingMessage) => void) | undefined,
errorCallback?: ((res: IncomingMessage, body: string) => void) | undefined
) {
const req = https.request(
{
hostname: "api.openai.com",
port: 443,
path: "/v1/chat/completions",
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${apiKey}`,
},
signal: abortController?.signal,
},
(res) => {
if (res.statusCode !== 200) {
let errorBody = "";
res.on("data", (chunk) => {
errorBody += chunk;
});
res.on("end", () => {
errorCallback?.(res, errorBody);
});
return;
}
callback?.(res);
}
);
req.write(payload);
req.end();
}
interface ChatCompletionParams {
model: string;
temperature: number;
top_p: number;
n: number;
stop: string;
max_tokens: number;
presence_penalty: number;
frequency_penalty: number;
logit_bias: string;
}
const paramKeys = [
"model",
"temperature",
"top_p",
"n",
"stop",
"max_tokens",
"presence_penalty",
"frequency_penalty",
"logit_bias",
];
export async function streamCompletion(
messages: Message[],
params: ChatCompletionParams,
apiKey: string,
abortController?: AbortController,
callback?: ((res: IncomingMessage) => void) | undefined,
endCallback?: ((tokensUsed: number) => void) | undefined,
errorCallback?: ((res: IncomingMessage, body: string) => void) | undefined
) {
const modelInfo = getModelInfo(params.model);
// Truncate messages to fit within maxTokens parameter
const submitMessages = truncateMessages(
messages,
modelInfo.maxTokens,
params.max_tokens
);
const submitParams = Object.fromEntries(
Object.entries(params).filter(([key]) => paramKeys.includes(key))
);
const payload = JSON.stringify({
messages: submitMessages.map(({ role, content }) => ({ role, content })),
stream: true,
...{
...submitParams,
logit_bias: JSON.parse(params.logit_bias || "{}"),
// 0 == unlimited
max_tokens: params.max_tokens || undefined,
},
});
let buffer = "";
const successCallback = (res: IncomingMessage) => {
res.on("data", (chunk) => {
if (abortController?.signal.aborted) {
res.destroy();
endCallback?.(0);
return;
}
// Split response into individual messages
const allMessages = chunk.toString().split("\n\n");
for (const message of allMessages) {
// Remove first 5 characters ("data:") of response
const cleaned = message.toString().slice(5);
if (!cleaned || cleaned === " [DONE]") {
return;
}
let parsed;
try {
parsed = JSON.parse(cleaned);
} catch (e) {
console.error(e);
return;
}
const content = parsed.choices[0]?.delta?.content;
if (content === undefined) {
continue;
}
buffer += content;
callback?.(content);
}
});
res.on("end", () => {
const tokensUsed =
countTokens(submitMessages.map((m) => m.content).join("\n")) +
countTokens(buffer);
endCallback?.(tokensUsed);
});
};
return _streamCompletion(
payload,
apiKey,
abortController,
successCallback,
errorCallback
);
}
Axios不像https.request()那样提供数据块。Axios抽象了底层的HTTP基础设施,提供了基于Promise的API,简化了HTTP/HTTPS请求和响应。整个响应作为Promise由Axios的request()方法返回,解析为一个Response对象。
为了处理大量的数据,Axios返回的响应对象提供了流式传输数据到Transform流的能力,以便更有效地处理数据。此外,Axios还提供了请求和响应拦截器等功能,可用于在请求和响应生命周期中进一步修改数据。
总的来说,https.request()提供了更低级别的数据流和请求/响应处理控制,而Axios提供了一种更现代和用户友好的方式来进行HTTP和HTTPS请求。
注意:
- https.request可以用在服务器端,也可以用在SSR的前端,比如nextjs(也在服务器端渲染)
- 在Node.js中处理数据块的方法不仅限于使用https.request()方法。
实际上,大多数Node.js HTTP客户端库都支持某种形式的流式传输。以下是一些流行的Node.js HTTP客户端库,它们提供流式传输功能:
首先是got:它是一个广受欢迎的HTTP客户端,支持流式请求和响应。使用streams2、stream2-demuxer和stream2-parallel来启用流式传输。
其次是request:它是另一个常用的HTTP客户端,可以通过默认方式流式传输响应。该库使用Node.js核心的http和https模块进行请求和响应。
最后是node-fetch:它提供了类似于fetch的接口,支持流式传输。Node-fetch在其fetch方法中提供了一个流选项,以便流式传输响应正文。
总的来说,Node.js中有很多处理数据块的方法,除了使用https.request()之外,大多数HTTP客户端库都支持流式传输。