リアル業務でChatGPT APIを使うコツ
まえおき
先日僕がWebエンジニアとして働いている株式会社Helpfeelで、Helpfeel Tech Hour vol.2 「GPT-3→GPT-4編」というイベントを開催しました。
僕からはリアル業務でChatGPT APIを使うコツというテーマで、ChatGPTやEmbeddingのAPIについて実践的なテクニックを紹介しました。
(イベントをご視聴いただいた皆さんありがとうございます)
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の単方向バージョンです。
標準APIならこれを使うのが良いのでは?と思う訳ですが、ChatGPTのAPIを使う場合、以下ような罠があるためオススメしません。
- メソッドがGETに限定されている
- 接続が正常終了した後に自動的に再送してしまう
-
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.body
は ReadableStream
であるというのはご存知でしょうか。ほとんどのユースケースでは 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が向いていない理由
ここからは蛇足です。興味のある人は読んでください。
- メソッドがGETに限定されている
WHATWGのspecには書かれていないようなのですが(?)、EventSourceではGET以外のメソッドでリクエストを送れないんですよね。HTTPメソッドを指定するオプションがないようです。詳しいことは僕にもよく分かりません。
Stackoverflowで「仕様的にGETだけだよ」と言っている人を見かけるなど(ホンマか?)
- 接続が正常終了した後に自動的に再送してしまう
EventSourceは一度初期化すると close()
するまで半永久的に接続を試みます。データを送り終えてレスポンスが閉じた後で、EventSourceは「接続が閉じてしまったぞ。もう一度繋ぎ直そう」と再リクエストしてしまう訳です。リアルタイムで情報を購読したいユースケースであれば非常に便利な機能ですが、OpenAIのAPIを無限に叩き続けてしまうのは困りますね。
クライアントサイドで close()
すれば良いのですが、そもそもの用途が違うので、やはりfetchを使うのが良いでしょう。
-
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しているのでしょうね。