Open2

リアル業務でChatGPT APIを使うコツ

寺本大輝寺本大輝

まえおき

先日僕がWebエンジニアとして働いている株式会社Helpfeelで、Helpfeel Tech Hour vol.2 「GPT-3→GPT-4編」というイベントを開催しました。

https://www.youtube.com/watch?v=thavLiDj9Gw

僕からはリアル業務でChatGPT APIを使うコツというテーマで、ChatGPTやEmbeddingのAPIについて実践的なテクニックを紹介しました。

https://docs.google.com/presentation/d/1rj0QOumDkh4elKMPBtvYHbky6io8vMuQlVgdSrQHZHM/edit#slide=id.g227ffdea5c5_0_199

(イベントをご視聴いただいた皆さんありがとうございます)
15分間という短い発表の中ではすべてを喋りきれなかったため、文章としてここに書いていこうと思います。そのうち別の媒体に移すかも知れません。

寺本大輝寺本大輝

streamオプションを使う時はEventSourceではなくfetchを使うべき

注:EventSourceをdisってる訳じゃないです

ChatGPTのstreamオプションを使うと、本家ChatGPTのようにパパパパッと文字を出すことができます。
これにはServer-Sent Eventsと呼ばれる標準的なプロトコルが用いられています。概要はスライドをご覧ください(一言で言うとJSON Linesです)。

本題ですが、WHATWG(HTML Standard)でServer-Sent Eventsを受け取るためのEventSourceというAPIが標準化されており、モダンブラウザやNode.jsなどで利用可能です。
EventSourceを一言で説明すると、WebSocketの単方向バージョンです。

https://html.spec.whatwg.org/multipage/server-sent-events.html

標準APIならこれを使うのが良いのでは?と思う訳ですが、ChatGPTのAPIを使う場合、以下ような罠があるためオススメしません。

  1. メソッドがGETに限定されている
  2. 接続が正常終了した後に自動的に再送してしまう
  3. text/event-stream 以外のContent-Typeが来ると終了してしまう

詳しくは後述することにして、まずはベストプラクティスを紹介します。

ベストプラクティス

fetch APIを使うのがオススメです。
やや雑なコードですが、フロントエンドはこんなイメージです。

async function fetchChatStream(
  prompt: string,
  callback: (text: string) => void
) {
  const response = await fetch(YOUT_API_ENDPOINT, {
    method: 'POST',
    headers: {
      'Content-Type': 'application/json'
    },
    body: JSON.stringify({ prompt }) // このprompt変数を使ってサーバ側でAPI callする
  });
  const reader = response.body?.getReader();
  if (!reader) return;

  let decoder = new TextDecoder();
  while (true) {
    const { done, value } = await reader.read();
    if (done) break;
    if (!value) continue;
    const lines = decoder.decode(value);
    const jsons = lines
      .split('data: ') // 各行は data: というキーワードで始まる
      .map(line => line.trim()).filter(s => s); // 余計な空行を取り除く
    for (const json of jsons) {
      try {
        if (json === '[DONE]') {
          return; // 終端記号
        }
        const chunk = JSON.parse(json);
        const text = chunk.choices[0].delta.content || '';
        callback(text);
      } catch (error) {
        console.error(error);
      }
    }
  }
}

await しているのにstreamになるのか疑問に思うかも知れませんが、問題ありません。

await fetch(...) の部分は、HTTPのHeaderがやってきた時点(という説明が厳密に正しいかは分からないが)でfullfillするからです。

そもそも Response.bodyReadableStream であるというのはご存知でしょうか。ほとんどのユースケースでは Response.text()Response.json() などを使うので忘れがちですが、実はStreamとして読めるインターフェースを持っています。
reader = response.body?.getReader(); という部分でGeneratorのようなものを作っており、 { done, value } = await reader.read(); で次々にデータをreadしていきます。

あとはパースするだけなので割愛します。

バックエンドはこんな感じです。
フレームワークは、expressかNext.jsか、なんかその辺りを使っているイメージです。

 async function handler(req, res) {
  const response = await fetch("https://api.openai.com/v1/chat/completions", {
    method: "POST",
    headers: {
      "Content-Type": "application/json",
      Authorization: "Bearer " + apiKey,
    },
    body: JSON.stringify({
      model: "gpt-3.5-turbo",
      messages: ...,
      stream: true,
    }),
  });
  if (!response.body) throw new Error("No body presented");

  res.status(200);

  // Server Sent Eventsをそのまま流す
  const reader = response.body.getReader();
  while (true) {
    const { value, done } = await reader.read();
    if (done) break;
    res.write(value);
  }
  res.end();
}

ほぼフロントエンドと同じですね。標準化のお陰です。
なお、Node.jsネイティブのfetch APIを使っている想定です。 node-fetch などのライブラリを使う場合はServer Sent Eventに対応できているか確認してください。

EventSourceが向いていない理由

ここからは蛇足です。興味のある人は読んでください。

  1. メソッドがGETに限定されている

WHATWGのspecには書かれていないようなのですが(?)、EventSourceではGET以外のメソッドでリクエストを送れないんですよね。HTTPメソッドを指定するオプションがないようです。詳しいことは僕にもよく分かりません。

Stackoverflowで「仕様的にGETだけだよ」と言っている人を見かけるなど(ホンマか?)
https://stackoverflow.com/questions/34261928/can-server-sent-events-sse-with-eventsource-pass-parameter-by-post

  1. 接続が正常終了した後に自動的に再送してしまう

EventSourceは一度初期化すると close() するまで半永久的に接続を試みます。データを送り終えてレスポンスが閉じた後で、EventSourceは「接続が閉じてしまったぞ。もう一度繋ぎ直そう」と再リクエストしてしまう訳です。リアルタイムで情報を購読したいユースケースであれば非常に便利な機能ですが、OpenAIのAPIを無限に叩き続けてしまうのは困りますね。

クライアントサイドで close() すれば良いのですが、そもそもの用途が違うので、やはりfetchを使うのが良いでしょう。

  1. text/event-stream 以外のContent-Typeが来ると終了してしまう

Server-Sent Eventsは text/event-stream というMIME typeを使うことが仕様によって定められています。つまりこれは正しい動作なのですが、これもやはりChatGPTのAPIを叩きたい場合には不便です。とくにサーバレスPaaSを利用している場合、Content-Typeを変更できないケースがあるため注意が必要です

逆に、fetch APIを使うデメリット

些細なデメリットですが、Chrome DevToolsの「EventSource」タブが使えません。
そんなタブあったの?という方がほとんどだと思いますし、 正直そんなに便利でもないので、 気にしなくていいと思います。
ちなみに、本家ChatGPTでもEventSourceタブは動作していません。おそらくfetch APIを使っているか、polyfillしているのでしょうね。