実践AIエージェント設計:Vercel AI SDKで実現する保守性の高いアーキテクチャ

Vercel AI SDKを使ったAIエージェント開発において、変更が頻繁に発生するツール定義とモデル定義を適切に分離し、レジストリパターンで管理することで保守性を高める設計手法を、 Amethyst の実装例とともに解説します。

JADEフロントエンドエンジニアの淺津です。

この記事は JADE Advent Calendar 2025 の14日目の記事です。

対象読者

  • Vercel AI SDKを使ったAIエージェント構築の実践に興味がある人
  • 設計改善やアーキテクチャに興味がある人

TL;DR

Vercel AI SDKを使ったAIエージェント開発では、変更が頻繁に発生する部分(ホット・スポット)を適切に分離することが重要です。具体的には:

  • 基本的なチャットUI、ストリーミング、LLM連携はSDKに任せる
  • ツール定義は独立したフォルダ構造に整理し、レジストリに登録
  • ツールごとに識別子、スキーマ、実行ロジック、確認UIを一箇所にまとめる
  • モデル定義も集約し、新モデル追加時の変更範囲を最小化
  • 利用側は具体的なツールやモデルの詳細を知らなくても使えるように抽象化

この設計により、AIエージェント機能の拡張が容易になり、複数人での並行開発も効率化できます。

注意

  • ソースコードは、コンセプトの説明のために簡略化・省略されています。

AIエージェントの差別化ポイントにフォーカスする

基本的なユースケースはSDKに任せる

チャットベースのUIやストリーミング表示など、基本的なユースケースをSDKに任せることで、差別化ポイントであるツールの開発に専念することができます。

私たちのサービス「Amethyst」のフロントエンドは、Next.jsとshadcn/uiを使って構築されています。そのため、Vercel AI SDKとAI Elementsを簡単に導入することができました。また、SDKによって特定のAI提供事業者にロックインされないというメリットもあります。

変更のホット・スポットを見つけて切り出す

AIエージェント開発でも、設計の基本方針は従来と変わりません。

  • 「変更が多い箇所(ホット・スポット)と、そうでない箇所を分離する」
  • 「機能追加・変更時の影響範囲を最小化する」

この2点が重要です。

私たちの場合、変更のホット・スポットはツール定義と対応モデルの定義でした。

はじめはSDKのサンプルコードをそのまま実装しましたが、初期段階で変更の難しさ(特にツール定義の並行開発時のコンフリクト)に直面しました。

そこで、以下の構成に移行するリファクタリングを実施しました。結果、ツール定義の追加やモデル変更がしやすくなりました。

変更範囲を最小化する工夫①:ツール定義

💡 Note
以下はAI SDK v5系の実装例です。v6系への移行時は、ツールの承認フローにExecution Approvalを使うとよさそうです。

ツール定義を1つのフォルダにまとめる

ツール定義には、「識別子」「説明」「入力スキーマ」「処理ロジック」「実行確認UI」といった要素があります。前者4つはすべてAI SDKの Tool 型に含まれていますが、「実行確認UI」も定義に含めるべきだと考えます。

私たちのプロジェクトでは、これらを1つのフォルダにまとめています。

./src/lib/ai/tools/create-project
├── constants.ts    # 識別子と使い方の説明
├── index.ts
├── schema.ts       # 入力値検証用のZodスキーマ
├── server.ts       # 実際の処理ロジックと、ツール定義の本体
└── ui.tsx          # 実行確認UI(オプショナル)

例として、 create_project ツールの server.ts は以下のようになっています。

確認UIを伴う場合は、Tool 型の定数を宣言する際に execute プロパティを指定せず、自動実行を防ぎます。実行関数は別途exportします。この方法は、AI SDK公式のクックブックに記載されている実装をそのまま使っています。

import "server-only";

// 実行関数
export async function executeCreateProject(input: unknown) {
  const parsed = createProjectInputSchema.parse(input);
  const { organizationID, name, kind, description } = parsed;
  
  // 中略: 具体的な処理。既存のAPIを叩く、エラーハンドリング等

  return {
    ...created,
    projectPath,
    projectUrl,
  };
}

export const createProjectTool = {
  name: CREATE_PROJECT_NAME,
  description: CREATE_PROJECT_DESC,
  inputSchema: createProjectInputSchema,
  // execute: executeCreateProject <- 確認UIを表示したいときは、これを指定しない
  //                                  自動で取得処理などを走らせたい場合は指定する
} as const satisfies Tool;

実行確認UIでは、part に含まれるツール引数を使って具体的な確認メッセージを表示し、承認・否認の結果を書き戻します。

export function CreateProjectConfirm({
  part,
  addToolResult,
  sendMessage,
}: Props) {
  const input = (part.input ?? {}) as Partial<CreateProjectInput>;

  // 必要に応じ、表示用データの取得など

  return (
    <div className="p-4 text-sm">
      <div className="mb-2 font-medium">
        プロジェクトを作成しますか?
      </div>
      <ul className="mb-4 list-disc pl-5 space-y-1">
        <li>
          プロジェクト名:
          <span className="font-medium">{input.name}</span>
        </li>
        {/* ... */}
      </ul>
      <div className="flex gap-2">
        <ApproveButton
          toolCallId={part.toolCallId}
          toolName={getToolName(part)}
          addToolResult={addToolResult}
          sendMessage={sendMessage}
        >
          <Trans>Create</Trans>
        </ApproveButton>
        <DenyButton
          toolCallId={part.toolCallId}
          toolName={getToolName(part)}
          addToolResult={addToolResult}
          sendMessage={sendMessage}
        >
          <Trans>Cancel</Trans>
        </DenyButton>
      </div>
    </div>
  );
}

ツールをレジストリに登録する

新しいツールを定義したら、サーバー側のレジストリに登録します。確認UIを表示する場合は、クライアント側のレジストリへの登録も必要です。

以下、サーバー側レジストリのコードです。先ほど、確認UIを伴うときはツール定義の execute プロパティを省略しました。ここで改めてツール名と実行関数を紐づけることで、APIルートからの実行が可能になります。

import "server-only";

const TOOL_REGISTRY = new Map
  string,
  { tool: unknown; executor?: ExecutorFn }
>();

// 1. ツールセット取得用
export function getToolSet(): ToolSet {
  const entries = Array.from(TOOL_REGISTRY.entries()).map(([name, reg]) => [
    name,
    reg.tool,
  ]);
  return Object.freeze(Object.fromEntries(entries));
}

// 2. 実行関数取得用: ツールに直接定義されていない、確認をともなうfunctionをここから取り出す
export function getExecutor(name: string): ExecutorFn | undefined {
  return TOOL_REGISTRY.get(name)?.executor;
}

// 3. 登録
function registerTool(args: { tool: ToolWithName; executor?: ExecutorFn }) {
  const { tool, executor } = args;
  TOOL_REGISTRY.set(tool.name, { tool, executor });
}

// 4. 実行確認の必要ない読み取り系ツールはそのまま登録
registerTool({ tool: listDashboardsTool });

// 5. 変更系ツールはexecutorを別途渡す
//    ここで紐付けすることで、承認されたときに呼び出せるようになる
registerTool({ tool: createProjectTool, executor: executeCreateProject });

また、以下がクライアント側レジストリです。ツール名と確認UIコンポーネントの紐付けを行います。

export type ToolConfirmRegistry = Record<string, ComponentType<ConfirmProps>>;

const CONFIRM_UI_REGISTRY = new Map<string, ComponentType<ConfirmProps>>();

// 1. 取得したレジストリから、nameを指定して確認UIのコンポーネントを取り出す
export function getToolConfirmRegistry(): ToolConfirmRegistry {
  return Object.fromEntries(CONFIRM_UI_REGISTRY.entries());
}

// 2. 登録
function registerUIConfirm(
  name: string,
  component: ComponentType<ConfirmProps>,
) {
  CONFIRM_UI_REGISTRY.set(name, component);
}
registerUIConfirm(CREATE_PROJECT_NAME, CreateProjectConfirm);

これで、ツール定義の追加が完了します。ツールを追加する際は、ここまで示したコード以外を変更する必要がありません。変更範囲を最小限に抑えられるため、開発効率が大きく向上します。

利用側はツールの知識を持たなくて良い

ツール定義をSDKに引き渡し、LLMのAPIをよびだすAPIルートの実装は、以下のようになっています。

export async function POST(req: NextRequest) {
  // ... 入力値検証など
  
  const stream = createUIMessageStream<AgentUIMessage>({    
    execute: async ({ writer }) => {
      const lastMessage = messages[messages.length - 1];
      if (lastMessage && Array.isArray(lastMessage.parts)) {
        // 承認・否認のメッセージを見てツールを実行し結果を書き戻す処理
        lastMessage.parts = await Promise.all(
          lastMessage.parts.map((part) =>
            handleToolConfirmationPart(part, writer),
          ),
        );
      }

      const result = streamText({
        tools: toolSet,
        messages: convertToModelMessages(normalizedMessages, {
          ignoreIncompleteToolCalls: true,
          tools: toolSet,
        }),
        // ...
      });

      const baseStream = result.toUIMessageStream<AgentUIMessage>({
        originalMessages: messages,
      });

      writer.merge(baseStream);
    },
  });

  return createUIMessageStreamResponse({ stream });
}

注目したいのが、「承認・否認のメッセージを見てツールを実行し結果を書き戻す処理」であるところの handleToolConfirmationPart の中身です。

以下で示すように、具体的なツールの中身について関知せず、サーバー側レジストリから実行関数を取得し、ただ「YESなら与えられたexecute関数を呼ぶ、NOなら何もしない」だけです。

import "server-only";

export async function handleToolConfirmationPart(
  part: UIMessagePart<UIDataTypes, UITools>,
  writer: UIMessageStreamWriter<AgentUIMessage>,
): Promise<UIMessagePart<UIDataTypes, UITools>> {
  // ...
  const toolName = getToolName(part);
  const executor = getExecutor(toolName); // ここで実行関数を取得
  if (!executor) return part;

  if (part.output === UI_CONFIRM_YES) {
    try {
      const result = await executor(part.input);
      writer.write({
        type: "tool-output-available",
        toolCallId: part.toolCallId,
        output: result,
      });
      return { ...part, output: result };
    } catch (e) {
      const msg = `Error: ${e instanceof Error ? e.message : String(e)}`;
      writer.write({
        type: "tool-output-available",
        toolCallId: part.toolCallId,
        output: msg,
      });
      return { ...part, output: msg };
    }
  }

  if (part.output === UI_CONFIRM_NO) {
    writer.write({
      type: "tool-output-available",
      toolCallId: part.toolCallId,
      output: DEFAULT_DENIED_MESSAGE,
    });
    return { ...part, output: DEFAULT_DENIED_MESSAGE };
  }

  return part;
}

同様に、チャットメッセージを表示する箇所も具体的なツールの知識を持たなくて良い作りになっています。ここでは単に、クライアント側レジストリから確認UIコンポーネントを取得して、現在のツールのInput(バリデーション済み)を与えたうえで、表示するだけです。

"use client";

function ToolUIPart({
  part,
  defaultOpen,
  addToolResult,
  sendMessage,
}: {
  part: ToolUIPart;
  defaultOpen: boolean;
  addToolResult: AddToolResult;
  sendMessage: SendMessage;
}) {
  const isInputAvailable = part.state === "input-available";
  const toolName = getToolName(part);
  const registry = getToolConfirmRegistry();
  const RegistryConfirm = registry[toolName]; // ここでコンポーネントを取得
  if (isInputAvailable && RegistryConfirm) {
    return (
      <Tool defaultOpen={defaultOpen}>
        <ToolHeader state={part.state} type={part.type} />
        <ToolContent>
          <RegistryConfirm
            part={part}
            addToolResult={addToolResult}
            sendMessage={sendMessage}
          />
        </ToolContent>
      </Tool>
    );
  }
  // ...
}

変更範囲を最小化する工夫②:モデル定義

ツールほど変更頻度が高くはありませんが、Google、OpenAI、Anthropicといった企業による激しい競争により、日々新しいモデルが登場する市場環境にも備えておく必要があります。幸い、Vercel AI SDKを使えば比較的簡単に対応できます。

AI SDKのプロバイダーへのマッピングや、UIのドロップダウンに表示する内容など、すべて個々の変更で対応できるようになっています。そのため、利用側は具体的にどんなモデルが存在するかの知識を持たなくて済む設計になっています。

// 1. 実際には、 models.dev から読み込むスクリプトを走らせてjsonとして保存・インポート
const MODEL_CATALOG = {
  "claude-3-5-sonnet-latest": { name: "Claude 3.5 Sonnet", outputLimit: 8192 },
  "claude-3-5-haiku-latest": { name: "Claude 3.5 Haiku", outputLimit: 4096 },
  "gemini-1.5-pro": { name: "Gemini 1.5 Pro", outputLimit: 8192 },
} as const;

// 2. 利用するモデルの定義(ここが Single Source of Truth)
//    プロバイダとモデルIDのペアをここで管理する
const FEATURED_MODELS = [
  { provider: "anthropic", modelId: "claude-3-5-sonnet-latest" },
  { provider: "anthropic", modelId: "claude-3-5-haiku-latest" },
  { provider: "google", modelId: "gemini-1.5-pro" },
] as const;

// 型定義:これを利用側で使えば、存在しないIDを指定するミスを防げる
export type ModelId = (typeof FEATURED_MODELS)[number]["modelId"];

// 3. UI向けデータ生成
//    モデルIDをキーにカタログから情報を引き、選択肢リストを作る
export const AVAILABLE_MODELS = FEATURED_MODELS.map(({ provider, modelId }) => {
  const meta = MODEL_CATALOG[modelId as keyof typeof MODEL_CATALOG];
  return {
    value: modelId,
    label: meta?.name ?? modelId, // メタデータがあれば正式名称、なければID
    provider,
  };
});

export const DEFAULT_MODEL_ID = AVAILABLE_MODELS[0].value;

// 4. AI SDK向けロジック
//    各プロバイダの初期化関数をマップ化
const providerFactories = {
  anthropic: (modelId: string) => anthropic(modelId),
  google: (modelId: string) => google(modelId),
};

/**
 * 利用側は `import { anthropic } ...` を意識せず、
 * 定義された ModelId を渡すだけでインスタンスを取得できる
 */
export function getLanguageModel(modelId: ModelId): LanguageModel {
  const config = FEATURED_MODELS.find((m) => m.modelId === modelId);
  if (!config) throw new Error(`Unknown model: ${modelId}`);

  const factory = providerFactories[config.provider];
  return factory(modelId);
}

まとめ

本稿では、Vercel AI SDKを利用したAIエージェント開発の実践の一例をご紹介しました。変更のホット・スポットとなりがちなツール定義とモデル定義を適切に他の箇所から切り離す工夫について、具体的なコードを交えながら説明しました。

SaaSにAIエージェント機能を組み込んでいく流れは今後ますます加速し、Webエンジニアにとっては慣れない仕事のように感じられるかもしれません。しかし、これまでの設計のベストプラクティスが変わってしまうわけではなく、引き続き設計原則とよばれるものは重要であり続けるでしょう。

「いま自分たちがフォーカスしたいのは何なのか?」「それを簡単にするためのアーキテクチャはどういうものなのか?」を考えるうえでの参考になれば幸いです。


明日のアドベントカレンダーもお楽しみに。