🗨️

フロントエンドエンジニアの為のチャットボット実装のすゝめ

に公開

はじめに

以前コーポレートサイト制作の案件でチャットボットを実装したのですが、当時の自分が参考にできる記事があまり無かった為、次回以降チャットボットを実装する際に必要な知識と実装手順をここに書き記します。
本記事で出てくる概念や技術の説明は、筆者の考えを元にした独自の解釈となっておりますので、より正確な情報を得たい場合は参考文献を参照してください。
尚、本記事ではチャットボットを実装する際にChatGPTを使用しますので、他のモデルを使用される方はご自身が使用したいモデルのAPIページをご覧ください。

ストリーミング

チャットボットや動画配信など、全体の処理に時間がかかってしまう場合に有用な技術がストリーミングです。
チャットボットではストリームを用いて生成している文章の完了を待たずとも徐々にに生成結果を返しています。

MDNでは以下の様に説明されています。

https://developer.mozilla.org/ja/docs/Web/API/Streams_API#関連情報

ストリーミングでは、ネットワーク経由で受信するリソースを小さなチャンク(塊)に分割し、少しずつ処理します。ブラウザーはメディア資産を受信する際にすでにこのような動作を行っています。動画はコンテンツのダウンロードが進むにつれてバッファーされ再生されますし、画像も読み込みが進むにつれて徐々に表示されることがあります。

ストリーミングを利用したことが無かった筆者は、データを小さな塊に分割し少しずつ処理をすることにあまり馴染みがなく、この概念を実装する場合どのように実装するのだろうと少し戸惑いました。

実際に普段の開発でサーバーサイドのAPIを叩いてデータを取得する際の例を見てみましょう。

順序としては以下の様になっています。

  1. クライアントサイドからリクエストを送る
  2. サーバーサイドで処理を行う
  3. 処理が完了したらクライアントサイドに返却する

筆者はこれがWeb開発における揺るがぬ基本であり、この流れを知っていれば困ることはないと信じていました。
しかし、ストリーミングはサーバーサイドで返却するデータを小分けにして、クライアントサイドに返却するというのです。
それを図で表すと以下の様になります。

ここで注目してほしいのは、クライアントサイドのリクエスト回数は1回であり、その後はレスポンスが完了(中断)されるまでサーバーサイドと接続し続ける状態となります。

この様にすることで、クライアントサイドではデータが送られてくる度に再描画し、全体の処理が完了せずとも部分的にレスポンスをユーザーに見せることができるので、UXの向上に繋がります。
30秒かかる処理の際にスピナーを30秒間回し続けるのはお世辞にもUXが優れてるとは言えませんからね。

では実際に簡単なストリーミング実装のサンプルを見ながら一連の流れの実装方法についてみていきましょう。

今回のデモはNext.jsを使用しています。
デモ全体のコードは以下のリポジトリからご覧ください。
https://github.com/chiro0114/stream-nextjs-demo

実装の内容としてはサーバー側で500ms毎に生成される文字列を、ストリーミング形式で取得する度に画面に描画しています。

ストリームを使用する際の文法についてサーバーサイドのコードから詳しく見ていきましょう。
最終的なコードは以下のプルダウンから確認してください

サーバーサイド
api/stream/route.ts
export const runtime = 'nodejs';

export async function GET() {
  const text = '株式会社コードユニット';
  let index = 0;

  const stream = new ReadableStream({
    start(controller) {
      // 500ms毎に文字を1文字ずつ送信
      const intervalId = setInterval(() => {
        if (index >= text.length) {
          // 文字列の終端に達したらintervalをクリアして、ストリームを閉じる
          clearInterval(intervalId);
          controller.close();
          return;
        }

        const chunk = text[index];
        const encoded = new TextEncoder().encode(chunk);
        controller.enqueue(encoded);
        index++;
      }, 500);
    },
  });

  // 返却時にレスポンスヘッダを設定
  return new Response(stream, {
    headers: {
      'Content-Type': 'text/plain; charset=utf-8',
    },
  });
}

  const stream = new ReadableStream();

クライアントのリクエストに対してストリーム形式でレスポンスを返却したいときはReadableStreamインスタンスを作成します。

  const text = '株式会社コードユニット';
  let index = 0;

  const stream = new ReadableStream({
    start(controller) {
      // 500ms毎に文字を1文字ずつ送信
      const intervalId = setInterval(() => {
        if (index >= text.length) {
          // 文字列の終端に達したらintervalをクリアして、ストリームを閉じる
          clearInterval(intervalId);
          controller.close();
          return;
        }

        const chunk = text[index];
        const encoded = new TextEncoder().encode(chunk);
        controller.enqueue(encoded);
        index++;
      }, 500);
    },
  });

ReadableStreamインスタンスを作成する際の引数にはインスタンスの動作を定義するオブジェクトを渡します。

startメソッドはインスタンス化した時に実行されるコードです。
今回はtextに格納されている文字列を500ms毎に1文字ずつ取り出して返却する関数をstartメソッドに定義しています。

startメソッドの引数にはcontrollerが渡ってきます。
このcontrollerを使用して値の返却やストリーミングの終了などの制御を行えます。

controller.enqueue(encoded);

ストリーム形式でデータを返却する際は、ストリームに対してデータの断片を少しづつ格納していきます。
この際のデータの断片をチャンク(chunks)と呼び、ストリームに置かれたチャンクはキューに入った(enqueued)と言われます。
この際キューに入ったチャンクは読み取り可能な状態となり、内部キュー(internal queue)によってチャンクの状態(読み取られたかどうか)を追跡しています

つまり上記のコードはコントローラのenqueueメソッドを使用して、チャンクを読み取り可能な状態にしているコードとなります。

        if (index >= text.length) {
          // 文字列の終端に達したらintervalをクリアして、ストリームを閉じる
          clearInterval(intervalId);
          controller.close();
          return;
        }

コントローラのcloseメソッドを使用することで、ストリームを終了し、クライアントサイドに終了した旨を伝えることができます。

startメソッドやその他の引数の詳細な説明は以下を参照してください。

https://developer.mozilla.org/ja/docs/Web/API/ReadableStream/ReadableStream

これは、オブジェクトが構築されるとすぐに呼び出されるメソッドです。 このメソッドの内容は開発者が定義し、ストリームのソースへのアクセスを取得し、ストリーム機能を設定するために必要な他のすべての操作を行う必要があります。

次にクライアントサイドでストリーム形式のレスポンスを受け取って表示するコードを見ていきましょう。

クライアントサイド
page.tsx
'use client';

import React, { useState } from 'react';

export default function Home() {
  const [chunks, setChunks] = useState<string[]>([]);

  const fetchCodeUnitHandler = async () => {
    if (chunks) {
      setChunks([]);
    }

    try {
      const response = await fetch('/api/stream');

      if (!response.body) return;

      // getReaderを実行してreaderを取得
      const reader = response.body.getReader();
      const decoder = new TextDecoder('utf-8');

      // whileループで/api/streamの処理が実行するまでデータを取得し続ける
      while (true) {
        // 処理が完了するまではvalueが返却され処理が完了したらvalueはundefinedになり、doneがtrueになる
        const { done, value } = await reader.read();
        if (done) {
          console.log('処理が完了しました。');
          break;
        }
        // valueはUint8Array型で取得されるので、TextDecoderを使用して文字列に変換する
        const chunkText = decoder.decode(value, { stream: true });
        setChunks((prev) => [...prev, chunkText]);
      }
    } catch (error) {
      console.error('Fetch error:', error);
    }
  };

  return (
    <div className="py-10 px-20">
      <h1 className="text-2xl font-bold">ストリーム体験サイト</h1>
      <div className="flex gap-10 mt-6">
        <div className="flex-1">
          <button
            onClick={fetchCodeUnitHandler}
            className="py-2 px-8 text-lg  bg-blue-600 rounded-full text-white"
          >
            実行
          </button>
          <h1 className="text-center text-2xl">
            {chunks.map((chunk, i) => {
              return <React.Fragment key={i}>{chunk}</React.Fragment>;
            })}
          </h1>
        </div>
      </div>
    </div>
  );
}

      const response = await fetch('/api/stream');
      if (!response.body) return;

      // getReaderを実行してreaderを取得
      const reader = response.body.getReader();
      const decoder = new TextDecoder('utf-8');

皆様がいつも利用しているfetchした際に返ってくるresponse.bodyReadableStream(読み取り可能なストリーム)となっています。
つまりbodyに新たにReadableStreamのインスタンスを作成して->レスポンスを格納するみたいなことをしなくても、ストリーミングが利用できる形式となっています。

しかしそのままでは、サーバーから送られてくるストリームを読むことができないので、getReaderメソッドを使用してリーダを取り付ける必要があります。
このメソッドを実行することでリーダが作成され、ストリームを読み取ることができます。

      // whileループで/api/streamの処理が実行するまでデータを取得し続ける
      while (true) {
        // 処理が完了するまではvalueが返却され処理が完了したらvalueはundefinedになり、doneがtrueになる
        const { done, value } = await reader.read();
        if (done) {
          console.log('処理が完了しました。');
          break;
        }
        // valueはUint8Array型で取得されるので、TextDecoderを使用して文字列に変換する
        const chunkText = decoder.decode(value, { stream: true });
        setChunks((prev) => [...prev, chunkText]);
      }

ストリームを取り付けたら、readメソッドを使用することでdoneプロパティとvalueプロパティを取得することができます。

doneプロパティはサーバーサイドのコントローラによってストリームが閉じられるとfalseとなり、それまではtrueが返ってきます。

valueプロパティには段階的なサーバーサイドからのレスポンスが返ってきます。
最終的にストリームが閉じられたときはundefinedが返ってきます。
valueプロパティに渡ってきた値を元にステートを更新し、画面を再レンダリングすることで、サーバー側の生成結果を段階的にレンダリングすることができます。

チャットボットの実装

ストリーミングについて理解したところで、次はチャットボットの実装手順を見ていきましょう。

OpenAI APIにログイン

ChatGPTのAPIを用いてチャットボットを開発する際には以下の2つが必要となります。

  1. OpenAIのAPIキー
  2. OpenAIのAssistantID

1 OpenAI APIにログインし、表示される内容通りに進めていくとAPIキーが発行されます。これは一度しか表示されないので安全な場所に保管し、他人には絶対に共有しないでください。

2 OpenAI APIのダッシュボード画面に遷移し、Assistantsからアシスタントを作成してAssistantIDを入手してください

以上の2つがあれば開発においてエラーが出ることは無いのですが、このままだとAIからの回答が返ってきません。
AIからの返答をアプリケーション側で取得するには、さらにクレジットをチャージする必要があります。

最低5$からチャージできるのですが、最近引っ越しをした私にはとても大打撃でした。
(この記事を見ているであろう弊社代表が、経費として落とすことを期待しています)
読者の皆様は上司に掛け合うなどして、会社の経費でチャージすることをおすすめします。

チャージする際の注意点として、オートチャージ機能がデフォルトでONになっていたので、チャットボットを本格的に導入する時以外はOFFにすることをおすすめします。

チャットボット実装

本記事の為にチャットボット実装をNext.jsを用いてサンプルを作成しましたので、以下のリポジトリからチャットボットの実装方法を確認していただければと思います。
https://github.com/chiro0114/nextjs-openai-chatbot

READMEにも記載があるのですが、今回のサンプルはOpenAI公式が出しているサンプルを、初めてチャットボットを実装する方が迷わないようにした最小限のコードとコメントを記載しています。
また、企業からのチャットボット制作を依頼された想定のレイアウトとなっています。

基本的にはリポジトリ内のコードを見るだけで、チャットボットを実装できるようにコメントを残していますが、要望があれば一部のコードの解説を追記しようと思っています。

まとめ

以前初めてチャットボットを実装した時はストリーミングに対しての知識が無かった為、サンプルのコードを見ても分からないことばかりでした。
ストリーミングの知識があれば、モデルが変わろうが使用するフレームワークが変わろうがUXの優れたチャットボットを実装できます。

弊社では一緒に働く仲間を募集しています!

株式会社コードユニットは 「エンジニアが自由に挑戦し、成長できる環境を創る」 をビジョンに掲げる札幌のIT企業です。
モダンな技術スタックを使った開発や、このような技術的課題に日々チャレンジできる環境で、楽しく開発をしています。
そんな弊社では以下のような方を募集しています:

  • 新しい技術に挑戦したい方
  • 現状に満足せず、常にスキルアップを目指せる方
  • 知らない情報にアンテナを張っている方
  • ビジョンに共感し、会社と共に成長してくれる方

興味を持っていただけた方は、ホームページからご連絡ください。カジュアル面談も実施していますので、お気軽にお問い合わせください!

株式会社コードユニット

Discussion