作りながら学ぶAIエージェント

第2回AIエージェントの基礎を作ろう
~Agent⁠Tools⁠Workflow⁠Channels / HITL (Human In The Loop)

エージェントがSlack上で受信メール4件を取引先ごとに要約して返している様子

メール返信エージェントの基本形を動かしてみよう

前回はAI(LLM)とAIエージェントの違いを整理し、完成形のメール返信エージェントのデモをお見せしました。今回からはいよいよ実装に入ります。

本回のゴールは、メール返信エージェントの基本形を動かすことです。具体的には次の3つを目指します。

  1. AIエージェントの基本形(LLM・Tools・指示文をどう束ねるか、Workflow との役割分担、Human-in-the-loopの入れ方)を概念として理解する
  2. Mastraを使い、メールを読み取って返信案を生成するエージェントを実装する
  3. 生成された返信案をSlackに通知し、人間が承認/却下できるフローを構築する

「過去のやり取りの記憶」「安全性のチェック」については、次回以降で順番に追加していきます。

なお本稿では、「AIエージェントはどんな機能要素から組み上がるのか」という汎用的な概念を学ぶことに主眼を置いています。そのうえで Mastra のサンプル実装を眺めながら、全体の構造を掴んでいただくのがゴールです。実装の細部や1行ずつの解説については、コードの完全形をリポジトリで公開していますので、そちらを参考にしてください。フレームワークが変わってもそのまま使える知識を、まずは一緒に押さえていきましょう。

環境構築とリポジトリの入手

サンプル実装はGitHubで公開しています。

git clone git@github.com:ToyB0x/gihyo-article-ai-agent.git
cd gihyo-article-ai-agent
pnpm install && cp .env.example .env  # GOOGLE_GENERATIVE_AI_API_KEY を設定
docker compose up -d                  # Mailpit を起動(受信用 / 送信用の 2 インスタンス)
pnpm seed && pnpm dev                 # デモメール投入 → Mastra Studio 起動

詳細なセットアップ手順(Slack Appの準備、Mailpitを2インスタンスに分けている理由など)リポジトリ READMEを参照してください。本連載は、Mastra公式チュートリアルよりやや高度な構成のため、初めての方は公式チュートリアルを一度動かしてから戻ってくると理解が深まります。

AIエージェントの基本構成

実装に入る前に、AIエージェントの基本構成を押さえておきましょう。特定のフレームワークに依らず、AIエージェントは大きく次の3つの要素から組み上がっています。

  • LLM(モデル): エージェントの「頭脳⁠⁠。本連載ではGoogle GeminiのFlashモデル(google/gemini-3-flash-preview)を採用します
  • Tools(ツール): 外部とつながる「手足⁠⁠。API呼び出しやDB操作など、LLM単体ではできない処理を担います
  • 指示文(Instructions): エージェントの振る舞いを定義するシステムプロンプト

他のフレームワークを使う場合でも「LLM・Tools・指示文を束ねる」という構造そのものは共通で、名前や書き方が変わるだけです。ここを理解しておけば、別の選択肢に乗り換えるときにも同じ感覚で組めます。

メール返信エージェントの実装

それでは実際にMastraを使って、⁠受信メールを読み、返信案を生成するエージェント」を順番に組み上げていきましょう。

Tools

コードを書き始める前に、まずToolsの考え方を整理しておきましょう。

Tools(ツール)は、LLMに外部機能を使わせるための仕組みです。関数の「名前・入出力スキーマ・説明」をLLMに渡しておくと、LLMが必要に応じて呼び出しを指示してくれます。Tool Calling/Function Callingとも呼ばれ、主要なLLMプロバイダが共通して備えている機能です。これがあることで、⁠外部の状況を取りに行き、その結果を踏まえて次の判断をする」という流れをLLMに任せられるようになります。

Mastraでは、ツールをcreateToolで定義します。たとえばMailpitから受信メールを取得するツールは、こんな雰囲気です(mailSchemaなどの型定義はリポジトリを参照してください⁠⁠。

export const getMailTool = createTool({
  id: "get-mail",
  description: `メール受信箱から、取引先などから届いたメール一覧を取得する。`,
  outputSchema: z.object({ mails: z.array(mailSchema) }),
  execute: async () => {
    const res = await fetch(`${env.mailpitInboxApi}/messages`);
    return { mails: (await res.json()).messages.map(normalize) };
  },
});

ポイントは、descriptionに「このツールが何をするか」を簡潔に書くことです。LLMはこの情報を頼りに、ツールを呼び出すべき場面を判断します。

エージェントにToolsを登録する

ツールが用意できたら、次はそれをエージェント本体に渡してあげる番です。Mastraでは、複数のツールと指示文(instructions⁠⁠・モデル(model)をAgentクラスで1つにまとめて定義します。

export const mailReplyAgent = new Agent({
  id: "mail-reply-agent",
  name: "Mail Reply Agent",
  instructions: "あなたはメール返信の担当者です…",
  model: "google/gemini-3-flash-preview",
  tools: agentTools,  // getMailTool / planTool / slackSnippetTool / sendMailTool
});

toolsに渡したツール群は、LLMが「いま使うべき」と判断したタイミングで自動的に呼び出されるようになります。エージェントの個性は、この「どのツールを持たせるか⁠⁠ × ⁠指示文でどう振る舞わせるか」の組み合わせで決まる、と考えると分かりやすいでしょう。なお、一緒に渡しているplanToolは後ほどの「Workflowで定型業務を切り出す」の項で登場するワークフローを起動する前に挟む承認ゲート用のツールです。

notice: 補足: 定義したエージェントをMastra({ agents })に登録すると、pnpm devで起動するMastra Studioから対話的にテストできます。ツールやエージェントの実装の全文はsrc/mastra/tools/src/mastra/agents/を参照してください。

Mastra Studioに登録されたMail Reply Agent

Human-in-the-loopで安全に運用する

ここまででエージェントが返信案を生成できるようになりました。ここで一度立ち止まって考えたいのが、「人間が内容を確認しないまま、エージェントに勝手に返信を送らせてしまって本当に良いのか?」という問いです。

近年のAIエージェント研究でも、影響の大きい操作までを完全に自動化してしまう設計は主流ではなく、重要な意思決定の場面では人間が判断のループに残るという考え方が広く支持されています。この設計パターンがHuman-in-the-loop(HITL)です。

本節ではこのHITLの考え方を整理しつつ、実際にメール送信の直前に人間の確認ステップを挟む実装を試してみます。

Human-in-the-loopとは

Human-in-the-loop(HITL)とは、エージェントの判断をそのまま実行に移すのではなく、影響の大きい操作の直前に人間による最終確認を差し挟む設計パターンです。多くのフレームワークで「承認が必要なツール」⁠承認待ちキュー」といった形で標準的に提供されています。

LLMは確率的に動く以上、どれほど精度を上げても完全に信頼することはできません。そのため、次のような観点で人間の介在点を設けるのが一般的です。

  • 後戻りできない操作を止める: メール送信・決済・本番DB書き込みなど、暴走時に物理的にブロックする
  • ハルシネーションの最終チェック: 事実誤りや誤った宛先を実行前に弾く
  • 段階的な自動化: 導入直後は全件承認、精度が見えてきたら一部自動通過、と運用調整できる

HITLはメール送信に限らず幅広い業務で応用できます。

シーン エージェントが任される範囲 確認するタイミング 確認する内容
経費精算・稟議 領収書の読み取り・申請内容の下書き 申請を提出する直前 金額・科目・領収書の対応に誤りがないか
カスタマーサポート 問い合わせ分類・下書き生成 顧客に返信を送る直前 返信文面が顧客に送って問題ない内容か
SNS・ブログ投稿 原稿の生成・タイトル案の提案 投稿ボタンを押す直前 公開しても問題のない内容になっているか

本連載のメール返信エージェントも、この表のうち「下書きはAI、送信は人間」という典型的なHITL構成の一例です。

Mastraでの実装⁠requireApprovalフラグ

Mastraでは、ツール定義にrequireApproval: trueを付けておくだけで、そのツールを呼び出す前に承認ダイアログを自動で割り込ませることができます。

export const sendMailTool = createTool({
  id: "send-mail",
  requireApproval: true, // ← この1行で承認ゲート化
  inputSchema: z.object({ to, title, message, summary /* ... */ }),
  execute: async (input) => {
    // 承認後に以下が実行され、実際のメール送信が行われる
    await fetch(`${env.mailpitOutboxApi}/send`, { /* ... */ });
    // 続けて Slack スレッドへ「送信完了」の通知も同じ execute 内で投稿
    await postTextToSlack(`✅ 送信完了: ${input.title}`, channelCtx);
  },
});

requireApprovalの挙動はシンプルで、ツールのexecuteが走る前に承認ステップを挟むだけです。承認を得てから初めてexecuteが呼ばれるので、メール送信のように「失敗したら取り返しがつかない処理」も、後続の通知処理も、execute内に素直にまとめて構いません。本サンプルでも、メール送信からSlackへの完了通知投稿までを1つのsendMailTool.executeの中で完結させています。

なお、inputSchemaに含まれるsummaryは、このあと触れるSlack上の承認カードに表示する確認文として使われます。

pnpm devで起動したMastra Studioでエージェントがこのツールを呼ぼうとすると、承認プロンプトが画面に割り込みます。人間が「Approve」を押すまで、executeは動きません。

Mastra Studioに割り込んだ承認プロンプト — Approve/Declineを選択できる

チームで運用する⁠Slack Channels

Studio上の承認は開発時には十分便利ですが、実運用となると話は変わってきます。承認を返すのは業務担当者であることが多く、その人たちの日常動線、つまりSlackやTeamsなどのコミュニケーションツールから承認できるようにしたいはずです。

Mastraには主要なメッセージングプラットフォームと接続するためのChannels機能が組み込まれています。エージェント定義にSlackアダプタを渡すだけで、先ほどの承認ダイアログがそのままSlackのインタラクティブカードのApprove/Denyボタンに姿を変えます。requireApproval自体の設定はそのままで構いません。

まずはSlackチャンネルを定義します。

export const slackChannel = {
  slack: {
    adapter: createSlackAdapter({
      botToken: process.env.SLACK_BOT_TOKEN,
      signingSecret: process.env.SLACK_SIGNING_SECRET,
    }),
  },
};

あとはエージェントに登録するだけです。

export const mailReplyAgent = new Agent({
  /* ... */
  channels: { adapters: slackChannel },
});

Channels機能を追加すると、まずユーザがメンションを送った直後に「タイピング中…」インジケータが自動で挿入されるようになります。エージェントが応答を組み立てている最中であることがSlack上で可視化されるため、待たされている感覚が和らぎます。

Slackで@ai-agentにメンションした直後、Channels機能が自動で挿入する「Typing...」インジケーター

続いて、エージェントが処理を終えると、その応答がそのままSlackの返信メッセージとして直接投稿されます。Webhook配線やchat.postMessageの組み立てを自前で書く必要はなく、エージェントが生成したテキストがそのままスレッドに流れます。

エージェントが受信メール4件を取引先ごとに要約してスレッドに返している様子

Slack上で承認カードを受け取る

先ほど扱ったrequireApproval: trueによる承認ダイアログも、Channels機能を有効にしておくだけでそのままSlackのインタラクティブカードとして流れてきます。業務担当者はSlackの日常動線の中でApprove/Denyを押せるため、HITLのフローがSlackだけで完結します。

承認スレッドにsendMailToolのApprove/Denyボタン付き承認カードが流れている様子(送信前のメール本文プレビューも併せて表示)

なお本サンプルでは、承認カードと一緒に送信予定の本文をSlackのテキストスニペット(折りたたみ可能なファイル添付)として並べることで、人間が最終判断を下しやすくしています。実装はsrc/mastra/channels/slack-snippet.tsを参照してください。

Workflowで定型業務を切り出す

ここまでで「ユーザの依頼を受けて自律的に判断するエージェント」が動くようになりました。一方で実務の現場には、手順がきっちり決まっているタスクもたくさんあります。たとえば「会食候補を提案する」業務は、

  1. 候補日時を調べる
  2. 候補のお店を調べる
  3. おすすめを1つ選んでSlackに画像付きで提案する

という流れが毎回ほぼ同じです。こうした定型処理まで毎回LLMに判断させてしまうと、精度面でもコスト面でも無駄が多く、しかも手順を勝手に省略したり入れ替えたりするリスクもついて回ります。

そこで多くのフレームワークは、⁠決まった手順」「自律判断」を組み合わせて使えるよう、ワークフローやそれに類する仕組みを用意しています。MastraではWorkflowとして提供されている機能がこれに当たります。

WorkflowとAgentの使い分け

Agent Workflow
振る舞い 自律的に判断・行動する 決まった手順で実行する
強み 柔軟で臨機応変に動ける 精度・再現性・コスト効率が高い
弱み 手順が揺れる・コストが嵩む 柔軟性に欠ける

判断基準はシンプルで、どちらに決めさせたいかです。本連載のエージェントではプリミティブな操作はTool、決まった手順はWorkflowとしてまとめ、エージェントからはどちらも同じように「呼べる機能」として扱えるようにしています。

サンプル実装の概要

例として、会食提案のワークフローをinvestigate(候補日時と店舗を調査⁠⁠→plan(おすすめを選定⁠⁠→propose(Slackに画像付きで投稿)の3ステップで定義しています。

export const proposalWorkflow = createWorkflow({ id, inputSchema, outputSchema })
  .then(investigateStep)
  .then(planStep)
  .then(proposeStep)
  .commit();

各ステップはcreateStep({ id, inputSchema, outputSchema, execute })で定義し、入出力をZodで型付けします。Toolsとほぼ同じ書き味で、ステップ間の入出力も型安全に流れていきます。

notice: 補足: 外部連携は一部モック化

レストラン検索やカレンダーAPIなど外部サービス呼び出しは、本サンプルではモック化しています(メール周りはMailpitをDockerで立てて本物に近い挙動にしています⁠⁠。実プロジェクトでは、ここをGoogle Calendar APIやグルメ系のAPIに差し替える必要があります。

エージェントにWorkflowを登録する

Workflowが用意できたら、Toolsと同じようにエージェント本体へ渡すだけで使えるようになります。Mastraでは、Agentクラスのworkflowsフィールドに登録するだけです。

export const mailReplyAgent = new Agent({
  /* ... */
  workflows: { proposalWorkflow },
});

こうしておくと、エージェントからツールと同じ感覚でワークフローを呼び出せるようになります。自律判断が欲しい部分はAgent、手順が決まっている部分はWorkflowという役割分担を持ち込むと、エージェント全体の設計がぐっと見通しやすくなります。

発展⁠特化エージェントとStructured Output

本記事では扱いませんでしたが、Workflowの中で特定の目的に特化した小さなエージェントを立ち上げる、というやり方もあります。

たとえば今回の例で言えば、ワークフロー内のplanStep(先ほど登場した承認ゲート用のplanToolとは別物で、ワークフロー内で第一候補を選び出すステップのこと)のような「候補の中から第一候補を選び、選定理由を文章で添える」処理は、それ専用の小さな会食の計画エージェントを作ってそこに任せると素直にハマるケースです。

この方式を取るときに気になるのが、特化エージェントの出力を後続ステップで安定して再利用できるかです。LLMの自由記述を後続のZodスキーマに無理矢理流し込むのは脆く、型定義の利点も失われがちです。

そこで適しているのが、Structured Output(構造化出力)という考え方です。Mastraではagent.generate({ structuredOutput: { schema } })を使うと、エージェントの応答を決まったスキーマのJSONとして受け取れます。

// 概念例: 計画エージェントの出力を構造化する
const result = await planAgent.generate(prompt, {
  structuredOutput: { schema: planSchema },  // { recommendedSlot, recommendedRestaurant, rationale }
});
return { ...inputData, plan: result.object };

これで後続のproposeStepは、LLMの出力を型安全に扱えます。

まとめと次回予告

本回では、Mastraを使ってメール返信エージェントの基本形を組み上げました。整理すると、登場した要素は次のとおりです。

  • Agent: LLM・指示文・ツールを束ねたエージェントを定義
  • Tools: LLMに外部機能を使わせる仕組み(Tool Calling/Function Calling)
  • Human-in-the-loop: requireApproval 1行でexecuteの手前に承認ゲートを挿入。承認後に送信処理が走る
  • Channels: 承認カードをSlackの日常動線へそのまま流す
  • Workflow: 決まった手順を直列ステップに切り出し、エージェントからツールとして呼ぶ

一方で、現時点のエージェントにはまだ次のような課題が残っています。

  • 記憶がない:同じ相手から2回目のメールが来ても、1回目のやり取りを覚えていない
  • 安全性が未考慮:個人情報や悪意あるプロンプトが紛れ込んでも検出できない

次回の第3回では、memoryフィールドで「記憶」を、inputProcessors/outputProcessorsフィールドで「安全性」をエージェントに追加していきます。さらに第4回では、scorersフィールドで応答品質を継続的に評価する仕組みを組み込みます。

なお第4回では、承認ゲート付きの本番エージェントを評価実行から外すために、ツールを外した「評価専用エージェント(mailReplyEvalAgent⁠⁠」も併用します(リポジトリにはすでに登録済みです⁠⁠。リポジトリの最終形にはこれらの要素がすべて入っているので、先に覗いてみると「これからどう育っていくか」のイメージが掴めるはずです。お楽しみに。

おすすめ記事

記事・ニュース一覧