旅行系AIアドバイザー「TripGPT」をリリースしました

はじめまして、中村tripqotです。ChatGPTのバズり方を見て、このビッグウェーブに乗るしかないと思って**旅行系AIアドバイザーTripGPT**をリリースしました。ログインしなくても使えるので良かったら試してみてください。

https://app.tripqot.com/trip-gpt

# デモ画面

① 最初に行き先と目的地を入力すると
![チャット開始](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3191565/79909330-cc70-90cb-9217-7891710a93d6.png)

② 行き方や予算や、おすすめスポット、グルメを紹介してくれます。
![最初の返答](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3191565/2eaba4a4-b229-ae7f-9e49-71f2c85c5978.png)

③ 2回め以降はフリーチャットです。内容について聞くもヨシ。**数学の問題を解かせてもヨシ**。
![フリーチャット](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3191565/ebba9ba9-ee3d-b8a8-55d6-6028b2001fc9.png)

**何を見てヨシって言ったんですか**
![数学の問題は解かない](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3191565/b3dc0f52-a303-ed2e-b182-fb5a98424a05.png)

# 主要な技術・ツール等

このあと実装について説明しますが、前提として使用している主要な技術・ツールは以下のとおりです。

- GPT-3
- AWS Amplify
- Next.js
- Chakra UI
- GraphQL
- SWR

# チャットボット設計・実装メモ

チャットボット作る上でいくつか新しい学びや躓いた点があったので、メモ程度に共有させていただきます。なにか知りたいことがあったら是非コメントやTwitterのDM、それか答えてくれるかわかりませんがTripGPTに聞いてみてください。

## OpenAI社のGPT-3を利用

チャットボットのメイン部分では流行りのChatGPTよろしくOpenAI社のGPT-3を利用しています。実際にJavaScriptコードに組み込んで動くまでの流れは以下のとおりです。

1. OpenAI社のページからAPI keyを取得(5分)
1. ```npm install openai``` (1, 2分)
1. 以下のコード書いて実行(1, 2分)
```js
const { Configuration, OpenAIApi } = require("openai");
const config = new Configuration({ apiKey: OPEN_AI_API_KEY }); // ここでAPIキー設定。コード内に書いちゃだめだゾ
const openai = new OpenAIApi(config);
const response = await openai.createCompletion(
  {
    model: "text-davinci-003",
    prompt: "その声は、我が友、李徴子ではないか？"
  }
);
console.log(response);
```

コード書き慣れてる人なら何も無い状態から最初の返答を貰うまで10分いりません。こんな簡単に使えるのヤバいですね。ヤングな言葉でいうと「**開発体験が良い**」ってやつですかね。

なお、上記コードではmodelとpromptしか書いてませんが、返答を微調整したい変態さんのためにたくさんパラメータ設定することが可能です。パラメータの調整はブラウザからOpenAIのページからマウスぽちぽちしていじれるようになっていてそこも感動ポイントでした。詳しくはドキュメントやplaygroundを確認してください。

## 2回め以降も会話を続ける方法

チャットと言っている以上、その時その時の質問だけでなく、それ以前の会話も含めた回答が必須になります。ただ、上記コードでは質問文単体を引数としており、またOpenAIのドキュメントを見てもそれまでの会話を持たせるようなパラメータはありません。ではどうするかと困ったところで、ドキュメントの別の部分にやり方が載っていました。

https://platform.openai.com/docs/guides/completion/conversation

要約すると「**① 最初に役割を説明して、② チャットっぽいテキストを生成し、③ 最後に役割名だけ書いて止める**」って書いてあります。イメージとしてはこんな感じです。

```js
const prompt =
  "あなたは李徴です。次の会話を読んで、袁傪の問に答えなさい\n"  // ① 最初に役割を説明して
  + "袁傪: その声は、我が友、李徴子ではないか？\n"  //② チャットっぽいテキストを生成し
  + "李徴: " // ③ 最後に役割名だけ書いて止める
;
const response = await openai.createCompletion(
  { model: "text-davinci-003", prompt }
);
console.log(response);
```

このあと更に続けて質問する場合はこうなります。

```js
const prompt =
  "あなたは李徴です。次の会話を読んで、袁傪の問に答えなさい\n"
  + "袁傪: その声は、我が友、李徴子ではないか？\n"
  + "李徴: 如何にも自分は隴西の李徴である\n" // 先程の質問に対する回答を追記
  + "袁傪: 何故叢から出て来ないのか\n"  // 新しい質問を追記
  + "李徴: "
;
const response = await openai.createCompletion(
  { model: "text-davinci-003", prompt }
);
console.log(response);
```

結構力技っぽくて個人的におもしろポイントでした。ただチャットが増えるとテキストが膨らんでいくがちょっと嫌ですね。

## データの持たせ方と画面の実装

このアプリ内では一つひとつの発言をまとめて```utterances```という配列に格納しています。配列の中身は、話者(```speaker```)と発言内容(```contents```)を格納したオブジェクトになっています。

```tsx
// AIとユーザーの発言を全て格納する変数
const [utterances, setUtterances] = useState<Utterances>([
  { speaker: "AI", contents: ["はじめに行き先と目的を教えてください！"] },
  { speaker: "user", contents: [] },
]);
```

さっきの例で言うとこんなイメージです。シンプルですね。

```tsx
[
  {speaker: "AI", contents: ["はじめに行き先と目的を教えてください！"]},
  {speaker: "User", contents: ["その声は、我が友、李徴子ではないか？"]},
  {speaker: "AI", contents: ["如何にも自分は隴西の李徴である"]},
  {speaker: "User", contents: ["何故叢から出て来ないのか"]},
  {speaker: "AI", contents: []},
]
```

この変数```utterances```に対してmap関数を用いてループさせ、話者による条件分岐を使って表示内容を分岐しています。なお、長くなるの嫌で中身のコード省略してますが要望があれば公開します。郵便またはFAXでお送りください。嘘です、コメントかTwitterDMください。

```tsx
return utterances.map(({ speaker, contents }, index) => { // map関数によるループ
  switch (speaker) { // switch文による条件分岐
    case "User":
      return <UserUtterance contents={contents} />;
    case "AI":
      return <UserUtterance contents={contents} />;
    default:
      return null;
  }
})
```

あとはユーザが質問したり、AIが応答するたびに```push()```で配列を増やしてあげれば大丈夫です。発言が増えるたびに画面が縦長になるので、スクロールの制御をすると非常に良いです。**そして誰かそのやり方を教えてください**。

## 通信時にAppSyncの30秒タイムアウトを回避した(但し回避したとは言っていない)

最終的に単純な解決方法でしたが、ここが一番の難所でした。今回、通信の流れとしては以下のとおりです。

**①ブラウザ → ②GraphQL API(AWS AppSync) → ③AWS Lambda → ④OpenAI API**

OpenAI APIの返答時間は、速ければ数秒ですが遅いと60秒以上待つことになります。ただ、このアプリで使用しているGraphQL APIの**タイムアウト時間が30秒と設定されており、かつ変更が出来ない仕様**になっています。これを回避するためにはstep functionやAPI Gatewayを使えば良いと、いろいろ出ては来ます。ただ、詳しいことは省略しますが、AWS Amplifyを使っている構成なのでAmplifyで扱っていないAWSサービスを使うのは少し面倒です。

結論から言うと、データ通信を2回に分けました。

1. 「OpenAI APIに問い合わせをして結果をDBに保存する」処理を非同期実行する
1. 問い合わせた結果がDBに保存されたのを検知したら画面に表示する

このやり方を取れたのは、GraphQL API自体のタイムアウトが30秒でも、その先にあるAWS Lamdbaは最大15分までタイムアウトを伸ばせるからです。つまりGraphQL APIにリクエストを投げるだけ投げておいて、GraphQL APIはタイムアウトまで結果を返そうとずっと待っているのに、返す相手はすでにもう待っていないのです。可哀想。しかしLmabdaはLambdaでちゃんとDBに保存するところまで処理してくれるので、DBを見張っておけば良いのです。

DBを見張るツールとして、データフェッチツールのSWRを使いました。データを取得する際の面倒ごとを上手く処理してくれる優れものです。今回はデータが取得できたとき出来なかったときで細かい制御をするため、```onSuccess, onErrorRetry```というオプションを使用しました。ざっくり以下のコードになります。

```tsx
  // エラー後リトライをカウントする変数
  const [retryCount, setRetryCount] = useState(0);

  // 成功時の処理関数
  const onSuccess = (data: string[], _key: any, _config: any) => {
    /* 成功処理。ここでは回答を画面に表示させる処理をしている */
  };

  // エラー後リトライ時の処理関数
  const onErrorRetry = (_error: any, _key: any, _config: any, revalidate: Revalidator ) => {
    // リトライ数を増加
    setRetryCount((count) => count + 1);
    // エラーが60回以上の場合再試行しない(≒タイムアウト60秒)
    if (retryCount >= 60) {
      /* タイムアウト処理。60回リトライしてだめならエラー文を出力している */
      return;
    }
    // エラーが60回を超えていなければ1秒後に再試行
    setTimeout(() => revalidate({ retryCount }), 1000);
  };

  // useSWRフック呼び出し。上記の関数をここで渡している
  useSWR( key, fetcher, { onSuccess, onErrorRetry });
```

実を言うと、onErrorRetry関数にはretryCountがデフォルトで用意されているんですが、別タブに移動したり画面のフォーカスを外したりすると**カウントが0に戻る仕様なのかバグなのかがあった**ため、useState()を使って管理しています。

# 今後の精度改善予定

実際に使われた方はわかるかと思いますが、現在の回答精度はあまり良くないです。そのため、今後は以下の方法などで精度改善を予定しています。あくまで予定で、実際にはその時一番冴えたやり方で実装します。

- BingAIなどのGPT-4を採用
- 旅行プランデータを使ってファインチューニング

# 補足：API使用料金

OpenAI社のAPIは2023/02/21現在、電話番号1つを代償に3ヶ月または$18-の無料枠を錬成できます。

テスト環境で5ユーザがチャットしまくってたら、3日で既に$5-くらいかかっていました。サービスとしては人気が出てほしいんですが、使用量がかさんで突然の死が訪れるかもしれないのでそのときが来たら察してください。

まだ始めたばかりなので全然データ無いですが、今後使用量追ってツイートでもしますかね。


以上です。
ここまで読んでいただきありがとうございました。

中村tripqot