Mastra×BedrockでLLMアプリを作成してみよう！~トレースも取得するよ~

こんにちは、ふくちです。

先日、JAWS-UG 東京支部 AWS Community Builders Nightにて登壇させていただきました。

https://jawsug.connpass.com/event/348745/

https://speakerdeck.com/har1101/bedrockxmcpdeshe-nei-buroguzhi-bi-wen-hua-woyu-tetai

この登壇にて、ブログアイデア出しエージェントをMastra上で構築し、Lambda Web Adapterにデプロイしてみたので、その手順やコードを共有します。

今回はひとまずアプリの作成を行い、ローカル環境で動作確認を行います。

別途、Lambda Web Adapterを用いる際のアプリデプロイにおける注意点やベストプラクティス(と思われる)アーキテクチャを構築するものも出します。こちらではAWS CDKを用いる予定です。

# Mastraとは
Mastraは、オープンソースのTypeScriptエージェントフレームワークです。

https://mastra.ai/

3月ころからかなりシェアを伸ばしており、LangGraphよりも用いられるケースが増えてきているとか。
(2025/04/24段階でどうなっているかは観測できていません)

https://x.com/yoshidashingo/status/1901281518426743076

エージェント、ワークフロー、RAG、メモリ、評価、デプロイまで機能が充実しており、サクッとPoCを作って動かすまでを爆速で行えます。
進化の早いLLM界隈において、このスピード感は非常に魅力的です。

# Next.js × Mastraでアプリを作成する
ここからはドキュメントを読みながら具体的な実装を進めていきます。

https://mastra.ai/ja/docs

一応、私の環境は以下の感じです。同じように進められる方は、必要に応じてインストールなどをお願いします。
- Macbook M2(ARM64)
- Cursor
- Docker Desktop
- aws-cli/2.25.11

## Next.jsアプリを作成する
まずはNext.jsアプリケーションを作成します。以下コマンドをCursorターミナルで実行します。
```bash
$  npx create-next-app@latest
Need to install the following packages:
create-next-app@15.3.1
Ok to proceed? (y) y

✔ What is your project named? … my-mastra-app
✔ 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のアプリケーション作成が完了すると、以下のようなディレクトリ構造になっているはずです。

```
$  tree -L 2 
.
└── my-mastra-app
    ├── README.md
    ├── eslint.config.mjs
    ├── next-env.d.ts
    ├── next.config.ts
    ├── node_modules
    ├── package-lock.json
    ├── package.json
    ├── postcss.config.mjs
    ├── public
    ├── src
    └── tsconfig.json

5 directories, 8 files
```

これでひとまず、Next.js側の準備ができました。

## Mastraアプリを作成する
続いて、先ほど作成したNext.jsプロジェクトにMastraを統合していきます。

[Mastraドキュメント内に、Next.jsとMastraの統合方法について記載](https://mastra.ai/ja/docs/frameworks/next-js)があります。
以下2パターンでの統合が可能なようです。
1. バックエンドの個別統合
    - 大規模プロジェクト向けに最適
        - AIバックエンドを独立してスケーリングしたい
        - 明確な関心の分離を維持したい
        - より柔軟なデプロイメントが必要
1. 直接統合
    - 小規模なプロジェクトやプロトタイプはこちら
        - MastraをNext.jsアプリケーションに直接バンドルする

今回は直接統合を用います。

Cursorのターミナルで以下コマンドを実行し、Mastraプロジェクトを統合していきます。
今回はメイン部分をAgentsで構築し、toolsとしてMCPを用いるため、以下のように設定していきます。

また、CursorやWindsurfを用いる場合は、Mastra Docs MCPサーバーの設定も1クリックで行うことができるようです(今回はスキップ)。
```bash
// Next.jsプロジェクトのルートに移動
$ cd my-mastra-app

// Mastra初期化コマンドを実行
$ npx mastra@latest init

◇  You do not have the @mastra/core package installed. Would you like to install it?
│  Yes
✔ @mastra/core installed successfully
┌  Mastra Init
│
◇  Where should we create the Mastra files? (default: src/)
│  src/
│
◇  Choose components to install:
│  Agents
│
◇  Add tools?
│  No
│
◇  Select default provider:
│  Anthropic
│
◇  Enter your anthropic API key?
│  Skip for now
│
◇  Add example
│  No
│
◇  Make your AI IDE into a Mastra expert? (installs Mastra docs MCP server)
│  Skip for now
```

現在のディレクトリは以下のようになっているはずです(node_modulesは除いています)。
```bash
$  tree -L 2 -I "node_modules"
.
├── README.md
├── eslint.config.mjs
├── next-env.d.ts
├── next.config.ts
├── package-lock.json
├── package.json
├── postcss.config.mjs
├── public
│   ├── file.svg
│   ├── globe.svg
│   ├── next.svg
│   ├── vercel.svg
│   └── window.svg
├── src
│   ├── app
│   └── mastra
└── tsconfig.json

5 directories, 13 files
```

続いて、`next.config.js`ファイルに以下を追加します。
```diff_typescript:next.config.js
import type { NextConfig } from "next";

const nextConfig: NextConfig = {
+  serverExternalPackages: ["@mastra/*"], //追加1
+  output: "standalone", //追加2
};

export default nextConfig;
```
1. `serverExternalPackages`とは、Next.jsの設定オプション
    - 外部パッケージ(ここではMastra)をサーバーサイドバンドルから除外する
    - 代わりに、実行時に依存関係としてロードする
    - @mastra/ で始まるすべてのパッケージがサーバーサイドバンドルから除外される
1. `output: "standalone"`とは、Next.jsの設定オプション
    - アプリケーションをコンテナ化する際に、最適化された出力形式を生成する
        - 外部の依存関係を含む、完全に独立したアプリケーションを生成できる
        - その際、実行に必要な最小限の依存関係のみを含むように最適化
    - ビルド出力は`.next/standalone`ディレクトリに作成される
        - サーバーファイル、依存関係、必要なアセットがすべて含まれる
    - コンテナサイズを小さく保つことができる
        - 従来のビルド出力では`node_modules`が必要だった
        - `stadnalone`を有効化することで依存関係を最小限にし、コンテナイメージを最小化

:::note
今回はローカル環境での動作確認のみですが、次回にコンテナパッケージとしてLambda Web Adapterへデプロイする想定です。

コンテナイメージをLambdaにデプロイする際は最大10GBまで可能だそうですが、まぁイメージは小さい方が良いということで有効化しておきます。

https://docs.aws.amazon.com/ja_jp/prescriptive-guidance/latest/patterns/deploy-lambda-functions-with-container-images.html
:::

ここまでできたら、一度アプリケーションをローカル環境でビルドしてみましょう。
```bash
// ディレクトリはmy-mastra-app/
$ npm run build

> my-mastra-app@0.1.0 build
> next build

   ▲ Next.js 15.3.1

   Creating an optimized production build ...
 ✓ Compiled successfully in 2000ms
 ✓ Linting and checking validity of types    
 ✓ Collecting page data    
 ✓ Generating static pages (5/5)
 ✓ Collecting build traces    
 ✓ Finalizing page optimization    

Route (app)                                 Size  First Load JS    
┌ ○ /                                    5.62 kB         107 kB
└ ○ /_not-found                            978 B         102 kB
+ First Load JS shared by all             101 kB
  ├ chunks/4bd1b696-7437b04a57dafff2.js  53.2 kB
  ├ chunks/684-a6a2af82d975c047.js       45.9 kB
  └ other shared chunks (total)          1.82 kB


○  (Static)  prerendered as static content
```

そして`.next`フォルダ配下を確認してみます。standaloneフォルダが作成されていればOKです。
```bash
$ tree -L 1 -d -a ./.next
./.next
├── cache
├── diagnostics
├── server
├── standalone
├── static
└── types

7 directories
```

ついでに、ローカル環境で開発環境を立ち上げてみましょう。
package.jsonのコマンドを一部修正します。
```diff_json:package.json
"scripts": {
+  "dev": "next dev --turbopack"
   ...
}
```

続いて、以下コマンドをターミナルで実行します。
```bash
# プロジェクトのルートディレクトリで実行する
$ npm run dev                                     

> my-mastra-app@0.1.0 dev
> next dev --turbopack

   ▲ Next.js 15.3.1 (Turbopack)
   - Local:        http://localhost:3000
   - Network:      http://192.168.10.104:3000
   - Environments: .env.development

 ✓ Starting...
 ✓ Ready in 749ms
 ○ Compiling / ...
 ✓ Compiled / in 1285ms
 GET / 200 in 1437ms
 ✓ Compiled /favicon.ico in 273ms
 GET /favicon.ico?favicon.45db1c09.ico 200 in 523ms
```

localhostのURLにアクセスし、以下のような画面になっていればOKです。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3616210/7bbfab63-21c6-497b-9aa4-35ce4f1dca98.png)

ここまでできていれば、ひとまずMastraアプリケーションの統合は完了です。

# Mastraの修正
ここからはMastraの編集を行います。
具体的には以下3点を実行します。

1. エージェントを構築する
1. MCPを設定する
1. Langfuseと統合する(オプション)

## 1.Mastraでエージェントを構築する
まずはエージェントを実装します。ドキュメントはこちらを参照ください。

https://mastra.ai/ja/docs/agents/overview

まず、新しく以下のファイルを作成します。
import部分で発生するエラーなどは後ほど解消します。
```src/mastra/agents/index.ts
import { initializeBedrockClient } from "@/lib/bedrock-client";
import { Agent } from "@mastra/core/agent";

const bedrock = initializeBedrockClient();

export const blogIdeaAgent = new Agent({
  name: "Blog Idea Agent",
  instructions: `
      あなたはブログ執筆をサポートするエージェントです。toolsを駆使して情報を検索し、その情報を元にしてブログのネタや構成を考えてください。
      返答は日本語で、かつ簡潔にお願いします。
      
      1. 情報収集フェーズ
      - github_brave_web_searchツールを使用して最新情報を検索してください。
      - ユーザーの入力から具体的な検索キーワードや質問文を抽出し、それをquery引数としてgithub_brave_web_searchツールへ渡してください。
      - 検索パラメータ:
        * query: 検索クエリ（必須）
        * country: 検索結果の国コード（例: JP, US）（オプション）
        * count: 返される検索結果の最大数（オプション）
        * search_lang: 検索言語（例: ja, en）（オプション）

      2. ブログ方針決定フェーズ
      - 検索結果を分析し、以下の要素を考慮してブログの方針を決定:
        * 対象読者層:
          - 初心者向け: ハンズオン形式、専門用語の平易な説明
          - 中級者向け: 実践的なTips、ベストプラクティス、パフォーマンス最適化
          - 上級者向け: アーキテクチャ設計、高度な技術解説、最新トレンドの深堀り
          - マネージャー向け: チーム開発の効率化、プロジェクト管理の視点
        * コンテンツの目的:
          - 教育・学習
          - 問題解決
          - 最新情報の共有
          - ベストプラクティスの提示

      3. ブログ構成作成フェーズ
      - 決定した方針に基づき、以下の要素を含むブログ構成を作成:
        * タイトル案（1-2個）
        * 導入部分の構成
        * メインコンテンツの章立て
        * 結論・まとめの方向性
        * 参考資料・リソースの提示方法

`,
  model: bedrock("us.anthropic.claude-3-7-sonnet-20250219-v1:0"),
});
```

ざっくりと解説すると、以下の感じです。
- Agentクラスを使用し、そのプロパティを定義
    - name: エージェントの名前
    - instructions: エージェントへの指示(システムプロンプト的な)
    - model: 使用するLLM(今回はBedrockのClaude 3.7 Sonnet)

続いて、上記で作成したエージェントをMastraに登録します。
```src/mastra/index.ts
import { Mastra } from "@mastra/core/mastra";
import { createLogger } from "@mastra/core/logger";

import { blogIdeaAgent } from "./agents";

export const mastra = new Mastra({
  agents: { blogIdeaAgent },
});
```
これで、Mastra上に作成したエージェントが登録されます。

続いては、Bedrockの設定です。
ローカル環境とLambda環境の両方に対応するため、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,
  });
}
```

ローカル環境で確認する際は、以下環境変数を設定しておきます。
```.env.development
# AWS Credentials
ACCESS_KEY_ID=your-value-here
SECRET_ACCESS_KEY=your-value-here
SESSION_TOKEN=your-value-here
REGION=your-value-here
```

ここで、必要なモジュールをインストールしておきます。ターミナルで以下を実行します。
```bash
# Amazon Bedrockのプロバイダー
$ npm install @ai-sdk/amazon-bedrock

# AWSにデプロイする際に必要なモジュール
$ npm install @aws-sdk/credential-providers
```

ここまでで、エージェント側の設定は完了です。

## 2.MCPを設定する
続いてMCP、Model Context Protocolをエージェントが使用できるように設定していきます。

https://mastra.ai/ja/docs/agents/mcp-guide

先ほど作成したエージェントの設定ファイルを修正していきます。
```diff_typescript:src/mastra/agents/index.ts
import { initializeBedrockClient } from "@/lib/bedrock-client";
import { Agent } from "@mastra/core/agent";
+ import { MCPConfiguration } from "@mastra/mcp";

const bedrock = initializeBedrockClient();

+ const mcp = new MCPConfiguration({
+   id: "brave-search-mcp",
+   servers: {
+     braveMcp: {
+       command: "npx",
+       args: ["-y", "@modelcontextprotocol/server-brave-search"],
+       env: {
+         BRAVE_API_KEY: process.env.BRAVE_API_KEY ?? "",
+       },
+     },
+   },
+ });

export const blogIdeaAgent = new Agent({
  name: "Blog Idea Agent",
  instructions: `
      あなたはブログ執筆をサポートするエージェントです。toolsを駆使して情報を検索し、その情報を元にしてブログのネタや構成を考えてください。
      返答は日本語で、かつ簡潔にお願いします。
      (中略)
`,
  model: bedrock("us.anthropic.claude-3-7-sonnet-20250219-v1:0"),
+  tools: await mcp.getTools(),
});
```
`MCPConfiguration`クラスは、複数のMCPクライアントを管理することなく、Mastraアプリケーションで複数のツールサーバーを管理する方法を提供します。

そしてエージェントの設定で、MCPサーバーで用意されているtoolsをAgentが使用するように追記します。

この時、MCPサーバー用の`id`を指定しておかないと実行時にエラーが発生することもあるので注意してください。

ローカル環境での動作確認として、環境変数にBraveのAPIキーを設定しておきます。
APIキーの発行は以下で、無料でできます。

https://brave.com/search/api/

```.env.development
# Brave Search API Key
BRAVE_API_KEY=your-value-here

# AWS Credentials
ACCESS_KEY_ID=your-value-here
SECRET_ACCESS_KEY=your-value-here
SESSION_TOKEN=your-value-here
REGION=your-value-here
```

MCPの設定は以上です。モジュールだけインストールしておきましょう。
```bash
$ npm install @mastra/mcp@latest
```

## 3.Langfuseと統合する(オプション)
LLMアプリに対するオブザーバビリティ機能として、Langfuseを設定します。
アカウント作成・Langfuseプロジェクト作成・キーの発行などは以下を参照して実施してください。

https://qiita.com/minorun365/items/9d0d11f0ecb4c29924d5

以下ファイルを修正していきます。Mastraがエージェントを使用する際に取得するトレースやログ記録の設定です。

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

import { blogIdeaAgent } from "./agents";

export const mastra = new Mastra({
  agents: { blogIdeaAgent },
+   logger: createLogger({
+     name: "Mastra",
+     level: "debug", //開発用の設定
+   }),
+   telemetry: {
+     serviceName: "ai",
+     enabled: true,
+     sampling: {
+       type: "always_on", // 開発用の設定、すべてのトレースを取得
+     },
+     export: {
+       type: "custom",
+       exporter: new LangfuseExporter({
+         publicKey: process.env.LANGFUSE_PUBLIC_KEY,
+         secretKey: process.env.LANGFUSE_SECRET_KEY,
+         baseUrl: process.env.LANGFUSE_BASEURL,
+       }),
+     },
+   },
});
```

`logger`配下では、文字通りログの設定を取得します。
こちらはコンソールやCloudWatch Logsなどで確認できます。

`telemetry`配下ではLLMアプリのトレース、すなわちエージェント動作やツール実行などを取得できるように設定しています。
そして取得したトレースをLangfuseへエクスポートします。

設定する際の注意点として、`serviceName`には必ず **ai** を設定してください。
公式ドキュメントによると、「Langfuseの統合が正しく機能するためには、traceNameパラメータを"ai"に設定する必要があります。」とのことでした。

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

また、`export`配下の内容を別ファイルに切り出すこともできます。
```src/instrumentation.ts
import {
  NodeSDK,
  ATTR_SERVICE_NAME,
  Resource,
} from "@mastra/core/telemetry/otel-vendor";
import { LangfuseExporter } from "langfuse-vercel";

export async function register() {
  // 環境に応じてエクスポーターを選択
  const exporter = new LangfuseExporter({
    enabled: true,
    publicKey: process.env.LANGFUSE_PUBLIC_KEY,
    secretKey: process.env.LANGFUSE_SECRET_KEY,
    baseUrl: process.env.LANGFUSE_BASE_URL || "https://cloud.langfuse.com",
  });

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

  sdk.start();
}
```

ここまで設定できたら、Langfuse用の環境変数を設定します。

```.env.development
# Brave Search API Key
BRAVE_API_KEY=your-value-here

# AWS Credentials
ACCESS_KEY_ID=your-value-here
SECRET_ACCESS_KEY=your-value-here
SESSION_TOKEN=your-value-here
REGION=your-value-here

# Langfuse
LANGFUSE_PUBLIC_KEY=your-value-here
LANGFUSE_SECRET_KEY=your-value-here
LANGFUSE_BASE_URL=https://us.cloud.langfuse.com
```

最後にモジュールインストールです。ターミナルで以下を実行します。
```bash
# Langfuseでのトレース取得に必要
$ npm install langfuse-vercel
```

ここまでで、Mastra側での設定は完了です。
エージェントの定義からMCPの連携まで、めっちゃ簡単じゃないですか？？

# Next.jsの修正
ここからはNext.js側の変更を行っていきます。
とはいえ、著者はNext.jsガチ初心者なので、ClineとCursorを使ったVibe Codingで実装を進めました。詳しくは末尾のGitHubをご参照ください。

一応Cursor/Clineに依頼してコードの解説コメントを付与してもらっていますが、もしかすると一部良くない処理等あるかもしれませんので、あらかじめご了承ください。

また、Next.jsの詳細解説は本題と少しずれるので、注意ポイントだけに絞って解説します。

## エージェントの呼び出し
まずはMastra側で設定したエージェントをNext.js側で取得します。
そのためには、`getAgent()`メソッドを用います。

その後、エージェントと対話して応答を取得します。
応答生成には`generate()`メソッドと、`stream()`メソッドを使用できます。
前者は回答をまとめて出力、後者は回答をストリーミング出力します。
```src/app/actions.ts
"use server";

import { mastra } from "@/mastra";
import { revalidatePath } from "next/cache";

/**
 * 最新のブログ記事アイデアを取得するサーバーアクション
 * route.tsでストリーミングレスポンスを処理するため、こちらはシンプルに結果のみを返す
 */
export async function getLatestUpdate(prevState: unknown, formData: FormData) {
  try {
    // フォームデータをパース
    const tech = JSON.parse(formData.get("tech") as string);
    const targetAudience = JSON.parse(formData.get("targetAudience") as string);

    // Mastraエージェントを取得
    const agent = mastra.getAgent("blogIdeaAgent");

    // エージェントを実行し、ブログ記事のアイデアを生成
    const stream = await agent.stream(
      `${tech}に関する最新情報を検索し、${targetAudience}向けのブログ記事のアイデアを提案してください。`,
      {
        // テレメトリ設定
        telemetry: {
          isEnabled: true,
          metadata: {
            query: tech,
            targetAudience: targetAudience,
          },
        },
      }
    );

    // ストリームを消費して最終的なテキストを取得
    let generatedText = '';
    for await (const chunk of stream.textStream) {
      generatedText += chunk;
    }

    // usage プロパティを解決して情報を取得
    const usageInfo = await stream.usage;

    // キャッシュを再検証
    revalidatePath("/");

    // 成功時のレスポンスを返す
    return {
      text: generatedText,
      finishReason: stream.finishReason || "不明",
      timestamp: new Date().toISOString(),
      totalTokens: usageInfo?.totalTokens,
    };
  } catch (error) {
    console.error("getLatestUpdate Action Error:", error);
    
    // エラーレスポンスを返す
    return {
      error: "ブログ記事のアイデア取得中にエラーが発生しました。",
      timestamp: new Date().toISOString(),
    };
  }
}
```

エージェントの回答出力設定はこれでOKです。

ただ、AIエージェントを組み込んだアプリケーションにはトレース出力も見られた方がUX的には良さそうです。
ということで、トレースを画面に表示できるよう処理を追加していきます。

## Mastraを用いる際のトレース出力
そもそもトレースとは、エージェントが回答を出力するまでの推論プロセスを出力することです。

エージェントの思考や動作といった、各ステップにおける詳細情報を可視化することで、ユーザー体験を改善します。

AIエージェントを用いたチャットシステムにおいてトレースを表示しなかった場合、裏側で何がどうなっているのかがユーザーから全く見えてこないため、ユーザーは「今動いてる？」「これいつ回答返ってくるんだろう？」など不安になることでしょう。

仮に下画像のように、「レビュー中…」とだけ出ていた場合、どうでしょうか？
これで20秒とか30秒待たされたとしたら、かなりユーザー体験が悪いのではないでしょうか。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3616210/32c7fbe5-62f2-443f-9500-a857852c729f.png)

その不安をできるだけ無くすためにも、トレースは表示しておきたいです。
ということで、Mastraを用いた場合でもトレースの表示を行えるようにしていきます。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3616210/13f2fc52-104b-4d00-bfc3-b5cb679c4966.png)

そのためには、`stream()`メソッドの **`onStepFinish`オプション**を用います。
これはストリーミング中の各ステップ後に呼び出されるコールバック関数です。

このオプションを用いて、エージェントの各ステップごとにトレース情報を出力するように設定します。

```src/app/api/stream/route.ts
// サーバーアクションとストリーミングを実装するためのファイル(一部抜粋)
import { NextRequest } from "next/server";
import { mastra } from "@/mastra";

// Streamable関数
export async function POST(req: NextRequest) {
  try {
    // リクエストボディからデータを取得
    const { tech, targetAudience } = await req.json();

    // エージェントを取得
    const agent = mastra.getAgent("blogIdeaAgent");

    // レスポンスヘッダーを設定
    const encoder = new TextEncoder();
    const customReadable = new ReadableStream({
      async start(controller) {
        try {
          // 初期メッセージ
          controller.enqueue(
            encoder.encode(
              JSON.stringify({
                type: "status",
                content: `検索開始: ${tech}に関する最新情報を取得します (対象: ${targetAudience})`,
              }) + "\n"
            )
          );

          // エージェントのストリーミングを開始
          const stream = await agent.stream(
            `${tech}に関する最新情報を検索し、${targetAudience}向けのブログ記事のアイデアを提案してください。`,
            {
              onStepFinish: (stepDetails) => {
                try {
                  // ステップ情報をJSONとしてパース
                  const stepInfo =
                    typeof stepDetails === "string"
                      ? JSON.parse(stepDetails)
                      : stepDetails;
                  console.log('stepInfo:', stepInfo); // デバッグ用ログ

                  // ステップタイプの判別とトレース情報の送信
                  if (stepInfo.stepType === "initial") {
                    const toolName =
                      stepInfo.toolCalls?.[0]?.toolName || "Unknown Tool";
                    const query =
                      stepInfo.toolCalls?.[0]?.args.query || "Unknown Query";
                    
                    // 初期化トレースを送信
                    controller.enqueue(
                      encoder.encode(
                        JSON.stringify({
                          type: "trace",
                          traceType: "initial",
                          content: "エージェントが処理を開始しました",
                          toolName: toolName,
                          query: query,
                        }) + "\n"
                      )
                    );
                    
                    // ツール結果があれば、すぐに別のトレースとして送信（ただし検索完了メッセージなし）
                    if (stepInfo.toolResults?.[0]?.result.content[0].text) {
                      const toolResultsText = stepInfo.toolResults[0].result.content[0].text;
                      controller.enqueue(
                        encoder.encode(
                          JSON.stringify({
                            type: "trace",
                            traceType: "tool-results-data",  // 新しいトレースタイプを使用
                            toolResultsText: toolResultsText,
                          }) + "\n"
                        )
                      );
                    }
                  } else if (stepInfo.stepType === "tool-result") {
                    // toolCalls情報を取得
                    const toolCallIndex = stepInfo.toolCalls && stepInfo.toolCalls.length > 0 ? 
                      stepInfo.toolCalls.length > 1 ? 1 : 0 : null;
                    
                    // toolNameとquery情報を取得
                    const toolName = toolCallIndex !== null ? 
                      stepInfo.toolCalls[toolCallIndex]?.toolName || "Unknown Tool" : "Unknown Tool";
                    const query = toolCallIndex !== null ? 
                      stepInfo.toolCalls[toolCallIndex]?.args.query || "Unknown Query" : "Unknown Query";
                    
                    // ツール結果があれば、まずツール使用情報とともに送信
                    if (stepInfo.toolResults?.[0]?.result.content[0].text) {
                      // まずツール使用情報を送信
                      if (toolName !== "Unknown Tool" && query !== "Unknown Query") {
                        controller.enqueue(
                          encoder.encode(
                            JSON.stringify({
                              type: "trace",
                              traceType: "tool-call",
                              toolName: toolName,
                              query: query
                            }) + "\n"
                          )
                        );
                      }
                      
                      // 次にツール結果を送信
                      const toolResultsText = stepInfo.toolResults[0].result.content[0].text;
                      controller.enqueue(
                        encoder.encode(
                          JSON.stringify({
                            type: "trace",
                            traceType: "tool-results-data",
                            toolResultsText: toolResultsText
                          }) + "\n"
                        )
                      );
                    }
                    
                    // 検索完了メッセージを送信
                    controller.enqueue(
                      encoder.encode(
                        JSON.stringify({
                          type: "trace",
                          traceType: "tool-result",
                          content: "検索完了"
                        }) + "\n"
                      )
                    );
                  } else {
                  (以下略)
```

この時注意点として挙げられるのが、`stepInfo.stepType`の値が直感的なイメージと少し異なる点です。

トレースの各ステップということなので、直感的には以下のようなイメージを私は持っていました。
こんな感じのステップごとに区切られており、そのステップタイプに応じて取得・表示するパラメータを変更すれば良い考えいました。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3616210/346556de-83f8-4045-8b8c-fd417272987e.png)

ただ、実際には以下の感じでした。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3616210/1d7c7429-838a-40c5-8ffd-66db75a11169.png)

先程のソースコードと照らし合わせると、以下の感じです。
- `stepInfo.stepType === "initial"`としたのがステップ1.初期化
- `stepInfo.stepType === "tool-result"`としたのがステップ2.ツール使用完了

なぜこのような形になるかというと、`stepInfo`をconsole.logで確認したところ、今回のケースではこの2タイプしか出力されなかったためです。

<details><summary>ステップ1.初期化における実際のトレース</summary>

```bash
stepInfo: {
  stepType: 'initial',
  text: 'Amazon Bedrockに関する最新情報を検索し、初心者向けのブログ記事のアイデアを提案します。まずは最新情報を収集しましょう。',
  reasoning: undefined,
  reasoningDetails: [],
  files: [],
  sources: [],
  toolCalls: [
    {
      type: 'tool-call',
      toolCallId: 'tooluse_id',
      toolName: 'github_brave_web_search',
      args: [Object]
    }
  ],
  toolResults: [
    {
      type: 'tool-result',
      toolCallId: 'tooluse_id',
      toolName: 'github_brave_web_search',
      args: [Object],
      result: [Object]
    }
  ],
  finishReason: 'tool-calls',
  usage: { promptTokens: 1449, completionTokens: 111, totalTokens: 1560 },
  warnings: [],
  logprobs: undefined,
  request: {},
  response: {
    id: 'hogehoge',
    timestamp: 2025-04-28T15:22:09.525Z,
    modelId: 'us.anthropic.claude-3-7-sonnet-20250219-v1:0',
    headers: {
      connection: 'keep-alive',
      'content-type': 'application/vnd.amazon.eventstream',
      date: 'Mon, 28 Apr 2025 15:22:09 GMT',
      'transfer-encoding': 'chunked',
      'x-amzn-requestid': 'fugafuga'
    },
    messages: [ [Object], [Object] ]
  },
  providerMetadata: undefined,
  experimental_providerMetadata: undefined,
  isContinued: false
}
```

</details>

<details><summary>ステップ2.ツール使用完了における実際のトレース</summary>

```bash
stepInfo: {
  stepType: 'tool-result',
  text: '\n\nさらに、Amazon Bedrockの機能や使い方に関する情報も収集しましょう。',
  reasoning: undefined,
  reasoningDetails: [],
  files: [],
  sources: [],
  toolCalls: [
    {
      type: 'tool-call',
      toolCallId: 'tooluse_id',
      toolName: 'github_brave_web_search',
      args: [Object]
    }
  ],
  toolResults: [
    {
      type: 'tool-result',
      toolCallId: 'tooluse_id',
      toolName: 'github_brave_web_search',
      args: [Object],
      result: [Object]
    }
  ],
  finishReason: 'tool-calls',
  usage: { promptTokens: 3279, completionTokens: 91, totalTokens: 3370 },
  warnings: [],
  logprobs: undefined,
  request: {},
  response: {
    id: 'hogehoge',
    timestamp: 2025-04-28T15:22:15.586Z,
    modelId: 'us.anthropic.claude-3-7-sonnet-20250219-v1:0',
    headers: {
      connection: 'keep-alive',
      'content-type': 'application/vnd.amazon.eventstream',
      date: 'Mon, 28 Apr 2025 15:22:15 GMT',
      'transfer-encoding': 'chunked',
      'x-amzn-requestid': 'fugafuga'
    },
    messages: [ [Object], [Object], [Object], [Object] ]
  },
  providerMetadata: undefined,
  experimental_providerMetadata: undefined,
  isContinued: false
}
```

</details>

<details><summary>ステップ2.ツール使用完了における回答出力の実際のトレース</summary>

```bash
stepInfo: {
  stepType: 'tool-result',
  text: '\n' +
    '\n' +
    '収集した情報を基に、初心者向けのAmazon Bedrockに関するブログ記事のアイデアを提案します。\n' +
    '\n' +
    '## ブログ方針決定\n' +
    '\n' +
    '**対象読者層**: 初心者向け\n' +
    '- AWS初心者や生成AI初心者を対象\n' +
    '- 専門用語を平易に説明\n' +
    '- ハンズオン形式で実際に触れながら学べる内容\n' +
    '\n' +
    '**コンテンツの目的**:\n' +
    '- 教育・学習: Amazon Bedrockの基本概念と使い方を学ぶ\n' +
    '- 最新情報の共有: 2024年の最新機能や更新情報を紹介\n' +
    '\n' +
    '## ブログ構成案\n' +
    '\n' +
    '### タイトル案\n' +
    '1. 「AWS初心者でも分かる！Amazon Bedrockではじめる生成AI入門 2024」\n' +
    '2. 「ゼロからはじめるAmazon Bedrock - 2024年最新版 生成AIサービス活用ガイド」\n' +
    '\n' +
    '### 導入部分の構成\n' +
    '1. 生成AIの基本概念と重要性の簡単な説明\n' +
    '2. AWSの生成AIサービスとしてのAmazon Bedrockの位置づけ\n' +
    '3. 2024年のAmazon Bedrockの主な更新点や新機能のハイライト\n' +
    '4. 記事の目的と読者が得られる知識・スキルの説明\n' +
    '\n' +
    '### メインコンテンツの章立て\n' +
    '\n' +
    '**1. Amazon Bedrockとは？**\n' +
    '- 生成AIサービスとしての基本概念\n' +
    '- 他のAWSサービスや競合サービスとの違い\n' +
    '- 利用できる基盤モデルの紹介（2024年最新情報）\n' +
    '  - Anthropic Claude\n' +
    '  - Meta Llama 2\n' +
    '  - Amazon Titan\n' +
    '  - Stability AI\n' +
    '  - その他の利用可能なモデル\n' +
    '\n' +
    '**2. Amazon Bedrockを始める準備**\n' +
    '- AWSアカウントの設定\n' +
    '- 必要な権限とIAMロールの設定\n' +
    '- 東京リージョンでの利用方法（2024年現在の対応状況）\n' +
    '- 料金体系の基本（初心者が理解しやすい形で）\n' +
    '\n' +
    '**3. はじめてのAmazon Bedrock - 基本的な使い方**\n' +
    '- コンソールからのアクセス方法\n' +
    '- テキスト生成の基本（プロンプトの書き方）\n' +
    '- 画像生成の基本\n' +
    '- 各モデルの特徴と選び方\n' +
    '\n' +
    '**4. 実践！簡単なプロジェクト例**\n' +
    '- テキスト要約ツールの作成\n' +
    '- 簡単なチャットボットの構築\n' +
    '- RAG（検索拡張生成）の基本と実装方法\n' +
    '- Knowledge Baseの活用方法\n' +
    '\n' +
    '**5. Amazon Bedrockの最新機能（2024年版）**\n' +
    '- AWS re:Invent 2024で発表された新機能\n' +
    '- Guardrailsの活用方法と価格改定\n' +
    '- モデル評価機能の使い方\n' +
    '- カスタマイズオプション（ファインチューニングなど）\n' +
    '\n' +
    '**6. 次のステップ**\n' +
    '- より高度な使い方へのリンク\n' +
    '- 関連するAWSサービスとの連携\n' +
    '- 学習リソースの紹介\n' +
    '\n' +
    '### 結論・まとめの方向性\n' +
    '- Amazon Bedrockを使った生成AI活用の可能性\n' +
    '- 初心者が次に学ぶべきポイント\n' +
    '- 実際のビジネスケースでの活用例の簡単な紹介\n' +
    '\n' +
    '### 参考資料・リソースの提示方法\n' +
    '- AWS公式ドキュメントへのリンク\n' +
    '- チュートリアル動画やハンズオン資料へのリンク\n' +
    '- コミュニティリソース（Qiitaなどの記事）\n' +
    '- 2024年の最新情報を追いかけるためのリソース\n' +
    '\n' +
    '## 差別化ポイント\n' +
    '\n' +
    'このブログ記事は以下の点で差別化されています：\n' +
    '\n' +
    '1. **最新情報の反映**: 2024年の最新機能や更新情報を含む\n' +
    '2. **ハンズオン形式**: 実際に手を動かしながら学べる構成\n' +
    '3. **初心者視点**: 専門用語を平易に説明し、段階的に理解を深められる\n' +
    '4. **実践的なプロジェクト例**: 単なる機能説明ではなく、実際に作れるものを提示\n' +
    '5. **次のステップの明確化**: 読者が学習を継続できるよう、次に学ぶべきことを明示\n' +
    '\n' +
    'この構成で、AWS初心者でも生成AIの基本から実践的な活用方法まで学べる記事になります。特に2024年の最新情報を取り入れることで、常に更新されるAmazon Bedrockの最新状況を反映した価値ある内容となるでしょう。',
  reasoning: undefined,
  reasoningDetails: [],
  files: [],
  sources: [],
  toolCalls: [],
  toolResults: [],
  finishReason: 'stop',
  usage: { promptTokens: 4923, completionTokens: 1393, totalTokens: 6316 },
  warnings: [],
  logprobs: undefined,
  request: {},
  response: {
    id: 'hogehoge',
    timestamp: 2025-04-28T15:22:21.292Z,
    modelId: 'us.anthropic.claude-3-7-sonnet-20250219-v1:0',
    headers: {
      connection: 'keep-alive',
      'content-type': 'application/vnd.amazon.eventstream',
      date: 'Mon, 28 Apr 2025 15:22:21 GMT',
      'transfer-encoding': 'chunked',
      'x-amzn-requestid': 'fugafuga'
    },
    messages: [ [Object], [Object], [Object], [Object], [Object] ]
  },
  providerMetadata: undefined,
  experimental_providerMetadata: undefined,
  isContinued: false
}
```

</details>

上記を元にして、各ステップのタイプごとに取得するパラメータを変化させる必要がありました。

したがって、ステップ1.初期化と言いながらも、そこでtoolNameやqueryを取得する必要があるような処理になっています。

他にもし良い書き方をご存じの方がいれば、ご教示いただけますと幸いです！

## Lambda Web Adapterへデプロイするための設定
最終的にこのアプリケーションはLambda Web Adapterへデプロイしますが、そのために必要な設定があります。

watanyさんの登壇資料では冒頭に記載されているのですが、私はこれに全然気づくことができず、約2日ほど無駄にしました…

https://speakerdeck.com/watany/aws-lambda-response-streaming-shi-zhuang-qian-nisiritaiyatu?slide=6

>カスタムランタイムではResponseに以下のHeaderをつけてPOST
>- `Lambda-Runtime-Function-Response-Mode": "streaming"`
>- `"Transfer-Encoding": "chunked"`

ということでこんな感じに書いてみました。
```src/app/api/stream/route.ts
export async function POST(req: NextRequest) {
  (処理は省略)

  // レスポンスを返す
  return new Response(customReadable, {
    headers: {
      "Content-Type": "text/event-stream",
      "Cache-Control": "no-cache, no-transform",
      "Connection": "keep-alive",
      "Transfer-Encoding": "chunked",
      "Lambda-Runtime-Function-Response-Mode": "streaming"
    },
  });
}
```

これがないとデプロイしても上手く動作しないので、要注意です。

ここまでで、Next.js側の設定は完了です。
もちろんこれ以外にもソースコードはあるのですが、そちらはGitHubをご参照ください。

:::note
※実際のデプロイ作業は別記事で行います。
:::
## 動作確認
ここまでできたら、開発サーバーを立ち上げてみましょう。
ターミナルで以下を実施します。
```bash
# プロジェクトのルートディレクトリで実行
$ npm run dev
```

以下のような画面が表示され、アプリケーションが問題なく実行できれば成功です！
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3616210/f8420905-a9f4-457a-bed5-7e048698bd41.png)

実際の動作イメージとしてはこんな感じ。

https://x.com/har1101mony/status/1912171108679524503

また、Langfuseでもトレースが確認できるはずです。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3616210/4717c229-34e9-4a13-8379-feb097aea858.png)


# まとめ
Mastra×Next.jsを使って、アプリを作成し、トレースまで取得してみました。
本当はLambda Web Adapter上へデプロイして動かすところまで盛り込みたかったのですが、なんか動かなくなった(ほんとになんで？)ので、動作確認でき次第、そちらも展開します…！

このアプリのGitHubはこちら(だいぶ未完成ですが…)↓

https://github.com/har1101/nextjs-mastra-app/tree/main
***
【5/3 追記】
森田さん(@moritalous さん)がSAMを用いてLambda Web Adapterへデプロイできるよう、諸々を修正してくださいました…！
本当にありがとうございます🙇‍♂️

https://github.com/moritalous/nextjs-mastra-app

https://x.com/moritalous/status/1918321733960077822

こちらのGitHubの修正内容を踏まえて、自分の実装のどこが良くなかったのか・どうして解決したのか・結局どうすればデプロイできるのかなどを別途まとめさせていただこうと思います。

# 参考

https://qiita.com/murodon/items/d6a94c448caea9111bc6

https://qiita.com/Syoitu/items/bfcc49e3941e8fefe4da

https://aws.amazon.com/jp/blogs/news/implementing-ssr-streaming-on-nextjs-with-aws-lambda-response-streaming/

https://aws.amazon.com/jp/builders-flash/202301/lambda-web-adapter/

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

https://mastra.ai/ja/docs

https://note.com/nike_cha_n/n/nbc7696f06eb8