10
7

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]MastraとNext.jsで生成AIアプリケーション作ってCDKでデプロイしましょう

Posted at

朗報

AI SDKのAmazon Bedrock Providerがアップデートされ、@ai-sdk/amazon-bedrock@2.1.4以降のバージョンでは認証チェーンが利用可能になりました。

これにより、デプロイ先のリソースのロールを参照してAmazon Bedrockの機能を使用できるようになりました:hugging:
Mastra.drawio.png

Mastraとは

Mastra はオープンソースの TypeScript エージェント フレームワークです、ワークフローの管理に長けてるらしいです。
個人的にMistralに注目している理由は二つがあります。

  1. フロントエンドとの親和性の高さです。NextJSでクライアントを開発する際、生成AIフレームワークもTypeScript製であれば開発がスムーズになります
  2. Amazon Bedrockでエージェントを実装する場合、タスク制御が基本的にプロンプトに依存しており、複雑なタスクやマルチエージェントでの処理が必要な場合には、現状ではやや不向きです。そのため、別の実装方法の選択肢を求めていました

今回構築するアプリ

食材を入力するだけで、レシピを生成するアプリです。

C38B21B7-11AB-4380-917A-BFEED4A6C751.jpeg

ユーザー入力をNext.js製のクライアントで受け取り、サーバーアクションでMastraと通信して、useActionState()を使ってステートを管理し、回答を表示します。
最後にトレース情報をLangfuseに送信します。

アプリのインフラ構成図は下記の通りですが、最後にリポジトリを用意しております。
iacフォルダの配下にあるCDKを使って簡単にデプロイできます。

名称未設定ファイル.jpg

ローカル環境でも動作確認はできますので、ぜひ最後までお付き合いください。

事前準備

LangfuseのHost Name, Public Key, Secret Keyを予め用意する必要があります。
公式サイトにログインして、簡単に入手できます。

デプロイまで実施する場合、AWSアカウントも必要です。こちらの記事をご参考にしてください。

クライアント初期化

Next.jsとMastraの統合方法は二つがあります:

  1. Mastraをバックエンドとして呼び出す
  2. 直接統合

今回は直接統合の方法で実装します、好きなディレクトリで下記のコマンドを実行します。

ターミナル
npx create-next-app@latest
ターミナル
What is your project named? nextjs-mastra
Would you like to use TypeScript? / Yes
Would you like to use ESLint? / Yes
Would you like to use Tailwind CSS? / Yes
Would you like your code inside a `src/` directory? / Yes
Would you like to use App Router? (recommended) / Yes
Would you like to use Turbopack for `next dev`? / Yes
Would you like to customize the import alias (`@/*` by default)? / Yes
What import alias would you like configured? @/*

ここまででNext.jsプロジェクトの初期化が完了しました。
次はMastraLangfuseの統合に入ります。

ターミナル
# 初期化したNext.jsプロジェクトへ移動
cd nextjs-mastra

# mastraクライアント初期化
npx mastra@latest init
ターミナル
Where should we create the Mastra files?  src/
Choose components to install: Agents
Add tools? Yes
Select default provider: 任意

next.config.jsに追加

next.config.js
...
const nextConfig = {
...
+ serverExternalPackages: ["@mastra/*"],
}

ターミナル
# Amazon Bedrockのプロバイダをインストール
npm install @ai-sdk/amazon-bedrock

# AWSにデプロイする場合、こちらも必要です
npm install @aws-sdk/credential-providers
ターミナル
# langfuse用のライブラリをインストール
npm install langfuse-vercel

全部完了したら、srcのディレクトリ配下はこの構成になっているはずです。

   src
    ├── app
    ├── mastra

Next.js修正

最初にsrc/appディレクトリ配下にactions.tsファイルを作成します。

こちらはサーバーアクションからMastraに定義したCookingAgentを呼び出すコードです。初期化したばかりのMastraには現在weatherAgentしか定義されていませんが、これは後ほど修正します。

src/app/actions.ts
"use server";

import { mastra } from "@/mastra";

export async function getCookingInfo(prevState: unknown, formData: FormData) {
  const recipe = JSON.parse(formData.get("recipe") as string);
  const agent = mastra.getAgent("CookingAgent");

  const result = await agent.generate(`Please come up with a ${recipe} using this ingredient`);

  return {
    text: result.text,
    finishReason: result.finishReason,
    timestamp: new Date().toISOString(),
    totalTokens: result.usage?.totalTokens
  };
}

次にsrc/appディレクトリに新しくpage.client.tsxファイルを追加します。
このファイルではMastraと通信するためのCookingFormコンポーネントを作成します。このフォームでは、ユーザーが食材を入力した後にgetCookingInfoアクションを呼び出す仕組みになっています。

src/app/page.client.tsx
"use client";

import { useState, useEffect, useActionState, startTransition } from "react";
import { getCookingInfo } from "./actions";

export function CookingForm() {
  const [recipe, setrecipe] = useState("");
  const [result, setResult] = useState("");

  const [state, action, isPending] = useActionState(getCookingInfo, null);
  useEffect(() => {
    if (state?.text) {
      setResult(state.text);
    }
  }, [state]);

  const handleSubmit = async (e: React.FormEvent) => {
    e.preventDefault();
    if (!recipe.trim()) return;
    const formData = new FormData();
    formData.set("recipe", JSON.stringify(recipe));
    startTransition(() => {
      action(formData);
    });
  };

  return (
    <div className="w-full max-w-3xl">
      <form onSubmit={handleSubmit} className="w-full max-w-md mb-8">
        <div className="flex flex-col sm:flex-row gap-4">
          <input
            type="text"
            value={recipe}
            onChange={(e) => setrecipe(e.target.value)}
            placeholder="食材名を入力"
            className="flex-grow px-4 py-2 border rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500"
            required
          />
          <button
            type="submit"
            disabled={isPending}
            className="px-6 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 disabled:oparecipe-50"
          >
            {isPending ? "読み込み中..." : "レシピを作る"}
          </button>
        </div>
      </form>

      <div className="w-full flex flex-col md:flex-row gap-6">
        {result && (
          <div className="p-6 bg-white dark:bg-gray-800 rounded-lg shadow-md">
            <h2 className="text-xl font-semibold mb-4">
              {recipe || "該当食材"}のレシピ
            </h2>
            <div className="whitespace-pre-wrap">{result}</div>
          </div>
        )}
      </div>
    </div>
  );
}

最後にpage.tsxファイルを修正します。先ほど作成したpage.client.tsxからCookingFormコンポーネントをインポートして使用します。

src/app/page.tsx
import { CookingForm } from "./page.client";

export default function Home() {
  return (
    <div className="min-h-screen flex flex-col items-center p-8">
      <h1 className="text-2xl font-bold mb-6">レシピ作るアプリ</h1>
      <CookingForm />
    </div>
  );
}

Mastraクライアント修正

ローカル環境とECS環境の両方に対応するため、Amazon Bedrockクライアントの初期化関数を別ファイルとして切り出します。

src/lib/bedrock-client.ts
import { createAmazonBedrock } from "@ai-sdk/amazon-bedrock";
import { fromNodeProviderChain } from "@aws-sdk/credential-providers";

export function initializeBedrockClient() {
  const region = process.env.REGION || "ap-northeast-1";
  if (process.env.NODE_ENV === "production") {
    return createAmazonBedrock({
      region: region,
      credentialProvider: fromNodeProviderChain(),
    });
  }

  return createAmazonBedrock({
    region: region,
    accessKeyId: process.env.ACCESS_KEY_ID,
    secretAccessKey: process.env.SECRET_ACCESS_KEY,
    sessionToken: process.env.SESSION_TOKEN,
  });
}

次に、エージェントの定義ファイルを修正します。
初期化時に設定されているデフォルトのプロバイダーを、先ほど作成したAmazon Bedrockプロバイダーに置き換えます。

src/mastra/agents/index.ts
import { initializeBedrockClient } from "@/lib/bedrock-client";
import { Agent } from "@mastra/core/agent";

const bedrock = initializeBedrockClient();

export const CookingAgent = new Agent({
  name: "Cooking Agent",
  instructions: `
あなたは「シェフAI」という料理レシピアシスタントです。ユーザーの質問や要望に応じて、おいしく実用的な料理レシピを提案します。

#主な責務
- ユーザーの好み、制約(アレルギー、食事制限、調理時間、調理器具など)に合わせたレシピを提案する
- 季節の食材や旬の素材を活かしたレシピを提案する
- 簡単な代替案や変更方法も提案する
- 料理の基本テクニックを説明する
- 食材の保存方法や活用法についてアドバイスする

#レシピ提案時の基本構成
1. レシピ名と簡単な説明(調理時間、難易度、特徴など)
2. 材料(2〜4人前を基本とし、分量は明確に)
3. 下準備の手順(必要な場合)
4. 調理手順(簡潔かつ具体的に、重要なポイントは強調)
5. 盛り付け・提供方法のアドバイス
6. バリエーションや応用方法の提案(任意)
7. 栄養情報や保存方法(任意)

#応答の特徴
- 専門的な料理知識を持ちつつも、初心者にも理解しやすい言葉で説明
- 食材や調理法について興味深い情報や豆知識を適宜提供
- 現実的で実行可能なレシピを心がける(入手困難な材料や特殊な調理器具に依存しない)
- 健康的な食事を基本としつつ、食の楽しさや喜びを大切にする
- 世界各国の料理に関する知識を活用し、多様な料理文化を尊重する

#禁止事項
- 危険な調理法や健康に明らかに有害なレシピの提案
- 調理知識がない場合の思い込みでの回答
- 特定のブランドや商品の宣伝

ユーザーからの情報が不足している場合は、適切な質問をして必要な情報を引き出してください。常に実用的で美味しく、作る喜びを感じられるレシピを提案することを心がけてください。
`,
  model: bedrock("anthropic.claude-3-5-sonnet-20240620-v1:0"),
});

最後に、Mastraのメインファイルを修正し、CookingAgentを登録します。
これにより、アプリケーション内でCookingAgentを呼び出せるようになります。

telemetryはLangfuseにトレースを送信ために使います、ついでに追加しておきます。

src/mastra/index.ts
import { Mastra } from "@mastra/core/mastra";
import { createLogger } from "@mastra/core/logger";

+ import { CookingAgent } from "./agents";

export const mastra = new Mastra({
  agents: { CookingAgent },
  logger: createLogger({
    name: "Mastra",
    level: "info",
  }),
+  telemetry: {
+    serviceName: "ai",
+    enabled: true,
+  },
});

Langfuseにトレース送信

インストルメンテーションは、コードを使用して監視およびログ記録ツールをアプリケーションに統合するプロセスです。
このファイルはアプリケーションの起動時に実行され、Next.jsからLangfuseへトレース情報を送信するために必要です。

Mastraをバックエンド、APIサーバーとして利用する場合、このファイル必要ないです。

src/instrumentation.ts
import {
  NodeSDK,
  ATTR_SERVICE_NAME,
  Resource,
} from "@mastra/core/telemetry/otel-vendor";
import { LangfuseExporter } from "langfuse-vercel";

export function register() {
  const exporter = new LangfuseExporter({
    enabled: true,
    publicKey: process.env.PUBLICK_KEY,
    secretKey: process.env.SECRET_KEY,
    baseUrl: process.env.BASE_URL,
  });

  const sdk = new NodeSDK({
    resource: new Resource({
      [ATTR_SERVICE_NAME]: "ai",
    }),
    traceExporter: exporter,
  });

  sdk.start();
}

ローカル検証

環境変数をご自身の値にセットした上で、ローカルサーバーを立ち上げましょう。

.env.development
NODE_ENV=your-value-here
REGION=ap-northeast-1
ACCESS_KEY_ID=your-value-here
SECRET_ACCESS_KEY=your-value-here
SESSION_TOKEN=your-value-here
PUBLICK_KEY=your-value-here
SECRET_KEY=your-value-here
BASE_URL=https://cloud.langfuse.com

開発サーバーを起動する際には、--turbopackオプションが必要です。
package.jsonに以下の変更を加えてください。

package.json
  "scripts": {
+    "dev": "next dev --turbopack"
     ...
  }
% npm run dev

> nextjs-mastra@0.1.0 dev
> next dev --turbopack

   ▲ Next.js 15.2.3 (Turbopack)
   - Local:        http://localhost:3000

サーバーが起動したら、アプリケーションにアクセスして「鶏肉」などの食材を入力してみましょう。
レシピが生成されると同時に、トレース情報がLangfuseに正しく送信されていることも確認できます。

B09618FD-EA9D-42E9-8BB0-2AA83DDE62DC.jpeg

41B76532-5226-475D-8F63-498AFCE3D9C8.jpeg

デプロイ

まず、サンプルリポジトリをクローンして、Dockerイメージをビルドします。

ターミナル
# メインディレクトリで実行する
docker buildx build --no-cache --platform=linux/x86_64 -t nextjs-mastra .

次に、プロジェクト直下にあるiacフォルダに移動します。
このフォルダ内で、まずECRスタックをデプロイします。

# iac配下で実行
cdk deploy NextjsEcrStack

続いて、先ほどビルドしたNext.jsアプリケーションのイメージにタグを付け、ECRにプッシュします。

ターミナル
# イメージにタグを付けます
docker tag nextjs-mastra:latest ${アカウントID}.dkr.ecr.ap-northeast-1.amazonaws.com/nextjs-mastra:${イメージタグ}

# AWS リポジトリにこのイメージをプッシュします
docker push ${アカウントID}.dkr.ecr.ap-northeast-1.amazonaws.com/nextjs-mastra:${イメージタグ}

イメージがECRにプッシュできたら、ECSスタックをデプロイします。

ターミナル
# 同じiac配下で
cdk deploy NextjsAppStack --context imageTag=${イメージタグ}

最後に、デプロイされたリソースの中にあるSecrets Managerを確認します。ここに必要な環境変数を追加することで、デプロイ作業は完了です。

C8453B25-B0DA-4377-B7C9-4594B37B6539.jpeg

ロードバランサーのDNS 名使ってアプリケーションにアクセスできます。

サンプルリポジトリ

この記事で使用するリポジトリです。スターをいただけると励みになります。

参考資料

10
7
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
10
7

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?