AIしか勝たん!:ChatGPT API を使ってみる

Date:

Share post:

ChatGPTがAI分野を席巻していますが、特に我々プログラマにとっては今年(2023年)3月にリリースされた「ChatGPT API」の存在は見逃せません。
と言うことで、早速使ってみました。
環境は、Apache+Laravel9(PHP)です。

なお、「ChatGPT API」を使用するためにはOpenAIに対してアカウント登録を行う必要があります。
また、「ChatGPT API」の利用は有償(0.002ドル/1000トークン)なので注意願います。
トークンは入出力する文字列を構成する単語や文字の数ってことなんですが、実のところ正確な数を予測することは結構難しいです。英語の場合は、ほぼ単語数と同等と解釈すれば良いようですが、日本語の場合は今ひとつ基準がはっきりしません。経験則としては文字数に近いです。

あと、「ChatGPT API」を語る上で一般的な用法(ブラウザからWebサービスとしてChatGPTを使用する方式)に言及したい局面がありますので、そちらは「ChatGPT Web」と表現することにします。

どうせならストリーミング

まず、「ChatGPT API」のインターフェース構造自体はHTTPを使用した単純なもので、所定の形式のデータ(JSON)およびヘッダ情報をHTTPリクエストとして送信し、その結果(JSON)をHTTPレスポンスとして受け取るだけです。「ChatGPT API」の使い方としてネット上にある情報の大半は基本的な用法(非ストリーミング方式)に関してで、一対のHTTPリクエスト送信+レスポンス受信で構成されます。HTTPレスポンスは回答内容となる文字列が全て生成されてから返される形になります。

ただ、ChatGPTを使ったことのある方であればお分かりになるかと思いますが、プロンプトを入力してからレスポンスが全て完了するまでには(内容次第ですが)数秒から数十秒の待ちが発生します。
「ChatGPT Web」では応答内容の表示は全文が完成してから行われる訳ではなく、文章の先頭の方から順次表示されていく(つまりストリーミング方式になっている)ため、待たされている感がかなり軽減されます(プログレスバーが伸びていくのを見ている感覚でしょうか)。
一方で、「ChatGPT API」を先に示した基本的な用法(非ストリーミング方式)で使用した場合、応答までの待たされている感や本当に動いているのかどうかに関する不安感がハンパないです。

「ChatGPT API」の仕様を詳しく見ていくとストリーミング方式にも対応している気配があり(具体的な利用方法はあまり詳しく説明されていませんが)、実際に「ChatGPT Web」の挙動としてはストリーミング方式になっているので、頑張れば「ChatGPT API」をストリーミング方式で使えるはずです。

ということで、本記事ではストリーミング方式での「ChatGPT API」の使用を目指します。

Guzzle HTTP クライアント

Laravel9からHTTPベースの外部APIを使用する方法はいくつか存在しますが、今回は特にストリーミングに対応しているということで「Guzzle HTTP クライアント」を使用します。

せっかくなので、「Guzzle HTTP クライアント」によってPOSTリクエストを送信し、応答をストリーミング方式で受け取る処理をリポジトリ化しておきたいと思います。

<?php

namespace App\Repositories\Http;

use GuzzleHttp\Client;

class PostStreamRepository
{
    public function __invoke($url, $data, $headers)
    {
        $clientObj = new Client();
        $responseObj = $clientObj->request('POST', $url, [
            'headers' => $headers,
            'json' => $data,
            'stream' => true,
        ]);
        return $responseObj;
    }
}

APIのURLや送信するPOSTデータおよびヘッダ情報は引数で取得するものとします。
「’stream’ => true」と設定しているところがポイントですね。

ChatGPT API の呼び出しと応答のストリーミング化

前述のリポジトリを使用して「ChatGPT API」を呼び出す処理を実装したいと思いますが、ここでもう一つ考えるべきは受信したデータをどのようにクライアント(自身の呼び出し元)に返すかと言う点です。
せっかく「ChatGPT API」からストリーミング方式で応答を取得しても、それをプログラム内で最後まで受信した上で1つのレスポンスとしてまとめて返したのでは意味がありません。
よって、クライアントへの応答もストリーミングになるように実装する必要があります。
この辺の考慮しながら、受信・送信それぞれの仕様を考えます。

まず、「ChatGPT API」からストリーミング方式で受信するデータは以下のような形式になっています。
これはSSE(server-sent events)という通信方式におけるデータ形式です。
非ストリーミング方式の場合とはデータ構造が異なっているので注意してください。

data: {"id":"<ID>","object":"<オブジェクト名>","created":<UNIXタイム>,"model":"gpt-3.5-turbo-0301","choices":[{"delta":{"content":"<応答内容>"},"index":0,"finish_reason":null}]}

上記のようなデータが改行のみの行を挟みながら複数行送られてきます。
ChatGPTにより生成された文章は上記の「content」の内容に分割して反映され、送信されてくる訳です。

ここで注意が必要なのは、上記のような形式のデータを断続的に受信する際に、1回の受信で取得したデータ(チャンク)の切れ目が必ずしも上記データ構造の切れ目と一致しないという点です。
つまり、チャンクの最後の部分が上記データ構造の途中であるということがあり得ます。

一方、クライアントへ送信するデータの構造に関してですが、今回の実装ではクライアント側の処理が上記「ChatGPT API」のデータ構造を認識し、JSONデコードした結果から「content」の中身を取得できるようになっています(ネットでそのような処理のサンプルが提供されている模様です)。
よって、本処理としては受信したデータ構造をそのままクライアントに送信すれば良いのですが、一点制約があるのは、クライアントでの受信データは必ず上記データ構造の切れ目で完結していることが期待されている点です。

例えば、ChatGPTから前述のデータ構造が3行分送られてきたとして、本処理がそれらを1.5行分ずつ2回に分けて受信した場合、それをそのままクライアントに送信することはできません。
この場合は1回目の受信から完結している1行目の分のみクライアントに送信し、残った0.5行分(2行目の前半)は続いて受信される1.5行分(2行目の後半と3行目)と連結して2行の完結したデータとしてクライアントに送る必要があります。

上記のようなデータの受信・送信を含めた「ChatGPT API」との通信処理を以下に示します。

<?php

namespace App\Repositories\ChatGPT;

use App\Repositories\Http\PostStreamRepository;
use Symfony\Component\HttpFoundation\StreamedResponse;

class V1ChatCompletionsStreamRepository
{
    private PostStreamRepository $HttpPostStreamRepository;

    public function __construct(PostStreamRepository $HttpPostStreamRepository)
    {
        $this->HttpPostStreamRepository = $HttpPostStreamRepository;
    }

    public function __invoke()
    {
        $url = /* ChatGPT API のURL */

        // POSTパラメータ
        $data = [
            'model' => 'gpt-3.5-turbo',
            'messages' => [
                'role' => 'user',
                'content' => /* プロンプト */
            ],
            'stream' => true,
        ];

        // ヘッダー
        $headers = [
            "Content-Type" => "application/json",
            "Authorization" => "Bearer "./* ChatGPT API のAPIキー */
        ];

        try {
            // ChatGPT API へのリクエスト送信
            $responseObj = ($this->HttpPostStreamRepository)($url, $data, $headers);
        } catch (\Exception $e) {
            throw new \Exception($e->getMessage(), $e->getCode());
        }

        // ストリーミング方式の応答になるようStreamedResponseを使用
        return new StreamedResponse(function () use ($responseObj) {
            $stream = $responseObj->getBody();
            while (!$stream->eof()) {
                $chunk = $stream->read(256);
                $this->parseResponse($chunk); // チャンクの解析とクライアントへのデータ送信
                usleep(200000); // 0.2s
            }
            $stream->close();
        }, 200, [
            'Content-Type' => 'text/event-stream',
            'Cache-Control' => 'no-cache',
            'Connection' => 'keep-alive',
        ]);
    }

POSTパラメータの構造は「ChatGPT API」の仕様に準じます。
特に「’stream’ => true」と指定して、ストリーミングを有効にしているところがポイントです。

ヘッダでは「ChatGPT API」のAPIキーなるものを指定しています。
このAPIキーはOpenAIにアカウント登録し、管理画面にログインできるようになった後に同管理画面上から取得可能になります。

クライアントへの応答のストリーミング化には「StreamedResponse」なるクラスを使用しています。
HTTPレスポンスのヘッダに「Content-Type: text/event-stream」と設定されるように指定しているところがポイントです。これは、応答がSSEであることを意味するものです。
また、このクラスではインスタンス化の際に指定したクロージャ内で断続的に「ChatGPT API」からのデータ受信およびクライアントへのデータ送信を行います。

なお、先に記述したチャンク関連の解析と結果の送信に関しては後述するプライベートメソッド「parseResponse」に集約してあります。

チャンクの解析とクライアントへのデータ送信

「ChatGPT API」から受信したチャンクはデータ本来の構造を考慮した切れ目になっていないため、クライアントに対して送信するデータがデータ構造として完結した形になるように調整する必要があります。

    private $buffer = '';

    private function parseResponse($chunk)
    {
        $this->buffer .= $chunk;
        $lines = explode("\n", $this->buffer);
        $this->buffer = '';

        foreach ($lines as $line) {
            if ($line === '') {
                // 空行は無視
                continue;
            }

            // 1行分の文字列をJSONデコード
            $json = str_replace('data: ', '', $line);
            $data = json_decode($json, true);
            if (json_last_error() !== JSON_ERROR_NONE) {
                // JSONが完結していない場合は残りを保存して処理を中断
                $this->buffer = $line;
                break;
            }

            // JSONとして完結した形式であることが確認できたので、当該行を送信
            echo $line."\n\n";
            flush();
        }
    }

処理の最初で、前回の処理時に残ったデータ(途中で途切れていた行の前方部分)と今回の受信分を連結しています。
その後、データを行単位に分割し、各行が所定のデータ構造として完結している(JSONデコード可能である)場合はクライアントに送信し、完結していなければ一旦処理を保留(残ったデータをプロパティに保存)し、次の受信データをまちます。

ApacheおよびPHPの設定

Laravel(PHP)での実装内容は(若干端折っていますが)上記に示した通りとして、実はこれだけではクライアントに対するデータ送信をストリーミング化できません。
ApacheやPHPには通信の効率化のために送信データをある程度溜めて、まとめて送信するような機能があって、その影響で前述のように実装上は受信データを逐次送信するようになっていたとしても、そのタイミングですぐにクライアントに送信されていないという状況が起こり得ます。

このような状況にならないようにApacheやPHPの設定を変更する必要があります。
ただし、これらの設定はPHPの動作モードに依存し、現在正常動作が確認できているのはFastCGIを使用した場合のみです(PHP-FPMでは成功パターンを発見できておらず、他は試していません)。

Apacheの設定

Apacheの設定方法に関しては環境によって違いがあると思いますが、ここではUbuntu22.04上でVirtualminによる仮装ホスト環境を構築している場合の例を示します。

この場合、各仮装ホスト環境に関する設定は「/etc/apache2/sites-available/<仮装ホストのドメイン名>.conf」のようなファイルとして存在してます。
その中に以下のような設定を行います。

FcgidOutputBufferSize 0

上記を追記する箇所ですが、VirtualminでFastCGI指定で環境構築すると既に当該ファイル内にFastCGI関連の他の設定(Fcgid…)が存在するかと思いますので、その辺に追記すれば良いかと。

PHPの設定

こちらもUbuntu22.04+Virtualminを想定すると、仮装ホストに関する固有の「php.ini」が同ホストを管理するユーザーのホームディレクトリ直下「etc/php.ini」として存在します。
このファイル内の以下のパラメータを書き換えます。

output_buffering = Off

同パラメータは当該ファイル内に既に存在していて、デフォルトでは4096が設定されていたりするかと思いますので、そこを書き換えます。

まとめ

上記により、一応「ChatGPT API」を呼び出し、その結果をストリーミング方式でクライアントまで伝える環境が構築できました。
ApacheやPHPの設定との絡みもあり、特定の環境では動作しても別の環境では期待通りに動作しなくなる(ストリーミングにならない)と言ったことがあって、結構苦労しました。
実は最初に非ストリーミング方式で実装しましたが、そちらはネット情報を元に1日程度で実装できたのですが。

HTTPでストリーミング方式の処理を実装したのは今回が初めてですし、そもそもHTTPでストリーミングが実現できること自体想定外でした。
興味深い技術ではありますが、それくらい使うきっかけがなかった技術でもあります。

ストリーミング対応するかどうかは別として、「ChatGPT API」を組み込んだシステムを今後開発することは大いに考えられるので、そちらに関しては今後も有効な用法を探っていきたいと思います。

Related articles

Laravel Filamentを使用した管理画面...

前回Breezeをインストールしたこと...

Laravel Filamentを使用した管理画面...

前回、filamentでのリソース作成...

Laravel Filamentを使用した管理画面...

前回、Filamentをインストールし...