ChatGPT流式streaming回复的实现

12,589 阅读5分钟

前提

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>

解释:

  1. 模板部分:

    • 我们有一个简单的模板,用来显示从API流式传输过来的内容和任何可能的错误信息。
    • v-if指令用于条件渲染内容和错误消息。
  2. 脚本部分:

    • 导入必要的函数: 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 端点(你需要在后端实现这个端点)。
        • 请求体包含 apiKeyprompt
      • 错误处理:
        • 我们检查响应是否ok (response.ok),如果请求有问题就抛出一个错误。
        • 使用一个 try...catch 块来处理流式传输过程中的潜在错误。
      • 流式响应:
        • response.body!.getReader() 获取响应流的读取器。
        • 我们用一个 while 循环来持续读取流中的数据块。
        • 数据处理:
          • decoder.decode(value) 将数据块从字节解码为字符串。
          • 我们用 chunk.split('\n') 将数据块分割成行。
          • 对于每一行非空行,我们解析JSON响应并将 content 添加到 result 变量中。
          • 我们用累积的 result 更新 content.value
    • 返回值:
      • 我们返回 contenterror,这样它们可以在模板中被访问。

记住:

  • 此代码假设你有一个后端端点(/api/stream-completion)处理与OpenAI API的通信并将响应流回客户端。
  • 替换 "your-api-key" 为你实际的OpenAI API密钥。
  • 根据你的需求调整 prompt 变量。

React实现

那我们来看看在React组件里如何使用这个API吧!

一开始,我们需要引入React的useEffectuseState。这两个都是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请求。

注意:

  1. https.request可以用在服务器端,也可以用在SSR的前端,比如nextjs(也在服务器端渲染)
  2. 在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客户端库都支持流式传输。