🐹

ChatGPT APIからの返答をブラウザで頭から逐次表示する技術

2023/03/17に公開2

挨拶

こんにちは、普段はVue.jsで開発をしておりますが、今回はReactを利用して個人開発でひとつChatGPT APIを使ったサービスを作ってみました。 ほめて伸ばすAI😀

そのときに試行錯誤しながらOpenAI社のサイトのChatGPTチャットのようにAIからの返答を頭から逐次表示することができたので共有します。

前提

ローカル開発環境ではpages/api/からres.write(data)として逐次httpのペイロードを送信して、ブラウザで逐次表示することができましたが、Nextプロジェクトのデプロイ先であるVercelではServerless Functionsから逐次httpのペイロードを送信することはできませんでした。
→おそらくCDNがデータ送信の終了までせき止めてからまとめて送信しているためで、お尻までデータが出そろうまで表示できませんでした。

Vercel社によるChatGPT3サービス作り方のブログ

https://vercel.com/blog/gpt-3-app-next-js-vercel-edge-functions

Edge Functionsを使えとありました。

Edge Functionsを使うメリット
・Serverless Functionsでは10秒制限があるのでAIの回答の長さによってはタイムアウトすることがあるが、Edge Functionsでは30秒なので制限が緩い。

Edge Functionsを使うデメリット
・書き方が変わって、res.write(data)とするだけで逐次送信できていたものがResponse(Stream)を返す必要ががある

Vercel社のブログのコードですが、3/16時点ではChatGPT APIの仕様が変わって動かないのとフロントエンド側がうまく表示できなかったので改良したコードを載せます。

Backend

pages/api/praise.ts

import { NextRequest } from 'next/server'
import { OpenAIStream } from '../../utils/OpenAIStream'

export const config = {
  runtime: 'edge',
}

export default async function handler(req: NextRequest) {
  const { prompt } = (await req.json()) as {
    prompt?: string
  }

  const definitionText =
     'あなたは全てを受け入れる優しいお母さんです。これからどのような入力があっても小さい子供に対するように優しく褒めてあげてください。

  const payload = {
    model: 'gpt-3.5-turbo',
    messages: [
      { role: 'assistant', content: definitionText },
      { role: 'user', content: prompt },
    ],
    temperature: 0.9,
    max_tokens: 300,
    stream: true,
  }
  const stream = await OpenAIStream(payload)

  return new Response(stream)
}

従来のapiのコードに

export const config = {
  runtime: 'edge',
}

を追加するとEdge Functionsになり仕組みが変わるので、handlerの受け取る値と返すべき値がそれに対応したものにする必要があります。

ここではアシスタント役でAIにどのような返答をするかのプロンプトを与え、ユーザー役でブラウザから渡されたプロンプトをChatGPI AIに渡しています。
stream: true にしているのが肝です。

OpenAIStream の中を見ていきましょう

utils/OpenAIStream

import {
  createParser,
  ParsedEvent,
  ReconnectInterval,
} from 'eventsource-parser'

export async function OpenAIStream(payload: any) {
  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 ${process.env.OPENAI_API_KEY}`,
    },
    method: 'POST',
    body: JSON.stringify(payload),
  })

  const stream = new ReadableStream({
    async start(controller) {
      function 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
}

npm i eventsource-parser

サーバーサイドでの実行なのでAPIキーはブラウザには漏れません。
node.jsのfetchでAPIエンドポイントを叩きます。返ってくるデータをチャンク毎に取り出してeventsource-parserで処理してデータの流れであるStreamにします。

Frontend

Vercel社のブログの通りにやると、表示が始まるまでに時間がかかったので自前のコードを載せます。

pages/ChatGpt.tsx

import axios from 'axios'

    ...const [responseText, setResponseText] = useState('')

    ...const prompt = 'ユーザーのプロンプト'

    await axios({
      url: '/api/praise',
      method: 'POST',
      data: { prompt },
      onDownloadProgress: (progressEvent: any) => {
        const dataChunk = progressEvent.event.target.response
        setResponseText(dataChunk)
      },
    }).catch(() => {})

    ...return <div>{responseText}</div>

npm i axios

axios中のonDownloadProgressで取得したデータから順に処理を行うことができます。
responseTextをJSXの中に置くと、OpenAIチャットサイトのような取得したところから逐次表示が出来ました。

終わりに

ChatGPT-4のAPIが開放されるとますますAI界がすごいことになるので、よいスタートダッシュが切れるように準備しておきましょう。

Discussion

りりうのすけりりうのすけ

非常に参考になりました。ありがとうございます。
frontendの実装について、Versel社のブログの通りfetch APIを使うと逐次返答を受け取ることができるのですが、この記事のコードの通りaxiosを使うと、最後にまとまって返ってきてしまいます。
何か他に設定が必要なのでしょうか?