【JavaScript】ブラウザーで OpenAI API のチャット (Chat Completions) のストリームを扱う方法

過去 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)。

今回のテーマであるストリーミングで応答を得たい場合は、リクエストパラメータの streamtrue をセットします。

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 で扱う場合の課題

リクエストパラメータの streamtrue をセットすると、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-Typetext/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 APITextDecoder を使って文字列に変換します。この文字列には、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));
            }
        }
    }
}

処理の流れは理解できたでしょうか。以上のサンプルを実際に試すと、次のように見えるはずです。

これで完成ではありません。すべての回答を得るまでには、かなり時間がかかります。そのため、実際のウェブアプリでは中止ボタンも必要になるでしょう。中止するには fetchAbortController を使います。使い方は簡単です。

まずコンストラクタから 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 を使っていると知ったときは、ブラウザーだけで簡単に実装が作れると舐めてました。そのため、このブログの記事にするほどの内容でもないと思っていました。しかし、いざ実装を作ってみてハマりました。みなさんも同じように無駄な時間を浪費しなくてもよいよう記事にしてみましたが、いかがでしたでしょうか。

今回は以上で終わりです。最後まで読んでくださりありがとうございました。それでは次回の記事までごきげんよう。

Share