メール返信エージェントの基本形を動かしてみよう
前回はAI
本回のゴールは、メール返信エージェントの基本形を動かすことです。具体的には次の3つを目指します。
- AIエージェントの基本形
(LLM・ Tools・ 指示文をどう束ねるか、Workflow との役割分担、Human-in-the-loopの入れ方) を概念として理解する - Mastraを使い、メールを読み取って返信案を生成するエージェントを実装する
- 生成された返信案をSlackに通知し、人間が承認/却下できるフローを構築する
「過去のやり取りの記憶」
なお本稿では、
環境構築とリポジトリの入手
サンプル実装は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 起動
詳細なセットアップ手順
AIエージェントの基本構成
実装に入る前に、AIエージェントの基本構成を押さえておきましょう。特定のフレームワークに依らず、AIエージェントは大きく次の3つの要素から組み上がっています。
- LLM
(モデル) : エージェントの「頭脳」。本連載ではGoogle GeminiのFlashモデル (google/ gemini-3-flash-preview) を採用します - Tools
(ツール) : 外部とつながる「手足」。API呼び出しやDB操作など、LLM単体ではできない処理を担います - 指示文
(Instructions) : エージェントの振る舞いを定義するシステムプロンプト
他のフレームワークを使う場合でも
メール返信エージェントの実装
それでは実際にMastraを使って、
Tools
コードを書き始める前に、まずToolsの考え方を整理しておきましょう。
Tools
Mastraでは、ツールをcreateToolで定義します。たとえばMailpitから受信メールを取得するツールは、こんな雰囲気です
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に
エージェントにToolsを登録する
ツールが用意できたら、次はそれをエージェント本体に渡してあげる番です。Mastraでは、複数のツールと指示文
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は後ほどの
notice: 補足: 定義したエージェントをMastra({ agents })に登録すると、pnpm devで起動するMastra Studioから対話的にテストできます。ツールやエージェントの実装の全文はsrc/
Human-in-the-loopで安全に運用する
ここまででエージェントが返信案を生成できるようになりました。ここで一度立ち止まって考えたいのが、
近年のAIエージェント研究でも、影響の大きい操作までを完全に自動化してしまう設計は主流ではなく、重要な意思決定の場面では人間が判断のループに残るという考え方が広く支持されています。この設計パターンがHuman-in-the-loop
本節ではこのHITLの考え方を整理しつつ、実際にメール送信の直前に人間の確認ステップを挟む実装を試してみます。
Human-in-the-loopとは
Human-in-the-loop
LLMは確率的に動く以上、どれほど精度を上げても完全に信頼することはできません。そのため、次のような観点で人間の介在点を設けるのが一般的です。
- 後戻りできない操作を止める: メール送信・
決済・ 本番DB書き込みなど、暴走時に物理的にブロックする - ハルシネーションの最終チェック: 事実誤りや誤った宛先を実行前に弾く
- 段階的な自動化: 導入直後は全件承認、精度が見えてきたら一部自動通過、と運用調整できる
HITLはメール送信に限らず幅広い業務で応用できます。
| シーン | エージェントが任される範囲 | 確認するタイミング | 確認する内容 |
|---|---|---|---|
| 経費精算・ |
領収書の読み取り・ |
申請を提出する直前 | 金額・ |
| カスタマーサポート | 問い合わせ分類・ |
顧客に返信を送る直前 | 返信文面が顧客に送って問題ない内容か |
| SNS・ |
原稿の生成・ |
投稿ボタンを押す直前 | 公開しても問題のない内容になっているか |
本連載のメール返信エージェントも、この表のうち
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が呼ばれるので、メール送信のように
なお、inputSchemaに含まれるsummaryは、このあと触れるSlack上の承認カードに表示する確認文として使われます。
pnpm devで起動したMastra Studioでエージェントがこのツールを呼ぼうとすると、承認プロンプトが画面に割り込みます。人間が
チームで運用する:Slack Channels
Studio上の承認は開発時には十分便利ですが、実運用となると話は変わってきます。承認を返すのは業務担当者であることが多く、その人たちの日常動線、つまりSlackやTeamsなどのコミュニケーションツールから承認できるようにしたいはずです。
Mastraには主要なメッセージングプラットフォームと接続するためのChannels機能が組み込まれています。エージェント定義にSlackアダプタを渡すだけで、先ほどの承認ダイアログがそのままSlackのインタラクティブカードのApprove/
まずは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の返信メッセージとして直接投稿されます。Webhook配線やchat.
Slack上で承認カードを受け取る
先ほど扱ったrequireApproval: trueによる承認ダイアログも、Channels機能を有効にしておくだけでそのままSlackのインタラクティブカードとして流れてきます。業務担当者はSlackの日常動線の中でApprove/
なお本サンプルでは、承認カードと一緒に送信予定の本文をSlackのテキストスニペット
Workflowで定型業務を切り出す
ここまでで
- 候補日時を調べる
- 候補のお店を調べる
- おすすめを1つ選んでSlackに画像付きで提案する
という流れが毎回ほぼ同じです。こうした定型処理まで毎回LLMに判断させてしまうと、精度面でもコスト面でも無駄が多く、しかも手順を勝手に省略したり入れ替えたりするリスクもついて回ります。
そこで多くのフレームワークは、
WorkflowとAgentの使い分け
| Agent | Workflow | |
|---|---|---|
| 振る舞い | 自律的に判断・ |
決まった手順で実行する |
| 強み | 柔軟で臨機応変に動ける | 精度・ |
| 弱み | 手順が揺れる・ |
柔軟性に欠ける |
判断基準はシンプルで、
サンプル実装の概要
例として、会食提案のワークフローをinvestigate
export const proposalWorkflow = createWorkflow({ id, inputSchema, outputSchema })
.then(investigateStep)
.then(planStep)
.then(proposeStep)
.commit();
各ステップはcreateStep({ id, inputSchema, outputSchema, execute })で定義し、入出力をZodで型付けします。Toolsとほぼ同じ書き味で、ステップ間の入出力も型安全に流れていきます。
notice: 補足: 外部連携は一部モック化
レストラン検索やカレンダーAPIなど外部サービス呼び出しは、本サンプルではモック化しています
エージェントにWorkflowを登録する
Workflowが用意できたら、Toolsと同じようにエージェント本体へ渡すだけで使えるようになります。Mastraでは、Agentクラスのworkflowsフィールドに登録するだけです。
export const mailReplyAgent = new Agent({
/* ... */
workflows: { proposalWorkflow },
});
こうしておくと、エージェントからツールと同じ感覚でワークフローを呼び出せるようになります。自律判断が欲しい部分はAgent、手順が決まっている部分はWorkflowという役割分担を持ち込むと、エージェント全体の設計がぐっと見通しやすくなります。
発展:特化エージェントとStructured Output
本記事では扱いませんでしたが、Workflowの中で特定の目的に特化した小さなエージェントを立ち上げる、というやり方もあります。
たとえば今回の例で言えば、ワークフロー内のplanStep
この方式を取るときに気になるのが、特化エージェントの出力を後続ステップで安定して再利用できるかです。LLMの自由記述を後続のZodスキーマに無理矢理流し込むのは脆く、型定義の利点も失われがちです。
そこで適しているのが、Structured Outputagent.を使うと、エージェントの応答を決まったスキーマの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フィールドで
なお第4回では、承認ゲート付きの本番エージェントを評価実行から外すために、ツールを外した
