使用 JavaScript、Mastra 和 Elasticsearch 构建代理 RAG 助手
了解如何在 JavaScript 生态系统中构建人工智能代理
我是在激烈的高风险梦幻篮球联赛中萌生这个想法的。我想知道我能否建立一个人工智能代理,帮助我在每周的对阵中占据优势?当然可以!
在本篇文章中,我们将探讨如何使用Mastra和一个轻量级 JavaScript 网络应用程序来构建一个代理 RAG 助手,并与其进行交互。通过将该代理连接到 Elasticsearch,我们可以让它访问结构化的球员数据,并能够运行实时统计汇总,从而为您提供基于球员统计数据的推荐。请访问 GitHub软件源,了解如何克隆和运行应用程序;README提供了相关说明。
下面是全部组装好后的样子:

注:本博文以 "使用 AI SDK 和 Elastic 构建 AI 代理"为基础。如果您是第一次接触人工智能代理及其用途,请从这里开始。
结构概述
该系统的核心是一个大型语言模型(LLM),它充当了代理的推理引擎(大脑)。它能解释用户输入,决定调用哪些工具,并协调生成相关响应所需的步骤。
代理本身由 JavaScript 生态系统中的代理框架 Mastra 搭建脚手架。Mastra 将 LLM 与后端基础设施封装在一起,将其作为 API 端点公开,并提供了一个用于定义工具、系统提示和代理行为的接口。
在前端,我们使用Vite快速搭建了一个 React 网络应用程序,它提供了一个聊天界面,用于向代理发送查询并接收其回复。
最后,我们还有 Elasticsearch,它存储了代理可以查询和汇总的球员统计数据和对阵数据。

背景
让我们来回顾一下几个基本概念:
什么是代理 RAG?
人工智能代理可以与其他系统互动,独立运行,并根据其定义的参数执行操作。代理式 RAG 将人工智能代理的自主性与检索增强生成的原则相结合,使 LLM 能够选择调用哪些工具和使用哪些数据作为上下文来生成响应。点击此处了解有关 RAG 的更多信息。
选择框架,为什么要超越 AI-SDK?
目前有许多人工智能代理框架,你可能听说过CrewAI、AutoGen和LangGraph 等比较流行的框架。这些框架大多有一套共同的功能,包括支持不同的模型、工具使用和内存管理。
下面是哈里森-蔡斯(LangChain 首席执行官)的框架比较表。
让我对 Mastra 产生兴趣的是,它是一个 JavaScript 优先框架,专为全栈开发人员设计,可以轻松地将代理集成到他们的生态系统中。Vercel 的 AI-SDK 也能实现大部分功能,但 Mastra 的优势在于当项目包含更复杂的代理工作流程时。Mastra 增强了 AI-SDK 设置的基本模式,在本项目中,我们将同时使用它们。
框架和模型选择考虑因素
虽然这些框架可以帮助您快速构建人工智能代理,但也有一些缺点需要考虑。例如,在使用人工智能代理或任何抽象层之外的其他框架时,你会失去一些控制权。如果 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 与代理互动。
创建用户界面,以保存格式正确的对话。
运行应用程序
同时启动后端(Mastra 服务器)和前端(React 应用程序)。
查询和使用示例。
下一步是什么?让代理更智能
增加语义搜索功能,提供更具洞察力的建议。
将搜索逻辑移至 Elasticsearch MCP(模型上下文协议)服务器,从而启用动态查询。
准备工作
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.你的终端应该会收到一些提示,第一个提示是命名项目后台:

3.接下来,我们将保留存储 Mastra 文件的默认结构,因此输入src/.

4.然后,我们将选择 OpenAI 作为默认的 LLM 提供商。

5.最后,它会要求你提供 OpenAI API 密钥。现在,我们选择跳过选项,稍后在 .env 文件中提供。

前台
返回根目录,使用此命令运行Vite 创建工具:
npm create vite@latest frontend -- --template react
这将创建一个名为frontend 的轻量级 React 应用程序,并为 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注意:通过在.gitignore 中添加.env ,确保将此文件排除在版本控制之外。
第 3 步:设置 Elasticsearch
首先,您需要一个活动的 Elasticsearch 集群。有两种选择:
选项 A:使用 Elasticsearch 云
注册弹性云
创建新的部署
获取端点 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
数据集:
在本项目中,我们将引用软件版本中后端/数据目录下的数据集。我们的 NBA 助手将以这些数据为知识基础,进行统计比较并生成建议。
sample_player_game_stats.csv- NBA 球员职业生涯的球员比赛统计数据样本(如得分、篮板、抢断等)。我们将使用该数据集进行聚合。(注:这是模拟数据,为演示目的而预先生成,并非来自 NBA 官方来源)。
playerAndTeamInfo.js- 替代通常由应用程序接口调用提供的球员和球队元数据,以便代理能将球员和球队名称与 ID 匹配。由于我们使用的是样本数据,我们不希望从外部应用程序接口获取数据造成开销,因此我们硬编码了一些代理可以引用的值。
实施:
在
backend/lib目录中,创建名为playerDataIngestion.js 的文件。设置导入、解析 CSV 文件路径并设置解析。同样,由于我们使用的是 ESM,因此需要重构
__dirname来解析 CSV 样本的路径。此外,我们还将导入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 类型,以确保包含小数值。最后,我们要添加映射属性dynamic: 'strict' ,这样 Elasticsearch 就不会动态映射未识别的字段。
// 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 的批量 API,在一次请求中摄取多个文档。下面的错误处理结构可以让你计算有多少文档未能被摄取,有多少文档被成功摄取。
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 聚合和合并
这些将是我们为人工智能代理定义工具时使用的主要功能,以便对球员的统计数据进行比较。
1.导航至backend/lib 目录,创建名为elasticAggs.js 的文件。
2.添加下面的查询,计算球员对特定对手的历史平均分。该查询使用bool 过滤器,其中包含两个条件:一个匹配player_id ,另一个匹配opponent_team_id ,以便只检索相关游戏。我们不需要返回任何文档,我们只关心聚合,因此我们设置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 使用了Zod模式,这是一个 TypeScript 模式验证库。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
在 Mastra 服务器中添加中间件以处理CORS。俗话说,人生有三件事无法避免:死亡、税收,而对于网络开发人员来说,就是 CORS。简而言之,跨源资源共享是一种浏览器安全功能,可阻止前台向运行在不同域或端口的后台发出请求。尽管我们在 localhost 上运行后端和前端,但它们使用不同的端口,从而触发了 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 代理。我们还将使用此钩子来显示标记的使用情况、工具调用情况并渲染对话。在上面的系统提示中,我们还要求代理以 markdown 格式输出响应,因此我们将使用react-markdown 来正确格式化响应。
1.在前端目录中,安装 @ai-sdk/react 软件包以使用 useChat() 钩子。
npm install @ai-sdk/react2.在同一目录下,安装 React Markdown,这样我们就能正确格式化代理生成的响应。
npm install react-markdown3.实施useChat() 。此钩子将管理前台与人工智能代理后台之间的交互。它可以处理消息状态、用户输入和状态,并为您提供生命周期钩子,以实现可观察性。我们提供的选项包括
api:这定义了 Mastra AI 代理的端点。默认端口为 4111,我们还要添加支持流式响应的路由。onToolCall:在代理调用工具时执行;我们用它来跟踪代理调用了哪些工具。onFinish:在代理完成完整响应后执行。尽管我们启用了流式传输,但onFinish仍将在收到完整报文后运行,而不是在每个分块后运行。在这里,我们用它来跟踪令牌的使用情况。这对监控 LLM 成本和优化成本很有帮助。
4.最后,前往frontend/components 目录中的ChatUI.jsx组件,创建用户界面来进行对话。接下来,用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 的基本原理,以及如何将人工智能代理的自主性与有效使用 RAG 的工具相结合,从而产生更细致入微、更具活力的代理。
Elasticsearch 及其数据存储能力和强大的本地聚合功能如何使其成为法律硕士知识库的最佳合作伙伴。
Mastra 框架及其如何为 javaScript 生态系统中的开发人员简化这些代理的构建。
无论你是篮球迷,还是在探索如何构建人工智能代理,或者像我一样两者兼而有之,我都希望这篇博客能为你提供一些入门的基础知识。完整的软件源可在GitHub 上获取,请随意克隆和修补。现在,去赢得梦幻联赛吧!




