Mastra 入門:TypeScript で AI エージェントを「テストできる形」で作る

Mastra 入門:TypeScript で AI エージェントを「テストできる形」で作る

#ai#LLM#TypeScript

LLMのAPIを直接呼ぶだけなら、数十行のコードでチャットボットは動きます。 ところが、ツール呼び出しを足し、会話履歴を持たせ、エージェントを二つ三つ連携させたあたりから、コードは急に手に負えなくなります。 出力が揺れるのでテストの基準を決めにくい、答えがおかしいときに原因を追いにくい、モデルを乗り換えると接続部分の修正が必要になる、という三つの壁に当たるためです。

この三つの壁に、フレームワークとして正面から答えているのがMastraです。 TypeScript製のオープンソースで、エージェント、ワークフロー、ツール、メモリ、RAG、そして出力を採点する Scorers を一つの型に揃えて提供します。

この記事では、AIエージェント開発が難しい理由を整理したうえで、生のAPI呼び出しとフレームワークの違い、Mastraの導入、最初のエージェント、Scorersによるプロンプト評価までを順に説明します。 対象はTypeScriptでLLMアプリを作り始めたエンジニアで、Mastraの事前知識は不要です。

Mastra とは?

Mastraは、AIエージェントとAIアプリケーションを作るためのTypeScriptフレームワークです。 静的サイトジェネレーターのGatsbyを作ったチームが開発しており、Apache-2.0ライセンスで公開されています。

提供する部品は次のとおりです。

部品役割
Agent指示文、モデル、ツール、メモリを束ねた「話しかける相手」
Workflow複数ステップの順序、分岐、並列、一時停止をコードで定義するグラフ。各ステップのLLM出力まで固定されるわけではない
Toolsエージェントが呼び出せる関数。入出力をZodスキーマで定義する
Memory会話履歴と長期記憶。ストレージを差し替えられる
RAGドキュメントの分割、埋め込み、ベクトル検索
Scorersエージェントやワークフローの出力を採点する評価の仕組み
Observabilityモデル入出力やツール呼び出しをトレースとして記録
Studioローカルで起動するWeb UI。エージェントとの対話、トレース、スコアの確認

特徴的なのは、モデルの指定を "openai/gpt-5.4" のような文字列一つで行う点です。 Mastraにはモデルルーターが内蔵されており、OpenAI、Anthropic、Google、xAIをはじめ多数のプロバイダーを同じ書式で切り替えられます。 プロバイダーごとのSDKを個別にインストールする必要はありません。

AI エージェント開発の本質的な難しさ

フレームワークの話に入る前に、そもそも何が難しいのかを押さえておきます。 難しさは大きく二つです。

出力が一意にならず、テストが書きにくい

決定的な処理なら、同じ入力に対して同じ出力を返すため、assertEqual で期待値と比較できます。

LLMはこの前提が崩れます。 同じ質問でも言い回しが変わり、temperatureの設定次第では内容まで揺れます。 モデルを変えれば正しさの傾向そのものが変わります。 「出力が完全一致すること」を条件にすると、正しい回答まで不合格になり、逆に条件を緩めると何でも通ってしまいます。

そのため確かめたいことは「一致するか」ではなく「要件を満たしているか」になります。 質問に関連した答えになっているか、根拠のない断定をしていないか、有害な表現を含まないか、といった観点です。 これらは完全一致のアサーションでは判定できないため、要件ごとの評価基準と、それを測る採点器が必要になります。 採点結果そのものはVitestやJestで検証でき、Mastraにもテストフレームワークと組み合わせる公式例があります。

問題が切り分けにくい

エージェントが変な答えを返したとき、原因の候補は複数あります。

  • 指示文(システムプロンプト)が曖昧だった
  • モデルがツールを呼ぶべき場面で呼ばなかった、または引数を間違えた
  • ツールが返したデータが期待と違った
  • 会話履歴やRAGで取り込んだ文脈が邪魔をした
  • モデル自体の性能が足りなかった

APIのレスポンスにはツール呼び出しやトークン使用量なども含まれますが、最終回答だけを保存していると、途中経過は追えません。 ツール呼び出しのループを自前で書く場合は、各処理の入出力を記録し、一つの実行として紐づける仕組みも必要です。 この記録がないと、プロンプトを勘で書き換えては目視で確かめる作業に陥りやすくなります。

AI エージェントの API をローレベルに投げるのと何が違う?

「フレームワークなど使わず、SDKを直接叩けばいい」という意見は根強くあります。 実際、単発のテキスト生成ならそれで十分です。

差が出るのは、エージェントらしい振る舞いを足したときです。 APIを直接呼ぶ構成では、次の処理を自分で実装するか、別のライブラリで補うことになります。

やりたいことAPIを直接呼ぶ構成で整備する処理
ツール呼び出しモデルが返した呼び出し要求を解釈し、関数を実行し、結果をメッセージに戻してもう一度モデルを呼ぶループ
会話履歴メッセージ配列の保存、読み出し、上限を超えたときの要約や切り捨て
ストリーミングチャンクの受信、途中でのツール呼び出し検出、クライアントへの中継
モデルの切り替えプロバイダーごとに異なるメッセージ形式、ツール定義の書式、エラー型への対応
複数ステップの処理途中で止めて人の承認を待つ、失敗したステップだけ再実行する、といった制御
トレース各呼び出しの入出力とツール実行を紐づけて記録する仕組み
評価出力を採点し、結果を保存して比較する仕組み

一つひとつは難しくありませんが、合計すると小さなフレームワークを自作している状態になります。 しかも、この自作部分にはテストが書かれないことが多く、APIの仕様変更のたびに保守の対象になります。

Mastraのようなフレームワークは、この表の右側を引き受けます。 アプリケーション側に残るのは、指示文、ツールの中身、エージェントの組み合わせという、サービス固有の部分が中心になります。

Mastra はこれを解決する

前節の表のうち、モデルの切り替え、評価、トレースの三つを取り上げます。

共通の呼び出し方でモデルを差し替えられる

Mastraのエージェントは、モデルを provider/model 形式の文字列で指定します。

import { Agent } from "@mastra/core/agent";

export const supportAgent = new Agent({
  id: "support-agent",
  name: "Support Agent",
  instructions:
    "あなたはECサイトのカスタマーサポートです。返品ポリシーは14日以内です。",
  model: "openai/gpt-5.4",
});

Anthropicのモデルに変えたいときは、モデルの指定を次のように書き換えます。

  model: "anthropic/claude-sonnet-5",

変更先の ANTHROPIC_API_KEY を環境変数に足せば、ツール定義、ストリーミングの受け取り方、呼び出し側のコードはそのまま動きます。 モデル固有の設定やコンテキスト上限の違いは残るので、その点だけ確認します。 複数のエージェントを持つ構成なら、分類だけ行う軽いエージェントには安価なモデル、最終回答を作るエージェントには高性能なモデル、という割り当ても文字列の違いで済みます。

これは「モデルの比較実験ができる」という意味でも重要です。 後述のScorersと組み合わせると、同じデータセットに対してモデルだけを変えたスコアを並べられます。 乗り換えの判断を「たぶん大丈夫」ではなく数字で行えます。

Scorers でプロンプトに評点をつけられる

Mastraの評価機能はScorersと呼ばれます。 Scorerは、エージェントなどの実行結果を受け取り、数値スコアと、必要なら理由を返す採点器です。 スコアの範囲と高低の意味は採点器ごとに決まります。

Scorerには二種類あります。

  • プリビルトのScorer: @mastra/evals パッケージに含まれる既製の採点器。回答の関連性、ハルシネーション、有害性、忠実性など、LLMを審査役に使うものが中心
  • カスタムScorer: createScorer で自作する。文字列の包含チェックのような決定的な判定から、独自の審査プロンプトまで書ける

Scorerはエージェントに直接アタッチでき、本番のリクエストを一定割合でサンプリングして採点し続けられます。 また、データセットに対して一括で走らせる runEvals や、Studio上でのトレース採点にも同じScorerを使います。 「テスト」と「本番の品質監視」が同じ採点器で繋がる点が、単体の評価ツールとの違いです。 ただし、データセットに付けた正解を参照するScorerは、正解を渡せるデータセット評価の場面で使います。

トレースで切り分けられる

MastraはObservabilityを設定すると、エージェントの実行をトレースとして記録します。 どの指示文で、どのモデルに、どんなメッセージを送り、どのツールをどの引数で呼び、何が返ったかが一つの実行単位として残ります。 StudioのObservabilityセクションで過去のトレースを開き、その場でScorerを走らせて採点することもできます。 前述の「問題が切り分けにくい」に対する直接の答えがこれです。

導入方法

Node.js 24系と、使うモデルのAPIキーを用意します。 以下ではOpenAIを例にします。 この記事のコードはNode.js 24.20.0、@mastra/core 1.71.0、@mastra/evals 1.10.3で型チェックと、模擬モデルによるツール呼び出し、ストリーミング、一括評価の動作を確認しています。 実際のLLMの回答品質や採点品質は、使うモデルとデータセットで変わります。

npm create mastra@latest my-mastra-app -- --llm openai
cd my-mastra-app

生成されるプロジェクトの主な構成は次のとおりです。

my-mastra-app/
├── src/mastra/
│   ├── index.ts        # Mastraインスタンスの定義(エージェント、ストレージ、ロガーを登録)
│   ├── agents/         # エージェント定義
│   ├── tools/          # ツール定義
│   └── workflows/      # ワークフロー定義
├── .env                # APIキー
└── package.json

.env にAPIキーを書きます。

OPENAI_API_KEY=sk-...

開発サーバーを起動します。

npm run dev

ブラウザで http://localhost:4111 を開くとMastra Studioが表示されます。 天気エージェントを含むサンプルが生成された場合は、それに話しかけて動作を確認できます。 Studioはエージェントとの対話だけでなく、ワークフローの実行、トレースの閲覧、Scorerの管理まで担う開発の中心画面です。

なお、既存のNext.jsやExpressのプロジェクトに組み込む場合は @mastra/core を直接インストールする手順もあります。 詳細は公式ドキュメントのインストール手順を参照してください。

動かしてみる

生成されたサンプルを離れて、ツール付きのエージェントを一つ作ります。 題材は「注文番号から配送状況を答えるサポートエージェント」です。

ツールを定義する

ツールは createTool で作ります。 入出力をZodスキーマで宣言すると、モデルに渡すツール定義と、TypeScriptの型の両方がここから生成されます。

// src/mastra/tools/order-status.ts
import { createTool } from "@mastra/core/tools";
import { z } from "zod";

export const orderStatusTool = createTool({
  id: "get-order-status",
  description: "注文番号から配送状況を取得する",
  inputSchema: z.object({
    orderId: z.string().describe("注文番号。例: ORD-12345"),
  }),
  outputSchema: z.object({
    status: z.enum(["preparing", "shipped", "delivered"]),
    estimatedArrival: z.string().optional(),
  }),
  execute: async ({ orderId }) => {
    // この例で扱う注文は1件だけ。実際にはDBや外部APIで検索する
    if (orderId !== "ORD-12345") {
      throw new Error("注文が見つかりません");
    }
    return { status: "shipped" as const, estimatedArrival: "2026-09-27" };
  },
});

execute の第1引数は入力スキーマに対応する値です。 この例では orderId を直接受け取ります。 実行コンテキストを使う場合は第2引数から取得します。

エージェントを定義する

// src/mastra/agents/support-agent.ts
import { Agent } from "@mastra/core/agent";
import { orderStatusTool } from "../tools/order-status";

export const supportAgent = new Agent({
  id: "support-agent",
  name: "Support Agent",
  instructions: `
あなたはECサイトのカスタマーサポートです。
注文番号を伝えられたら必ず get-order-status ツールで状況を確認してから答えてください。
返品ポリシーは商品到着から14日以内です。
推測で答えず、分からないことは分からないと伝えてください。
`,
  model: "openai/gpt-5.4",
  tools: { "get-order-status": orderStatusTool },
});

Mastra インスタンスに登録する

トレースをStudioで確認し、採点結果を再起動後も残せるように、保存先とObservabilityも設定します。 必要なパッケージを追加してください。

npm install @mastra/libsql @mastra/observability

次は、このサポートエージェントだけを登録する構成です。 既存のエージェントやワークフローも使う場合は、それらの登録を残して追加します。

// src/mastra/index.ts
import { Mastra } from "@mastra/core";
import { LibSQLStore } from "@mastra/libsql";
import { Observability, MastraStorageExporter } from "@mastra/observability";
import { supportAgent } from "./agents/support-agent";

export const mastra = new Mastra({
  agents: { supportAgent },
  storage: new LibSQLStore({
    id: "support-storage",
    url: "file:./mastra.db",
  }),
  observability: new Observability({
    configs: {
      default: {
        serviceName: "support-app",
        exporters: [new MastraStorageExporter()],
      },
    },
  }),
});

この設定ではローカルのLibSQLに保存します。 @mastra/core 1.71.0では、storage を省略するとメモリ内ストレージが使われますが、その内容は再起動すると失われます。 Observabilityも省略すると、トレースは記録されません。 保存先を変更する場合は公式のObservability設定を参照してください。

呼び出す

Studioから話しかけてもよいですし、コードから呼ぶなら次のようになります。

// src/run.ts
import { mastra } from "./mastra";

const agent = mastra.getAgent("supportAgent");

const result = await agent.generate("ORD-12345 の配送状況を教えてください");
console.log(result.text);
// 例: ご注文 ORD-12345 は発送済みで、9月27日にお届け予定です。
await mastra.observability.flush();

このファイルを単独で実行する場合は、プロジェクトのルートで次のコマンドを使います。 .env の読み込みも明示しています。 末尾の flush() は、短いスクリプトが終了する前にトレースを保存先へ送るための処理です。

npm install --save-dev tsx
npx tsx --env-file=.env src/run.ts

ストリーミングで受け取る場合は agent.stream を使います。

const stream = await agent.stream("返品したいのですが");
for await (const chunk of stream.textStream) {
  process.stdout.write(chunk);
}

ここまでで、1回の実行内でツールを呼び、その結果をモデルに戻すループは自分で書いていません。 複数回の呼び出しをまたいで会話履歴を保持したい場合は、別途Memoryと会話を識別するthreadなどを設定します。 Studioのトレースを開くと、モデルが get-order-status をどの引数で呼び、何を受け取って最終回答を組み立てたかが確認できます。

Mastra Scorers を使ってプロンプトを評価しよう

エージェントが動いたら、次は「指示文を変えたときに良くなったか悪くなったか」を判定できるようにします。

プリビルトの Scorer をエージェントにアタッチする

npm install @mastra/evals
// src/mastra/agents/support-agent.ts
import { Agent } from "@mastra/core/agent";
import {
  createAnswerRelevancyScorer,
  createToxicityScorer,
} from "@mastra/evals/scorers/prebuilt";
import { orderStatusTool } from "../tools/order-status";

export const supportAgent = new Agent({
  id: "support-agent",
  name: "Support Agent",
  instructions: `
あなたはECサイトのカスタマーサポートです。
注文番号を伝えられたら必ず get-order-status ツールで状況を確認してから答えてください。
返品ポリシーは商品到着から14日以内です。
推測で答えず、分からないことは分からないと伝えてください。
`,
  model: "openai/gpt-5.4",
  tools: { "get-order-status": orderStatusTool },
  scorers: {
    relevancy: {
      scorer: createAnswerRelevancyScorer({ model: "openai/gpt-5.4-mini" }),
      sampling: { type: "ratio", rate: 0.5 },
    },
    toxicity: {
      scorer: createToxicityScorer({ model: "openai/gpt-5.4-mini" }),
      sampling: { type: "ratio", rate: 1 },
    },
  },
});

sampling は採点する割合です。 上の例では、回答の関連性は半分のリクエストで、有害性はすべてのリクエストで採点します。 審査役のモデルは、回答を作るモデルより安価なもので十分なことが多いです。 なお、関連性は高いほど良い指標ですが、有害性を表すToxicityは低いほど良く、0が有害な要素を検出しなかった状態です。

前節のように保存先を設定し、Mastraに登録したエージェントを実行すると、採点結果を保存してStudioのScorers画面で確認できます。 この二つのプリビルトScorerは理由も生成するので、「関連性0.4」で終わらず「質問は返品期限を聞いているのに、回答は配送状況しか述べていない」といった説明が読めます。

カスタムの Scorer を作る

業務固有の要件は自作します。 ここではデータセットの各質問に期待するキーワードを付け、その文字列が回答に含まれるかを判定します。 返品期限なら「14日以内」、配送状況なら「発送済み」という指定です。

// src/mastra/scorers/expected-text.ts
import { createScorer } from "@mastra/core/evals";
import { getAssistantMessageFromRunOutput } from "@mastra/evals/scorers/utils";

export const expectedTextScorer = createScorer({
  id: "expected-text",
  description: "データセットで指定したキーワードが回答に含まれるか",
  type: "agent",
})
  .generateScore(({ run }) => {
    const output = getAssistantMessageFromRunOutput(run.output) ?? "";
    const expected = run.groundTruth;
    if (typeof expected !== "string" || expected.length === 0) {
      throw new Error("groundTruthに期待するキーワードを指定してください");
    }
    return output.includes(expected) ? 1 : 0;
  })
  .generateReason(({ score }) =>
    score === 1 ? "期待するキーワードを含む" : "期待するキーワードを含まない",
  );

エージェント用Scorerの run.input はメッセージ配列そのものではなく、inputMessages などを持つオブジェクトです。 出力の content も単純な文字列とは限らないため、テキスト抽出用の関数を使います。

このScorerは groundTruth が必要なので、後述するデータセット評価用です。 期待値がない本番リクエストにそのままアタッチする用途には向きません。 また、「14日以内の返品はできません」でもキーワードが含まれれば1点になります。 意味の正しさや言い換えまで評価する場合は、LLMを使った判定や人の確認を組み合わせます。

createScorer は前処理、判定、理由生成の各ステップをチェーンで組み立てられ、判定にLLMを使うこともできます。 まずは上のような単純な判定から始め、目視で拾った失敗パターンを一つずつScorerに落としていくのが現実的です。

データセットで一括評価する

指示文を変えるたびに手で試すのではなく、質問と期待値のセットに対してまとめて採点します。

先に、src/mastra/index.ts にScorerの登録を追加します。 前節の設定に expectedTextScorer を加えた全体は、次のとおりです。

// src/mastra/index.ts
import { Mastra } from "@mastra/core";
import { LibSQLStore } from "@mastra/libsql";
import { Observability, MastraStorageExporter } from "@mastra/observability";
import { supportAgent } from "./agents/support-agent";
import { expectedTextScorer } from "./scorers/expected-text";

export const mastra = new Mastra({
  agents: { supportAgent },
  scorers: { expectedText: expectedTextScorer },
  storage: new LibSQLStore({
    id: "support-storage",
    url: "file:./mastra.db",
  }),
  observability: new Observability({
    configs: {
      default: {
        serviceName: "support-app",
        exporters: [new MastraStorageExporter()],
      },
    },
  }),
});

登録しておくと、Mastra経由で評価した結果を保存し、Studioからも採点器を選べます。

// src/evaluate.ts
import { runEvals } from "@mastra/core/evals";
import { mastra } from "./mastra";
import { expectedTextScorer } from "./mastra/scorers/expected-text";

const result = await runEvals({
  target: mastra.getAgent("supportAgent"),
  data: [
    { input: "返品したいです", groundTruth: "14日以内" },
    { input: "ORD-12345 はいつ届きますか", groundTruth: "発送済み" },
    {
      input: "商品到着後、何日以内なら返品できますか",
      groundTruth: "14日以内",
    },
  ],
  scorers: [expectedTextScorer],
  concurrency: 3,
});

console.log(result.scores);
await mastra.observability.flush();
npx tsx --env-file=.env src/evaluate.ts

groundTruth は書くだけで自動的に照合されるわけではなく、Scorer側で run.groundTruth を参照して採点します。 この例なら、全件に「対応できません」と回答した場合、3件とも0点になります。 対象外の質問を無条件に満点にすると、実際には評価していない回答が平均点を押し上げてしまいます。

result.scores にはScorerごとの平均点が入ります。 このスクリプトは点数を表示するだけなので、CIで変更を止めたい場合は、許容する下限を決めてアサーションも追加します。 指示文やモデルを変える前後で同じデータセットを実行すると、定義した評価基準に対する変化を比較できます。 本番で拾った失敗ケースをデータセットに足していくと、評価が実態に沿って育ちます。

Studio で見る

Studioでは、各エージェントのEvaluateタブからScorerとデータセットを管理し、実験を実行できます。 expectedTextScorer を使う場合は、データセットの各項目に groundTruth を設定します。 Observabilityセクションでは、記録済みのトレースを選び、関連性や有害性など、正解データを必要としないScorerをその場で走らせられます。 本番で見つけた失敗ケースをデータセットに保存し、次の評価に加える、という流れがUI上で完結します。

promptfoo との使い分け

評価ツールとしてpromptfooを使っている場合、役割が重なるように見えます。 違いは立ち位置です。 promptfooはアプリケーションの外側からプロンプトとモデルを比較するテストランナーで、フレームワークを問いません。 Mastra Scorersはアプリケーションの内側にあり、本番のトレースに対してそのまま採点できます。 Mastraで作るならScorersが第一候補で、複数のアプリケーションを横断してモデルを比較したいときにpromptfooを足す、という組み合わせが自然です。

promptfoo 入門:チャットボットの「答えが合っているか」をテストする最短の方法

LLMチャットボットの回答をpromptfooで自動テストする入門記事です。YAMLで書く最初の設定、contains・llm-rubric・similarの使い分け、simulated-userによる多ターン会話のテスト、GitHub Actionsでの回帰テスト化までを、ECサポートボットの例で順に解説します。

2026年9月24日
この記事をシェア