過去 2 回の記事では OpenAI API の音声認識 (Speech to Text) と音声合成 (Text to Speech) を扱いましたが、今回は ChatGPT でもおなじみのチャット (Chat Completions) を扱います。単にメッセージを送って応答を得るだけなら簡単です。ここではストリームを使って ChatGPT のようにリアルタイムに文字ごとに応答を得る方法にチャレンジします。
OpenAI API のチャット (Chat Completions) の概要
OpenAI のチャットの Chat Completions API の詳細は、API reference の [Chat > Create chat completion] に記載されています。curl
コマンドであれば次のようにしてメッセージをアップロードして、その応答を取得することができます。
curl https://api.openai.com/v1/chat/completions \
-H "Content-Type: application/json" \
-H "Authorization: Bearer sk-axcuw8EQwBnPVLexeKj83G9RblPqWG3qwalWvmdeLoFng2py" \
-d '{
"model": "gpt-3.5-turbo",
"messages": [
{
"role": "user",
"content": "OpenAI API の Chat Completions について簡潔に説明してください。"
}
]
}'
Authorization
ヘッダーの値にある sk-axcuw8EQwBnPVLexeKj83G9RblPqWG3qwalWvmdeLoFng2py
の部分は API キーです(この値はこの記事のためにランダムに作った文字列ですので API キーとしては利用できません)。
上記リクエストを送ると、次のようなレスポンスが返ってきます。
{
"id": "chatcmpl-8ZYbnZiu69bYyjnXRu3gSaE1b7Qcc",
"object": "chat.completion",
"created": 1703485207,
"model": "gpt-3.5-turbo-0613",
"choices": [
{
"index": 0,
"message": {
"role": "assistant",
"content": "OpenAI APIのChat Completionsは、..."
},
"logprobs": null,
"finish_reason": "stop"
}
],
"usage": {
"prompt_tokens": 32,
"completion_tokens": 230,
"total_tokens": 262
},
"system_fingerprint": null
}
このリクエストでは、OpenAI 側で応答が完成するまで待たされることになります。そして、上記の得られたレスポンスには、完成された応答が丸ごと入っています (response_data.choices[0].message.content
)。
今回のテーマであるストリーミングで応答を得たい場合は、リクエストパラメータの stream
に true
をセットします。
curl https://api.openai.com/v1/chat/completions \
-H "Content-Type: application/json" \
-H "Authorization: Bearer sk-axcuw8EQwBnPVLexeKj83G9RblPqWG3qwalWvmdeLoFng2py" \
-d '{
"model": "gpt-3.5-turbo",
"messages": [
{
"role": "user",
"content": "OpenAI API の Chat Completions について簡潔に説明してください。"
}
],
"stream": true
}'
すると、次のように data:
で始まる行がダラダラとやってきます。data:
の後ろは JSON 文字列です。そして、data: [DONE]
の行で終わります。
data: {"id":"chatcmpl-8ZYhjQsbZ2dVfDwuGEp72Ki49WExd","object":"chat.completion.chunk","created":1703485575,"model":"gpt-3.5-turbo-0613","system_fingerprint":null,"choices":[{"index":0,"delta":{"role":"assistant","content":""},"logprobs":null,"finish_reason":null}]}
data: {"id":"chatcmpl-8ZYhjQsbZ2dVfDwuGEp72Ki49WExd","object":"chat.completion.chunk","created":1703485575,"model":"gpt-3.5-turbo-0613","system_fingerprint":null,"choices":[{"index":0,"delta":{"content":"Open"},"logprobs":null,"finish_reason":null}]}
[省略]
data: [DONE]
1 行分の JSON データの構造が分かりづらいので、ある 1 つの JSON データを以下に整形しました。
{
"id": "chatcmpl-8ZYhjQsbZ2dVfDwuGEp72Ki49WExd",
"object": "chat.completion.chunk",
"created": 1703485575,
"model": "gpt-3.5-turbo-0613",
"system_fingerprint": null,
"choices": [
{
"index": 0,
"delta": {
"content": "ます"
},
"logprobs": null,
"finish_reason": null
}
]
}
欲しいのは “ます” (response_data.choices[0].delta.contant
) の部分です。このように非常に小さな塊がバラバラとやってくることになります。本記事では、以上のことが分かっていれば大丈夫です。API のリクエストとレスポンスの仕様の詳細は OpenAI の API reference をご覧ください。
ストリーミングを使わない場合の実装
いちおう、ストリーミングを使わない場合の JavaScript の実装を見てみましょう。この実装に迷うことはないはずです。
(async () => {
// 送信メッセージ
const message = 'OpenAI API の Chat Completions について簡潔に説明してください。';
// fetch で OpenAI API にリクエスト
const response = await fetch('https://api.openai.com/v1/chat/completions', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': 'Bearer sk-axcuw8EQwBnPVLexeKj83G9RblPqWG3qwalWvmdeLoFng2py'
},
body: JSON.stringify({
model: 'gpt-3.5-turbo',
messages: [{ role: 'user', content: message }]
})
});
// レスポンスボディを JSON として取り出す
const rdata = await response.json();
// 応答メッセージを抜き出してコンソールに出力
console.log(rdata.choices[0].message.content);
})();
OpenAI API は、このような簡単な質問に対しても、しっかりとした回答を返そうとするため、回答が出力されるまでに少し時間がかかります。ChatGPT がストリーミングで回答を出力する理由が良く分かります。
もし、OpenAI API の API キーをお持ちなら、次のサンプルを試してください。OpenAI の API キーはこのサイトのサーバーでは一切保存していませんのでご安心ください。とはいえ、すでに利用中の API キーを使いまわすことはしないでください。OpenAI の API キーを新たに生成し、ここで使った後、破棄 (revoke) してください。
ストリーミングを JavaScript で扱う場合の課題
リクエストパラメータの stream
に true
をセットすると、Web 開発者の方であれば良く知っている Server-Sent Events (サーバー送信イベント) と呼ばれる形式で応答がメッセージごとにダラダラとやってきます。
OpenAI API のチャット (Chat Completions) のストリーミングで Server-Sent Events を採用していると聞いて、ストリーミングの受信の実装はチョロいと思ったのではないでしょうか。実は、残念ながら、そうではありません。
ご存じの通り、OpenAI API のチャット (Chat Completions) のリクエストには Authorization
ヘッダーをつける必要があります。しかし、Server-Sent Events API には HTTP リクエストヘッダーを指定する仕組みがありません。したがって、fetch
を使って Server-Sent Events API がやっていることと同じことを自作しないといけません。とはいえ、受信するだけであれば、さほど難しくはありません。
Server-Sent Event の仕様
Server-Sent Events のイベントストリームを fetch
で扱うためには、少しだけ Server-Sent Events の仕様について理解が必要です。Server-Sent Events のレスポンスの Content-Type
は text/event-stream
です。もしレスポンスが Server-Sent Events のイベントストリームかどうかを判定する必要がある場合は、この Content-Type
を見ると良いでしょう。レスポンスを受信し始めると、サーバーまたはクライアントが切断するまで HTTP コネクションは維持され続けます。
1 つのメッセージの先頭には前述の data:
がセットされます。data:
の後ろに半角スペースが入り、その後ろにメッセージ本体がセットされます。Server-Sent Events の仕様では data:
の他にも event:
で始まるメッセージも許されていますが、OpenAI API のチャット (Chat Completions) のストリーミングでは data:
のみが採用されています (event:
が現れることはありません)。
メッセージの文字エンコーディングは必ず UTF-8 です。そして、1 つのメッセージは 2 つの改行をもって終了とみなされます。この改行とは、Server-Sent Events の仕様上は CRLF
でも LF
でも CR
でも良いとされています。OpenAI API の Chat Completions API では LF
で区切られています。このように、Server-Sent Events は改行文字でメッセージを区切るため、メッセージ本文に改行文字が含まれることはありません。
以上を踏まえ、OpenAI API の Chat Completions API のイベントストリームを fetch
で受信してみましょう。
fetch でイベントストリームを受信する方法
実装のポイントを簡単にまとめると、fetch
のレスポンスを ReadableStream
として扱います。ここで得られるストリームデータはバイナリーデータですので、それを Encoding API の TextDecoder
を使って文字列に変換します。この文字列には、Server-Sent Events のイベントストリームのメッセージが含まれていますので、それを分解して必要となる回答の文字列を抜き出します。
以上を踏まえて、ステップを踏んでコードを見ていきましょう。以下のコードは async
ブロックの中とします。まずはリクエストのために fetch
を実行するところです。
// 送信メッセージ
const message = 'OpenAI API の Chat Completions について簡潔に説明してください。';
// fetch で OpenAI API にリクエスト (stream: true)
const response = await fetch('https://api.openai.com/v1/chat/completions', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': 'Bearer sk-axcuw8EQwBnPVLexeKj83G9RblPqWG3qwalWvmdeLoFng2py'
},
body: JSON.stringify({
model: 'gpt-3.5-turbo',
messages: [{ role: 'user', content: message }],
stream: true
})
});
fetch
のリクエストデータ body
に指定した JSON で "stream: true"
がセットされています。それ以外に特筆するべき点はありません。
リクエストが完了したら、response.body
から得られる ReadableStream
オブジェクトの getReader()
メソッドを使ってストリームのリーダーとなる ReadableStreamDefaultReader
オブジェクト (以下のコードでは変数 reader
) を取得します。
const reader = response.body.getReader();
次に、UTF-8 エンコーディングの TextDecoder
オブジェクト (以下のコードでは変数 decoder
) を生成しておきます。
const decoder = new TextDecoder('utf-8');
以上で、fetch
のレスポンスをストリームとして受信する準備ができました。以降は、イベントストリームをチャンクごとに処理します。一気にコードをご覧ください。
while (true) {
const { done, value } = await reader.read();
if (done) {
break;
}
// 受信したバイナリーデータ (value) をテキストに変換する
const text = decoder.decode(value);
// テキストには複数のメッセージが含まれている可能性があるため、
// 改行 (LF) で分離して、1 行ずつ処理する
const lines = text.split(/\n+/);
for (const line of lines) {
// 先頭の "data: " を削除してメッセージ本文 (JSON 文字列か "[DONE]" のいずれか) を抜き出す
const json_text = line.replace(/^data:\s*/, '');
if (json_text === '[DONE]') {
break;
} else if (json_text) {
const data = JSON.parse(json_text);
const content = data.choices[0].delta.content;
if (content) {
// BODY 要素の最後に文字を挿入
document.body.append(document.createTextNode(content));
}
}
}
}
処理の流れは理解できたでしょうか。以上のサンプルを実際に試すと、次のように見えるはずです。
これで完成ではありません。すべての回答を得るまでには、かなり時間がかかります。そのため、実際のウェブアプリでは中止ボタンも必要になるでしょう。中止するには fetch
で AbortController
を使います。使い方は簡単です。
まずコンストラクタから AbortController
オブジェクト (以下のコードでは変数 controller
) を生成します。そして、signal
プロパティから AbortSignal
オブジェクト (以下のコードでは変数 signal
) を準備しておきます。その AbortSignal
オブジェクトを fetch
のパラメータ signal
にセットしておきます。
const controller = new AbortController();
const signal = controller.signal;
const response = await fetch('https://api.openai.com/v1/chat/completions', {
...
signal: signal
});
では、中止を実行するコードを見てみましょう。中止を行うには AbortController
オブジェクト (変数 controller
) の abort()
メソッドを呼び出します。
controller.abort();
以上で実用的なウェブアプリケーションが作れるのではないでしょうか。以上を踏まえてサンプルを用意しましたので、もし OpenAI API の API キーをお持ちならお試しください。
まとめ
筆者自身、OpenAI の Chat Completions API のドキュメントで、ストリーミングに Server-Sent Events を使っていると知ったときは、ブラウザーだけで簡単に実装が作れると舐めてました。そのため、このブログの記事にするほどの内容でもないと思っていました。しかし、いざ実装を作ってみてハマりました。みなさんも同じように無駄な時間を浪費しなくてもよいよう記事にしてみましたが、いかがでしたでしょうか。
今回は以上で終わりです。最後まで読んでくださりありがとうございました。それでは次回の記事までごきげんよう。