JavaScript、Mastra、Elasticsearch を使用したエージェント型 RAG アシスタントの構築
JavaScript エコシステムで AI エージェントを構築する方法を学ぶ
このアイデアは、白熱したハイリスクなファンタジー バスケットボール リーグの最中に思いつきました。私はこう考えました。 「毎週の対戦で優位に立つのに役立つ AI エージェントを構築できるだろうか?」 もちろんです!
この記事では、 Mastraとそれと対話するための軽量 JavaScript Web アプリケーションを使用して、エージェント RAG アシスタントを構築する方法について説明します。このエージェントを Elasticsearch に接続することで、構造化されたプレイヤーデータへのアクセスとリアルタイムの統計集計の実行が可能になり、プレイヤー統計に基づいた推奨事項を提供できるようになります。GitHubリポジトリにアクセスして手順を確認してください。READMEには、アプリケーションを独自に複製して実行する方法が記載されています。
すべてをまとめると次のようになります。

注: このブログ投稿は、「 AI SDK と Elastic を使用した AI エージェントの構築」に基づいています。AI エージェント全般とその用途についてよく知らない場合は、まずそこから始めてください。
アーキテクチャの概要
システムの中核となるのは、エージェントの推論エンジン(脳)として機能する大規模言語モデル(LLM)です。ユーザー入力を解釈し、呼び出すツールを決定し、関連する応答を生成するために必要な手順を調整します。
エージェント自体は、JavaScript エコシステムのエージェント フレームワークである Mastra によって構築されます。Mastra は、LLM をバックエンド インフラストラクチャでラップし、それを API エンドポイントとして公開し、ツール、システム プロンプト、エージェントの動作を定義するためのインターフェイスを提供します。
フロントエンドでは、 Viteを使用して、エージェントにクエリを送信してその応答を受信するためのチャット インターフェイスを提供する React Web アプリケーションを迅速に構築します。
最後に、エージェントがクエリして集計できるプレーヤーの統計情報と対戦データを保存する Elasticsearch があります。

背景
いくつかの基本的な概念を確認してみましょう。
エージェントRAGとは何ですか?
AI エージェントは他のシステムと対話し、独立して動作し、定義されたパラメータに基づいてアクションを実行できます。Agentic RAG は、AI エージェントの自律性と検索拡張生成の原理を組み合わせ、LLM が応答を生成するために呼び出すツールとコンテキストとして使用するデータを選択できるようにします。RAG の詳細については、こちらをご覧ください。
フレームワークを選択する場合、なぜ AI-SDK を超えるのでしょうか?
利用可能な AI エージェント フレームワークは数多くあり、 CrewAI 、 AutoGen 、 LangGraphなどの人気のフレームワークについてはおそらく聞いたことがあるでしょう。これらのフレームワークのほとんどは、さまざまなモデルのサポート、ツールの使用、メモリ管理など、共通の機能セットを共有しています。
こちらは、Harrison Chase (LangChain CEO) によるフレームワーク比較シートです。
私が Mastra に興味を持ったのは、フルスタック開発者がエージェントをエコシステムに簡単に統合できるように構築された JavaScript ファーストのフレームワークであるという点です。Vercel の AI-SDK もこのほとんどを実行しますが、プロジェクトにさらに複雑なエージェント ワークフローが含まれている場合は、Mastra が真価を発揮します。Mastra は AI-SDK によって設定された基本パターンを強化しており、このプロジェクトではそれらを連携して使用します。
フレームワークとモデル選択の考慮事項
これらのフレームワークは AI エージェントを迅速に構築するのに役立ちますが、考慮すべき欠点もいくつかあります。たとえば、AI エージェントや一般的な抽象化レイヤー以外のフレームワークを使用する場合、制御が少し失われます。LLM がツールを正しく使用しなかったり、望ましくないことを実行したりする場合、抽象化によってデバッグが難しくなります。それでも、私の意見では、特にこれらのフレームワークは勢いを増しており、継続的に反復されているため、このトレードオフは、構築時に得られる容易さとスピードの価値があります。
繰り返しになりますが、これらのフレームワークはモデルに依存しません。つまり、さまざまなモデルをプラグ アンド プレイできます。モデルはトレーニングに使用されたデータ セットによって異なり、その結果、モデルが提供する応答も異なることに注意してください。一部のモデルではツールの呼び出しすらサポートされていません。したがって、さまざまなモデルを切り替えてテストし、どのモデルが最適な応答を返すかを確認することは可能ですが、それぞれのシステム プロンプトを書き換える必要がある可能性が高いことに注意してください。例えば、Llama3.3を使用する場合GPT-4o よりも、必要な応答を得るために、より多くのプロンプトと具体的な指示が必要になります。
NBAファンタジーバスケットボール
ファンタジー バスケットボールでは、友達のグループでリーグを開始し (グループの競争力に応じて、友情のステータスに影響する可能性があります)、通常はいくらかのお金が賭けられます。その後、各自が 10 人のプレイヤーでチームを編成し、毎週交互に他の友達の 10 人のプレイヤーと対戦します。全体のスコアに加算されるポイントは、特定の週に各プレイヤーが対戦相手に対して行ったパフォーマンスです。
チームの選手が負傷したり、出場停止になったりした場合は、チームに追加できるフリーエージェント選手のリストが表示されます。ファンタジー スポーツでは、選べる選手の数が限られており、誰もが常に最高の選手を選ぶために奔走しているため、ここで多くの難しい思考が生まれます。
これは、どの選手を選択するかをすぐに決定しなければならない状況で特に役立つ、NBA AI アシスタントの出番です。特定の対戦相手に対するプレーヤーのパフォーマンスを手動で調べる代わりに、アシスタントがそのデータをすばやく見つけて平均を比較し、情報に基づいた推奨事項を提供します。
エージェント RAG と NBA ファンタジー バスケットボールの基本がわかったので、実際に見てみましょう。
プロジェクトの構築
途中で行き詰まったり、最初から構築したくない場合は、リポジトリを参照してください。
取り上げる内容
プロジェクトの足場作り:
バックエンド (Mastra): npx create mastra@latest を使用してバックエンドをスキャフォールディングし、エージェント ロジックを定義します。
フロントエンド (Vite + React): npm create vite@latest を使用して、エージェントと対話するためのフロントエンド チャット インターフェイスを構築します。
環境変数の設定
環境変数を管理するには、dotenv をインストールします。
.envを作成するファイルを開き、必要な変数を指定します。
Elasticsearchの設定
Elasticsearch クラスターを起動します (ローカルまたはクラウド上)。
公式 Elasticsearch クライアントをインストールします。
環境変数にアクセスできることを確認します。
クライアントへの接続を確立します。
NBA データを Elasticsearch に一括取り込み
集計を有効にするには、適切なマッピングを使用してインデックスを作成します。
プレイヤーのゲーム統計を CSV ファイルから Elasticsearch インデックスに一括取り込みます。
Elasticsearchの集計を定義する
特定の対戦相手に対する過去の平均を計算するクエリ。
特定の対戦相手に対するシーズン平均を計算するクエリ。
プレーヤー比較ユーティリティファイル
ヘルパー関数と Elasticsearch 集計を統合します。
エージェントの構築
エージェント定義とシステム プロンプトを追加します。
zod をインストールし、ツールを定義します。
CORS を処理するためのミドルウェア設定を追加します。
フロントエンドの統合
AI-SDK の useChat を使用してエージェントと対話します。
適切にフォーマットされた会話を保持するための UI を作成します。
アプリケーションの実行
バックエンド (Mastra サーバー) とフロントエンド (React アプリ) の両方を起動します。
サンプルクエリと使用方法。
次はエージェントのさらなるインテリジェント化
セマンティック検索機能を追加して、より洞察力のある推奨を可能にします。
検索ロジックを Elasticsearch MCP (Model Context Protocol) サーバーに移動することで、動的クエリを有効にします。
要件
Node.js と npm : バックエンドとフロントエンドの両方が Node 上で実行されます。Node 18+ と npm v9+ (Node 18+ にバンドルされています) がインストールされていることを確認してください。
Elasticsearch クラスター:ローカルまたはクラウド上のアクティブな Elasticsearch クラスター。
OpenAI API キー: OpenAI 開発者ポータルのAPI キー ページで生成します。
プロジェクト構造

ステップ1:プロジェクトの足場作り
まず、nba-ai-assistant-js ディレクトリを作成し、次のコマンドを使用して内部に移動します。
mkdir nba-ai-assistant-js && cd nba-ai-assistant-jsバックエンド:
次のコマンドで Mastra 作成ツールを使用します。
npx create-mastra@latest2. ターミナルにいくつかのプロンプトが表示されます。最初のプロンプトでは、プロジェクトに backend という名前を付けます。

3. 次に、Mastra ファイルを保存するためのデフォルトの構造を維持するため、 src/を入力します。

4. 次に、デフォルトの LLM プロバイダーとして OpenAI を選択します。

5. 最後に、OpenAI API キーの入力が求められます。ここでは、スキップするオプションを選択し、後で .envファイルで提供します。

フロントエンド:
ルート ディレクトリに戻り、次のコマンドを使用してVite 作成ツールを実行します。
npm create vite@latest frontend -- --template react
これにより、React 専用のテンプレートを使用して、 frontendという名前の軽量 React アプリが作成されます。
すべてがうまくいけば、プロジェクト ディレクトリ内に、Mastra コードを保持するバックエンド ディレクトリと、React アプリを含むfrontendディレクトリが表示されるはずです。
ステップ2: 環境変数の設定
機密キーを管理するために、
dotenvパッケージを使用して.envから環境変数を読み込みます。ファイル。バックエンドディレクトリに移動してdotenvをインストールします。
cd backend
npm install dotenv --save2. バックエンド ディレクトリでは、適切な変数を入力するための example.env ファイルが提供されます。独自に作成する場合は、次の変数を必ず含めてください。
# OpenAI Configuration
OPENAI_API_KEY=your_openai_api_key_here
# Elasticsearch Configuration
ELASTIC_ENDPOINT=your_elasticsearch_endpoint_here
ELASTIC_API_KEY=your_elasticsearch_api_key_here注意: .env .gitignoreに追加して、このファイルがバージョン管理から除外されていることを確認してください。
ステップ3: Elasticsearchの設定
まず、アクティブな Elasticsearch クラスターが必要です。次の 2 つのオプションがあります。
オプションA: Elasticsearch Cloudを使用する
Elastic Cloudにサインアップ
新しいデプロイメントを作成する
エンドポイント URL と API キー(エンコード済み)を取得します
オプションB: Elasticsearchをローカルで実行する
Elasticsearchをローカルにインストールして実行する
エンドポイントとして http://localhost:9200 を使用します
APIキーを生成する
バックエンドに Elasticsearch クライアントをインストールする:
まず、バックエンド ディレクトリに公式 Elasticsearch クライアントをインストールします。
npm install @elastic/elasticsearch2. 次に、再利用可能な関数を保持するディレクトリ lib を作成し、そこに移動します。
mkdir lib && cd lib3. 内部にelasticClient.jsという新しいファイルを作成します。このファイルは Elasticsearch クライアントを初期化し、プロジェクト全体で使用できるように公開します。
4. ECMAScript モジュール (ESM) を使用しているため、 __dirname and __ファイル名は使用できません。環境変数が.envから正しく読み込まれていることを確認するにはバックエンド フォルダー内のファイルで、ファイルの先頭に次の設定を追加します。
import { config } from 'dotenv';
import { fileURLToPath } from 'url';
import { dirname, join } from 'path';
import { Client } from '@elastic/elasticsearch';
// Grab current directory and load .env from backend folder
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
const envPath = join(__dirname, '../.env');
// Load environment variables from the correct path
config({ path: envPath });5. 次に、環境変数を使用して Elasticsearch クライアントを初期化し、接続を確認します。
//Elastic client Initialization, make sure environment variables are being loaded in correctly
const config= {
node: `${process.env.ELASTIC_ENDPOINT}`,
auth: {
apiKey: `${process.env.ELASTIC_API_KEY}`,
},
};
export const elasticClient = new Client(config);
//Check if the client is connected
async function checkConnection() {
try {
const info = await elasticClient.info();
console.log('Elasticsearch is connected:', info);
} catch (error) {
console.error('Elasticsearch connection error:', error);
}
}
checkConnection();これで、このクライアント インスタンスを、Elasticsearch クラスターと対話する必要がある任意のファイルにインポートできます。
ステップ4: NBAデータをElasticsearchに一括取り込み
データセット:
このプロジェクトでは、リポジトリのbackend/dataディレクトリにあるデータセットを参照します。当社の NBA アシスタントは、このデータを知識ベースとして使用し、統計的な比較を実行し、推奨事項を生成します。
sample_player_game_stats.csv - サンプルプレーヤーのゲーム統計 (例: NBA キャリア全体におけるプレーヤーごとのゲームごとのポイント、リバウンド、スティールなど)。このデータセットを使用して集計を実行します。(注: これはデモ用に事前に生成された模擬データであり、公式 NBA ソースから取得されたものではありません。)
playerAndTeamInfo.js - 通常は API 呼び出しによって提供されるプレーヤーとチームのメタデータを置き換え、エージェントがプレーヤーとチームの名前を ID に一致できるようにします。サンプル データを使用しているため、外部 API から取得する際のオーバーヘッドを避け、エージェントが参照できるいくつかの値をハードコードしました。
実装:
backend/libディレクトリで、 playerDataIngestion.jsという名前のファイルを作成します。インポートを設定し、CSV ファイル パスを解決し、解析を設定します。ここでも、ESM を使用しているため、サンプル CSV へのパスを解決するには
__dirnameを再構築する必要があります。また、 Node.jsの組み込みモジュールfsとreadlineを使用して、指定された CSV ファイルを行ごとに解析します。
import fs from 'fs';
import readline from 'readline';
import path from 'path';
import { fileURLToPath } from 'url';
import { elasticClient } from './elasticClient.js';
const indexName = 'sample-nba-player-data'; //Replace with your preferred index name
//Since we are using ES modules __dirname and __filename don't exist, so this is a workaround that allows us to use the absolute file path for our sample data.
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const filePath = path.resolve(__dirname, '../data/sample_nba_data.csv');これにより、一括取り込み手順で CSV を効率的に読み取って解析できるようになります。
3. 適切なマッピングを使用してインデックスを作成します。Elasticsearch は動的マッピングを使用してフィールド タイプを自動的に推測できますが、ここでは各統計が数値フィールドとして扱われるように明示的に指定します。これらのフィールドは後で集計に使用するため、これは重要です。また、ポイントやリバウンドなどの統計情報には、小数値が含まれるようにするために、タイプfloat を使用します。最後に、Elasticsearch が認識されないフィールドを動的にマッピングしないように、マッピング プロパティdynamic: 'strict'を追加します。
// Function to create an index with mappings
async function createIndex() {
try {
// Check if the index already exists
const exists = await elasticClient.indices.exists({ index: indexName });
if (exists) {
console.log(`Index "${indexName}" already exists, deleting it now.`);
await elasticClient.indices.delete({ index: indexName });
console.log(`Deleted index "${indexName}".`);
}
// Create the index with mappings
const response = await elasticClient.indices.create({
index: indexName,
body: {
mappings: {
dynamic: 'strict', // Prevent dynamic mapping
properties: {
game_id: { type: 'integer' },
game_date: { type: 'date' },
player_id: { type: 'integer' },
player_full_name: { type: 'text' },
player_team_id: { type: 'integer' },
player_team_name: { type: 'text' },
home_team: { type: 'boolean' },
opponent_team_id: { type: 'integer' },
opponent_team_name: { type: 'text' },
points: { type: 'float' },
rebounds: { type: 'float' },
assists: { type: 'float' },
steals: { type: 'float' },
blocks: { type: 'float' },
fg_percentage: { type: 'float' },
minutes_played: { type: 'float' },
},
},
},
});
console.log('Index created:', response);
return true;
} catch (error) {
console.error('Error creating index:', error);
return false;
}
}4. CSV データを Elasticsearch インデックスに一括で取り込む機能を追加します。コード ブロック内では、ヘッダー行をスキップします。次に、各行項目をコンマで分割し、ドキュメント オブジェクトにプッシュします。このステップでは、それらをクリーンアップし、適切なタイプであることを確認します。次に、ドキュメントをインデックス情報とともに bulkBody 配列にプッシュします。これは、Elasticsearch への一括取り込みのペイロードとして機能します。
async function bulkIngestCsv(filePath) {
const readStream = fs.createReadStream(filePath);
const rl = readline.createInterface({
input: readStream,
crlfDelay: Infinity,
});
const bulkBody = [];
let lineNum = 0;
//Skip the header line
let headerLine = true;
for await (const line of rl) {
if (headerLine) {
headerLine = false;
continue;
}
lineNum++;
// Split the line by comma and remove whitespace
const [
game_id,
game_date,
player_id,
player_full_name,
player_team_id,
player_team_name,
home_team,
opponent_team_id,
opponent_team_name,
points,
rebounds,
assists,
steals,
blocks,
fg_percentage,
minutes_played,
] = line.split(',');
// Create a document object
const document = {
game_id: parseInt(game_id),
game_date: game_date.trim(),
player_id: parseInt(player_id),
player_full_name: player_full_name.trim(),
player_team_id: parseInt(player_team_id),
player_team_name: player_team_name.trim(),
home_team: home_team.trim() === 'True', // Converts True/False into a boolean
opponent_team_id: parseInt(opponent_team_id),
opponent_team_name: opponent_team_name.trim(),
points: parseFloat(points),
rebounds: parseFloat(rebounds),
assists: parseFloat(assists),
steals: parseFloat(steals),
blocks: parseFloat(blocks),
fg_percentage: parseFloat(fg_percentage),
minutes_played: parseFloat(minutes_played),
};
// Prepare the bulk operation format
bulkBody.push({ index: { _index: indexName } });
bulkBody.push(document);
}
console.log(`Parsed ${lineNum} lines from CSV`);5.次に、 elasticClient.bulk()で Elasticsearch のBulk API を使用して、1 回のリクエストで複数のドキュメントを取り込むことができます。以下のエラー処理は、取り込みに失敗したドキュメントの数と、取り込みに成功したドキュメントの数を示すように構成されています。
try {
// Perform the bulk request
const response = await elasticClient.bulk({ body: bulkBody });
if (response.errors) {
console.log('Bulk Ingestion had some hiccups:');
// Count successful vs failed operations
let successCount = 0;
let errorCount = 0;
const errorDetails = [];
response.items.forEach((item, index) => {
const operation = item.index || item.create || item.update || item.delete;
if (operation.error) {
errorCount++;
errorDetails.push({
document: index + 1,
error: operation.error,
});
} else {
successCount++;
}
});
console.log(`Successfully indexed: ${successCount} documents`);
console.log(`Failed to index: ${errorCount} documents, here are the details`, errorDetails);
} else {
console.log(`Bulk Ingestion fully successful!`);
}
} catch (error) {
console.error('Error performing bulk ingestion:', error);
}
}6. 以下のmain()関数を実行して、 createIndex()関数とbulkIngestCsv()関数を順番に実行します。
// Run this function
async function main() {
const result = await createIndex();
if (!result) {
console.error('Index setup failed. Aborting.');
return;
}
await bulkIngestCsv(filePath);
console.log('Bulk ingestion completed!');
}
main();一括取り込みが成功したことを示すコンソール ログが表示された場合は、Elasticsearch インデックスを簡単にチェックして、ドキュメントが実際に正常に取り込まれたかどうかを確認します。
ステップ5: Elasticsearchの集計の定義と統合
これらは、プレイヤーの統計を相互に比較するために AI エージェントのツールを定義するときに使用される主な関数になります。
1. backend/libディレクトリに移動し、 elasticAggs.jsというファイルを作成します。
2. 特定の対戦相手に対するプレイヤーの過去の平均を計算するには、以下のクエリを追加します。このクエリでは、2 つの条件(1 つはplayer_idに一致し、もう 1 つはopponent_team_idに一致する)を持つboolフィルターを使用して、関連するゲームのみを取得します。ドキュメントを返す必要はなく、集計のみを対象とするため、 size:0を設定します。aggsブロックでは、 points, rebounds, assists, steals, blocksやfg_percentageなどのフィールドに対して複数のメトリック集計を並行して実行し、平均値を計算します。LLM は計算で成功するか失敗するかのどちらかですが、このプロセスは Elasticsearch にオフロードされ、NBA AI アシスタントが正確なデータにアクセスできるようになります。
export async function getHistoricalAveragesAgainstOpponent(player_id, opponent_team_id) {
try {
//Query for Historical Averages
const historicalQuery = await elasticClient.search({
index: 'sample-nba-player-data',
size: 0,
query: {
bool: {
must: [
{
term: {
player_id: {
value: player_id,
},
},
},
{
term: {
opponent_team_id: {
value: opponent_team_id,
},
},
},
],
},
},
aggs: {
avg_points: { avg: { field: 'points' } },
avg_rebounds: { avg: { field: 'rebounds' } },
avg_assists: { avg: { field: 'assists' } },
avg_steals: { avg: { field: 'steals' } },
avg_blocks: { avg: { field: 'blocks' } },
avg_fg_percentage: { avg: { field: 'fg_percentage' } },
},
});
return {
points: historicalQuery.aggregations.avg_points.value || 0,
rebounds: historicalQuery.aggregations.avg_rebounds.value || 0,
assists: historicalQuery.aggregations.avg_assists.value || 0,
steals: historicalQuery.aggregations.avg_steals.value || 0,
blocks: historicalQuery.aggregations.avg_blocks.value || 0,
fgPercentage: historicalQuery.aggregations.avg_fg_percentage.value || 0,
};
} catch (error) {
console.error('Query error from getHistoricalAveragesAgainstOpponent function:', error);
return { error: 'Queries failed in getting historical averages against opponent.' };
}
}3. 特定の対戦相手に対するプレーヤーのシーズン平均を計算するには、履歴クエリとほぼ同じクエリを使用します。このクエリの唯一の違いは、 boolフィルターにgame_dateの追加条件があることです。フィールドgame_dateは、現在の NBA シーズンの範囲内に収まる必要があります。この場合、範囲は2024-10-01から2025-06-30の間になります。以下の追加条件により、後続の集計で今シーズンのゲームのみが分離されることが保証されます。
{
range: {
//Range for this season, change to match current season
game_date: {
gte: '2024-10-01',
lte: '2025-06-30',
},
},ステップ6: プレーヤー比較ユーティリティ
コードをモジュール化して保守しやすい状態に保つために、メタデータ ヘルパー関数と Elasticsearch 集計を統合するユーティリティ ファイルを作成します。これにより、エージェントが使用するメイン ツールが強化されます。詳細は後述します。
1. backend/libディレクトリに新しいファイルcomparePlayers.jsを作成します。
2. 以下の関数を追加して、メタデータ ヘルパーと Elasticsearch 集約ロジックを、エージェントが使用するメイン ツールを強化する単一の関数に統合します。
import { playersByName } from '../data/playerAndTeamInfo.js';
import { teamsByName } from '../data/playerAndTeamInfo.js';
import { upcomingMatchups } from '../data/playerAndTeamInfo.js';
import { getHistoricalAveragesAgainstOpponent } from './elasticAggs.js';
import { getSeasonAveragesAgainstOpponent } from './elasticAggs.js';
//Simple helper functions to simulate API calls for player and team metadata. These reference the hardcoded values from playerAndTeamInfo.js in the data directory
export function getPlayerInfo(playerFullName) {
return playersByName[playerFullName];
}
export function getTeamID(teamFullName) {
return teamsByName[teamFullName];
}
export function getUpcomingMatchups(teamId) {
return upcomingMatchups[teamId];
}
//Main function used by the 'playerComparisonTool' agent tool
export async function comparePlayersForNextMatchup(player1Name, player2Name) {
//Get Player Info
const player1Info = getPlayerInfo(player1Name);
const player2Info = getPlayerInfo(player2Name);
//Get upcoming matchups
const player1NextGame = getUpcomingMatchups(player1Info.team_id)[0];
const player2NextGame = getUpcomingMatchups(player2Info.team_id)[0];
//Get season and historical averages against next opponent for player 1
const player1SeasonAverages = await getSeasonAveragesAgainstOpponent(
player1Info.player_id,
player1NextGame.opponent_team_id
);
const player1HistoricalAverages = await getHistoricalAveragesAgainstOpponent(
player1Info.player_id,
player1NextGame.opponent_team_id
);
//Get season and historical averages against next opponent for player 2
const player2SeasonAverages = await getSeasonAveragesAgainstOpponent(
player2Info.player_id,
player2NextGame.opponent_team_id
);
const player2HistoricalAverages = await getHistoricalAveragesAgainstOpponent(
player2Info.player_id,
player2NextGame.opponent_team_id
);
const player1 = {
name: player1Name,
playerId: player1Info.player_id,
teamId: player1Info.team_id,
nextOpponent: {
teamId: player1NextGame.opponent_team_id,
teamName: player1NextGame.opponent_team_name,
home: player1NextGame.home,
},
stats: {
seasonAverages: player1SeasonAverages,
historicalAverages: player1HistoricalAverages,
},
};
const player2 = {
name: player2Name,
playerId: player2Info.player_id,
teamId: player2Info.team_id,
nextOpponent: {
teamId: player2NextGame.opponent_team_id,
teamName: player2NextGame.opponent_team_name,
home: player2NextGame.home,
},
stats: {
seasonAverages: player2SeasonAverages,
historicalAverages: player2HistoricalAverages,
},
};
return [player1, player2];
}ステップ7: エージェントの構築
フロントエンドとバックエンドのスキャフォールディングを作成し、NBA ゲームデータを取り込み、Elasticsearch への接続を確立したので、すべてのピースをまとめてエージェントを構築し始めることができます。
エージェントの定義
1. backend/src/mastra/agentsディレクトリ内のindex.tsファイルに移動し、エージェント定義を追加します。次のようなフィールドを指定できます。
名前:フロントエンドで呼び出されたときに参照として使用されるエージェントの名前を指定します。
指示/システム プロンプト:システム プロンプトは、対話中に従うべき初期コンテキストとルールを LLM に提供します。これは、ユーザーがチャット ボックスを通じて送信するプロンプトに似ていますが、こちらはユーザー入力の前に表示されます。繰り返しになりますが、これは選択したモデルに応じて変わります。
モデル:使用する LLM (Mastra は OpenAI、Anthropic、ローカル モデルなどをサポートしています)。
ツール:エージェントが呼び出すことができるツール関数のリスト。
メモリ: (オプション) エージェントに会話履歴などを記憶させたい場合。簡単にするために、Mastra は永続メモリをサポートしていますが、永続メモリなしで開始できます。
import { openai } from '@ai-sdk/openai';
import { Agent } from '@mastra/core/agent';
import { playerComparisonTool } from '../tools';
export const basketballAgent = new Agent({
name: 'Basketball Agent',
instructions: `
You are a NBA Basketball expert.
Your primary function is to compare two NBA players and recommend which one is the better fantasy pickup.
Only compare players from the following list:
- LeBron James
- Stephen Curry
- Jayson Tatum
- Jaylen Brown
- Nikola Jokic
- Luka Doncic
- Kyrie Irving
- Anthony Davis
- Kawhi Leonard
- Russell Westbrook
Input Handling Rules:
- If the user asks about a player that is not on this list, respond with the list of available players for comparison.
- If the user only inputs one player, ask the user to add another player from the list provided.
- If the user inputs a player with the wrong spelling or capitalizations, infer from the list of available players provided.
- IMPORTANT: If the user asks a question or asks you to generate a response about anything outside of basketball or the scope of this project, DO NOT answer and affirm you can only talk about basketball.
Tool Usage:
- Extract and standardize player names to match the list exactly.
- Use the playerComparisonTool, passing both names as strings.
- The tool will return an object with game information, stats, and analysis.
Format your response using Markdown syntax. Use:
Example output format:
#### Next Game Info
- ***LeBron James** vs Warriors, May 24 (Home)
- ***Stephen Curry** vs Lakers, May 24 (Away)
#### Stats Comparison
\`\`\`
Stat LeBron James (vs Warriors) Stephen Curry (vs Lakers)
-------------------- ----------------------------- ----------------------------
Historical Points 28.3 30.3
Historical Assists 6.7 8.7
Season Points 28.8 23.3
Season Assists 6.2 4.7
\`\`\`
#### Fantasy Recommendation
Explain which player is the better fantasy pickup and why.
`,
model: openai('gpt-4o'),
tools: { playerComparisonTool },
});ツールの定義
backend/src/mastra/toolsディレクトリ内のindex.tsファイルに移動します。次のコマンドを使用して Zod をインストールします。
npm install zod3. ツール定義を追加します。このツールを呼び出すときにエージェントが使用するメイン関数として、 comparePlayers.jsファイル内の関数をインポートすることに注意してください。Mastra のcreateTool()関数を使用して、 playerComparisonToolを登録します。フィールドには次のものが含まれます。
id: これは、エージェントがツールの機能を理解するのに役立つ自然言語による説明です。input schema: ツールの入力の形状を定義するために、Mastra は TypeScript スキーマ検証ライブラリであるZodスキーマを使用します。Zod は、エージェントが正しく構造化された入力を入力したことを確認し、入力構造が一致しない場合はツールが実行されないようにすることで役立ちます。description: これは、エージェントがいつ電話をかけてツールを使用するかを理解するのに役立つ自然言語による説明です。execute: ツールが呼び出されたときに実行されるロジック。私たちの場合、インポートしたヘルパー関数を使用してパフォーマンス統計を返します。
import { comparePlayersForNextMatchup } from '../../../lib/comparePlayers.js'
import { createTool } from "@mastra/core/tools";
import { z } from "zod";
export const playerComparisonTool = createTool({
id: "Compare two NBA players",
inputSchema: z.object({
player1:z.string(),
player2:z.string()
}),
description: "Use this tool to compare two players given in the user prompt.",
execute: async ({ context: { player1, player2 } }) => {
return await comparePlayersForNextMatchup(player1, player2);
},
})CORSを処理するミドルウェアの追加
CORS を処理するために、Mastra サーバーにミドルウェアを追加します。人生には避けられないことが 3 つあると言われています。死、税金、そして Web 開発者にとっては CORS です。簡単に言うと、クロスオリジン リソース共有は、フロントエンドが別のドメインまたはポートで実行されているバックエンドにリクエストを送信するのをブロックするブラウザのセキュリティ機能です。バックエンドとフロントエンドの両方をローカルホストで実行しているにもかかわらず、それらは異なるポートを使用するため、CORS ポリシーがトリガーされます。バックエンドがフロントエンドからのリクエストを許可するように、 Mastra ドキュメントで指定されているミドルウェアを追加する必要があります。
1. backend/src/mastraディレクトリ内のindex.tsファイルに移動し、CORS の設定を追加します。
origin: ['http://localhost:5173']このアドレス(Vite のデフォルト アドレス)からのリクエストのみを許可します
allowMethods: ["GET", "POST"]許可される HTTP メソッド。ほとんどの場合、POST が使用されます。
allowHeaders: ["Content-Type", "Authorization", "x-mastra-client-type, "x-highlight-request", "traceparent"],これらはリクエストで使用できるカスタムヘッダーを決定します
import { Mastra } from '@mastra/core/mastra';
import { basketballAgent } from './agents';
console.log('Starting Mastra server...');
export const mastra = new Mastra({
agents: { basketballAgent },
server:{
timeout: 10 * 60 * 1000, // 10 minutes
cors: {
origin: ['http://localhost:5173'],
allowMethods: ["GET", "POST"],
allowHeaders: [
"Content-Type",
"Authorization",
"x-mastra-client-type",
"x-highlight-request",
"traceparent",
],
exposeHeaders: ["Content-Length", "X-Requested-With"],
credentials: false,
},
},
});
console.log('Mastra server configured.'); // Log after server configurationステップ8: フロントエンドの統合
この React コンポーネントは、 @ai-sdk/reactのuseChat()フックを使用して Mastra AI エージェントに接続するシンプルなチャット インターフェースを提供します。このフックを使用して、トークンの使用状況やツールの呼び出しを表示したり、会話をレンダリングしたりします。上記のシステム プロンプトでは、エージェントに応答をマークダウンで出力するように要求しているため、 react-markdownを使用して応答を適切にフォーマットします。
1.フロントエンド ディレクトリにいる間に、useChat() フックを使用するために @ai-sdk/react パッケージをインストールします。
npm install @ai-sdk/react2. 同じディレクトリで、React Markdown をインストールして、エージェントが生成する応答を適切にフォーマットできるようにします。
npm install react-markdown3. useChat()を実装します。このフックは、フロントエンドと AI エージェントのバックエンド間のやり取りを管理します。メッセージの状態、ユーザー入力、ステータスを処理し、監視の目的でライフサイクル フックを提供します。渡すオプションは次のとおりです。
api:これは、Mastra AI エージェントのエンドポイントを定義します。デフォルトではポート 4111 に設定されており、ストリーミング応答をサポートするルートも追加する必要があります。onToolCall: これは、エージェントがツールを呼び出すたびに実行されます。エージェントがどのツールを呼び出しているかを追跡するために使用します。onFinish: エージェントが完全な応答を完了した後に実行されます。ストリーミングを有効にしても、onFinish各チャンクの後ではなく、完全なメッセージが受信された後に実行されます。ここでは、トークンの使用状況を追跡するためにこれを使用しています。これは、LLM コストを監視して最適化するときに役立ちます。
4. 最後に、 frontend/componentsディレクトリのChatUI.jsxコンポーネントに移動して、会話を行うための UI を作成します。次に、エージェントからの応答を適切にフォーマットするために、応答をReactMarkdownコンポーネントでラップします。
import React, { useState } from 'react';
import { useChat } from '@ai-sdk/react';
import ReactMarkdown from 'react-markdown';
export default function ChatUI() {
const [totalTokenUsage, setTotalTokenUsage] = useState(0);
const [promptTokenUsage, setPromptTokenUsage] = useState(0);
const [completionTokenUsage, setCompletionTokenUsage] = useState(0);
const [toolsCalled, setToolsCalled] = useState([]);
const { messages, input, handleInputChange, handleSubmit, status } = useChat({
api: 'http://localhost:4111/api/agents/basketballAgent/stream', //Replace with your own endpoint for your agent
id: 'my-chat-session',
//Optional parameter to check agent tool calls
onToolCall: ({ toolCall }) => {
setToolsCalled((prev) => [...prev, toolCall.toolName]);
},
//Optional parameter to check token usages
onFinish: (message, { usage }) => {
setTotalTokenUsage((prev) => prev + usage.totalTokens);
setPromptTokenUsage((prev) => prev + usage.promptTokens);
setCompletionTokenUsage((prev) => prev + usage.completionTokens);
},
//Optional parameter for error handling
onError: (error) => {
console.error('Agent error:', error);
},
});
return (
<div>
<div className="agent-info">
<h4 className="stats-title">What's My Agent Doing?</h4>
<div className="stats-box">
<strong className="stats-sub-title">Tools Called:</strong>
<ul className="tool-list">
{toolsCalled.map((tool, idx) => (
<li key={idx}>{tool}</li>
))}
{toolsCalled.length === 0 && <li>No tools called yet.</li>}
</ul>
<div className="usage-stats">
<p>Prompt Token Usage: {promptTokenUsage}</p>
<p>Completion Token Usage: {completionTokenUsage}</p>
<p>Total Token Usage: {totalTokenUsage}</p>
</div>
</div>
</div>
<strong>Conversation:</strong>
<div className="convo-box">
{messages.map((msg) => (
<div key={msg.id} className="message-item">
<strong className="message-role">{msg.role === 'assistant' ? 'Basketbot' : 'You'}:</strong>
<ReactMarkdown>{msg.content}</ReactMarkdown>
</div>
))}
</div>
<form onSubmit={handleSubmit}>
<input
type="text"
value={input}
onChange={handleInputChange}
placeholder="Input two players you want to compare."
className="input-box"
/>
<button type="submit" disabled={status === 'streaming'}>
{status === 'streaming' ? 'Thinking...' : 'Send'}
</button>
</form>
</div>
);
}ステップ9: アプリケーションの実行
おめでとうございます!これでアプリケーションを実行する準備が整いました。バックエンドとフロントエンドの両方を起動するには、次の手順に従います。
ターミナル ウィンドウで、ルート ディレクトリからバックエンド ディレクトリに移動し、Mastra サーバーを起動します。
cd backend
npm run dev2. 別のターミナル ウィンドウで、ルート ディレクトリからフロントエンド ディレクトリに移動し、React アプリを起動します。
cd frontend
npm run dev3. ブラウザで次の場所に移動します。
チャット インターフェースが表示されるはずです。次のサンプルプロンプトを試してみてください。
「レブロン・ジェームズとステフィン・カリーを比較」
「ジェイソン・テイタムとルカ・ドンチッチのどちらを選ぶべきでしょうか?」
次はエージェントのさらなるインテリジェント化
アシスタントをよりエージェント的にし、推奨事項をより洞察力のあるものにするために、次のイテレーションでいくつかの重要なアップグレードを追加する予定です。
NBAニュースのセマンティック検索
プレーヤーのパフォーマンスに影響を与える要因は数多くありますが、その多くは生の統計には表示されません。負傷報告、ラインナップの変更、さらには試合後の分析などは、ニュース記事でしか見つけることができません。この追加のコンテキストを捉えるために、エージェントが関連する NBA の記事を取得し、その内容を推奨事項に組み込めるよう、セマンティック検索機能を追加します。
Elasticsearch MCPサーバーによる動的検索
MCP (モデル コンテキスト プロトコル) は、エージェントがデータ ソースに接続する方法の標準として急速に普及しつつあります。検索ロジックを Elasticsearch MCP サーバーに移行します。これにより、エージェントは、私たちが提供する定義済みの検索機能に頼るのではなく、動的にクエリを構築できるようになります。これにより、より自然な言語ワークフローを使用できるようになり、すべての検索クエリを手動で記述する必要性が軽減されます。Elasticsearch MCP サーバーとエコシステムの現在の状態の詳細については、こちらをご覧ください。
これらの変更はすでに進行中ですので、お楽しみに!
まとめ
このブログでは、JavaScript、Mastra、Elasticsearch を使用して、ファンタジー バスケットボール チームに合わせた推奨事項を提供するエージェント RAG アシスタントを構築しました。取り上げた内容:
エージェント RAG の基礎と、AI エージェントの自律性と RAG を効果的に使用するツールを組み合わせることで、より繊細で動的なエージェントを実現できる方法について説明します。
Elasticsearchとそのデータ ストレージ機能および強力なネイティブ集約により、それが LLM のナレッジ ベースとして優れたパートナーとなる理由について説明します。
Mastraフレームワークと、それが JavaScript エコシステムの開発者にとってこれらのエージェントの構築をどのように簡素化するかについて説明します。
あなたがバスケットボールの熱狂的なファンであっても、AI エージェントの構築方法を検討している方であっても、あるいは私のようにその両方であっても、このブログが、始めるための基礎を提供できれば幸いです。完全なリポジトリはGitHubで入手できます。自由にクローンして改良してください。さあ、ファンタジーリーグで優勝しましょう!




