⚗️

Mastra + MCP + AWS Lambdaで作るエンジニア求人提案AIエージェント

に公開

はじめに

Mastra と LAPRAS MCP Server を活用し、AWS Lambda 上で動作するエンジニア求人提案 AI エージェントをつくってみました。
ユーザーが指定した要件に基づき、最適な求人情報を自動的に検索・分析し、Slack 通知を行います。

Github にコードを公開しています。
https://github.com/2bo/personal-recruit-agent

Mastra のバージョンについて

本記事の執筆時点では version 0.10.10 を使用しています。

実行結果

先に実行結果の例を紹介します。

求人要件の入力例

- LLMをつかったプロダクトを開発したい
- 機械学習やAIエンジニアの経験がもとめられない
- 自社開発
- フルリモート可
- できれば、生成AIツールを開発に活発に活用している企業
- マネージャーやテックリード職ではない

結果例

この結果では、合致した求人が 2 件通知されています。
実際の求人表の内容も確認したところ、要件に対してある程度合致している内容でした。

Slackでの求人通知結果

全体構成

AWS の EventBridge Scheduler で定期的に Lambda 関数をトリガーし、求人検索エージェントを実行します。
内部処理としては Mastra のワークフローで指定されたユーザー要件に基づき、要件を整理し、求人を検索・分析して、要件に合致した求人を Slack に通知します。
ワークフローでは以下のエージェントを使用しています。

  • ChecklistAgent: ユーザーの要件を Markdown 形式のチェックリストに変換
  • JobSearchAgent: 求人を検索
  • JobMatcherAgent: 求人とチェックリストの詳細マッチング分析

求人の検索と取得はLAPRAS MCP Serverを使用し、 Slack 通知は Webhook API で行います。

Mastra のワークフロー概要

Mastra で構築したワークフローの概要は次の通りです。以降で各ステップの詳細を説明します。

ワークフロー詳細

最初に複数のステップに関連する、Memory や MCP サーバーの設定について説明します。
その後に、全体のフローと各ステップの詳細を説明します。

Memory の設定

LibSQLStore を使用しています。:memory:を指定することで、インメモリで動作するようにしています[1]
永続化はされませんが、今回は AWS Lambda で実行することから、毎回新しいセッションで実行されるため、インメモリで十分と判断しました。

src/mastra/index.ts
import { Mastra } from "@mastra/core";
import { LibSQLStore } from "@mastra/libsql";
import { PinoLogger } from "@mastra/loggers";
import { JobSearchAgent } from "./agents/job-search-agent";
import { ChecklistAgent } from "./agents/checklist-agent";
import { JobMatcherAgent } from "./agents/job-matcher-agent";
import { recruitWorkflow } from "./workflows/recruit-workflow";

const storage = new LibSQLStore({
  url: ":memory:",
});
// 中略
export const mastra = new Mastra({
  storage,
  logger,
  agents: { JobSearchAgent, ChecklistAgent, JobMatcherAgent },
  workflows: { recruitWorkflow },
});

Agent が Memory を使用する場合は、resourceId と threadId を指定する必要があります。
次のように設定しています。

src/mastra/workflows/recruit-workflow.ts
const workflowSessionIds = {
  resourceId: "anonymous-job-seeker",
  threadId: randomUUID(),
};

今回、メモリを永続化しないので、resourceId は固定の値を設定しています。
threadId はデバッグ時やログを出す際に識別しやすくする狙いで、ランダムな UUID を生成しています。

Lapras MCP Server の設定

求人の検索、取得を行うために、https://lapras.com の公式 MCP Serverを呼び出すように設定しています。
今回、Production 環境として利用する AWS Lambda では npx が実行できないため、MCP Server を node で実行するように設定しています。

src/mastra/mcp-client/lapras-mcp.ts
import { MCPClient } from '@mastra/mcp';

const isProduction = process.env.NODE_ENV === 'production';
const serverConfig = isProduction
  ? {
      command: 'node',
      args: ['node_modules/@lapras-inc/lapras-mcp-server/dist/index.js'],
    }
  : { command: 'npx', args: ['-y', '@lapras-inc/lapras-mcp-server'] };

export const LaprasMCP = new MCPClient({
  id: 'lapras-mcp',
  servers: {
    lapras: {
      ...serverConfig,
      logger: logMessage => {
        console.log(
          `[MCP] [${logMessage.level}] ${logMessage.serverName}: ${logMessage.message}`
        );
        if (logMessage.details) {
          console.log(`[MCP] Details:`, logMessage.details);
        }
      },
      enableServerLogs: true,
    },
  },
});

ステップの流れ

ワークフローのステップの流れは次のように定義されています。
基本的に then による直列実行ですが、jobMatchingStepだけは、foreach と concurrency を使用して、並行処理ができるようになっています。

もともと concurrency は 2 を指定していましたが、私のアカウントの Tier では OpenAI の TPM 制限に引っかかったため、1 にしています。

src/mastra/workflows/recruit-workflow.ts
recruitWorkflow
  .then(checklistStep)
  .then(jobSearchStep)
  .then(filterRecentJobsStep)
  .foreach(jobMatchingStep, { concurrency: 1 }) // モデルやTierによってconcurrencyは調整する
  .then(filterMatchingResults)
  .then(slackNotificationStep);

ステップ 1: 要件の構造化 (checklistStep)

ChecklistAgentがユーザーの自然言語要件を優先度付き Markdown チェックリストに変換します。

ChecklistAgent の実装

ユーザーからの求人要件を受け取り、Markdown 形式のチェックリストに整理する指示を与えています。
必須条件や高優先度条件を特定するためのガイドラインも含めています。

src/mastra/agents/checklist-agent.ts
export const ChecklistAgent = new Agent({
  name: "ChecklistAgent",
  instructions: `あなたは求人条件を整理するスペシャリストです。ユーザーの求人の条件や要望を、優先度・重要度を明確化した構造化されたMarkdown形式のチェックリストに変換します。

## 重要な改善点: 条件の優先度・重要度の明確化
ユーザーの表現から条件の重要度を正確に判定し、構造化されたチェックリストを作成してください。

## 優先度判定の指針
1. **必須条件の特定**: 「必須」「絶対」「マスト」「〜でないとダメ」等の表現
2. **高優先度条件の特定**: 「重要」「重視」「優先したい」等の表現
3. **希望条件の特定**: 「希望」「できれば」「理想的には」等の表現
4. **その他条件**: 明確な優先度表現がない条件

// 中略

# 求人条件チェックリスト
## 【必須条件】
*これらの条件を満たしていない求人は除外対象*
- [ ] **[必須と判定した条件]**: [詳細内容]
- [ ] **[必須と判定した条件]**: [詳細内容]

## 【高優先度条件】
*重視したい条件(適合度評価で重要視)*
- [ ] **[高優先度と判定した条件]**: [詳細内容]
- [ ] **[高優先度と判定した条件]**: [詳細内容]

## 【希望条件】
*あれば良いが必須ではない条件*
- [ ] **[希望と判定した条件]**: [詳細内容]
- [ ] **[希望と判定した条件]**: [詳細内容]

## 【基本情報】
- [ ] **職種**: [ユーザーの希望に基づく具体的な職種]
- [ ] **経験年数**: [指定があれば記載、なければ「未指定」]
- [ ] **希望勤務地**: [指定があれば記載、なければ「未指定」]

## 【技術・スキル要件】
- [ ] **関連技術**: [使用技術・ツール名]
- [ ] **開発対象**: [何を開発・構築したいか]
- [ ] **業務内容**: [その技術で具体的に何をしたいか]

## 【給与・待遇条件】
- [ ] **希望年収**: [金額または「未指定」]
- [ ] **福利厚生**: [要望または「未指定」]

## 【働き方の希望】
- [ ] **雇用形態**: [正社員/契約社員/業務委託など または「未指定」]
- [ ] **リモートワーク**: [希望または「未指定」]
- [ ] **勤務形態**: [フレックス、時短勤務、週○勤務など、ユーザーが言及した働き方]

// 中略

必ずMarkdownコードブロック内でチェックリストを出力してください。`,
  model: MODELS.GPT_4_1_MINI,
});

checklistStep の実装

ChecklistAgent を呼び出しているだけで、特に複雑な処理は行っていません。

src/mastra/workflows/recruit-workflow.ts
const checklistStep = createStep({
  id: "create-checklist",
  inputSchema: z.object({
    userRequirements: z.string(),
  }),
  outputSchema: z.object({
    requirementsList: z.string(),
  }),
  execute: async ({ inputData, mastra }) => {
    const result = await ChecklistAgent.generate(inputData.userRequirements);
    const logger = mastra.getLogger();
    logger.info("チェックリスト生成結果:", result.text);
    logger.debug("セッションID:", workflowSessionIds);

    return {
      requirementsList: result.text,
    };
  },
});

ステップ 2: 段階的求人検索 (jobSearchStep)

JobSearchAgent5 段階の検索戦略で目標 10 件以上の求人を発見します。厳密条件から最小条件まで段階的に条件を緩和しながら求人を収集します。

JobSearchAgent の実装

プロンプトには段階的な検索戦略と、Lapras の API 仕様を考慮した検索条件指定の指示を与えています。
Tools に Lapras MCP Server を指定することで、Agent が求人検索 API を呼び出せるようにしています。
Memory を設定し、検索履歴や実行状況を保持するようにしています。

src/mastra/agents/job-search-agent.ts
const jobSearchMemory = new Memory({
  options: {
    lastMessages: 20,
    semanticRecall: false,
    workingMemory: {
      enabled: true,
      template: `# エンジニア転職希望者プロフィール
## 基本情報
- 名前:
- 現在の職種:
- 経験年数:

// 中略

## 検索フェーズ履歴
- Phase 1 (厳密条件): 結果件数 - 使用キーワード -
- Phase 2 (関連語検索): 結果件数 - 使用キーワード -
- Phase 3 (条件緩和): 結果件数 - 使用キーワード -
- Phase 4 (基本条件): 結果件数 - 使用キーワード -
- Phase 5 (最小条件): 結果件数 - 使用キーワード -
`,
    },
  },
});

export const JobSearchAgent = new Agent({
  name: "JobSearchAgent",
  instructions: `あなたは経験豊富なエンジニア専門のリクルーターです。ユーザーの要望に基づいて最適な案件を見つけることが使命です。

## 重要な改善点: 条件解析とキーワード抽出の徹底
ユーザーの要件から**あらゆる条件**を漏れなく抽出し、LAPRAS APIの全パラメータを活用してください。

// 中略

## 段階的検索戦略

### Phase 1: 厳密条件検索
- ユーザー条件を完全に反映した検索
- 結果が0件の場合は即座にPhase 2へ
- 結果が1-9件の場合も追加でPhase 2を実行

### Phase 2: 関連語検索
**AND検索の性質を考慮した条件変更**:
- keyword: Phase 1のキーワードを関連語・類義語に**変更**(追加ではない)
- positions: 関連職種を追加
- 技術系パラメータ: 関連技術を追加
- 必須条件は維持
- **重要**: 前回と同じキーワードは使用しない

### Phase 3: 条件緩和検索
**キーワード数を減らして検索範囲を拡大**:
- keyword: Phase 2よりもキーワード数を削減、または汎用的なキーワードに変更
- positions: 更に幅広い関連職種を追加
- 技術系パラメータ: より幅広い技術を含める
- 必須条件は維持
- **重要**: キーワードを減らすことで結果件数を増やす

// 中略
`,
  model: MODELS.GPT_4_1_MINI,
  memory: jobSearchMemory,
  tools: await LaprasMCP.getTools(),
});

jobSearchStep の実装

jobSearchStep では、checklistStep で作成した条件チェックリストをもとに JobSearchAgent を呼び出して求人を検索し、重複を除外した求人の配列を返すようにしています。

generate でexperimental_outputオプションを使用して、指定したスキーマに基づく構造化出力を要求しています。
tools を利用する Agent の出力を構造化するためには 、experimental_outputを使用する必要があります[2]
なお、Gemini のモデルを指定した Agent で指定すると、実行時にエラーになるため、OpenAI のモデルを使用しています。

複数回 LLM を呼び出す想定のため、maxStepsを指定して、最大 10 回まで段階的に検索を行うようにしています。

src/mastra/workflows/recruit-workflow.ts
const jobSchema = z.object({
  job_description_id: z.string().describe("求人の一意な識別子"),
  title: z.string().describe("求人のタイトル"),
  updated_at: z.string().describe("求人の最終更新日時"),
});

const jobSearchResultSchema = z.object({
  jobs: z.array(jobSchema).describe("検索結果の求人リスト"),
});

const jobSearchStep = createStep({
  id: "search-jobs",
  inputSchema: z.object({
    requirementsList: z.string(),
  }),
  outputSchema: z.array(jobSchema), // Job[]を直接返す
  execute: async ({ inputData, mastra }) => {
    const result = await JobSearchAgent.generate(
      `以下のチェックリストに基づいて求人を検索してください。

${inputData.requirementsList}`,
      {
        experimental_output: jobSearchResultSchema,
        maxSteps: 10,
        threadId: workflowSessionIds.threadId,
        resourceId: workflowSessionIds.resourceId,
      }
    );
    // 重複した求人を除外してJob[]配列を直接返す
    const jobs = result.object.jobs;
    const uniqueJobs = Array.from(
      new Map(jobs.map((job) => [job.job_description_id, job])).values()
    );
    const logger = mastra.getLogger();
    logger.info("求人検索結果:", uniqueJobs);

    return uniqueJobs;
  },
});

ステップ 3: 日付フィルタリング (filterRecentJobsStep)

jobSearchStep で取得した求人から、指定期間内に更新された求人のみを抽出します。
ワークフローを定期実行することを想定をしているため、過去に取得した求人が再度通知されないようにするためのステップです。

filterRecentJobsStep の実装

環境変数JOB_FILTER_DAYS(デフォルト 7 日)で指定された期間内の求人のみを抽出します。

src/mastra/workflows/recruit-workflow.ts
const filterRecentJobsStep = createStep({
  id: "filter-recent-jobs",
  inputSchema: z.array(jobSchema),
  outputSchema: z.array(jobSchema),

  execute: async ({ inputData, mastra }) => {
    const logger = mastra.getLogger();
    const filterDays = Number(process.env.JOB_FILTER_DAYS) || 7;
    const cutoffDate = subDays(new Date(), filterDays);

    const recentJobs = inputData.filter((job) => {
      const jobDate = new Date(job.updated_at);
      return isAfter(jobDate, cutoffDate);
    });

    logger.info(
      `求人日付フィルタリング結果: ${inputData.length.toString()}件 → ${recentJobs.length.toString()}件 (直近${filterDays.toString()}日以内)`
    );

    return recentJobs;
  },
});

ステップ 4: 並列マッチング分析 (jobMatchingStep)

filterRecentJobsStep で抽出された求人を 1 件ずつに対して、JobMatcherAgentが各求人とチェックリストの適合度分析をします。年収条件、技術スタック、働き方条件等を重み付き評価し 0-100%の適合スコアを算出します。

JobMatcherAgent の実装

Lapras MCP Server を使用して求人の詳細を取得し、checklistStep で作成したチェックリストと比較して適合率を算出します。

src/mastra/agents/job-matcher-agent.ts
export const JobMatcherAgent = new Agent({
  name: "JobMatcherAgent",
  instructions: `あなたは経験豊富な転職コンサルタントです。求職者のチェックリストと案件の適合性を正確に分析し、適合率を算出します。

## 主要な役割
1. job_description_idから案件詳細を取得
2. チェックリストから条件を抽出・分類
3. 条件と案件を項目別に比較し適合率を計算
4. **簡潔なマッチ理由**と**プロのエージェントとしてのおすすめ文章**を生成

## 条件抽出と重み配分
- 年収・給与条件(20%の重み)
- 技術スタック(30%の重み)
- 働き方・労働条件(25%の重み)
- 企業・事業条件(15%の重み)
- その他条件(10%の重み)

// 中略

**重要**: 正確性を最優先とし、客観的で一貫した分析を提供してください。記載なし条件は制約なしとして扱い、明示的記載のみで適合度を評価してください。`,
  model: MODELS.GPT_4_1_MINI,
  memory: new Memory({
    options: {
      lastMessages: 10,
      semanticRecall: false,
      workingMemory: {
        enabled: true,
        template: `# 案件適合性分析履歴
- 分析案件数:
- 平均適合率:
- 最高適合率:
- 高適合率案件の特徴:
- 分析精度の改善点: `,
      },
    },
  }),
  tools: await LaprasMCP.getTools(),
});

jobMatchingStep の実装

求人 1 件分の適合度分析をします。
前述の通りこのステップは foreach で逐次実行されるため、各求人に対して個別に適合度分析を行います。
求人の分析に失敗した場合は、適合率 0% として結果を返します。 これはワークフロー全体を停止させないためです。

src/mastra/workflows/recruit-workflow.ts
const jobMatchingStep = createStep({
  id: "job-matching",
  inputSchema: jobSchema,
  outputSchema: matchResultSchema,
  execute: async ({ inputData, getStepResult, mastra }) => {
    const checklistResult = getStepResult(checklistStep);
    const requirementsList = checklistResult.requirementsList;
    const logger = mastra.getLogger();

    try {
      const result = await JobMatcherAgent.generate(
        `以下のチェックリストと求人の適合度を分析してください。

## チェックリスト:
${requirementsList}

## 分析対象求人:
- 求人ID: ${inputData.job_description_id}
- タイトル: ${inputData.title}`,
        {
          experimental_output: matchResultSchema.omit({ success: true }),
          threadId: workflowSessionIds.threadId,
          resourceId: workflowSessionIds.resourceId,
        }
      );
      return { ...result.object, success: true };
    } catch (error) {
      logger.error(
        `求人ID ${inputData.job_description_id} の分析でエラー:`,
        error
      );
      return {
        job_description_id: inputData.job_description_id,
        title: inputData.title,
        url: "",
        companyName: "",
        salaryMin: 0,
        salaryMax: 0,
        positionName: "",
        matchingScore: 0,
        matchingReason: "エラーにより分析できませんでした",
        recommendationReason: "エラーにより分析できませんでした",
        success: false,
      };
    }
  },
});

ここでもexperimental_outputを使用して、matchResultSchemaに基づく構造化出力を要求しています。
matchResultSchema は次のように定義しています。
適合度だけでなく、その理由やプロのエージェントとしてのおすすめ理由も含めて、ユーザーに提供するようにしています。

src/mastra/types/recruitment.ts
import { z } from "zod";

// 求人マッチング結果のスキーマ定義
export const matchResultSchema = z.object({
  job_description_id: z.string(),
  title: z.string(),
  url: z.string().describe("求人のURL"),
  companyName: z.string().describe("会社名"),
  salaryMin: z.number().describe("最低給与"),
  salaryMax: z.number().describe("最高給与"),
  positionName: z.string().describe("職種"),
  matchingScore: z.number().min(0).max(100).describe("適合率(0-100%)"),
  matchingReason: z.string().describe("適合理由"),
  recommendationReason: z
    .string()
    .describe("プロのエージェントとしてのおすすめ理由"),
  success: z.boolean().describe("処理の成功可否"),
});

// 型定義をエクスポート
export type MatchResult = z.infer<typeof matchResultSchema>;

ステップ 5: 結果フィルタリング (filterMatchingResults)

分析結果から適合率 80%以上の高精度マッチのみを抽出し、適合率降順でソートします。分析に失敗した求人は適切に除外され、ユーザーには厳選された求人のみが提示されます。

filterMatchingResults の実装

src/mastra/workflows/recruit-workflow.ts
export const filterMatchingResults = createStep({
  id: "filter-matching-results",
  inputSchema: z.array(matchResultSchema),
  outputSchema: z.array(matchResultSchema),
  execute: async ({ inputData, mastra }) => {
    // 成功したマッチング結果のみをフィルタリング
    const filteredResults = inputData.filter((result) => result.success);

    // 適合率が80%以上のものを抽出
    const highMatchingResults = filteredResults.filter(
      (result) => result.matchingScore >= 80
    );

    // 適合率でソート(降順)
    highMatchingResults.sort((a, b) => b.matchingScore - a.matchingScore);

    return highMatchingResults;
  },
});

ステップ 6: Slack 通知配信 (slackNotificationStep)

最終結果を Slack に通知します。

slackNotificationStep の実装

メッセージを Slack のフォーマットに変換し、Slack Webhook API を使用して通知を送信します。

src/mastra/workflows/recruit-workflow.ts
const slackNotificationStep = createStep({
  id: "slack-notification",
  inputSchema: z.array(matchResultSchema),
  outputSchema: z.object({
    notified: z.boolean(),
    message: z.string(),
    resultCount: z.number(),
  }),
  execute: async ({ inputData, mastra }) => {
    const logger = mastra.getLogger();

    // 結果が空の場合は通知しない
    if (inputData.length === 0) {
      logger.info("マッチング結果が空のため、Slack通知をスキップしました");
      return {
        notified: false,
        message: "No results to notify",
        resultCount: 0,
      };
    }

    try {
      // 求人結果をSlackメッセージ形式にフォーマット
      const slackMessage = formatJobResultsForSlack(inputData, {
        useRichFormat: true,
      });

      // フォーマット結果がnullの場合(空の結果)は既にハンドル済み
      if (!slackMessage) {
        logger.info("求人結果が空のため、Slack通知をスキップしました");
        return {
          notified: false,
          message: "No results to notify",
          resultCount: 0,
        };
      }

      // Slack通知を実行
      const result = await sendSlackNotification(slackMessage);

      logger.info("Slack通知結果:", result);

      return {
        notified: result.notificationSent,
        message: result.message,
        resultCount: inputData.length,
      };
    } catch (error) {
      const errorMessage =
        error instanceof Error ? error.message : String(error);
      logger.error("Slack通知でエラーが発生しました:", errorMessage);

      return {
        notified: false,
        message: `Slack通知エラー: ${errorMessage}`,
        resultCount: inputData.length,
      };
    }
  },
});

ワークフローの説明は以上です。

AWS Lambda へのデプロイ

ここからは、AWS Lambda へのデプロイ方法について説明します。
今回は Container Image を使用しました。

なお、ここでは本プロジェクトを実行するためにしたことの説明にフォーカスし、 基本的な AWS Lambda 自体の設定方法の詳細は省略します。

handler 関数の実装

AWS Lambda 上でワークフローを実行するため、EventBridge からのイベントを処理する専用の handler 関数を作成しています。

EventBridge からのスケジュールイベントを受け取り、detailフィールドからuserRequirementsを抽出します。

src/lambda/index.ts
import { z } from 'zod';
import { mastra } from '../mastra';

interface EventBridgeEvent {
  source: string;
  'detail-type': string;
  detail: {
    userRequirements?: string;
    conditions?: Record<string, unknown>;
  };
}

export const handler = async (event: EventBridgeEvent) => {
  try {
    // EventBridgeからの詳細情報を取得
    const detail = event.detail;

    // userRequirementsを取得(detail直下またはconditions内)
    const userRequirements =
      detail.userRequirements ??
      (detail.conditions?.userRequirements as string);

    if (!userRequirements) {
      throw new Error('userRequirements is required');
    }

    const parsedInput = RecruitmentInputSchema.safeParse({
      userRequirements,
    });

    if (!parsedInput.success) {
      throw new Error(
        `Invalid input: ${parsedInput.error.issues.map(i => i.message).join(', ')}`
      );
    }

    const workflow = mastra.getWorkflow('recruitWorkflow');
    const run = workflow.createRun();
    const result = await run.start({ inputData: parsedInput.data });

    console.log('Recruitment workflow completed successfully');
    return result;
  } catch (error) {
    console.error('Recruitment workflow failed:', error);
    const errorMessage =
      error instanceof Error ? error.message : 'Unknown error';
    throw new Error(`Recruitment workflow failed: ${errorMessage}`);
  }
};

Dockerfile

Container Image 用の Dockerfile です。

# AWS Lambda Node.js 20 runtime
FROM public.ecr.aws/lambda/nodejs:20

WORKDIR /var/task

# 依存関係のインストール
COPY package*.json ./
COPY tsconfig.json ./
RUN npm ci

# アプリケーションコードをコピー
COPY src/ ./src/

# Mastra本体をビルド
RUN npm run build

# Lambda関数をesbuildでコンパイル
RUN npx esbuild src/lambda/index.ts \
  --bundle \
  --platform=node \
  --target=node20 \
  --format=esm \
  --outfile=index.mjs \
  --packages=external \
  --keep-names

# Lambda Runtime Interface Client を設定
CMD ["index.handler"]

Dockerfile の中で、Lambda 関数を esbuild を使用してビルドしています:
ここで、--packages=externalを指定しているのは次の理由によります。

  1. MCP Server の子プロセス起動
    Lapras MCP Server を node_modules から直接実行するため、esbuild でバンドルすると、パッケージが見つからずエラーになります。
// 本番環境でのMCP Server起動コマンド
command: 'node',
args: ['node_modules/@lapras-inc/lapras-mcp-server/dist/index.js']
  1. ネイティブバイナリの除外
    LibSQL のネイティブバイナリが動的に require されるため、esbuild でバンドルするとエラーになるため、バンドルから除外します。

イメージのビルドとデプロイ

次のコマンドで Docker イメージをビルドし、ECR にプッシュします。
<AWS_ACCOUNT_ID> と <REGION> は実際の AWS アカウント ID とリージョンに置き換えてください。

# 1. Dockerイメージビルド
docker build --platform linux/arm64 --provenance=false -t personal-recruit-agent .

# 2. ECRタグ付け
docker tag personal-recruit-agent:latest \
  <AWS_ACCOUNT_ID>.dkr.ecr.<REGION>.amazonaws.com/personal-recruit-agent:latest

# 3. ECRプッシュ
docker push <AWS_ACCOUNT_ID>.dkr.ecr.<REGION>.amazonaws.com/personal-recruit-agent:latest

Lambda の 設定

AWS Lambda コンソールで新しい関数を作成し、ECR から先ほどプッシュしたイメージを選択します。
タイムアウトは 10 分程度に設定します。
環境変数を設定して、必要な API キーや Slack の Webhook URL を指定します。

NODE_ENV=production
OPENAI_API_KEY=your-openai-api-key
SLACK_WEBHOOK_URL=your-slack-webhook-url
JOB_FILTER_DAYS=7

EventBridge スケジューラー設定

EventBridge でスケジュールイベントを作成し、Lambda 関数を任意の期間で定期実行するように設定します。
入力定数は次のように設定します。userRequirements には求人の要件を記載します。

{
  "source": "aws.events",
  "detail-type": "Scheduled Event",
  "detail": {
    "userRequirements": "TypeScript, React, リモートワーク可能, 年収500万以上"
  }
}

AWS Lambda で実行するための設定は以上です。

開発で直面した課題

開発中に直面したいくつかの課題とその対応方法について説明します。

experimental_output のエラー

もともと Gemini 2.5 flash モデルを使用していた際に、experimental_outputを指定するとエラーが発生しました。
そのため、OpenAI のモデルを使用するように変更しました。
このことから、モデル選択の柔軟性が失われるため、experimental_outputは 現時点では極力使わない方が良さそうと考えています。
Tools を使用して結果を返す Agent と、その結果を構造化する Agent を分けることで対応できると考えています。

OpenAI の TPM 制限

今回は、LLM に GPT-4.1-mini を使いましたが、API の TPM(Tokens Per Minute)の制限に引っかかることがありました。
特に jobMatchingStep での foreach による並列実行時に、制限を超えてしまうことが多かったです。
結局は concurrency を 1 に設定して、直列で実行することで緩和させました。 ただ、それでも制限に引っかかることがありました。
別の対応として、別のモデルを検討する、Tier を上げるなどの方法が考えられます。

AWS Lambda の実行

私が実装した時点では、Mastra の公式情報として、AWS Lambda での実行方法が提供されていなかったため、試行錯誤が必要でした[3]

すでに Mastra を AWS Lambda で実行している方の記事なども参考にさせていただきました。
また、MCP Server の実行は、npx が使用できないなどの制約があり、node で直接実行するための設定が必要でした。

適合度分析の精度

求人の条件に「自社開発を希望」と指示していないのに、なぜかマッチ理由に「【企業】自社開発で希望条件に合致」という内容が出力されるという問題が発生しました。

例指定していないのに、「自社開発で希望条件に合致」が出力される
📝マッチ理由:
【必須条件】バックエンドエンジニアポジションで条件クリア。LLMを活用した生成AI電話サービスの開発が明示的に記載されており、LLM関連技術に完全適合。【技術】Python, TypeScript, Next.js, Node.js, React等の技術を使用し、バックエンド開発に適合。【働き方】一部リモート明記で評価対象外のため適合率計算から除外。【企業】自社開発で希望条件に合致。【その他】副業からのジョイン可、アジャイル開発、スタートアップ環境など好条件多数。

現時点で解決できていないですが、プロンプト改善が必要と考えています。
jobMatchingStep で適合度分析だけでなく、マッチ理由やおすすめ理由も出力させているため、やや役割を詰め込みすぎている可能性もあります。
適合度分析自体も複数の Agent に分割して、役割を明確にすることで精度向上を図ることなど、様々な改善アプローチがありそうです。

まとめ

Mastra のキャッチアップを目的に、エンジニアの求人を提案する AI エージェントを実装しました。
ワークフローの構築方法は直感的でわかりやすく、TypeScript の型定義を活用して、入力や出力のスキーマを明確に定義できる点が非常に便利でした。また、Mastra 自体が開発用の MCP Server を提供しているため、Claude Code などの AI コーディングツールで開発しやすく、開発体験が良かったです。 慣れたら、自分専用の AI エージェントを気軽に作成できると感じました。

ワークフローの構築は比較的やりやすい一方で、品質を高めるためのプロンプト設計や、Agent の役割分担など、AI エージェントの設計には試行錯誤が必要でした。まだまだ改善の余地があると感じています。

AI エージェントの品質を改善する方法を知るために、今後は次のことも試してみたいと考えています。

  • ワークフローのテストコード実装
  • Evals による評価
  • OpenTelemetry によるトレース、ログ、メトリクスの収集

参考

https://qiita.com/har1101/items/ff154108043c94f2ceae

https://www.onocomm.jp/news-blog/mastra-ai-agent-20250527.html

https://github.com/jalpp/mastraonaws

https://github.com/tied-inc/mastra-lambda-docker-deploy

脚注
  1. パラメータ - LibSQL | Mastra ↩︎

  2. ツールの使用 - Mastra ↩︎

  3. AWS Lambda - Mastra が最近提供されたようです。 ↩︎

Discussion