🤖

Agents for Amazon Bedrock で Webサイトにチャットボット機能を足してみる

2024/04/30に公開

はじめに

いったい何をしようとしているのかというと、生成AIを使ってWebサイトにチャットボットを手軽に実現してみよう、という試みです。

今回は Agents for Amazon Bedrock というサービスを使ってみることにします。
https://aws.amazon.com/jp/bedrock/agents/

何をしてくれるサービスなのかは「よくある質問」を引用して紹介します。

Amazon Bedrock のエージェントとは、生成 AI ベースのアプリケーションをデベロッパーが簡単に作成できるようにするフルマネージド型の機能です。これにより、さまざまなユースケースの複雑なタスクを実行したり、独自のナレッジソースに基づいて最新の回答を提供したりすることができます。

素晴らしいですね。チャットボットとして活躍してくれそうです。

新人スタッフ、いきなり店頭デビュー

さっそく使ってみましょう。ということで、サクッと画面に一問一答のインターフェースを追加します。ちなみに、このWebサイトは実在しない架空の和菓子店のホームページです。

なお、キャラクターは、Microsoft Copilot Designer に描いてもらい、コンテンツの文言や商品名などは ChatGPT に適当なモノを生成してもらいました。生成AI、便利すぎる。

裏のアーキテクチャはこんな感じです。Serverlessバンザイ!な非常にシンプルな構成のWebサイトになっています。

Agent に質問を投げるためのコードはNuxt3のサーバーサイドAPIとして実装しています。こちらも難しいことはせず、とりあえず呼べればいいや、ぐらいのシンプルさです。

/server/api/ask-question.ts
import { BedrockAgentRuntimeClient, InvokeAgentCommand } from '@aws-sdk/client-bedrock-agent-runtime';

const client = new BedrockAgentRuntimeClient({
    region: 'us-east-1',
});

interface RequestParams {
    sessionId: string;
    question: string;
}

const runtimeConfig = useRuntimeConfig();
const agentId = runtimeConfig.agent_id;
const agentAliasId = runtimeConfig.agent_alias_id;

export default defineEventHandler(async (event): Promise<string | undefined> => {

    const { sessionId, question } = await readBody<RequestParams>(event);
    const command = new InvokeAgentCommand({
        agentId, agentAliasId, sessionId,
        endSession: false,
        enableTrace: false,
        inputText: question,
    });

    return await client.send(command).then(async response => {
        if (!response.completion) {
            return undefined;
        }

        const decorder = new TextDecoder('utf-8');
        const answer = [] as string[];
        for await (const stream of response.completion) {
            if (stream.chunk && stream.chunk.bytes) {
                const chunk = decorder.decode(stream.chunk.bytes)
                answer.push(chunk);
            }
        }
        return answer.join('');
    });
})

質問してみる

いよいよ、質問してみます。

チョットなに言っているか分からない

生成AIってバカなんでしょうか?どんな経緯でこんな回答に至ったのか調べてみることにします。まず Agent にトレースオプションを有効にして同じ質問をしてみましょう。

右側にトレースが出ていますが、文字が若干潰れてしまっているので以下に引用しておきます。

お客様から、お店の場所についての質問がありました。まずは、お店の所在地を確認する必要があります。

なるほど。以下のような思考だったようです。

  1. 店の場所を聞かれたけれど、
  2. 店の場所を知らないから、
  3. 店の場所を聞いた人に、
  4. 店の場所を聞いた。

なんて素直な子なんでしょう。可愛くなってしまいますが、店頭でお客様の応対をするスタッフとしては失格です。きちんと店の場所を知識として蓄えてもらう必要がありそうです。

再デビューに向けて必要な知識を勉強させる

Agentに知識を追加するには、Knowledge Base という機能を利用します。ザックリ説明すると、生成AIの裏にいる大規模言語モデルが利用可能な形式にデータを整形して突っ込んであるデータベースです。生成AIはユーザーに質問された内容に関連していそうな文章やコンテンツをこのデータベースに照会し、得られた結果を元に回答を生成することが出来るのです。

さて、Amazon Bedrock の Knowledge Base はフルマネージドな機能として提供されています。利用者はデータベースに格納するデータの元ネタが配置されている S3 と、元ネタを生成AIが利用可能な形式に変換するための大規模言語モデルを指定するだけで、サクッと Knowledge Base を立ち上げることが出来ます。

興味のある方はこの辺を読んでみると良いと思います。
https://docs.aws.amazon.com/ja_jp/bedrock/latest/userguide/kb-how-it-works.html

デフォルトの設定で立ち上げた Knowledge Base のデータ格納先には OpenSearch Service が選択されます。Bedrock サービスが裏で勝手に使うだけなので存在を意識する必要はありませんが、マネジメントコンソールから存在を確認することは出来ます。

OpenSearch Service (Serverless) のダッシュボードです

今回はホームページ上に公開されている文書の元ネタになっているmarkdownファイルと、商品一覧などのマスターデータが格納されたcsvファイル、それら2種類のファイルをそれぞれ別のソースとして利用したので、OpenSearch Serviceのコレクションも2つ出来上がることになりました。なお、Knowledge Base は複数のソースをサポートしているので、両方のソースを同じ Knowledge Base に登録しています。

結果、アーキテクチャはこのようになります。

Serverlessだけ!

改めて質問してみる

店舗に関する知識も無事に習得できたと思われるので、改めて同じ質問をしてみます。

よく勉強している!

完璧な答えです。変な住所なのは、この和菓子屋が架空の店舗だからです。答え合わせのために正解が書いてあるページを示しておきます。

思考の過程も改めてトレースで確認してみましょう。トレースはかなり量が多いので、スクリーンショットではなく出力結果の一部を引用していきます。前回は1ステップだけでしたが、今回は3ステップありました。

ステップ 1

「店の場所がわからないなら、質問してきた人に聞いてみよう」という態度を改めて、自分自身で解決すべく Knowledge Base から情報を引っ張ってきています。どうやら店舗の場所をキーワードに Knowledge Base に照会したようです。照会するためのキーワードを自律的に決めることができるなんて地味に驚きです。生成AI、バカじゃありませんでした。

ここには照会結果を1件しか引用してきていませんが、実際には複数件が返ってきています。返ってきた結果には、テキストだけでなく、ソースとなったファイルを特定する場所まで含まれているのは嬉しいですね。

  "rationale": {
    "text": "お客様の質問に回答するために、まずは店舗の場所を確認する必要があります。",
    "traceId": "16d3ff60-d45e-4186-ba27-6c2b5d634e70-0"
  },
  "invocationInput": {
    "invocationType": "KNOWLEDGE_BASE",
    "knowledgeBaseLookupInput": {
      "knowledgeBaseId": "4TYP5E01QQ",
      "text": "店舗の場所"
    },
    "traceId": "16d3ff60-d45e-4186-ba27-6c2b5d634e70-0"
  },
  "observation": {
    "knowledgeBaseLookupOutput": {
      "retrievedReferences": [
        {
          "content": {
            "text": "# お問い合わせ情報  |          |                             | |----------|-----------------------------| | **郵便** | 京都府京都市中京区四条通り三条下ル東側町1-2-3 | | **電話番号** | 0123-456-7890             | | **電子メールj** | info@mahitotsuya.co.jp |"
          },
          "location": {
            "s3Location": {
              "uri": "s3://mahitotsuyastack-contentsbucket571b0902-qaeh4b9lrhtf/public/contents/help/contact.md"
            },
            "type": "S3"
          }
        },

ステップ 2

このステップは何をやっているのか正確に読み解けませんでした。前のステップで取得した複数件の照会結果を次のステップで使えるように取りまとめるような作業、のように見えました。

ステップ 3

このステップでいよいよ何を回答すべきか考えます。どうやら Knowlege Base からの結果を吟味した結果、店舗の所在地を確認できたと判断したようです。前のステップで取りまとめた内容に基づいて回答を生成し、質問者へと回答を返しています。

  "rationale": {
    "text": "お客様の質問に対して、知識ベースから店舗の所在地を確認できました。これを回答として提供します。",
    "traceId": "16d3ff60-d45e-4186-ba27-6c2b5d634e70-1"
  },
  "observation": {
    "finalResponse": {
      "text": "まひとつや株式会社の本店は京都府京都市中京区四条通り三条下ル東側町1-2-3に所在しています。"
    },
    "traceId": "16d3ff60-d45e-4186-ba27-6c2b5d634e70-1",
    "type": "FINISH"
  }

学習の効果がバッチリ出ていて、嬉しくなりますね。

いよいよ販売にも挑戦する

新人にはお客様のお買い物のお手伝いにも挑戦してもらいましょう。お題はこちらです。

Q. 瑞糸を2つください。

さあ、シンプルだけど難しいです。新人君は「瑞糸」という商品はすでに知っていますが、それをお客様の買い物かごに商品を入れる手段を知りません。なので、まずはそれを教えてあげないとならないですね。このように Agent に何か作業をさせたいときは Action Group という機能を利用します。簡単に言うと、Agent がいい感じに呼び出す対象の操作や引数を判定して Lambda 関数を呼び出してくれる機能です。

今回はすでにWebサイトに用意されている関数を Action Group の Lambda 関数を経由して実行することで買い物かごに商品を入れてもらうことにしました。最終的なアーキテクチャは以下のようなイメージになります。

では、Action Group に Lambda 関数を登録していきしょう。

Action Group に登録する Lambda 関数を用意する

登録されるLambda関数のリクエストとレスポンスの構造については、こちらのドキュメントを参考にしてください。この内容に沿った Lambda 関数を用意する必要があります。
https://docs.aws.amazon.com/ja_jp/bedrock/latest/userguide/agents-lambda.html

今回作ったのはこちらです。Agent が発行したリクエストをちょっと変形してWebサイトのAPIエンドポイント ${baseUrl}api/shopping/add-items-to-cart へ送りつけて、結果をまた整形して Agent に返すだけのシンプルなものです。

import { Handler } from "aws-lambda";

const toMap = (kvArray: {
    name: string;
    type: string;
    value: string;
}[]) => {
    return kvArray.reduce((obj, kv) => {
        obj[kv.name] = kv.value;
        return obj;
    }, {} as { [key: string]: string })
}

const baseUrl = process.env.API_BASE_URL;
export const handler: Handler = async (event, context) => {

    const params = toMap(event.parameters);
    const requestBody = {
        sessionId: event.sessionId,
        item: {
            giftId: params.giftId,
            count: Number.parseInt(params.count),
        }
    };

    const responseBody = await fetch(`${baseUrl}api/shopping/add-items-to-cart`, {
        method: 'POST',
        headers: {
            'Content-Type': 'application/json'
        },
        body: JSON.stringify(requestBody),
    }).then(res => res.text());

    const response = {
        messageVersion: '1.0',
        response: {
            actionGroup: event['actionGroup'],
            apiPath: event['apiPath'],
            httpMethod: event['httpMethod'],
            httpStatusCode: 200,
            responseBody: {
                'application/json': {
                    body: responseBody,
                }
            },
        },
        sessionAttributes: event['sessionAttributes'],
        promptSessionAttributes: event['promptSessionAttributes']
    };

    return response
}

Lambda 関数の呼び出し方を Agent に定義する

登録する Lambda 関数も用意できたので、今度は呼び出し方の定義をしていきます。登録方法は簡易な独自形式のJSONとOpenAPI定義が選択できます。

マネジメントコンソールはよくレイアウトが変わります・・・

今回はOpenAPI定義でいきます。パラメータ等の定義も重要ですが、Agent が呼び出す関数や設定する値を判断するために description の内容を利用するので、こちらもシッカリと定義していきましょう。

openapi: 3.0.0
info:
  title: Shopping API
  version: 1.0.0
  description: 買い物かごに関する操作が可能なAPIです。
paths:
  /api/shopping/add-items-to-cart:
    get:
      description: ユーザーが指定した商品を指定した数だけ買い物かごへ追加する操作です。ユーザーが商品の購入を希望した場合に利用してください。
      operationId: addItemsToCart
      parameters:
        - name: giftId
          in: query
          description: 買い物かごに追加したい商品のid。商品のnameを指定してはなりません。
          required: true
          schema:
            type: string
        - name: count
          in: query
          description: 買い物かごに追加する商品の個数。1以上の整数を指定可能です。0と負数は指定することはできません。
          required: true
          schema:
            type: integer
      responses:
        "200":
          description: 操作の結果
          content:
            application/json:
              schema:
                type: boolean

大事ことは明確に伝えておく

これで、商品を買い物かごに入れるための手段は Agent に伝わりました。ただし、もう一つだけ問題があります。Action Group に定義された Lambda 関数を呼び出す際に指定する値は商品コードですが、お客さんが指定してくるのは商品です。この二つの値の変換方法も教えておくのが良さそうです。

今回は、Agent 向けの指示に手順を明記しておくことにします。以下が最終的な指示です。

あなたは老舗和菓子店の販売員です。ユーザーからの質問と要望に対応します。
質問には丁寧に回答してください。
利用可能なツールが見つかった場合、直ちに実行して、結果をユーザーに報告してください。

商品を購入する場合、商品名は商品IDに置き換える必要があります。商品IDは次のように探します。
<steps>
- knowledge baseで商品のnameとidの一覧を取得します
- nameカラムの値が商品名と一致する行を見つけます
- 見つかった行のidカラムの値を使います。GFT_YKN_で始まる文字列であることを確認してください。
</steps>

ユーザーへの回答は日本語で行います。

商品名と商品IDの変換以外にもチラホラと指示が書いてありますが、これは記事作成の裏でコソコソ努力した成果だと思ってください。このように prompt に、 Agent の実際の挙動を確認しながら、やってほしいことやってほしくないことを明確に指示する必要があります。例えば、prompt の末尾にある「日本語で」という指示は、実際に動かしている中で突然英語で回答したケースが発生したため、念のために追加したものです。「言わなくても分かるだろう」は結構危険なのです、、、。

ともあれ、これで準備は整いました。

準備万端、いざ挑戦

Action Group の登録を終え、Agent の準備とデプロイまで完了したら、お題に挑戦です。

すでにベテランのような回答

買い物かごの中身も完璧です

なんか、いろいろと省略した感はありますが、大成功です。トレースも確認してみます。今回は6ステップで完了していました。随分と複雑なことが出来るようになったものです。今回もトレースは全文ではなく、紹介したい部分だけを抜粋しています。

ステップ1

自分が何をすべきか理解しているようです。素晴らしいですね。指示した内容が伝わっていて、嬉しくなってしまいます。

  "rationale": {
    "text": "ユーザーから「瑞糸を2つください」との要求がありました。瑞糸という商品名から、知識ベースを検索して商品IDを特定する必要があります。",
    "traceId": "0815d0eb-56f5-4850-97af-23d4b4ab8add-0"
  },
  "invocationInput": {
    "invocationType": "KNOWLEDGE_BASE",
    "knowledgeBaseLookupInput": {
      "knowledgeBaseId": "4TYP5E01QQ",
      "text": "瑞糸"
    },
    "traceId": "0815d0eb-56f5-4850-97af-23d4b4ab8add-0"
  },

ステップ2

残念ながら、何をやっているのか読み解けませんでした。引き続き Knowledge Base を検索しているように見えますが確信が持てず。

ステップ3

商品自体の知識が得られたところで、今度は商品IDを Knowledge Base で探そうとしているようです。検索キーワードが「瑞糸」から「product id for "瑞糸"」に変わっていますね。ところで、この新人は難しいことを考える場合は英語で思考するようですね。

  "rationale": {
    "text": "The search results indicate that 瑞糸 is a type of gold leaf yokan (a traditional Japanese confection) offered by the company. It has 4 pieces and costs 3,600 yen. The description states that it is a gold leaf yokan that imagines the moist and lush threads, and wishes for happiness from all directions and brings joy to the recipient.\n\nTo add this item to the cart, I will need to look up the product ID in the knowledge base.",
    "traceId": "0815d0eb-56f5-4850-97af-23d4b4ab8add-1"
  },
  "invocationInput": {
    "invocationType": "KNOWLEDGE_BASE",
    "knowledgeBaseLookupInput": {
      "knowledgeBaseId": "4TYP5E01QQ",
      "text": "product id for 瑞糸"
    },
    "traceId": "0815d0eb-56f5-4850-97af-23d4b4ab8add-1"
  },

ステップ4

どうもステップ2と同じく、前のステップからの引き続き Knowledge Base に照会をしている、もしくは照会結果の処理の続きをやっている、ようです。

ステップ5

いよいよ本題の処理です。商品IDが GFT_YKN_04 と判明したようです。カートへの商品の追加処理が実行できると判断し、実際に Action Group の実行を行っています。この際に Lambda 関数に渡された実際のパラメータ値や、Lambda 関数からの戻り値もトレースから取得することが出来ます。

  "rationale": {
    "text": "Okay, I have the product ID for 瑞糸 as GFT_YKN_04. Now I can add 2 of these items to the cart.",
    "traceId": "0815d0eb-56f5-4850-97af-23d4b4ab8add-2"
  },
  "invocationInput": {
    "actionGroupInvocationInput": {
      "actionGroupName": "shopping-apis",
      "apiPath": "/api/shopping/add-items-to-cart",
      "parameters": [
        {
          "name": "count",
          "type": "integer",
          "value": "2"
        },
        {
          "name": "giftId",
          "type": "string",
          "value": "GFT_YKN_04"
        }
      ],
      "verb": "get"
    },
    "invocationType": "ACTION_GROUP",
    "traceId": "0815d0eb-56f5-4850-97af-23d4b4ab8add-2"
  },
  "observation": {
    "actionGroupInvocationOutput": {
      "text": "true"
    },
    "traceId": "0815d0eb-56f5-4850-97af-23d4b4ab8add-2",
    "type": "ACTION_GROUP"
  }

ステップ6

最後のステップはいつも通り、これまでの内容に基づいてお客様に返すべき文言を組み立てています。

少々お待ちください・・・

ちなみに、今回のこの処理において、サーバー側からの応答を待っていた時間は約15秒でした。

もっとサクサクと処理してほしいと思うところではあるものの、Claude 2.1 のころは数十秒は当たり前のようにかかっていたので、Claude 3 にしてからの性能向上は目を見張るものがあります。

応答の正確さと速さを天秤にかけて利用する生成AIを選択する機会は多そうですね。

さいごに

Agents for Amazon Bedrock をアプリケーションに組み込むことで、柔軟な応対が可能なチャットボットを簡単に導入すことが可能であることが分かりました。

しかし、意図したとおりの応対を常に行ってくれるのかは不安があります。Agent に対する指示、Action Group の OpenAPI 定義、ユーザーとの会話履歴、など 最終的に提示される回答に影響を与える要素が非常に多いです。開発者も意図しない回答や挙動が Agent から示される可能性が付きまといます。そのリスクをいかに回避したり抑止したりするのか、そのあたりのノウハウの蓄積が必要になりそうです。

あとは、コストですかね。まぁ、月額数十ドルで生成AIつかったアプリを作り放題だと思えば安いのかな、、、。今後も、この遊び場に感謝の気持ちを忘れず楽しんでいくことにします!

Discussion