🏗️

AIエージェント開発にドメイン駆動設計の考え方を応用した話

に公開

先日、オンライン家庭教師の検索AIエージェントをリリースしました。

https://zenn.dev/manalink_dev/articles/teacher-search-agent-by-mastra

この開発から数ヶ月経ち、改修や機能拡張などの経験から「ドメイン駆動設計などの考え方を応用してアーキテクチャを考えると、保守性・拡張性が高く運用できるかも」との仮説に至りました。

本記事では、従来からのソフトウェアアーキテクチャの考え方を応用しながらAI AgentをNext.jsに組み込む事例、考え方について説明します。

前提

対象読者

  • これまでシステム開発でDDDやレイヤードアーキテクチャ、クリーンアーキテクチャなどに触れたことがあり、そのうえで保守性の高いAI Agentの開発に関心がある方

システム要件

  • Mastra 0.10系近辺をNext.js API Routeに実装しているケースを題材にしつつ、できるだけWebアプリケーションにAgentを実装しているケースを幅広くカバーできるようにお話します

AI Agentにアーキテクチャの考え方を導入するまで

前提知識(ドメイン駆動設計を例に)

ドメイン駆動設計において、システムは一般的に以下の4つの層に分離されます。

  • Presentation層: ユーザーインターフェースや外部システムとの接点
  • UseCase層: アプリケーションの具体的な処理フロー
  • Domain層: ビジネスロジックの核心部分
  • Repository層: データの永続化や外部リソースへのアクセス

これらの層分離の考え方は、AI Agentの設計においても極めて有効です。

DDDにおけるPresentation層、UseCase層、Domain層、Repository層が、AI Agentの考え方にも応用できます。むしろこれを意識してAgent及び周辺モジュールを実装すると、再利用可能なAgent開発が可能になります。


次節から、実際に私たちがオンライン家庭教師検索エージェントをリリースしてから改修していく中で、どのようにアーキテクチャの考え方を導入していったかをお話します。

開発初期:ブラックボックスとしての理解

Mastra開発当初は、フレームワークのキャッチアップで精一杯だし、組み込みができて動くところまで頑張って進めていたので、以下のようにブラックボックスに見えていました。

Mastra初期構成

この段階では、Next.js API Route上にAgentが載っているという程度の理解で、内部構造については深く意識していませんでした。

マルチプラットフォーム対応:Presentation層の分離

プロジェクト後半にスマホアプリ対応も考えるにあたって、API RouteのHandler部分に認証が必要になりました。WebサイトとアプリでそれぞれAgentを使えるようにする必要があり、認証方式やHTTPレイヤーの処理が異なることで、エージェントの周辺部分とコア部分の区別が意識されるようになりました。

認証対応後の構成

この時点で、Mastraのコアエージェント部分とAPI Routeの処理を切り分ける構造が見えてきました。

複数ユースケース対応:設計による拡張性の実現

後日の追加施策で、既存顧客向けの先生レコメンド機能という別のユースケースでAgentを使いたいとなりました。このとき、顧客が現在契約している先生の特性情報などをエージェントに渡したり、出力形式を既存の検索とは異なるものにする必要が生じました。

ここで重要だったのは、新しい要件に対してAgent本体やToolsを複製・改造するのではなく、既存のAgent本体を変更せずに拡張する設計アプローチを取ったことです。具体的には、InstructionやSchema、RuntimeContextといったパラメータ層のみをカスタマイズすることで、異なるユースケースに対応しました。

複数ユースケース対応後の構成

この設計判断により、AgentやToolsの乱立を避けながら、コアロジックの安定性を保ったまま機能拡張を実現できました。

この構造を振り返ってみると、DDDの層分離と本質的に同じパターンが見えてきます。DDDでは、ビジネスロジックの核心部分であるDomain層が最も安定しており、UseCase層やPresentation層が要件に応じて変化します。今回のAgent開発においても、「先生を適切にマッチングする」という核心的な責務を持つAgent本体が安定したDomain層として機能し、その周辺の設定部分がUseCase層として柔軟に変化する構造を実現できたのです。

AI AgentにおけるDDD層分離の実装

ここからは、具体的な実装コードを通して、AI AgentにおけるDDD層分離の実践方法を解説します。

Presentation層:API Route(Controller相当)

まず、Webから利用される先生検索エージェントのエンドポイントを見てみましょう。

export async function POST(req: Request): Promise<NextResponse | Response> {
  try {
    const body = (await req.json()) as AgentRequestBody;
    const messages = body.messages || [];

    // 認可処理(Presentation層の責務)
    await authorizeAgentAccess(req, messages);

    // UseCase層への処理移譲
    const response = await generateAgentResponse({
      messages,
      // ... その他のオプション
    });

    return response;
  } catch (error: unknown) {
    // ... エラーハンドリング(中略)
    return NextResponse.json({ error: '処理に失敗しました' }, { status: 500 });
  }
}

このコードで重要なポイントは、認可処理を別関数(authorizeAgentAccess)として分離していることです。Presentation層では、HTTPリクエストの受け取り、認証・認可、エラーハンドリングといった外部インターフェースに関わる責務のみを担当し、実際のAgent処理はgenerateAgentResponse関数(UseCase層)に移譲しています。

Presentation層の多様性:異なる認証方式への対応

一方、スマホアプリからのアクセス用エンドポイントでは、異なる認証方式を使用しています。

export async function POST(req: Request): Promise<NextResponse | Response> {
  try {
    const headersList = headers();
    const authHeader = headersList.get('authorization');

    // JWT認証(アプリ用の認証方式)
    await authorizeAgentAccessForApp(authHeader);

    const body = (await req.json()) as AgentRequestBody;
    const messages = body.messages || [];

    // 同じUseCase層を呼び出し
    const response = await generateAgentResponse({
      messages,
      // ... アプリ用のクライアント設定
    });

    return response;
  } catch (error: unknown) {
    // ... エラーハンドリング(中略)
    return NextResponse.json({ error: '処理に失敗しました' }, { status: 500 });
  }
}

注目すべきは、認証方式(Cookie認証 vs JWT認証)やクライアント設定は異なるものの、核となるgenerateAgentResponse関数は全く同じものを呼び出している点です。これにより、Presentation層の違いに関係なく、UseCase層以下のロジックを共通化できています。

まさしくDDDやオニオンアーキテクチャ、クリーンアーキテクチャ等におけるPresentation層の役目を、Agent on API Routeでも同様に実現できるわけです。外部からのアクセス方法や認証方式の違いを吸収し、内部のビジネスロジックを保護する境界として機能しています。

UseCase層とInfrastructure層:RuntimeContextによるDI実装

従来のバックエンドフレームワーク(LaravelやNest.js等)では、DIコンテナが標準で提供されており、認証方式の切り替えやサービスの注入は自動化されています。しかし、AI Agent開発では、そうした機能を自分たちで実装する必要があります。

ここで活用したのがruntimeContextによる依存性注入の仕組みです。これにより、2つの重要な課題を解決できました。

1. Toolsの動的分割

まず、プライバシー要件に応じて利用可能なToolを動的に変更する必要がありました。例えば、ユーザーの行動履歴を利用した検索精度向上は有効ですが、プライバシーの観点から必ずしも全てのケースで利用できるわけではありません。

以下のコードは、runtimeContextを使ってDI機能を実現している例です。

export const agentDefinition = new Agent({
  name: 'Search Agent',
  instructions: `/* ... エージェントへのプロンプト ... */`,
  model: openai('gpt-4'),
  tools: ({ runtimeContext }) => {
    // 履歴利用可否フラグに基づいてToolsを動的に分割
    const allowUseHistory = runtimeContext.get('allowUseHistory');

    const baseTools = {
      searchTool,
      profileTool,
      contentTool,
      // ... その他の基本ツール
    };

    return {
      ...baseTools,
      // 条件に応じて履歴取得ツールを追加
      ...(allowUseHistory && { getUserHistoryTool }),
    };
  },
});

この実装では、allowUseHistoryフラグの値に応じて、履歴取得ツールを含めるかどうかを動的に決定しています。これにより、同じAgent定義でも、呼び出し時の設定によって利用可能な機能が変わるのです。

ちなみにAgent本体をDomain層として扱う本記事の思想では、当該フラグの値に応じてプロンプトを変えることはしません。できる限りAgent本体のInstructionにはTool名やToolの使い方について記載せず、Tool側のDescriptionやSchemaによって利用用途を表現します。これらの思想により、Agent本体をより強固なDomain層として扱えると考えています。

2. 認証方式の抽象化

次に、WebとアプリでBackendアクセス時の認証ヘッダー仕様が異なる問題がありました。Webでは Cookie認証、アプリではJWT認証を使う必要があります。

// Tool内でのRuntimeContext活用例
export const createSearchTool = (config) => {
  return createTool({
    id: config.id,
    description: config.description,
    execute: async ({ context, runtimeContext }, options) => {
      // 認証方式に応じたAPIクライアントを取得
      const client = runtimeContext.get('client');

      // clientを使ってBackendにアクセス
      const results = await searchData(searchParams, {
        runtimeContext: { client }
      });

      // ... 処理続行
    },
  });
};

ここで重要なのは、Agent本体(Domain層相当)は認証方式について一切知る必要がないことです。DDDにおいてDomain層が認証方式について知らなくて良いのと同様です。

Tool内では、runtimeContextから取得したクライアントを使ってBackendにアクセスしますが、そのクライアントがCookie認証を使うのかJWT認証を使うのかは関知しません。この抽象化により、同じToolが異なる認証方式で動作できるのです。

これにより、従来のバックエンドフレームワークが提供するDI機能を、AI Agent開発においても自前で実現できています。

Agent視点でも、適切なツールはDescriptionとInput/Output Schemaによって選定するだけなので、内部挙動は目的を達成してくれればどうでもいいわけで、まさしくレイヤードアーキテクチャによる責務分離の効能の1つを体現します。

UseCase層:Agentコア動作の安定性とユースケース対応の両立

DDDにおいてDomain層を安定させながらUseCase層で多様な要件に対応するように、AI Agent開発でも同様のアプローチが取れます。Mastraにおいては、stream関数のexperimental_outputcontextruntimeContextという3つのパラメータを活用することで、Agent本体をコアドメインとして維持しながら、ユースケースに応じた挙動の最適化が可能です。

以下は、既存顧客向けの先生レコメンド機能を実装したUseCase層の例です。

export const generateRecommendTeachers = async ({ messages, runtimeContext, options }: Props) => {
  try {
    const result = await mastra.getAgent('searchAgent').stream(messages, {
      // 出力形式をユースケースに特化
      experimental_output: TeacherRecommendationSchema,
      // 余談ですがAbortSignalちゃんと設定したほうがユーザー側が利用中に切ったときに費用を節約できるので嬉しい
      abortSignal: options?.signal,
      // ユースケース固有の指示を追加
      context: [
        {
          role: 'system',
          content: 'ユーザーの要求に最も適した候補を5名程度選定してください。判断に必要な情報は積極的に収集し、再検索も厭わず実行してください。',
        },
      ],
      // Tool動作やクライアント設定をカスタマイズ
      runtimeContext,
    });

    return result.toDataStreamResponse();
  } catch (error) {
    // ... エラーハンドリング
  }
};

このアプローチの重要な点は、Agent本体の定義は一切変更せずに、呼び出し時のパラメータのみでユースケースに応じた挙動を実現していることです。

  • experimental_output: レスポンス形式を検索用からレコメンド用に変更
  • context: ユースケース固有の指示を追加(「候補数指定」「情報収集方針」など)
  • runtimeContext: 認証情報や利用可能Toolセットを注入

これにより、同一のAgent定義から、検索用途とレコメンド用途という異なる振る舞いを実現できています。Agent本体は「先生とユーザーを適切にマッチングする」というコアドメインの責務に集中し、ユースケース固有の要件はUseCase層で吸収する構造です。

補足:適用範囲と限界について

なお、この手法はMastraのバージョン0.10近辺における実装例であり、フレームワークの進化に応じて変化する可能性があります。また、Multi AgentやWorkflowといった別軸の解決策も生まれているため、常に適用可能とは限りません。

ただし、Agent本体レイヤーをDomain層に見立てて応用度を高めるという設計思想は、フレームワークに依存しない汎用的なメソッドだと考えています。重要なのは、コア責務の安定性を保ちながら、周辺パラメータで多様性に対応するという設計原則です。

まとめ

DDDの層分離の考え方は、AI Agent開発においても有効に活用できます。Agent本体をDomain層として安定させ、Presentation層やUseCase層で多様な要件に対応する構造により、再利用可能で保守しやすいAgent開発が実現できます。

Mastraはフレームワークであり、設計の思想を強制するものでは有りませんが、その機能群は旧来のWebアプリケーションフレームワークが提供するものと抽象的に見れば似ているので、活用の仕方に応用できるのが本プロジェクトを通しての気づきでした。

RuntimeContextを活用したDI実装や、パラメータによるユースケース対応など、従来のソフトウェア設計の知見をAI Agent開発にも適用することで、より良いアーキテクチャを構築していきましょう。

Discussion