【生成AI】Amazon Bedrock+TypeScript・Next.jsでサクッとチャットボットを作る

はじめに

こんにちは。ぐっさんです。

ChatGPTをはじめとした生成AIが当たり前のものになってきました。今後Webアプリから使ってみたくなる機会も増えそう!ということで、今回はお試しにAIに話しかけるちょっとしたチャットボットを作ってみたいと思います。

生成AIは、AWSを活用している当社の既存技術スタックに合わせ、Amazon Bedrockを使用します。

  • 生成AI
    Amazon Bedrock
  • 言語・フレームワーク
    TypeScript : v5.2.2
    Next.js : v13.5.6

作っていく

事前準備

※AWSのアカウント作成については割愛します。

AWSコンソールから、利用したいモデルの有効化を行います。
Bedrockのメニュー「Model access」から、利用したいモデルを有効化します。

なんか色々ある!

「Access status」の「Available to request」をクリックして申請します。モデルによっては申請に会社情報等の登録が必要&数日かかるものもあるようです。利用可能な言語モデルはリージョンによって異なり、また、価格もモデルによって異なるため、ニーズに合ったものを選択する必要がありそうです。上記は2023.11.10現在のバージニア北部(us-east-1)リージョンの例です。

今回は「Available to request」をポチッとクリックしてサクッと有効化することができ、気軽な価格で利用できそうな「AI21 Labs」の「Jurassic-2 Mid」を使います。

環境構築

Node.jsのパッケージマネージャーはnpmを使用します。

まずはNext.jsの新規プロジェクトを作成。

npx create-next-app@latest

TypeScriptを使いますか?と聞かれたら、Yesで。他はお好み。

√ Would you like to use TypeScript? ... No / Yes

AWSサービスおよびBedrockへ接続するため、さきほどnpx create-next-app@latestで作成したプロジェクト内に、下記のnpmパッケージをインストールします。

AWS SDK for JavaScript

@aws-sdk/client-bedrock-runtime

npm install aws-sdk @aws-sdk/client-bedrock-runtime

とりあえず、Bedrockに接続してみる

ひとまず、AWS SDK経由で先ほど有効化したBedrockのモデルへプロンプトを送って回答を受け取ってみます。

ざっと全体はこちら・・・

import {
  BedrockRuntime,
  InvokeModelCommand,
  InvokeModelCommandInput,
} from "@aws-sdk/client-bedrock-runtime";

// AWS認証情報(シークレットキー&アクセスキー)
const aws_access_key_id = process.env.AWS_ACCESS_KEY_ID as string;
const aws_secret_access_key = process.env.AWS_SECRET_ACCESS_KEY as string;

export const runAi21 = async (prompt: string): Promise<string> => {
  const client = new BedrockRuntime({
    region: "us-east-1", // バージニア北部
    credentials: {
      accessKeyId: aws_access_key_id,
      secretAccessKey: aws_secret_access_key,
    },
  });

  const bodyJson = {
    prompt: prompt,
    maxTokens: 10,
  };

  const bodyString = JSON.stringify(bodyJson);

  const params: InvokeModelCommandInput = {
    modelId: "ai21.j2-mid-v1",
    contentType: "application/json",
    accept: "*/*",
    body: bodyString,
  };

  const command = new InvokeModelCommand(params);

  let result: string = "";

  try {
    await client
      .send(command)
      .then((data) => {
        const resultString = data.body.transformToString();
        const resultJson = JSON.parse(resultString);
        result = resultJson.completions[0].data.text;
      })
      .catch((error) => {
        console.log(error);
      });
  } catch (e) {
    console.log(e);
  }

  return result;
};

基本のリクエスト&レスポンスの方法ですが、「BedrockRuntimeのClientに、プロンプトを入れたInvokeModelCommandを渡してSendすると、応答がある」という動きをします。

Client定義はひとまず最小限として、使うリージョンと認証情報だけ渡す、下記のような感じに。

  const client = new BedrockRuntime({
    region: "us-east-1", // バージニア北部
    credentials: {
      accessKeyId: aws_access_key_id,
      secretAccessKey: aws_secret_access_key,
    },
  });

続いてInputCommandの定義です。

  const bodyJson = {
    prompt: prompt, // AIに送るプロンプト(prompt:string の引数です)
    maxTokens: 10,
  };

  const bodyString = JSON.stringify(bodyJson);

  const params: InvokeModelCommandInput = {
    modelId: "ai21.j2-mid-v1", // 使うAIモデル
    contentType: "application/json",
    accept: "*/*",
    body: bodyString,
  };

  const command = new InvokeModelCommand(params);

InvokeModelCommandに渡すパラメータ(上記だと、bodyJsonの内容)は言語モデルによって異なるようですが、今回はAI21を使用しているので、こちらのパラメータが該当します。

どのパラメータをいじったらどうなるのかは「正直よくわからん!!」のですが、「maxToken」の値が大きいほど、返信文の長さが長くなりました。利用料がトークン数によって計算されるため、ここでは小さ~く10にしています。(maxTokenの値による応答文量の違いと価格は後述の「気になるお値段」にまとめています!)

いざ送信&受信!

  let result: string = "";

  try {
    await client
      .send(command)
      .then((data) =&gt; {
        const resultString = data.body.transformToString(); // Uint8Array → String
        const resultJson = JSON.parse(resultString);
        result = resultJson.completions[0].data.text;
      })
      .catch((error) =&gt; {
        console.log(error);
      });
  } catch (e) {
    console.log(e);
  }

  return result;

すこしハマった部分としては、レスポンスのdata.bodyはUint8Array(8ビット符号なし整数値の配列)が返却されるため、transformToString()メソッドで文字列に変換した上でJSONにパースしてあげる必要があります。

チャットボットの体裁にする

AIへの質問&応答ロジックができたので、あとはブラウザ表示用に少し成型して・・・

page.tsx

"use client";
import { useChat, questionSet } from "../../hooks/useChat";
import { useEffect, useState } from "react";

export default function Page() {
  const { talkLogs, send } = useChat();
  const [questionText, setQuestionText] = useState("");
  const initState: questionSet[] = [];
  const [dispTalkLogs, setDispTalkLogs] = useState(initState);

  useEffect(() => {
    console.log("useEffect");
    setDispTalkLogs([...talkLogs]);
  }, [talkLogs]);

  return (
    <div className="m-4">
      <h1 className="font-extrabold text-xl">教えてAI21</h1>
      <hr className="border border-black" />
      <div>
        {dispTalkLogs.map((talk, index) => {
          return (
            <div key={index} className="mt-8">
              <li className="list-none font-bold">{talk.question}</li>
              <li className="list-none text-sm">> {talk.reply}</li>
            </div>
          );
        })}
      </div>
      <div className="mt-4 w-max">
        <input
          type="text"
          value={questionText}
          onChange={(e) => setQuestionText(e.target.value)}
          className="border border-slate-500 p-1 w-80"
        />
        <button
          onClick={() => send(questionText)}
          className="border border-slate-500 bg-slate-400 font-bold py-1 px-2"
        >
          Send
        </button>
      </div>
    </div>
  );
}

※CSSはTailwind CSSを使用しています。

useChat.ts(AIへ話しかける機能の独自フック)

import { useState } from "react";
import { runAi21 } from "../lib/aws-sdk";

// 質問と応答のセット型
export type questionSet = {
  question: string | null;
  reply: string | null;
};

export const useChat = () =&gt; {
  const initState: questionSet[] = [];
  const [talkLogs, setTalkLogs] = useState(initState);

  const send = async (question: string) =&gt; {
    const result = await runAi21(question);
    const set: questionSet = { question: question, reply: result };
    const updatedTalkLogs = [...talkLogs, set];
    setTalkLogs(updatedTalkLogs);
  };

  return { talkLogs, setTalkLogs, send };
};

手軽にそれっぽくなりました!

気になるお値段

Amazon Bedrockは残念ながらAWS無料枠は適用されないようです。今回は使った分だけ請求となる「オンデマンド」形態でのお試しとなりました。料金はこちらを参考にすると・・・

  • Jurassic-2 Mid は入力/出力ともに1000トークン毎に0.0125 USD
  • 執筆当日(2023.11.10)現在、1USD=約150円なので、1000トークンで1.875円

トークンって言われてもよくわからんなあ・・・ということで、maxToken設定値をいじって、文章量を比較してみました。AI21は日本語が苦手なので、英語で質問します。

Tell me about popular Pokémon in the United States.(アメリカで人気のポケモンを教えて。)

maxToken : 10 の場合

There are many different species of Pokémon, but some of the most
popular ones include Pikachu

maxToken : 50の場合

In the United States, some of the most popular Pokémon include
Pikachu, Charizard, Squirtle, Butterfree, Bulbasaur, Charmander and
Mewtwo. Pikachu, aElectric-type Pokémon, was introduced as the first
character of the franchise in 1996 and has since become one of the
most recognizable and popular Pokémon. Charizard

maxToken : 100の場合

However, it is worth noting that the popularity of Pokémon changes
with time, and what is popular today may not be so popular a few years
down the line. That being said, here are a few of the most well-known
and beloved Pokémon in the United States: Pikachu: Pikachu is one of
the most iconic and well-known Pokémon of all time. It is a
lightning-rod shaped yellow rodent with electric powers and is the
beloved mascot of the Pokémon brand. Charizard: Charizard is a
fire-breathing dragon Pokémon that is often cited as one of the most
popular and powerful Pokémon of all. It is known for its fierce
temperament and its ability to learn powerful fire-type moves. Mewtwo:
Mewtwo is a Genetic Pokémon that was created through

maxToken:10の返信文章量を見るに、入力トークンも10程度に収まっていると考えられるので、いずれのmaxTokenの例でも、利用料は1円未満ということになりますでしょうか。へぇ~。

(アメリカでも赤・緑世代のポケモンが人気なんですね。ウソかホンマか知らんけど。)

この記事を書くためにテスト利用した範囲では、トータルで100円程度でした。

まとめ

言語モデルへの接続&話しかけて回答をもらうだけなら、かなりお手軽に実現できました!

実際にWebアプリに組み込むなら、独自の学習をさせたいところが課題となってくると思います。今回は割愛しましたが、「Custom Models」を有効化し、トレーニングおよび検証データをAmazon S3バケットへ配置することで学習させることができるようです。

また、このブログの執筆にあたっては、ChatGPTやGitHub CopilotといったBedrock以外の言語系AIサービスも使用しました。便利ですね~~