AWSでReactとCDKを使った生成AIアプリケーションを作るガイド

**AWS で生成 AI を使ったアプリケーションを構築してみたい！** けど、フロントエンド (React) はある程度わかりつつ AWS は EC2 や S3 がわかる程度、であれば本記事はそんなあなたのために書かれています。本記事では、フロントエンドに [React](https://ja.react.dev/) 、バックエンドに Lambda を中心としたサーバーレスの API を [AWS Cloud Development Kit(CDK)](https://docs.aws.amazon.com/ja_jp/cdk/v2/guide/home.html) (=IaC を実現する技術の一つ) を用い開発する方法を実戦形式で解説します。

# 1: 目標設定 - 実践を通じ開発方法を学ぶ

本記事では、AWS がオープンソースで公開している [Generative AI Use Cases JP](https://github.com/aws-samples/generative-ai-use-cases-jp) (GenU) に新しいユースケースを追加することを目指します。この過程で、フロントエンドからバックエンド、デプロイまでの一連の流れを体験し、AWS 上で生成 AI アプリケーション開発するために必要な知識が得られます。

GenU は生成 AI の鉄板ユースケースがプロンプトなしに使えるアプリケーションです。簡単かつセキュアに生成 AI を利用できる環境を構築でき、すでに様々な企業で導入されています。その実装には AWS 上で生成 AI アプリケーションを実装する際のベストプラクティスが詰め込まれており、しかもソースコードが公開されているため学習にも最適です。本記事を通じてその実装のエッセンスを理解することで、自分のアプリケーション開発に必要な知識も得ることができるでしょう。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/25990/bdd9d492-8149-4032-ac08-eeddc9f1bc2e.png)

本記事では特に、次の実装に着目して解説していきます。

* React の仕組みを活かした再利用性を高めたフロントエンド開発
* AWS CDK を使ったバックエンドの実装
* Amazon Bedrock を使った生成 AI モデルへのアクセス

でははじめていきましょう！

# 2: こんにちは GenU

私たちエンジニアが、はじめての技術を学ぶ際は "Hello World" からはじめます。まず GenU を AWS にデプロイしてみましょう。インストールの手順は [GitHub リポジトリに記載されています](https://github.com/aws-samples/generative-ai-use-cases-jp)。

```bash
# リポジトリのクローン
git clone https://github.com/aws-samples/generative-ai-use-cases-jp
cd generative-ai-use-cases-jp

# 依存パッケージのインストール
npm ci

# CDKのブートストラップ（AWSアカウントごとに初回のみ）
npx -w packages/cdk cdk bootstrap

# デプロイ
npm run cdk:deploy
```

デプロイには 20 分ほどかかります。終了したら、出力のうち `WebUrl` へアクセスしてみてください。GenU が利用できるはずです！

:::note info
(コマンドラインからの出力を見逃してしまった方は、AWS Concole へアクセスし  CloudFormation > Stacks > GenerativeAiUseCasesStack > Outputs から確認できます)
:::

上記のコマンドを実行するにあたり、npm がない、AWS に接続できない、といったことがあるかもしれません。その場合は次の手順を参考ください。

<details><summary> npm がインストールされていない場合</summary>

Node.js / npm のインストールを行うにあたっては、プロジェクトごとのバージョンの切り替えなどがしやすいよう [`nvm`](https://github.com/nvm-sh/nvm) の使用を推奨します。

**Windows**

[Chocolatey](https://community.chocolatey.org/) でも [Scoop](https://scoop.sh/#/) でも構いませんが、最近は Scoop の方を推しているため Scoop でのセットアップ手順を紹介します。

Scoopのインストール ([最新は公式を参照](https://scoop.sh/#/))
```powershell
# PowerShellを管理者権限で実行
Set-ExecutionPolicy -ExecutionPolicy RemoteSigned -Scope CurrentUser
Invoke-RestMethod -Uri https://get.scoop.sh | Invoke-Expression
```

nvm のインストール
```powershell
scoop install nvm
```

Node.js をインストール : ([執筆時点で、CDK でサポートされている最新は 22 系のようなので 22 系を推奨します](https://github.com/aws/aws-cdk/issues/27003))

```powershell
# 利用可能なバージョンの確認
nvm list available

# 特定バージョンのインストール (例: 22.10.0)
nvm install 22.10.0
nvm use 22.10.0
```

**Mac**

1. nvm のインストール ([公式を参照]())
```bash
brew install nvm
```

後の手順は同様です。

</details>

<details><summary> AWS にアクセスできない場合 </summary>

AWS のアカウントがない方は [AWS アカウント作成の流れ](https://aws.amazon.com/jp/register-flow/) を参考にアカウントを作成してください

* 開発に慣れている方であれば、[AWS Organization](https://docs.aws.amazon.com/ja_jp/organizations/latest/userguide/orgs_tutorials_basic.html) か [AWS Control Tower](https://aws.amazon.com/jp/blogs/startup/multi-accounts-and-control-tower/) を導入し、デプロイ用のアカウントを作成することをお勧めします。既存の環境に影響を与えずデプロイでき、不要になったらすぐ破棄できるためです

AWS の環境を作成したら、外部からアクセスするためのユーザーが必要です。[Authenticating using IAM user credentials for the AWS CLI](https://docs.aws.amazon.com/ja_jp/cli/v1/userguide/cli-authentication-user.html) を参照し、IAM ユーザーとアクセスするためのクレデンシャルを発行ください。なお、**CDK を実行する都合上、Administrator Access の権限が必要です。**

* IAM ユーザー (しかも Administrator Access) を払い出してアクセスキーを使うのはセキュアなアクセスとは言い難いです。一時的なクレデンシャルでセキュアにアクセスする方法は [IAM Identity Center をつかったセットアップ](https://zenn.dev/esaka/articles/8f4f79354ae8dc)を参考ください。AWS Control Tower を使用していれば IAM Identity Center は付随的に作成されます

作成した AWS の環境にアクセスするには、[AWS CLI](https://aws.amazon.com/jp/cli/) をインストールします。

**Windows**

```powershell
# AWS CLI v2のインストール
scoop install aws

# インストール確認
aws --version
```

**Mac ([公式の手順を参照](https://docs.aws.amazon.com/ja_jp/cli/latest/userguide/getting-started-install.html))**

```bash
# AWS CLI v2のダウンロードとインストール
curl "https://awscli.amazonaws.com/AWSCLIV2.pkg" -o "AWSCLIV2.pkg"
sudo installer -pkg AWSCLIV2.pkg -target /

# インストール確認
aws --version

# インストーラーの削除
rm AWSCLIV2.pkg
```

**AWS 認証情報の設定**

```bash
aws configure

# 以下の情報を順に入力
AWS Access Key ID: [your_access_key]
AWS Secret Access Key: [your_secret_key]
Default region name: [your_region] (例: ap-northeast-1)
Default output format: json
```

SSO を使用する場合
```
aws configure sso
```

すでに開発に使っている環境のプロファイルがあり、今回とは分けたい場合があると思います。その場合は、プロファイル名を変更して設定を行ってください (詳細 : [AWS CLI での設定と認証情報ファイル設定](https://docs.aws.amazon.com/ja_jp/cli/latest/userguide/cli-configure-files.html))。

```
aws configure --profile <プロファイル名>
```

これから作業する際、逐一プロファイルを設定するのは手間なので (一時的な) 環境変数にプロファイルを設定してください。

```
# Mac
export AWS_PROFILE=プロファイル名
# Windows (PowerShell)
$env:AWS_PROFILE = "dev"
```
</details>

# 3: GenU のアーキテクチャの理解

## ディレクトリ構成

GenU は Node.js 形式のアプリケーションで、[workspaces](https://docs.npmjs.com/cli/v7/using-npm/workspaces) 機能を使用しフロントエンド、バックエンドなど個別の開発ごと独立に必要なパッケージを管理しています。

```
generative-ai-use-cases-jp/
├── packages/
│   ├── cdk/       # バックエンド (AWS リソース定義)
│   ├── common/    # 共通関数
│   ├── types/     # 型定義 (.d.ts)
│   └── web/       # フロントエンド共有の型定義
```

## アーキテクチャ

GenU の AWS アーキテクチャは次のようになっています ([公式サイトより引用](https://github.com/aws-samples/generative-ai-use-cases-jp))

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/25990/5fb6428b-a729-4056-8b8d-42fdabdecc4f.png)

このインフラは AWS Console でポチポチと作成されているわけではなく、コードによるインフラ定義、Infrastructure as Code (IaC) を実現する [AWS Cloud Development Kit(CDK)](https://docs.aws.amazon.com/ja_jp/cdk/v2/guide/home.html) で実装されています。例えば、GenU の中では AWS Lambda の中でファイルを処理するための S3 Bucket を次のように定義しています。S3 の定義画面を見たことがある方なら、なんとなく何をしているのかわかるのではないかと思います。

```typescript:packages/cdk/lib/construct/api.ts
    // S3 (File Bucket)
    const fileBucket = new Bucket(this, 'FileBucket', {
      encryption: BucketEncryption.S3_MANAGED,
      removalPolicy: RemovalPolicy.DESTROY,
      autoDeleteObjects: true,
      enforceSSL: true,
      blockPublicAccess: BlockPublicAccess.BLOCK_ALL,
    });
    fileBucket.addCorsRule({
      allowedOrigins: ['*'],
      allowedMethods: [HttpMethods.GET, HttpMethods.POST, HttpMethods.PUT],
      allowedHeaders: ['*'],
      exposedHeaders: [],
      maxAge: 3000,
    });
```

このバケットに対する権限設定もコードで表現できます。

```typescript:packages/cdk/lib/construct/api.ts
    const getSignedUrlFunction = new NodejsFunction(this, 'GetSignedUrl', {
      runtime: Runtime.NODEJS_LATEST,
      entry: './lambda/getFileUploadSignedUrl.ts',
      timeout: Duration.minutes(15),
      environment: {
        BUCKET_NAME: fileBucket.bucketName,
      },
    });
    fileBucket.grantWrite(getSignedUrlFunction);
```

`entry` で指定された Lambda 関数の定義ファイルを見ると、与えられた環境変数、ファイル書き込み権限を使って (1 時間) アクセス可能な `signedUrl` を発行し返却していることがわかります。

```typescript:packages/cdk/lambda/getFileUploadSignedUrl.ts
import { v4 as uuidv4 } from 'uuid';
import { APIGatewayProxyEvent, APIGatewayProxyResult } from 'aws-lambda';
import { getSignedUrl } from '@aws-sdk/s3-request-presigner';
import { PutObjectCommand, S3Client } from '@aws-sdk/client-s3';
import { GetFileUploadSignedUrlRequest } from 'generative-ai-use-cases-jp';

export const handler = async (
  event: APIGatewayProxyEvent
): Promise<APIGatewayProxyResult> => {
  try {
    const req: GetFileUploadSignedUrlRequest = JSON.parse(event.body!);
    const filename = req.filename;
    const uuid = uuidv4();

    const client = new S3Client({});
    // アップロード先は XXXXX/image.png 形式。ダウンロード時に正しいファイル名でダウンロード可能。
    const command = new PutObjectCommand({
      Bucket: process.env.BUCKET_NAME,
      Key: `${uuid}/${filename}`,
    });

    const signedUrl = await getSignedUrl(client, command, { expiresIn: 3600 });

    return {
      statusCode: 200,
      headers: {
        'Content-Type': 'application/json',
        'Access-Control-Allow-Origin': '*',
      },
      body: signedUrl,
    };
  } catch (error) {
    console.log(error);
    return {
      statusCode: 500,
      headers: {
        'Content-Type': 'application/json',
        'Access-Control-Allow-Origin': '*',
      },
      body: JSON.stringify({ message: 'Internal Server Error' }),
    };
  }
};

```

`packages/cdk/lib/construct/api.ts` から CDK によるインフラ定義を追ってきましたが、`construct` はインフラを構成するパーツ (Building Block) の位置づけで、 Stack がデプロイを行う単位になります。Stack は下図のように CloudFormation に変換されてデプロイされます。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/25990/70128a4a-45ed-4008-a944-4168649736e8.png)

GenU の AWS インフラは、このように CDK を利用し構築されています。

# 4 : GenU に新しいユースケースを追加する

今回は、GenU に新しいユースケースとして「英単語を入れたら例文を作ってくれる」ユースケースを追加してみます。これぐらいであれば標準のユースケースビルダーを使い実装できますが、開発の練習として行ってみましょう。ユースケース名は "Today's English" です。画面では試しに "bedrock" を使った例文を作成してもらっています。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/25990/85534b19-4a70-4e4c-a2dd-1162361b1075.png)

ユースケースを加えるための修正は、下記の Pull Request ですべて参照できます。

https://github.com/icoxfog417/generative-ai-use-cases-jp/pull/1/files

## 4.1 新規ユースケースを登録する

はじめに、新規ユースケースのページ `TodaysEnglishPage.tsx` を `packages/web/src/pages` に作成します。この時点では中身はほぼ空です。

```typescript: packages/web/src/pages/TodaysEnglishPage.tsx
const TodaysEnglishPage: React.FC = () => {
    return (<div>Hello</div>);
}

export default TodaysEnglishPage;
```

作成したページにアクセスできるよう、`main.tsx` のルーティングに登録します。ここでは`/todays-english`のパスでアクセスできるようにしました。

```typescript:packages/web/src/main.tsx
const routes: RouteObject[] = [
  {
    path: '/',
    element: <LandingPage />,
  },
  {
    path: '/setting',
    element: <Setting />,
  },
  {
    path: '/chat',
    element: <ChatPage />,
  },
  {
    path: '/chat/:chatId',
    element: <ChatPage />,
  },
  {
    path: '/share/:shareId',
    element: <SharedChatPage />,
  },
  {
    path: '/todays-english',
    element: <TodaysEnglishPage />,
  },
```

クエリパラメーター経由でもアクセスできるよう、`navigate.d.ts`にも追加しておきます。

```typescript:packages/web/src/@types/navigate.d.ts
export type TodaysEnglishQueryParams = BaseQueryParams & {
  word?: string;
};
```

アプリケーションのトップ画面から遷移できるよう、`App.tsx` のメニューに登録します。

```typescript:packages/web/src/App.tsx
  const items: ItemProps[] = [
    {
      label: 'ホーム',
      to: '/',
      icon: <PiHouse />,
      display: 'usecase' as const,
    },
    {
      label: '設定情報',
      to: '/setting',
      icon: <PiGear />,
      display: 'none' as const,
    },
    {
      label: 'チャット',
      to: '/chat',
      icon: <PiChatsCircle />,
      display: 'usecase' as const,
    },
    {
      label: "Today's English",
      to: '/todays-english',
      icon: <PiBirdLight />,
      display: 'usecase' as const,
    },
```

これで一旦ページへアクセスできるようになりました。

## 4.2 ローカルで動作確認をする

ローカルで開発環境を立ち上げて動作を確認します。GenU はバックエンドが AWS なので動作させるには AWS 環境への事前デプロイが必要ですが、それはもう済ませています。そのため、ローカルでフロントエンドの React アプリケーションを動作させる開発サーバーを立ち上げれば動作を確認することが出来ます。ローカル環境の立上げ手順は公式の「[ローカル環境構築手順](https://github.com/aws-samples/generative-ai-use-cases-jp/blob/main/docs/DEVELOPMENT.md)」に記載されています。

### Mac 等 Unix 系コマンドが使える場合

次のコマンドで開発環境を立ち上げられます。`jq` のライブラリが必要な点に注意してください。

```
npm run web:devw
```

:::note info
使用したいプロファイルが環境変数で設定されていることを確認してください。
export AWS_PROFILE=''
export AWS_DEFAULT_REGION=''
:::

### Windows の場合

```
npm run web:devww
```

:::note info
使用したいプロファイルが環境変数で設定されていることを確認してください。
$env:AWS_PROFILE = "your profile"
:::

立上げに成功すると、`http://localhost:5173` からアクセスできます。

# 5: フロントエンドの開発に必要なコンポーネント / Hooks の確認

page の中身を実装するにあたり、GenU はすでに十分な Component / Hook を提供してくれています。簡単なユースケースであれば、CDK 側の Lambda を追加・更新する必要もないでしょう。

`TodaysEnglishPage.tsx` の編集をしていきます。`pages` 配下の `tsx` ファイルは、基本的には次の構成になっています。

## 5.1 Page における State の定義

はじめに、ページ上で管理する状態を定義します。GenU では状態管理に[`zustand`](https://github.com/pmndrs/zustand) を使用しています。

```jsx:packages/web/src/pages/TodaysEnglishPage.tsx
import React, { useCallback, useEffect, useMemo } from 'react';
import { useLocation } from 'react-router-dom';
import InputText from '../components/InputText';
import Select from '../components/Select';
import Button from '../components/Button';
import Markdown from '../components/Markdown';
import useTyping from '../hooks/useTyping';
import useChat from '../hooks/useChat';
import { create } from 'zustand';
import debounce from 'lodash.debounce';
import { TodaysEnglishQueryParams } from '../@types/navigate';
import { MODELS } from '../hooks/useModel';
import { getPrompter } from '../prompts';
import queryString from 'query-string';


type StateType = {
  word: string;
  setWord: (s: string) => void;
  generatedConversation: string;
  setGeneratedConversation: (s: string) => void;
  clear: () => void;
};

const useTodaysEnglishState = create<StateType>((set) => {
  return {
    word: '',
    generatedConversation: '',
    setWord: (s: string) => {
      set(() => ({
        word: s,
      }));
    },
    setGeneratedConversation: (s: string) => {
      set(() => ({
        generatedConversation: s,
      }));
    },
    clear: () => {
      set(() => ({
        word: '',
        generatedConversation: '',
      }))
    }
  };
});
```

## 5.2 Page における Component の定義

そのあとに、ページ上の機能とレイアウトを実装するステートレスな Functional Component (FC) を定義します。

```jsx
const TodaysEnglishPage: React.FC = () => {
  const {
    word,
    setWord,
    generatedConversation,
    setGeneratedConversation,
    clear,
  } = useTodaysEnglishState();

  const { pathname, search } = useLocation();

  const {
    getModelId,
    setModelId,
    loading,
    messages,
    postChat,
    continueGeneration,
    clear: clearChat,
    updateSystemContextByModel,
    getStopReason,
  } = useChat(pathname);
  const { setTypingTextInput, typingTextOutput } = useTyping(loading);
  const { modelIds: availableModels } = MODELS;
  const modelId = getModelId();
  const prompter = useMemo(() => {
    return getPrompter(modelId);
  }, [modelId]);
  const stopReason = getStopReason();
  
  //<----- 中略 ---->

  const getConversation = (
    word: string
  ) => {
    postChat(
      word.trim(),
      true
    );
  };

  //<----- 中略 ---->

  // 作文を実行
  const onClickExec = useCallback(() => {
    if (loading) return;
    getConversation(word);
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, [word]);

  // リセット
  const onClickClear = useCallback(() => {
    clear();
    clearChat();
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, []);

  return (
    <div className="flex h-full flex-col">
      <div className="mb-4 flex flex-col gap-4 p-4">
        <h1 className="text-2xl font-bold">Today's English Conversation</h1>
        <p className="text-sm text-gray-600">
          Enter a word to generate business English conversations
        </p>
        <Select
              value={modelId}
              onChange={setModelId}
              options={availableModels.map((m) => {
                return { value: m, label: m };
              })}
            />
        <div className="flex gap-2">
          <InputText
            value={word}
            onChange={setWord}
            placeholder="Enter a word..."
          />
          <Button disabled={!word.trim() || loading} onClick={onClickExec}>
            実行
          </Button>
          <Button outlined onClick={onClickClear} disabled={disabledExec}>
            クリア
          </Button>
        </div>

        <div className="flex gap-2">
          <Markdown>{typingTextOutput}</Markdown>
            {loading && (
              <div className="border-aws-sky size-5 animate-spin rounded-full border-4 border-t-transparent"></div>
            )}
            {!loading && generatedConversation === '' && (
              <div className="text-gray-500">
                作成文がここに表示されます
              </div>
            )}
        </div>
      </div>
    </div>
  );
};

export default TodaysEnglishPage;
```

## 5.3 事前定義済みの Components / Hooks の理解を深める

Hook `useChat` (`packages/web/src/hooks/useChat.ts`)で定義されている `postChat` が、単語からの生成処理を担っています。全体として次の流れになっています。

* `packages/web/src/hooks/useChat.ts`
   * `useChat` の `postChat` では、内部的に `useChatState` の `post` メソッドを使用
   * `useChatState` の `post` メソッドでは対話の状態を管理しており、User/Assistant の発言を反映したリクエストを `generateMessage` メソッドに渡して生成
   * `generateMessage` では、 `useChatApi.tx` の `predictStream` で生成を実施
* `packages/web/src/hooks/useChatApi.ts`
   * `predictStream` の中で、 `LambdaClient` を使用し CDK で定義した `Lambda` を実行

GenU での入力 => 出力の状態管理を含めた生成は `useChat` が担っており、これを使いまわすことで履歴の登録なども自動で行われます。ユースケースごとのプロンプトは `packages/web/src/prompts` で行われており、現状 `claude.ts` (Claude 用のプロンプト) のみがありそちらでシステムプロンプト等を設定できます。


```packages/web/src/prompts/claude.ts
const systemContexts: { [key: string]: string } = {
  '/chat': 'あなたはチャットでユーザを支援するAIアシスタントです。',
<中略>
  '/todays-english': 'あなたは単語からビジネス英会話を生成するAIアシスタントです。入力された単語を使用して2-3つの短い会話を生成してください。すべての会話は、ビジネスシーンで実際に使える実践的で自然な英語表現を使用し、その単語を効果的に取り入れてください。各会話の前には状況説明を日本語で簡潔に記載してください。',
}
```

これでフロントエンドの実装は完了です。ここまででバックエンドの細かい実装を紹介していないので、Amazon Bedrock を使う Lambda のコードが気になる方もいるでしょう。以下のリポジトリに、API Gateway + AWS Lambda + Amazon Bedrock の最小構成で API エンドポイントを作成する CDK のコードを掲載しています。こちらをベースに `packages\cdk\lambda\predict.ts` を読むと実装のイメージがつけやすいと思います

https://github.com/icoxfog417/amazon-bedrock-api-simple


# 6 : Wrap-up

本記事を通じ、次のことが学べたと思います。

1. AWS で生成 AI アプリケーションを構築する際の基本的なプロジェクト・インフラ構成
1. CDK を使用したインフラの定義方法
1. GenU をベースとした、アプリケーション開発のポイント

あなたの実際のアプリケーション開発のお役に立てば幸いです！