🔰

Mastraを使ってAIエージェントを作ろう! - 基礎編(3) -

に公開

https://supporterz-seminar.connpass.com/event/370911/

はじめに

本ハンズオンでは、AIエージェントフレームワークのMastraを使って、シンプルなAIエージェントの実装を解説します。

基礎編3つ目の記事では、天気情報エージェントの機能を少しばかり改良し、複数のToolを自律的に操作するエージェントを実装してみます。

【前回】Mastraを使ってAIエージェントを作ろう! - 基礎編(2) -

ハンズオンのステップ

  1. MastraのサンプルAIエージェントをVercelにデプロイし、curlコマンドで実行する
  2. サンプルAIエージェントにチャットUIを実装し、Vercelにデプロイする
  3. 【本記事で解説】サンプルAIエージェントを改造し、複数のToolを自律的に実行するAIエージェントを実装する

事前に準備が必要なもの

0. 改良の方針

突然ですが、前回まで触れてきた天気情報エージェントに、「東京の天気」を尋ねてみましょう。

気温だけでなく、湿度や風速など、複数の情報を読みやすい書式で回答してくれます。すごくありがたいのですが、同時にこんな疑問も湧きます。

「東京」というのは、いったい「東京のどこ」なのだろうか…?

Toolの返り値に緯度・経度も含めてデバッグ

試しに、外部から位置情報と天気情報を取得するツールであるweatherToolの返り値に、緯度・経度の情報も含めてみましょう。

mastra/tools/weather-tool.ts
@@ -34,6 +34,8 @@
 export const weatherTool = createTool({
     // ...
     windGust: z.number(),
     conditions: z.string(),
     location: z.string(),
+    latitude: z.number(),
+    longitude: z.number(),
   }),
   execute: async ({ context }) => {
     return await getWeather(context.location);

@@ -64,6 +66,8 @@
 const getWeather = async (location: string) => {
     // ...
     windGust: data.current.wind_gusts_10m,
     conditions: getWeatherCondition(data.current.weather_code),
     location: name,
+    latitude,
+    longitude,
   };
 };

もう一度チャット画面で東京の天気を尋ねてみると、weatherToolのResultに緯度・経度が表示されました👀

北緯35.6895° / 東経139.69171°の位置にあるのは、東京都庁第一庁舎。すなわち、このエージェントは東京の天気を尋ねると、暗黙的に東京都新宿区の天気を返す仕様となっていたのでした🤔

改良点: 都道府県レベルで地点名を指定された場合、市区町村レベルまでAIに地点名を絞ってもらう

「東京の天気」を尋ねたら、「新宿区の天気」が返ってくるエージェントは、本当にユーザーの期待に応えられるのでしょうか?

実際のユースケースを考えれば、「東京の天気」を尋ねた際、ユーザーの期待する情報が「新宿区の天気」なのか、「千代田区の天気」なのか、「八王子市の天気」なのかは、エージェントを利用するタイミングによって変わるはずです。[2]

「都道府県レベルでユーザーから地点名を指定された場合、AIが市区町村レベルまで地点名を絞ってから天気情報を探す」 仕様に修正する。そのようにすることで、今回の天気情報エージェントは、より多くのユーザーの期待に応えられるものへと改良できそうです。

1. プロンプトの日本語化

エージェントの仕様を変更するに当たって、まずはプロンプトを日本語化します。以下の箇所で用いられる自然言語を日本語へと変換します。

  • Agentのinstructions
  • ToolのdescriptioninputSchemaoutputSchemadescribe
  • WorkflowのstepのdescriptioninputSchemaoutputSchemadescribe、変数prompt ※今回はスコープ外ですが、念の為



手作業なんてやってられないので、Claude Code先輩に丸投げ😋

2. 地点名から緯度・経度を取得するAPIを差し替える

サンプルコードではOpenMeteoGeocoding APIが使われていますが、これをNAVITIME Geocodingの住所オートコンプリートAPIに差し替えます。

NAVITIME Geocodingを採用した理由
  • 曖昧な地点名から、住所を検索できるAPIであること
  • APIレスポンスに、今回の仕様を実現するために必要な以下の情報が揃っていること
    • 緯度・経度
    • 住所レベル(1 → 都道府県レベル / 2 → 市区町村レベル)
  • 無料プランで月500リクエストまで利用できること
mastra/tools/weather-tool.ts
@@ -41,15 +45,26 @@
 const getWeather = async (location: string) => {
-  const geocodingUrl = `https://geocoding-api.open-meteo.com/v1/search?name=${encodeURIComponent(location)}&count=1`;
-  const geocodingResponse = await fetch(geocodingUrl);
-  const geocodingData = (await geocodingResponse.json()) as GeocodingResponse;
+  const rapidApiKey = process.env.RAPIDAPI_KEY;
+  if (!rapidApiKey) {
+    throw new Error('RAPIDAPI_KEY environment variable is not set');
+  }
+
+  const geocodingUrl = `https://navitime-geocoding.p.rapidapi.com/address/autocomplete?word=${encodeURIComponent(location)}`;
+  const geocodingResponse = await fetch(geocodingUrl, {
+    headers: {
+      'x-rapidapi-host': 'navitime-geocoding.p.rapidapi.com',
+      'x-rapidapi-key': rapidApiKey,
+    },
+  });
+
+  const geocodingData = (await geocodingResponse.json()) as NavitimeGeocodingResponse;

-  if (!geocodingData.results?.[0]) {
+  if (!geocodingData.items?.[0]) {
     throw new Error(`Location '${location}' not found`);
   }

-  const { latitude, longitude, name } = geocodingData.results[0];
+  const { coord: { lat: latitude, lon: longitude }, name } = geocodingData.items[0];

   const weatherUrl = `https://api.open-meteo.com/v1/forecast?latitude=${latitude}&longitude=${longitude}&current=temperature_2m,apparent_temperature,relative_humidity_2m,wind_speed_10m,wind_gusts_10m,weather_code`;

また、天気予報エージェントのプロンプトから、地点名を英語に翻訳する指示を削除します。無料プランのNAVITIME Geocoding APIは、英語表記の地点名を受け付けてくれないためです。

mastra/agents/weather-agent.ts
@@ -8,8 +8,6 @@
 export const weatherAgent = new Agent({
   // ...
       あなたの主な機能は、特定の地点の天気の詳細情報を取得することです。応答する際は以下に従ってください:
       - 地点が指定されていない場合は、必ず地点を尋ねてください
-      - 地点名が英語でない場合は、英語に翻訳してください
-      - 複数の部分を持つ地点(例:「New York, NY」)が指定された場合は、最も関連性の高い部分(例:「New York」)を使用してください
       - 湿度、風の状況、降水量などの関連する詳細を含めてください
       - 簡潔で有益な応答を心がけてください
       - ユーザーがアクティビティを尋ね、天気予報を提供した場合は、天気予報に基づいたアクティビティを提案してください

3. WeatherToolを2つのToolに分割する

WeatherToolは「地点名から緯度・経度を取得する」「緯度・経度から現在の天気情報を取得する」という2つの役割を持っていますが、これを役割ごとに2つのToolに分割します。

3-1. MunicipalityToolを新たに作成する

NAVITIME Geocoding APIを利用し、「曖昧な地点名から緯度・経度・住所テキスト・住所レベルを取得する」役割を持つ、ManicipalityToolを作成します。

mastra/tools/municipality-tool.ts
export const municipalityTool = createTool({
  id: 'get-municipality',
  description: '曖昧な地点名から緯度・経度・住所情報を取得する',
  inputSchema: z.object({
    location: z.string().describe('曖昧な地点名(都市名、住所など)'),
  }),
  outputSchema: z.object({
    latitude: z.number().describe('緯度'),
    longitude: z.number().describe('経度'),
    address: z.string().describe('住所テキスト'),
    addressLevel: z.number().describe('住所レベル(1:都道府県, 2:市区町村, 3:町, 4:丁目, 5:街区, 6:地番, 7:枝番)'),
  }),
  execute: async ({ context }) => {
    return await getMunicipality(context.location);
  },
});

const getMunicipality = async (location: string) => {
  const rapidApiKey = process.env.RAPIDAPI_KEY;
  if (!rapidApiKey) {
    throw new Error('RAPIDAPI_KEY environment variable is not set');
  }

  const geocodingUrl = `https://navitime-geocoding.p.rapidapi.com/address/autocomplete?word=${encodeURIComponent(location)}`;
  const geocodingResponse = await fetch(geocodingUrl, {
    headers: {
      'x-rapidapi-host': 'navitime-geocoding.p.rapidapi.com',
      'x-rapidapi-key': rapidApiKey,
    },
  });

  const geocodingData = (await geocodingResponse.json()) as NavitimeGeocodingResponse;

  if (!geocodingData.items?.[0]) {
    throw new Error(`Location '${location}' not found`);
  }

  const firstResult = geocodingData.items[0];
  const { coord: { lat: latitude, lon: longitude }, name: address } = firstResult;

  const addressLevel = firstResult.details.length > 0
    ? Number(firstResult.details[firstResult.details.length - 1].level)
    : 1;

  return {
    latitude,
    longitude,
    address,
    addressLevel,
  };
};

3-2. WeatherToolから緯度・経度取得ロジックを削除する

WeatherToolについては、役割を「緯度・経度・住所テキストから、天気情報を取得する」ことに絞り、緯度・経度の取得ロジックを削除します。

mastra/tools/weather-tool.ts
@@ -26,9 +15,11 @@
 export const weatherTool = createTool({
   id: 'get-weather',
-  description: '指定された地点の現在の天気を取得する',
+  description: '緯度・経度から現在の天気を取得する',
   inputSchema: z.object({
-    location: z.string().describe('都市名'),
+    latitude: z.number().describe('緯度'),
+    longitude: z.number().describe('経度'),
+    address: z.string().describe('住所テキスト(表示用)'),
   }),
   outputSchema: z.object({
     temperature: z.number().describe('気温(℃)'),

@@ -37,35 +28,14 @@
 export const weatherTool = createTool({
     windSpeed: z.number().describe('風速(m/s)'),
     windGust: z.number().describe('最大瞬間風速(m/s)'),
     conditions: z.string().describe('天気の状態'),
-    location: z.string().describe('地点名'),
+    address: z.string().describe('住所テキスト'),
   }),
   execute: async ({ context }) => {
-    return await getWeather(context.location);
+    return await getWeather(context.latitude, context.longitude, context.address);
   },
 });

-const getWeather = async (location: string) => {
-  const rapidApiKey = process.env.RAPIDAPI_KEY;
-  if (!rapidApiKey) {
-    throw new Error('RAPIDAPI_KEY environment variable is not set');
-  }
-
-  const geocodingUrl = `https://navitime-geocoding.p.rapidapi.com/address/autocomplete?word=${encodeURIComponent(location)}`;
-  const geocodingResponse = await fetch(geocodingUrl, {
-    headers: {
-      'x-rapidapi-host': 'navitime-geocoding.p.rapidapi.com',
-      'x-rapidapi-key': rapidApiKey,
-    },
-  });
-
-  const geocodingData = (await geocodingResponse.json()) as NavitimeGeocodingResponse;
-
-  if (!geocodingData.items?.[0]) {
-    throw new Error(`Location '${location}' not found`);
-  }
-
-  const { coord: { lat: latitude, lon: longitude }, name } = geocodingData.items[0];
-
+const getWeather = async (latitude: number, longitude: number, address: string) => {
   const weatherUrl = `https://api.open-meteo.com/v1/forecast?latitude=${latitude}&longitude=${longitude}&current=temperature_2m,apparent_temperature,relative_humidity_2m,wind_speed_10m,wind_gusts_10m,weather_code`;

   const response = await fetch(weatherUrl);

@@ -78,7 +48,7 @@
 const getWeather = async (location: string) => {
     windSpeed: data.current.wind_speed_10m,
     windGust: data.current.wind_gusts_10m,
     conditions: getWeatherCondition(data.current.weather_code),
-    location: name,
+    address,
   };
 };

3-3. 2つのToolを組み合わせてタスクを実行するよう、プロンプトを修正する

WeatherAgentにManicipalityToolを登録し、プロンプトも2つのToolを組み合わせてタスク実行に向かうよう、修正します。

mastra/agents/weather-agent.ts
@@ -6,15 +7,17 @@
 export const weatherAgent = new Agent({
   // ...
   instructions: `
       あなたは正確な天気情報を提供し、天気に基づいた活動計画のサポートができる親切な天気アシスタントです。

-      あなたの主な機能は、特定の地点の天気の詳細情報を取得することです。応答する際は以下に従ってください:
+      天気情報を取得する際は、以下の2段階の手順で実行してください:
+      1. まず municipalityTool を使用して、ユーザーが指定した地点名から緯度・経度・住所情報を取得してください
+      2. 次に weatherTool を使用して、取得した緯度・経度・住所情報から天気情報を取得してください
+
+      応答する際は以下に従ってください:
       - 地点が指定されていない場合は、必ず地点を尋ねてください
       - 湿度、風の状況、降水量などの関連する詳細を含めてください
       - 簡潔で有益な応答を心がけてください
       - ユーザーがアクティビティを尋ね、天気予報を提供した場合は、天気予報に基づいたアクティビティを提案してください
       - ユーザーがアクティビティを尋ねた場合は、要求された形式で応答してください
-
-      現在の天気データを取得するには、weatherToolを使用してください。
 `,
   model: 'openai/gpt-4o-mini',
-  tools: { weatherTool },
+  tools: { municipalityTool, weatherTool },
 });

3-4. 動作確認

チャットUIで動作確認してみましょう。「Used tool」にmunicipalityToolも表示され、正しく天気情報が返ってくれば、成功です!🔥

4. 地点名が市区町村レベルに絞られるまで、AIが聞き返し続けるようにする

最も重要な部分です。WeatherAgentのプロンプトを municipalityToolから取得した住所レベルが2以上になるまで、地点名を聞き返す」 ような指示内容に修正します。

mastra/agents/weather-agent.ts
@@ -7,12 +7,22 @@
 export const weatherAgent = new Agent({
   instructions: `
       あなたは正確な天気情報を提供し、天気に基づいた活動計画のサポートができる親切な天気アシスタントです。

-      天気情報を取得する際は、以下の2段階の手順で実行してください:
+      天気情報を取得する際は、以下の手順で実行してください:
+
       1. まず municipalityTool を使用して、ユーザーが指定した地点名から緯度・経度・住所情報を取得してください
-      2. 次に weatherTool を使用して、取得した緯度・経度・住所情報から天気情報を取得してください
+
+      2. 取得した addressLevel(住所レベル)を確認してください:
+         - addressLevel が 1 以下の場合(都道府県レベル):
+           天気情報を取得する前に、ユーザーに対して市区町村レベルまで地点を絞り込むよう聞き返してください
+           例:「東京都のどの市区町村の天気をお調べしますか?(例: 千代田区、八王子市など)」
+         - addressLevel が 2 以上の場合(市区町村レベル以上):
+           次のステップに進んでください
+
+      3. addressLevel が 2 以上であることを確認した後、weatherTool を使用して天気情報を取得してください

       応答する際は以下に従ってください:
       - 地点が指定されていない場合は、必ず地点を尋ねてください
+      - 住所レベルが市区町村レベル(2)未満の場合は、必ずより詳細な地点を尋ねてください
       - 湿度、風の状況、降水量などの関連する詳細を含めてください
       - 簡潔で有益な応答を心がけてください
       - ユーザーがアクティビティを尋ね、天気予報を提供した場合は、天気予報に基づいたアクティビティを提案してください

また、NAVITIME Geocoding APIは、地点名のテキストを「東京都千代田区」のような住所表記で渡す必要があるため、ManicipalityToolの引数であるlocationの説明も修正します。

※修正しないと、「東京の天気を教えて!」「千代田区で!」と分けて地点を伝えた際、"千代田区, 東京"のように、狙った文字列とは異なる形式でAIが値を渡す現象が発生してしまいます。

mastra/tools/municipality-tool.ts
@@ -23,7 +23,7 @@
 export const municipalityTool = createTool({
   id: 'get-municipality',
   description: '曖昧な地点名から緯度・経度・住所情報を取得する',
   inputSchema: z.object({
-    location: z.string().describe('曖昧な地点名(都市名、住所など)'),
+    location: z.string().describe('住所文字列(例: 東京、東京都、東京都千代田区など)'),
   }),
   outputSchema: z.object({
     latitude: z.number().describe('緯度'),

これで最後!動作確認をしてみましょう…!

「東京」とだけ伝えると、市区町村レベルまで聞き返してくれるようになりました!🎉🎉🎉

因みに、ダイレクトに市区町村名まで含めて尋ねると、聞き返すことなくそのまま天気情報を返してくれます。 このように、入力のゆらぎにも柔軟に対応してくれるのが、AIの便利なところです😎

まとめ

3記事にわたり解説したMastraハンズオン、いかがだったでしょうか?

Python製のLangChain・LangGraphと比べると後発のフレームワークではありますが、そのビハインドを感じさせないほど機能が充実している点は、僕自身推せるポイントかと思っています。

ただ、良くも悪くも開発が非常に盛んに行われているため、Breaking Changeが頻繁に発生する点は注意したいところです。ドキュメントが最新版に追いついていないこともあるため、困った時には公式のDiscordサーバーを覗きに行くのがよいでしょう。

このハンズオンをきっかけに、AIエージェントの開発にチャレンジする方が少しでも増えてくれたら幸いです。ここまでご覧いただき、ありがとうございました🙇

参考リンク

レポジトリはこちら
https://github.com/subroh0508/mastra-with-ui-sample/tree/basic-3

この記事での修正をまとめたPull Requestはこちら
https://github.com/subroh0508/mastra-with-ui-sample/pull/1

脚注
  1. Mastraが対応しているモデルプロバイダーは、以下の3つに大別されます。
    AI SDKチームがメンテナンスする公式プロバイダー
    OpenAI互換プロバイダー
    コミュニティプロバイダー ↩︎

  2. 僕の地元・伊豆大島も東京都ですし、なんなら小笠原諸島も東京都です。新宿と離島の天気が全く違うなんて当たり前のことですし、東京 = 新宿とイコールで結ばれるのは、ちょっと看過できませんでした😇 「東京」と聞いて「伊豆大島」や「小笠原」を連想する人はほぼいないですが ↩︎

TOKIUMプロダクトチーム テックブログ

Discussion