作者:来自 Elastic JD Armada

了解如何在 JavaScript 生态系统中构建 AI 代理。

Elasticsearch 与业界领先的生成式 AI 工具和服务商有原生集成。查看我们的网络研讨会,了解如何超越 RAG 基础,或使用 Elastic 向量数据库构建可投入生产的应用。

为了为你的使用场景构建最佳搜索解决方案,现在就开始免费云端试用,或者在本地机器上试用 Elastic。


这个想法是在一场激烈且充满赌注的幻想篮球联赛中冒出来的。我在想:我能否构建一个 AI 代理,帮助我主宰每周的对决?当然可以!

在这篇文章中,我们将探索如何使用 Mastra 和一个轻量级的 JavaScript Web 应用来构建一个具备代理能力的 RAG 助手,并与它进行交互。通过将这个代理连接到 Elasticsearch,我们为它提供了访问结构化球员数据的能力,并能执行实时统计聚合,从而为你提供基于球员数据的推荐。前往 GitHub 仓库查看详情;README 文件提供了如何克隆并在本地运行该应用的说明。

当所有部分组装完成后,它应该是这样的:

注意:这篇博文是在《使用 AI SDK 和 Elastic 构建 AI 代理》的基础上扩展的。如果你对 AI 代理还不熟悉,或者想了解它们可以用于什么场景,建议先阅读那篇文章。

架构概览

系统的核心是一种大型语言模型(LLM),它充当代理的推理引擎(大脑)。它负责解释用户输入,决定调用哪些工具,并协调生成相关响应所需的步骤。

代理本身是由 Mastra 搭建的,它是 JavaScript 生态中的一个代理框架。Mastra 将 LLM 封装进后端基础架构,作为一个 API 端点暴露出来,并提供一个界面,用于定义工具、系统提示词和代理行为。

在前端,我们使用 Vite 快速搭建一个 React Web 应用,为用户提供一个聊天界面,用于发送查询给代理并接收其响应。

最后,我们有 Elasticsearch,用于存储球员统计和对阵数据,供代理查询和聚合使用。

背景

让我们先了解一些基本概念:

什么是 agentic RAG?

AI 代理可以与其他系统交互,独立运行,并根据其定义的参数执行操作。Agentic RAG 将 AI 代理的自主性与检索增强生成(retrieval augmented generation - RAG)的原理相结合,使 LLM 能够选择调用哪些工具、使用哪些数据作为上下文来生成响应。你可以在这里关于 RAG 的内容。

选择框架,为什么要超越 AI-SDK?

目前市面上有许多 AI 代理框架,你可能听说过比较流行的,比如 CrewAI、AutoGen 和 LangGraph。这些框架大多具有一套通用功能,包括对不同模型的支持、工具使用和记忆管理等。

以下是 LangChain CEO Harrison Chase 提供的一份框架对比表。

我之所以对 Mastra 感兴趣,是因为它是一个面向 JavaScript 的框架,为全栈开发者设计,可以轻松将代理集成进他们的开发生态。Vercel 的 AI-SDK 也能完成大部分工作,但 Mastra 的优势在于处理更复杂的代理工作流时更胜一筹。Mastra 在 AI-SDK 提供的基础模式上进行了增强,在这个项目中,我们将二者结合使用。

关于框架和模型选择的考量

虽然这些框架可以帮助你快速构建 AI 代理,但也存在一些缺点。例如,当你使用这些抽象层而不是自己动手实现时,会失去一些控制权。如果 LLM 没有正确调用工具或做出不符合预期的行为,由于抽象层的存在,调试会更加困难。尽管如此,我认为这种权衡是值得的,因为这些框架开发速度快,生态正在快速发展。

此外,这些框架都是模型无关的,也就是说你可以自由切换不同的模型,但要记住,各个模型训练的数据集不同,因此响应表现也会不同。有些模型甚至不支持调用工具。所以你可以尝试多个模型,看看哪个效果最好,不过你很可能需要为每个模型重新编写系统提示词。例如,如果你想用 Llama3.3 替代 GPT-4o,就需要提供更多提示词和具体指令才能获得理想结果。

NBA 幻想篮球

幻想篮球是和一群朋友组成联盟(提醒:如果你的朋友们非常好胜,可能会影响你们的友情),通常还会涉及一些金钱下注。你们每人选出 10 名球员组成自己的队伍,每周与别人的队伍进行对战。你每周的得分取决于你的球员在现实比赛中的表现。

如果你队伍中的某个球员受伤、被禁赛等,可以从自由球员列表中添加新球员。这也是幻想体育中思考最多的环节,因为你的增援次数有限,而其他人也都在寻找最有潜力的球员。

这正是我们的 NBA AI 助手大放异彩的时刻,尤其是在你需要快速决定该选哪位球员时。你无需再手动查找某个球员对某个对手的历史表现,助手可以快速获取数据并对比平均值,为你提供有根据的推荐。

现在你已经了解了 agentic RAG 和 NBA 幻想篮球的一些基础知识,让我们看看它是如何实际运作的。

构建项目

如果你在构建过程中遇到问题,或者不想从零开始搭建,请参考项目仓库。

我们将涵盖以下内容:

搭建项目结构

后端(Mastra):使用 npx create mastra@latest 搭建后端并定义代理逻辑。
前端(Vite + React):使用 npm create vite@latest 创建前端聊天界面,用于与代理交互。

设置环境变量

  • 安装 dotenv 来管理环境变量。

  • 创建 .env 文件并提供所需变量。

设置 Elasticsearch

  • 启动一个 Elasticsearch 集群(本地或云端均可)。

  • 安装官方 Elasticsearch 客户端。

  • 确保环境变量可被访问。

  • 建立与客户端的连接。

将 NBA 数据批量导入 Elasticsearch

  • 创建索引并定义适当的 mapping,以支持聚合操作。

  • 从 CSV 文件中批量导入球员比赛统计数据到 Elasticsearch 索引中。

定义 Elasticsearch 聚合

  • 查询某球员对特定对手的历史平均值。

  • 查询某球员对特定对手的赛季平均值。

玩家比较工具文件

  • 整合辅助函数和 Elasticsearch 聚合逻辑。

构建代理

  • 添加代理定义和系统提示词。

  • 安装 zod 并定义工具。

  • 添加中间件以处理 CORS。

集成前端

  • 使用 AI-SDK 的 useChat 与代理交互。

  • 创建 UI 界面以支持格式化的对话显示。

运行应用

  • 启动后端(Mastra 服务器)和前端(React 应用)。

  • 尝试示例查询,体验应用使用方式。

下一步:让代理更智能

  • 添加语义搜索(semantic search)功能,以实现更有洞察力的推荐。

  • 通过将搜索逻辑转移到 Elasticsearch MCP(模型上下文协议)服务器,实现动态查询。

前置条件

  • Node.js 和 npm:后端和前端均基于 Node 运行。请确保你安装了 Node 18+ 和 npm v9+(Node 18+ 已自带 npm)。

  • Elasticsearch 集群:本地或云端运行的 Elasticsearch 集群。

  • OpenAI API 密钥:在 OpenAI 开发者门户的 API 密钥页面生成。

项目结构

npx create-mastra@latest

第 1 步:搭建项目框架

首先,创建目录 nba-ai-assistant-js 并使用以下命令进入该目录:

mkdir nba-ai-assistant-js && cd nba-ai-assistant-js
后端

1)使用 Mastra 创建工具,命令如下:

npx create-mastra@latest

2)你会在终端看到一些提示,第一个提示中,我们将项目命名为 backend:

3)接下来,我们保持默认的 Mastra 文件存储结构,所以输入 src/。

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

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

前端

1)返回到根目录,运行 Vite 创建工具,命令如下:

npm create vite@latest frontend -- --template react

这会创建一个名为 frontend 的轻量级 React 应用,使用专门的 React 模板。

如果一切顺利,你的项目目录下应该有一个存放 Mastra 代码的 backend 目录和一个包含 React 应用的 frontend 目录。

第 2 步:设置环境变量

1)为了管理敏感密钥,我们将使用 dotenv 包从 .env 文件加载环境变量。进入 backend 目录并安装 dotenv:

cd backend
npm install dotenv --save

2)在 backend 目录中,有一个 example.env 文件,里面包含需要填写的变量。如果你自己创建 .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 Cloud
    • 注册 Elastic Cloud

    • 创建一个新部署

    • 获取你的端点 URL 和 API 密钥(已编码)

  • 选项 B:本地运行 Elasticsearch
    • 安装并本地运行 Elasticsearch

    • 使用 http://localhost:9200 作为端点

    • 生成 API 密钥

在后端安装 Elasticsearch 客户端

1)首先,在 backend 目录安装官方 Elasticsearch 客户端:

npm install @elastic/elasticsearch

2)然后创建一个名为 lib 的目录来存放可复用函数,并进入该目录:

mkdir lib && cd lib

3)在里面创建一个名为 elasticClient.js 的新文件。这个文件将初始化 Elasticsearch 客户端,并供项目中各处使用。

4)由于我们使用的是 ECMAScript 模块(ESM),所以 __dirname 和 __filename 不可用。为了确保环境变量能正确从 backend 文件夹的 .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 获取的开销,代理可以参考这里硬编码的值。

    实现步骤:

    1)在 backend/lib 目录下创建名为 playerDataIngestion.js 的文件。

    2)设置导入内容,解析 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)创建一个带有合适 mapping 的索引。虽然 Elasticsearch 支持动态映射自动推断字段类型,但这里我们希望明确指定,以确保每个统计字段都被视为数值型字段。这很重要,因为我们后续会用这些字段进行聚合计算。对于得分、篮板等统计数据,我们使用 float 类型,以确保包含小数值。最后,我们添加 mapping 属性 dynamic: 'strict',防止 Elasticsearch 对未识别字段进行动态映射。

    // Function to create an index with mappings
    async function createIndex() {try {// Check if the index already existsconst 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 mappingsconst response = await elasticClient.indices.create({index: indexName,body: {mappings: {dynamic: 'strict', // Prevent dynamic mappingproperties: {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 linelet headerLine = true;for await (const line of rl) {if (headerLine) {headerLine = false;continue;}lineNum++;// Split the line by comma and remove whitespaceconst [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 objectconst 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 booleanopponent_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 formatbulkBody.push({ index: { _index: indexName } });bulkBody.push(document);}console.log(`Parsed ${lineNum} lines from CSV`);
    

    5)然后,我们可以使用 Elasticsearch 的 Bulk API,通过 elasticClient.bulk() 在一次请求中导入多条文档。下面的错误处理逻辑会统计有多少文档导入失败,以及有多少成功。

    try {// Perform the bulk requestconst response = await elasticClient.bulk({ body: bulkBody });if (response.errors) {console.log('Bulk Ingestion had some hiccups:');// Count successful vs failed operationslet 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)添加下面的查询,用于计算某球员对特定对手的历史平均数据。该查询使用一个包含两个条件的 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 Averagesconst 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 seasongame_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 Infoconst player1Info = getPlayerInfo(player1Name);const player2Info = getPlayerInfo(player2Name);//Get upcoming matchupsconst player1NextGame = getUpcomingMatchups(player1Info.team_id)[0];const player2NextGame = getUpcomingMatchups(player2Info.team_id)[0];//Get season and historical averages against next opponent for player 1const 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 2const 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 的连接,我们可以开始把各部分组合起来,构建代理。

    定义代理

    进入 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 WestbrookInput 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 },
    });
    

    定义工具

    1)进入 backend/src/mastra/tools 目录下的 index.ts 文件。

    2)使用以下命令安装 Zod:

    npm install zod

    3)添加工具定义。注意,我们导入了 comparePlayers.js 文件中的函数,作为代理调用该工具时使用的主要函数。使用 Mastra 的 createTool() 函数,我们将注册 playerComparisonTool。字段包括:

    • id:用自然语言描述工具功能,帮助代理理解工具作用。

    • input schema:定义工具输入的数据结构,Mastra 使用 Zod schema(一个 TypeScript 的 schema 验证库)。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。简单来说,跨域资源共享(CORS)是浏览器的一种安全机制,阻止前端向运行在不同域名或端口的后端发请求。即使我们前后端都运行在 localhost,但它们端口不同,会触发 CORS 策略。我们需要按照 Mastra 文档添加中间件,使后端允许来自前端的请求。

    进入 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 minutescors: {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() hook 连接到 Mastra AI 代理。我们还会用这个 hook 显示 token 使用情况、工具调用,并渲染对话内容。在系统提示词中,我们要求代理以 markdown 格式输出响应,所以会使用 react-markdown 来正确格式化响应。

    1) 在 frontend 目录下,安装 @ai-sdk/react 包以使用 useChat() hook。

    npm install @ai-sdk/react

    2)在同一目录下,安装 React Markdown,以便我们能够正确格式化代理生成的响应内容。

    npm install react-markdown

    3)实现 useChat()。这个 hook 将管理前端与 AI 代理后端之间的交互。它负责处理消息状态、用户输入、交互状态,并提供生命周期钩子用于可观测性。我们传入的选项包括:

    • api:定义 Mastra AI 代理的接口地址。默认使用 4111 端口,并添加支持流式响应的路由。

    • onToolCall:每当代理调用某个工具时执行;我们用它来追踪代理调用了哪些工具。

    • onFinish:代理完整响应完成后执行。尽管启用了流式响应,onFinish 仍会在完整消息接收后执行,而不是每个 chunk 后执行。我们在这里用它来统计 token 使用量,这对监控 LLM 成本和优化非常有帮助。

    4)最后,进入 frontend/components 目录下的 ChatUI.jsx 组件,创建用于显示对话的界面。然后使用 ReactMarkdown 组件包裹响应内容,以便正确格式化代理的 markdown 响应。

    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 agentid: 'my-chat-session',//Optional parameter to check agent tool callsonToolCall: ({ toolCall }) => {setToolsCalled((prev) => [...prev, toolCall.toolName]);},//Optional parameter to check token usagesonFinish: (message, { usage }) => {setTotalTokenUsage((prev) => prev + usage.totalTokens);setPromptTokenUsage((prev) => prev + usage.promptTokens);setCompletionTokenUsage((prev) => prev + usage.completionTokens);},//Optional parameter for error handlingonError: (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}><inputtype="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 步:运行应用程序

    恭喜!现在你已经可以运行整个应用程序了。按照以下步骤同时启动后端和前端:

    1)在终端窗口中,从项目根目录开始,进入 backend 目录并启动 Mastra 服务器:

    cd backendnpm run dev

    2)在另一个终端窗口中,从项目根目录开始,进入 frontend 目录并启动 React 应用:

    cd frontendnpm run dev

    3)打开浏览器,访问:

    http://localhost:5173

    你应该能看到聊天界面。试试以下示例提示:

    • "Compare LeBron James and Stephen Curry"
    • "Who should I pick between Jayson Tatum and Luka Doncic?"

    下一步:让代理更智能

    为了让助手更加智能化、推荐更具洞察力,我将在下一个迭代中添加几个关键升级。

    对 NBA 新闻进行语义搜索

    影响球员表现的因素有很多,其中很多并不会体现在原始统计数据中。例如伤病报告、首发阵容变动、甚至赛后分析,这些只能通过新闻报道获取。为了捕捉这些额外的上下文信息,我将添加语义搜索功能,让代理能够检索相关的 NBA 文章,并将这些叙事信息纳入推荐逻辑中。

    使用 Elasticsearch MCP 服务器进行动态搜索

    MCP(Model Context Protocol)正快速成为代理连接数据源的标准方式。我会将搜索逻辑迁移到 Elasticsearch MCP 服务器中,这样代理就能动态构建查询,而不再依赖我们手动定义的搜索函数。这使得我们可以使用更自然的语言工作流,也减少了手动编写每一个查询语句的需求。
    在这里了解更多关于 Elasticsearch MCP 服务器和当前生态的内容。

    这些改进正在进行中,敬请期待!

    总结

    在这篇博客中,我们用 JavaScript、Mastra 和 Elasticsearch 构建了一个 agentic RAG 助手,为你的 fantasy 篮球队提供个性化推荐。我们介绍了:

    • Agentic RAG 的基本原理,以及如何将 AI 代理的自主性与 RAG 工具结合,从而实现更细致、更动态的智能助手;

    • Elasticsearch 的数据存储能力和强大的原生聚合功能,使其成为 LLM 知识库的理想搭档;

    • Mastra 框架,以及它如何简化 JavaScript 生态中开发 AI 代理的流程。

    无论你是篮球迷、AI 代理开发者,还是两者兼具,希望这篇博客能为你提供一些构建起点。完整代码仓库已上传至 GitHub,欢迎克隆和自由探索。
    现在,去赢下你那场 fantasy 联赛吧!

    原文:Building an agentic RAG assistant with JavaScript, Mastra and Elasticsearch - Elasticsearch Labs

    本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。
    如若转载,请注明出处:http://www.pswp.cn/web/87224.shtml
    繁体地址,请注明出处:http://hk.pswp.cn/web/87224.shtml
    英文地址,请注明出处:http://en.pswp.cn/web/87224.shtml

    如若内容造成侵权/违法违规/事实不符,请联系英文站点网进行投诉反馈email:809451989@qq.com,一经查实,立即删除!

    相关文章

    Active Directory 环境下 Linux Samba 文件共享服务建设方案

    Active Directory 环境下 Linux Samba 文件共享服务建设方案 目录 需求分析方案总体设计技术架构与选型详细部署规划共享文件性能测试非域终端共享配置运维与权限安全管理建议1. 需求分析 因某公司(编的)新增多个部门,各部门之间存在多类型终端系统,但又有同时访问文件库…

    Python爬虫网安-项目-简单网站爬取

    源码&#xff1a; https://github.com/Wist-fully/Attack/tree/pc pc_p1 目标&#xff1a; 1.进入列表页&#xff0c;顺着列表爬取每个电影详情页 2.利用正则来提取&#xff0c;海报&#xff0c;名称&#xff0c;类别&#xff0c;上映的时间&#xff0c;评分&#xff0c;剧…

    Golang中的数组

    Golang Array和以往认知的数组有很大不同。有点像Python中的列表 1. 数组&#xff1a;是同一种数据类型的固定长度的序列。 2. 数组定义&#xff1a;var a [len]int&#xff0c;比如&#xff1a;var a [5]int&#xff0c;数组长度必须是常量&#xff0c;且是类型的组成部分。一…

    《Origin画百图》之矩阵散点图

    矩阵散点图的作用 一、直观展示多变量间的两两关系 矩阵散点图的基本单元是两两变量的散点图&#xff0c;每个散点图对应矩阵中的一个单元格&#xff0c;可直接反映变量间的&#xff1a; 相关性方向&#xff1a;正相关&#xff08;散点向右上倾斜&#xff09;或负相关&#x…

    Flask文件下载send_file中文文件名处理解决方案

    Flask文件下载send_file中文文件名处理解决方案 Flask文件下载中文文件名处理解决方案问题背景问题分析核心问题常见症状 解决方案技术实现关键技术点 完整实现示例 Flask文件下载中文文件名处理解决方案 问题背景 在Web应用开发中&#xff0c;当用户下载包含中文字符的文件时…

    新手指南:在 Ubuntu 上安装 PostgreSQL 并通过 VS Code 连接及操作

    本文档记录了一个初学者在 Ubuntu 系统上安装、配置 PostgreSQL 数据库&#xff0c;并使用 Visual Studio Code (VS Code) 作为客户端进行连接和操作的全过程。其中包含了遇到的常见错误、分析和最终的解决方案&#xff0c;旨在为新手提供一个清晰、可复现的操作路径。 最终目…

    二刷 苍穹外卖day10(含bug修改)

    Spring Task Spring框架提供的任务调度工具&#xff0c;可以按照约定的时间自动执行某个代码逻辑 cron表达式 一个字符串&#xff0c;通过cron表达式可以定义任务触发的时间 **构成规则&#xff1a;**分为6或7个域&#xff0c;由空格分隔开&#xff0c;每个域代表一个含义 …

    Android Native 之 inputflinger进程分析

    Android IMS原理解析 - 简书 Android 输入事件分发全流程梳理&#xff08;一&#xff09;_android input事件分发流程-CSDN博客 Android 输入事件分发全流程梳理&#xff08;二&#xff09;_android输入事件流程图-CSDN博客 inputflinger模块与surfaceflinger模块在同级目录…

    Python实例题:基于 Flask 的在线聊天系统

    目录 Python实例题 题目 要求&#xff1a; 解题思路&#xff1a; 代码实现&#xff1a; Python实例题 题目 基于 Flask 的在线聊天系统 要求&#xff1a; 使用 Flask 框架构建一个实时在线聊天系统&#xff0c;支持以下功能&#xff1a; 用户注册、登录和个人资料管理…

    v-bind指令

    好的&#xff0c;我们来学习 v-bind 指令。这个指令是理解 Vue 数据驱动思想的基石。 核心功能&#xff1a;v-bind 的作用是将一个或多个 HTML 元素的 attribute (属性) 或一个组件的 prop (属性) 动态地绑定到 Vue 实例的数据上。 简单来说&#xff0c;它在你的数据和 HTML …

    【设计模式04】单例模式

    前言 整个系统中只会出现要给实例&#xff0c;比如Spring中的Bean基本都是单例的 UML类图 无 代码示例 package com.sw.learn.pattern.B_create.c_singleton;public class Main {public static void main(String[] args) {// double check locking 线程安全懒加载 ⭐️ //…

    飞算科技依托 JavaAI 核心技术,打造企业级智能开发全场景方案

    在数字经济蓬勃发展的当下&#xff0c;企业对智能化开发的需求愈发迫切。飞算数智科技&#xff08;深圳&#xff09;有限公司&#xff08;简称 “飞算科技”&#xff09;作为自主创新型数字科技公司与国家级高新技术企业&#xff0c;凭借深厚的技术积累与创新能力&#xff0c;以…

    20250701【二叉树公共祖先】|Leetcodehot100之236【pass】今天计划

    20250701 思路与错误记录1.二叉树的数据结构与初始化1.1数据结构1.2 初始化 2.解题 完整代码今天做了什么 题目 思路与错误记录 1.二叉树的数据结构与初始化 1.1数据结构 1.2 初始化 根据列表&#xff0c;顺序存储构建二叉树 def build_tree(nodes, index0):# idx是root开始…

    Web应用开发 --- Tips

    Web应用开发 --- Tips General后端需要做参数校验代码风格和Api设计风格的一致性大于正确性数据入库时间应由后端记录在对Api修改的时候&#xff0c;要注意兼容情况&#xff0c;避免breaking change 索引对于查询字段&#xff0c;注意加索引对于唯一的字段&#xff0c;考虑加唯…

    CSS 安装使用教程

    一、CSS 简介 CSS&#xff08;Cascading Style Sheets&#xff0c;层叠样式表&#xff09;是用于为 HTML 页面添加样式的语言。通过 CSS 可以控制网页元素的颜色、布局、字体、动画等&#xff0c;是前端开发的三大核心技术之一&#xff08;HTML、CSS、JavaScript&#xff09;。…

    机器学习中为什么要用混合精度训练

    目录 FP16与显存占用关系机器学习中一般使用混合精度训练&#xff1a;FP16计算 FP32存储关键变量。 FP16与显存占用关系 显存&#xff08;Video RAM&#xff0c;简称 VRAM&#xff09;是显卡&#xff08;GPU&#xff09;专用的内存。 FP32&#xff08;单精度浮点&#xff09;&…

    [附源码+数据库+毕业论文+答辩PPT]基于Spring+MyBatis+MySQL+Maven+vue实现的中小型企业财务管理系统,推荐!

    摘 要 现代经济快节奏发展以及不断完善升级的信息化技术&#xff0c;让传统数据信息的管理升级为软件存储&#xff0c;归纳&#xff0c;集中处理数据信息的管理方式。本中小型企业财务管理就是在这样的大环境下诞生&#xff0c;其可以帮助管理者在短时间内处理完毕庞大的数据信…

    华为云Flexus+DeepSeek征文 | 对接华为云ModelArts Studio大模型:AI赋能投资理财分析与决策

    引言&#xff1a;AI金融&#xff0c;开启智能投资新时代​​ 随着人工智能技术的飞速发展&#xff0c;金融投资行业正迎来前所未有的变革。​​华为云ModelArts Studio​​结合​​Flexus高性能计算​​与​​DeepSeek大模型​​&#xff0c;为投资者提供更精准、更高效的投资…

    从模型部署到AI平台:云原生环境下的大模型平台化演进路径

    &#x1f4dd;个人主页&#x1f339;&#xff1a;慌ZHANG-CSDN博客 &#x1f339;&#x1f339;期待您的关注 &#x1f339;&#x1f339; 一、引言&#xff1a;部署只是起点&#xff0c;平台才是终局 在过去一年&#xff0c;大语言模型的飞速发展推动了AI生产力浪潮。越来越多…

    UI前端大数据可视化创新:利用AR/VR技术提升用户沉浸感

    hello宝子们...我们是艾斯视觉擅长ui设计、前端开发、数字孪生、大数据、三维建模、三维动画10年经验!希望我的分享能帮助到您!如需帮助可以评论关注私信我们一起探讨!致敬感谢感恩! 在大数据与沉浸式技术高速发展的今天&#xff0c;传统二维数据可视化已难以满足复杂数据场景的…