0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

Amazon Bedrock × Next.jsでAIチャットアプリを作ってみた【サーバーレス構成】

0
Posted at

はじめに

「ChatGPTって実際どうやって動いているんだろう?」「AWSのサービスを組み合わせて何か作ってみたい」そんな興味から、AIチャットアプリをゼロから作ってみました。

作成した動機は主に3つです。

  • チャット機能の仕組みを理解したい : 普段何気なく使っているチャットUIが、バックエンドでどう実現されているかを実装を通して学びたかった
  • AWS CDKを使ってみたい : インフラをコードで管理するIaC(Infrastructure as Code)の概念を、実際のプロジェクトで体感したかった
  • DBの設計を学びたい : NoSQLであるDynamoDBで、チャットアプリのようなデータをどう設計するかを考えたかった

この記事では、完成したアプリの構成と実装のポイントを紹介します。

今回のコードは下記です。

完成したアプリの概要

こんなチャットアプリができました。

  • 会話のCRUD処理 : 複数の会話スレッドを作成・切り替え・削除できる
  • 会話履歴の保持 : 過去のやりとりを記憶してAIが返答する
  • マークダウン表示 : AIの返答をマークダウン形式で表示(コードブロックのシンタックスハイライトも対応)
  • ユーザー認証 : Cognitoによるログイン機能

技術スタック

カテゴリ 技術
フロントエンド Next.js (TypeScript) + Tailwind CSS
バックエンド AWS Lambda (Python)
AI Amazon Bedrock (Claude Haiku 4.5)
DB Amazon DynamoDB
認証 Amazon Cognito
API Amazon API Gateway
IaC AWS CDK (Python)

すべてサーバーレス構成のため、サーバー管理が不要です。

アーキテクチャ

下記のようなアーキテクチャとなります。

チャットアプリ.png


1. AWS CDKでインフラをコード管理する

AWSのリソースは手動でコンソールから作成するのではなく、AWS CDK(Cloud Development Kit) を使いPythonコードで定義しました。

CDKとは

インフラの構成(サーバー・DB・API等)をプログラムコードで記述し、自動でAWSにデプロイできるツールです。コードで管理することで、設定の再現性が高まり、チームでの共有も容易になります。

スタック構成

インフラは4つのスタックに分割しています。

# cdk/app.py
from aws_cdk import App
from stacks.database_stack import DatabaseStack
from stacks.auth_stack import AuthStack
from stacks.lambda_stack import LambdaStack
from stacks.api_stack import ApiStack

app = App()

db_stack = DatabaseStack(app, "DatabaseStack")
auth_stack = AuthStack(app, "AuthStack")
lambda_stack = LambdaStack(app, "LambdaStack",
    conversations_table=db_stack.conversations_table,
    messages_table=db_stack.messages_table,
)
api_stack = ApiStack(app, "ApiStack",
    lambda_function=lambda_stack.lambda_function,
    user_pool=auth_stack.user_pool,
)

スタックを分割することで、それぞれの役割が明確になり、変更の影響範囲を限定できます。

デプロイコマンド

cd cdk
cdk deploy --all

たったこれだけで、DynamoDB・Cognito・Lambda・API Gatewayの4つのAWSリソースが一括作成されます。


2. DynamoDBのテーブル設計

チャットアプリのデータをDynamoDBでどう表現するかは、最も頭を使った部分です。

ER図は下記です。

テーブル構成

今回は 会話テーブルメッセージテーブル の2つに分けました。

ConversationsTable(会話一覧)

属性 キー 説明
userId String パーティションキー Cognitoのユーザーid
conversationId String ソートキー UUID
title String - メッセージの先頭50文字
createdAt Number - 作成日時(Unixタイムスタンプ)
updatedAt Number - 最終更新日時
messageCount Number - メッセージ数

MessagesTable(メッセージ一覧)

属性 キー 説明
conversationId String パーティションキー 会話id
timestamp Number ソートキー 送信日時(Unixタイムスタンプ)
messageId String - UUID
role String - user または assistant
content String - メッセージ本文

GSI(グローバルセカンダリインデックス)について

サイドバーには「最近更新された会話が上に来る」ソート順で会話一覧を表示したいです。

DynamoDBはデフォルトではパーティションキーでしか検索できないため、GSI(グローバルセカンダリインデックス) を使います。

# cdk/stacks/database_stack.py
from aws_cdk import aws_dynamodb as dynamodb, RemovalPolicy

conversations_table = dynamodb.Table(
    self, "ConversationsTable",
    partition_key=dynamodb.Attribute(
        name="userId",
        type=dynamodb.AttributeType.STRING
    ),
    sort_key=dynamodb.Attribute(
        name="conversationId",
        type=dynamodb.AttributeType.STRING
    ),
    billing_mode=dynamodb.BillingMode.PAY_PER_REQUEST,
    removal_policy=RemovalPolicy.DESTROY,
)

# 「userId + updatedAt」でクエリできるGSIを追加
conversations_table.add_global_secondary_index(
    index_name="userId-updatedAt-index",
    partition_key=dynamodb.Attribute(
        name="userId",
        type=dynamodb.AttributeType.STRING
    ),
    sort_key=dynamodb.Attribute(
        name="updatedAt",
        type=dynamodb.AttributeType.NUMBER
    ),
    projection_type=dynamodb.ProjectionType.ALL,
)

このGSIを使うことで、userIdで絞り込みながらupdatedAtの降順(最新順)で会話一覧を取得できます。

# 会話一覧を最新順で取得
response = conversations_table.query(
    IndexName="userId-updatedAt-index",
    KeyConditionExpression=Key("userId").eq(user_id),
    ScanIndexForward=False,  # 降順(最新が先頭)
    Limit=20,
)

3. Cognito認証

Cognitoとは

AWS提供のユーザー認証サービスです。ユーザー登録・ログイン・トークン管理などの機能が揃っており、自前で認証サーバーを実装する必要がありません。

ユーザープールの設定

# cdk/stacks/auth_stack.py
from aws_cdk import aws_cognito as cognito

user_pool = cognito.UserPool(
    self, "ChatUserPool",
    user_pool_name="bedrock-chat-users",
    self_sign_up_enabled=True,
    sign_in_aliases=cognito.SignInAliases(username=True),
    password_policy=cognito.PasswordPolicy(
        min_length=8,
        require_uppercase=True,
        require_lowercase=True,
        require_digits=True,
        require_symbols=False,
    ),
)

user_pool_client = user_pool.add_client(
    "ChatAppClient",
    auth_flows=cognito.AuthFlow(
        user_password=True,  # ユーザー名+パスワード認証
        user_srp=True,       # SRP(安全なリモートパスワード)認証
    ),
    access_token_validity=Duration.hours(1),
    id_token_validity=Duration.hours(1),
    refresh_token_validity=Duration.days(30),
)

認証フロー

  1. ユーザーがNext.jsのログイン画面でユーザー名・パスワードを入力
  2. amazon-cognito-identity-js ライブラリがCognitoへ認証リクエスト送信
  3. 認証成功 → IdToken を取得
  4. APIリクエスト時に Authorization: Bearer {IdToken} ヘッダーを付与
  5. API GatewayがCognito Authorizerでトークンを検証
  6. 検証OK → Lambdaへリクエスト転送
// frontend/src/lib/cognito.ts(抜粋)
export const login = (username: string, password: string): Promise<string> => {
  return new Promise((resolve, reject) => {
    const cognitoUser = new CognitoUser({ Username: username, Pool: getUserPool() });
    const authDetails = new AuthenticationDetails({ Username: username, Password: password });

    cognitoUser.authenticateUser(authDetails, {
      onSuccess: (result) => {
        resolve(result.getIdToken().getJwtToken());
      },
      onFailure: (err) => {
        // エラーメッセージを統一(ユーザー列挙攻撃対策)
        reject(new Error("ユーザー名またはパスワードが正しくありません"));
      },
    });
  });
};

ポイントは エラーメッセージを統一している点 です。「ユーザー名が存在しない」「パスワードが違う」を区別せず同じメッセージを返すことで、存在するユーザー名を特定する攻撃(ユーザー列挙攻撃)を防ぎます。


4. Bedrock × Lambdaでチャット機能を実装

Amazon Bedrockとは

AWSが提供するAI基盤モデルの利用サービスです。Anthropic・Amazon・Metaなどが開発したLLMをAPI経由で呼び出せます。今回は Claude Haiku 4.5 を使用しています。

Lambdaのバックエンド構成

lambda/
├── handler.py              # エントリーポイント・ルーティング
└── services/
    ├── bedrock_service.py  # Bedrock呼び出し
    └── dynamodb_service.py # DynamoDB操作

Bedrockへのメッセージ送信

# lambda/services/bedrock_service.py
import boto3, os

class BedrockService:
    def __init__(self):
        self.client = boto3.client("bedrock-runtime", region_name="us-east-1")
        self.model_id = os.environ.get("BEDROCK_MODEL_ID")

    def generate_response_with_history(self, messages: list) -> str:
        """会話履歴を含めてBedrockに問い合わせる"""
        # DynamoDB形式 → Bedrock Converse API形式に変換
        bedrock_messages = [
            {
                "role": msg["role"],  # "user" or "assistant"
                "content": [{"text": msg["content"]}],
            }
            for msg in messages
        ]

        response = self.client.converse(
            modelId=self.model_id,
            messages=bedrock_messages,
            inferenceConfig={
                "maxTokens": 2048,
                "temperature": 1.0,
            },
        )
        return response["output"]["message"]["content"][0]["text"]

Bedrockの Converse API を使うことで、会話履歴をそのまま渡してマルチターン会話を実現できます。

メッセージ送受信の全体フロー

# lambda/handler.py(handle_chat関数・抜粋)
def handle_chat(user_id, body):
    message = body["message"]
    conversation_id = body.get("conversationId")  # 新規の場合はNone

    # 1. 新規会話なら作成
    if not conversation_id:
        conversation_id = str(uuid4())
        dynamodb_service.create_conversation(
            user_id=user_id,
            conversation_id=conversation_id,
            title=message[:50],  # メッセージ先頭50文字をタイトルに
        )

    # 2. ユーザーメッセージを保存
    dynamodb_service.save_message(conversation_id, "user", message)

    # 3. 会話履歴をすべて取得(古い順)
    history = dynamodb_service.get_conversation_history(conversation_id)

    # 4. Bedrockで応答生成
    ai_response = bedrock_service.generate_response_with_history(history)

    # 5. AI応答を保存
    dynamodb_service.save_message(conversation_id, "assistant", ai_response)

    # 6. 会話メタデータ更新(updatedAt, messageCount)
    dynamodb_service.update_conversation_metadata(conversation_id)

    return {
        "conversationId": conversation_id,
        "response": ai_response,
    }

会話履歴を 古い順にDynamoDBから取得してBedrockに渡す ことで、「前のやりとりを覚えているAI」を実現しています。

APIエンドポイント一覧

メソッド パス 説明
POST /chat メッセージ送信・AI応答取得
GET /conversations 会話一覧取得
GET /conversations/{id} 特定会話のメッセージ取得
DELETE /conversations/{id} 会話削除

スロットリングの設定

API Gatewayにはスロットリング(レート制限)を設定しています。

# cdk/stacks/api_stack.py(抜粋)
deployment_stage = api.add_stage(
    "prod",
    throttling_rate_limit=25,    # 全体: 25 req/秒
    throttling_burst_limit=50,
)

# /chat は特に厳しく制限(Bedrockのコスト対策)
chat_method = chat_resource.add_method("POST", ...)
chat_method.add_method_throttling(
    throttling_rate_limit=2,     # /chat: 2 req/秒
    throttling_burst_limit=5,
)

Bedrockの呼び出しはコストが発生するため、/chatエンドポイントは特に厳しく制限しています。


5. フロントエンド(Next.js)

フロントエンドはNext.js(App Router)とTailwind CSSで構築しています。コンポーネント構成は以下の通りです。

src/
├── app/
│   ├── page.tsx        # メインチャット画面
│   └── login/
│       └── page.tsx    # ログイン画面
├── components/
│   ├── Sidebar.tsx         # 会話一覧サイドバー
│   ├── ChatArea.tsx        # メッセージ表示エリア
│   ├── MessageBubble.tsx   # 個別メッセージ(マークダウン対応)
│   ├── MessageInput.tsx    # 入力フォーム
│   └── ConversationItem.tsx # サイドバーの会話アイテム
├── contexts/
│   └── AuthContext.tsx  # 認証状態管理
└── lib/
    ├── api.ts           # バックエンドAPI呼び出し
    └── cognito.ts       # Cognito操作

マークダウンレンダリング

AIの返答にはコードや表が含まれることが多いため、react-markdownreact-syntax-highlighterを使ってマークダウンを整形表示しています。


実装を通じて学んだこと

DynamoDBはアクセスパターンから設計する

RDBと異なり、DynamoDBは**「どう検索するか(アクセスパターン)」を先に決めてからテーブルを設計する** 必要があります。

今回は以下2つのアクセスパターンを満たす設計にしました。

  • あるユーザーの会話一覧を「最新順」で取得する → GSIにより解決
  • ある会話のメッセージを「送信順」で取得する → ソートキーにtimestampを設定

CDKを使用すると楽

CDKを使用すると慣れた言語で実装することが出来るのでツール固有の文法などに悩まされずに済むので開発体験がよかったです。

会話履歴の扱いが会話の肝

Bedrockに**「ユーザーとアシスタントが交互に話した履歴」をすべて渡す**ことで、文脈を踏まえた返答が得られます。逆に言えば、履歴を渡さなければAIは毎回初対面扱いになります。


おわりに

AWSのサービスを組み合わせることで、本格的なAIチャットアプリをサーバーレスで構築できました。

特にCDKは「インフラ構成をコードで残せる」点が非常に便利で、cdk deploy --all 一発でリソースが再現できるのは快感です。

次は Knowledge Basesを使用したRAGあたりの実装にも挑戦してみたいと思っています。

この記事が同じようにAWSやAI開発に挑戦したい方の参考になれば幸いです。

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?