[Amazon Bedrock]MastraとNext.jsで生成AIアプリケーション作ってCDKでデプロイしましょう

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

https://sdk.vercel.ai/providers/ai-sdk-providers/amazon-bedrock

これにより、デプロイ先のリソースのロールを参照して`Amazon Bedrock`の機能を使用できるようになりました:hugging:
![Mastra.drawio.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/320164/cd93cc2d-1e80-4c0c-9513-bf4360ac53ef.png)


## Mastraとは

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

https://mastra.ai/

https://qiita.com/Syoitu/items/3835a23f39969eab0111

## 今回構築するアプリ

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

![C38B21B7-11AB-4380-917A-BFEED4A6C751.jpeg](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/320164/8ddf5dae-381f-4763-bcb1-24bafb535508.jpeg)

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

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

![名称未設定ファイル.jpg](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/320164/072cd17a-03e3-43a1-ae76-45dddf59fb6f.jpeg)

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

## 事前準備

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

https://langfuse.com/

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

https://aws.amazon.com/jp/register-flow/

## クライアント初期化

Next.jsとMastraの統合方法は二つがあります：
1. Mastraをバックエンドとして呼び出す
2. 直接統合

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

```bash:ターミナル
npx create-next-app@latest
```

```bash:ターミナル
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`プロジェクトの初期化が完了しました。
次は`Mastra`と`Langfuse`の統合に入ります。

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

# mastraクライアント初期化
npx mastra@latest init
```

```bash:ターミナル
Where should we create the Mastra files?  src/
Choose components to install: Agents
Add tools? Yes
Select default provider: 任意
```

next.config.jsに追加

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

```

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

# AWSにデプロイする場合、こちらも必要です
npm install @aws-sdk/credential-providers
```

```bash:ターミナル
# langfuse用のライブラリをインストール
npm install langfuse-vercel
```

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

```
   src
    ├── app
    ├── mastra
```

## Next.js修正

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

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

```typescript: 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コンポーネントをインポートして使用します。

```typescript: 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クライアントの初期化関数を別ファイルとして切り出します。

```typescript: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プロバイダーに置き換えます。

```typescript: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にトレースを送信ために使います、ついでに追加しておきます。

```diff_typescript: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サーバーとして利用する場合、このファイル必要ないです。

```typescript: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に以下の変更を加えてください。

```diff_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](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/320164/de94059a-f10b-40e7-9586-cf51d2f38cba.jpeg)

![41B76532-5226-475D-8F63-498AFCE3D9C8.jpeg](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/320164/b1a9aef9-2a10-41e6-8f7a-252d4b1e2f3e.jpeg)

## デプロイ

まず、サンプルリポジトリをクローンして、Dockerイメージをビルドします。
```bash:ターミナル
# メインディレクトリで実行する
docker buildx build --no-cache --platform=linux/x86_64 -t nextjs-mastra .
```

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

```bash
# iac配下で実行
cdk deploy NextjsEcrStack
```

続いて、先ほどビルドしたNext.jsアプリケーションのイメージにタグを付け、ECRにプッシュします。
```bash:ターミナル
# イメージにタグを付けます
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スタックをデプロイします。

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

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

![C8453B25-B0DA-4377-B7C9-4594B37B6539.jpeg](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/320164/d1bda342-1ab5-4697-96f3-513e4520e037.jpeg)

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


## サンプルリポジトリ

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

https://github.com/huanshenyi/nextjs-mastra

## 参考資料

https://mastra.ai/docs/frameworks/01-next-js

https://mastra.ai/docs/reference/observability/providers/langfuse