🐈

Agent Engine × ADK × Next.js × AI SDKで頑張る人のためのAPI設計

に公開

この記事では、筆者が「社内データの分析エージェント」を構築したときの工夫や苦労をもとにして、以下を統合するためのAPI設計と実装パターンについて紹介します。

  • Vertex AI Agent Engine
  • Agent Development Kit
  • Next.js
  • AI SDK by Vercel

どんなエージェントを作ったのかについては、こちらの記事をご参照ください。

https://zenn.dev/readyfor_blog/articles/3f6909aff58261

Next.js を採用するモチベーション

背景

筆者の在籍しているREADYFOR株式会社では、クラウドファンディングと寄付のプラットフォームを運営していて、主な技術スタックは以下です。

  • バックエンド:Ruby on Rails
  • フロントエンド:React SPA または Next.js

また、社内の業務はGoogle Workspaceの上で成り立っており、データ基盤はBigQueryで運用されています。

似たような技術スタック・業務環境でWebサービスを運営している会社は世の中それなりに多いのではないかと思います。

この組織の中で、データ基盤の活用をより強く推進していくためのエージェントをどう作っていくかを考えました。

採用したアーキテクチャ

Vertex AI Agent Engine + Agent Development Kitの組み合わせは、Agent Starter Packのおかげもあって、社内向けのエージェントを素早く構築して試すにはうってつけでした。

特に、チャットの永続化のためのDB設計やリソース確保を考えずにスタートできるのはありがたい。

ですが、「Agent Engineにデプロイしたエージェントをエンドユーザーにどう開放するか?」はちょっとした難題になります。

結果的に採用したのは以下のようなアーキテクチャです。

なぜ Next.js?

一つは、社内の既存技術スタックとの親和性です。

プロトタイプとはいえ、作ってすぐ捨てることは想定していなくて、ある程度時間をかけて改善を重ねるつもりだったので、保守・運用していくことも意識する必要がありました。

もう一つは、ライブラリの充実度です。

Reactでは、AI SDK by Vercelをはじめとして、AIアプリケーションを構築するために有用なライブラリが充実しています。

  • AI SDK UI:AIアプリケーション特有の複雑なストリーミング通信・状態管理を抽象化してくれます
  • AI Elements:AIアプリケーションによく見られるUIコンポーネントを提供してくれます。

Rechartsなどインタラクティブな可視化のためのライブラリが豊富なことも決め手の一つでした。

アプリケーションの構成

Agent Starter Packのプロジェクトテンプレートをベースに、以下のようなディレクトリ構成にしました。

project-root/
├── agent/                      # Python Agent (ADK + Agent Engine)
│   ├── app/                    # エージェントのメインコード
│   │   ├── agent.py            # ルートエージェント定義
│   │   ├── agent_engine_app.py # Agent Engine デプロイロジック
│   │   └── utils/              # ユーティリティ関数
│   ├── tools/                  # エージェントツール実装
│   ├── tests/                  # テストコード
│   ├── Makefile                # タスク実行 (playground, backend, test)
│   └── pyproject.toml          # Python 依存関係
│
├── webapp/                     # Next.js Frontend (App Router)
│   ├── app/                    # Next.js App Router
│   ├── lib/                    # アプリケーションロジック
│   ├── components/             # React コンポーネント
│   ├── package.json            # npm 依存関係
│   └── Dockerfile              # Cloud Run デプロイ用
│
├── deployment/                 # インフラストラクチャ
│
└── Justfile                    # モノレポタスクランナー

API設計

Agent Engine + ADKのチャットが、Next.js + AI SDKを通ってクライアントのUIに表示されるまでの流れは、以下のようなイメージです。

チャットデータの形式が4種類登場していて、これを最終的にはAI SDK UIが期待するUIMessageに変換していくのですが、これについては後半で詳述します。

Agent Engine Client の実装パターン

ここからは、Next.jsとAgent Engineの間でのデータのやり取りをどう実装したかをまとめていきます。

クライアントのベース部分

Agent EngineにデプロイしたADKのエージェントをREST APIで利用する例が公式ドキュメントに載っているので、これを参考にAPIクライアントを実装します。

https://docs.cloud.google.com/agent-builder/agent-engine/use/adk?hl=ja

※以降の実装例では、説明のために一部省略・簡略化している箇所があるのでご注意ください。

webapp/lib/agentEngine/client.ts
export class AgentEngineClient {
  private auth: GoogleAuth;
  private baseUrl: string;

  constructor(config?: Partial<AgentEngineConfig>) {
    // Vertex AI Reasoning Engine のエンドポイント
    this.baseUrl = `https://${location}-aiplatform.googleapis.com/v1beta1/
      projects/${projectId}/locations/${location}/
      reasoningEngines/${reasoningEngineId}`;

    this.auth = new GoogleAuth({
      scopes: ["https://www.googleapis.com/auth/cloud-platform"],
    });
  }

  private async getAccessToken(): Promise<string> {
    const client = await this.auth.getClient();
    const accessToken = await client.getAccessToken();
    return accessToken.token!;
  }
}

Long-Running Operation (LRO) のハンドリング

セッション作成は非同期操作になるのでポーリングしています。非同期の処理は1〜3秒以内には完了する印象です。

webapp/lib/agentEngine/client.ts
async createSession(userId: string): Promise<Session> {
  const token = await this.getAccessToken();

  const response = await fetch(`${this.baseUrl}/sessions`, {
    method: "POST",
    headers: {
      Authorization: `Bearer ${token}`,
      "Content-Type": "application/json; charset=utf-8",
    },
    body: JSON.stringify({ user_id: userId }),
  });

  const initialOperation = await response.json();

  // LRO のポーリング(最大30秒、1秒間隔)
  const completedOperation = await this.pollOperation(
    initialOperation.name,
    maxRetries: 30,
    delayMs: 1000
  );

  return completedOperation.response;
}

SSE ストリーミングの実装

Agent Engine は ?alt=sse で SSE (Server-Side Event) を返すので、Next.js側で受け取れるようにします。

webapp/lib/agentEngine/client.ts
async *streamQueryIterator(
  userId: string,
  sessionId: string,
  message: string,
): AsyncGenerator<StreamEvent, void, unknown> {
  const response = await this.streamQuery(userId, sessionId, message);
  const ct = response.headers.get("content-type") || "";
  if (!ct.includes("application/json")) {
    throw new Error(`Unexpected content-type: ${ct}`);
  }

  // SSE を逐次パース
  for await (const ev of parseJSONStream(response)) {
    yield ev; // Zod でバリデーション・型付けしてyieldする
  }
}

チャットデータの形式

上記のように実装したAgentEngineClientを使って、ADKのエージェントとのやり取りをフロントエンドのAI SDK UIに橋渡しするわけですが、そのためのデータ変換に一工夫が必要になります。

非常に複雑で難解な点として、

  • バックエンドのAgent Engine + ADKとフロントエンドのAI SDKでは、チャットのデータ形式や粒度が異なる
  • 双方とも、ストリーム用と、非ストリーム用でデータの形式が異なる

つまり、ライブラリの違い × ストリーム/非ストリームの違いで 2 × 2 = 4通りのデータ形式を意識する必要があります。

Agent Engine + ADK のデータ形式

Agent Engine + SDKでは以下の2パターンがありますが、差異はそこまで大きくありません。

  • ストリーミングデータ(POST /:streamQuery?alt=sse
  • 非ストリームのチャット履歴データ(GET /sessions/:session_id/events

粒度は同じで、

  • スネークケースかキャメルケースかの表記の違い
  • 一部フィールドの有無や名前の違い

くらいの範疇です。

ストリーミングデータ

POST /:streamQuery?alt=sse
// POST https://LOCATION-aiplatform.googleapis.com/v1beta1/projects/PROJECT_ID/locations/LOCATION/reasoningEngines/REASONING_ENGINE_ID/:streamQuery?alt=sse
{
  "id": "xxxxxxxxxxxxxxxxxxxx",
  "timestamp": 1765291632,
  "author": "root_agent",
  "invocation_id": "xxxxxxxxxxxxxxxxxxxx",
  "content": {
    "role": "model",
    "parts": [
      {
        "text": "SQLを実行します"
      },
      {
        "function_call": {
          "id":  "xxxxxxxxxxxxxxxxxxxx",
          "name": "execute_query",
          "args": {
            "sql": "SELECT ..."
          }
        }
      },
    ]
  }
}

参考(データの形式は載っていないので、実際にAPIを呼んで確かめました)

https://docs.cloud.google.com/vertex-ai/generative-ai/docs/reference/rest/v1/projects.locations.reasoningEngines/streamQuery#response-body

非ストリームのチャット履歴データ
GET /sessions/:session_id/events
// GET https://LOCATION-aiplatform.googleapis.com/v1/projects/PROJECT_ID/locations/LOCATION/reasoningEngines/REASONING_ENGINE_ID/sessions/:session_id/events
{
  "name": "xxxxxxxxxxxxxxxxxxxx",
  "timestamp": "2025-12-09T14:47:12Z",
  "author": "root_agent",
  "invocationId": "xxxxxxxxxxxxxxxxxxxx",
  "content": {
    "role": "model",
    "parts": [
      {
        "text": "SQLを実行します",
        "thoughtSignature": "xxxxxxxxxxxxxxxxxxxx"
      },
      {
        "functionCall": {
          "id":  "xxxxxxxxxxxxxxxxxxxx",
          "name": "execute_query",
          "args": {
            "sql": "SELECT ..."
          }
        }
      },
    ]
  }
}

参考:

https://docs.cloud.google.com/vertex-ai/generative-ai/docs/reference/rest/v1beta1/projects.locations.reasoningEngines.sessions.events/list

AI SDK by Vercelのデータ形式

AI SDKでは UIMessageChunk というストリーム通信用の型と UIMessage という表示用の型の2種類があります。

この2つは粒度から異なる点に注意が必要です。

UIMessageChunk

内部的に UIMessage を組み立てるためのチャンクデータで、細切れのServer-Side Eventとしてストリーミングで送られることを前提としています。

UIMessageChunkの例
{
  "type": "text-start",
  "id": "xxxxxxxxxxxxxxxxxxxx"
}
{
  "type": "text-delta",
  "id": "xxxxxxxxxxxxxxxxxxxx",
  "delta": "SQLを実行します"
}
{
  "type": "text-end",
  "id": "xxxxxxxxxxxxxxxxxxxx"
}
{
  "type": "tool-input-available",
  "toolCallId": "xxxxxxxxxxxxxxxxxxxx",
  "toolName": "execute_query",
  "input": {
    "sql": "SELECT ..."
  }
}

詳しくは、Stream Protocolとして以下のドキュメントにまとまっています。

https://ai-sdk.dev/docs/ai-sdk-ui/stream-protocol

UIMessage

上記の UIMessageChunk はどちらかというと内部的な表現で、普通のAI SDK利用者が意識するのは以下の UIMessage です。

UIMessageの例
{
  "id": "xxxxxxxxxxxxxxxxxxxx",
  "role": "assistant",
  "parts": [
    {
      "type": "text",
      "text": "SQLを実行します"
    },
    {
      "type": "tool-execute_query",
      "toolCallId": "xxxxxxxxxxxxxxxxxxxx",
      "state": "output-available",
      "input": {
        "sql": "SELECT ..."
      },
      "output": {
        "result": ...
      }
    }
  ]
}

両者の関係性

ストリームのチャット時は、useChat()内部でSSEとして受け取ったUIMessageChunkUIMessageに変換されます。

一方、ストリーミングではなく例えばチャット履歴を取得して表示する際は、初めからUIMessageを渡す必要があります。

webapp/components/Chat.tsx
export type ChatProps = {
  initialMessages: UIMessage[];
}

export const Chat = ({ initialMessages }: ChatProps) => {
  const { messages, sendMessage, status } = useChat({
    id,
    messages: initialMessages, // チャット履歴はUIMessage[]型
    transport: new DefaultChatTransport({
      api: "/api/agent/chat", // APIからストリーミングで受け取るのはUIMessageChunk型
    })
  });

  // UIに渡せるmessagesはUIMessage[]型
 return messages.map((message) => <Message message={message} />)
}

Next.js API Routes の実装パターン

上記の違いを踏まえながら、Next.js API Routesでは以下を実装する必要があります。

  • ADKのエージェントとやり取りするためのストリーミングチャットAPI
  • Agent Engineに永続化されているセッションイベント(チャット履歴)を取得するAPI

(このほか、セッションの作成・一覧取得・詳細取得のAPIも必要です)

ストリーミングチャット API

ストリーミングのチャットAPIでは、AgentEngineClient から ReadableStream を受け取って、Agent EngineのストリームイベントをAI SDK向けの SSE(UIMessageChunk) に変換してクライアントに送ります。

webapp/app/api/chat/route.ts
// POST /api/chat
export async function POST(req: NextRequest) {
  // 1. IAP認証
  const authResult = await authenticateIapRequest(req);

  // 2. リクエストのバリデーション(詳細は割愛)
  const { id: sessionId, messages } = parseChatPostRequest(await req.json());

  // 3. ストリームの作成
  const client = getAgentEngineClient();
  const stream = new ReadableStream({
    async start(controller) {
      try {
        const iterator = client.streamQueryIterator(
          authResult.user.id,
          sessionId,
          userMessage,
        );

        for await (const event of iterator) {
          // StreamEvent → UIMessageChunk に変換
          for await (const chunk of transformStreamEventsToUIMessageChunks([event])) {
            controller.enqueue(
              encoder.encode(`data: ${JSON.stringify(chunk)}\n\n`)
            );
          }
        }
      } catch (error) {
        // エラーを SSE で送信
        controller.enqueue(
          encoder.encode(`data: ${JSON.stringify({ type: "error", errorText })}\n\n`)
        );
      } finally {
        controller.enqueue(encoder.encode("data: [DONE]\n\n"));
        controller.close();
      }
    },
  });

  return new Response(stream, {
    headers: { "Content-Type": "text/event-stream" },
  });
}

データの形式が異なるだけでなく、粒度も異なる点に注意しながら、変換のための関数を実装してください。

Agent Engineのストリームイベントのほうが大きい粒度なので、AsyncGenerator内でUIMessageChunkの型にあったデータをyieldします。

import { UIMessageChunk } from "ai";
import type { StreamEvent } from "../client/types";

export async function* transformStreamEventsToUIMessageChunks(
  events: StreamEvent[],
): AsyncGenerator<UIMessageChunk> {
  // Agent Engineのストリームイベント → AI SDKの UIMessageChunk に変換(詳細は割愛)
}

セッションイベント一覧API

ページリロード時や履歴表示時に、過去の会話をUIに復元するためにはもう一つAPIが必要です。

webapp/app/api/agent/sessions/[sessionId]/events/route.ts
export async function GET(req: NextRequest, { params }) {
  // 1. IAP認証
  const authResult = await authenticateIapRequest(req);

  // 2. リクエストのバリデーション(詳細は割愛)
  const validation = parseSessionEventsRequest(req, await params);
  
  // 3. Agent Engineから全イベントを再帰的に取得(ページネーション対応)
  const listSessionEventsRecursively = async (
    events: SessionEvent[],
    pageToken?: string,
  ): Promise<SessionEvent[]> => {
    const response = await client.listSessionEvents(
      validation.data.sessionId,
      {
        pageSize: validation.data.pageSize,
        pageToken,
        filter: validation.data.filter,
      },
    );
    events.push(...response.sessionEvents);
    
    // 次ページがあれば再帰的に取得
    if (response.nextPageToken) {
      return await listSessionEventsRecursively(events, response.nextPageToken);
    }
    return events;
  };

  // 4. SessionEvent → UIMessage 形式に変換
  const allEvents = await listSessionEventsRecursively([]);
  const messages = Array.from(transformSessionEventsToUIMessages(allEvents));

  return NextResponse.json({ success: true, data: { messages } });
}

ストリーミング時とは違ってAgent Engineのセッションイベントのほうが細かい粒度なので、データをreduceする形で変換する必要がある点に注意です。
(ツール呼び出しの入力と出力をまとめる、など)

import type { UIMessage } from "ai";
import type { SessionEvent } from "../client/types";

export function transformSessionEventsToUIMessages(
  events: SessionEvent[],
): UIMessage[] {
  // Agent Engineのセッションイベント → AI SDKの UIMessage に変換(詳細は割愛)
}

おわりに

振り返ってみると大きな遠回りになりましたが、今ではAgent Engine + ADKの恩恵とNext.js + AI SDKの恩恵を両方受けながらAIエージェントサービスを開発できています。

Webサービスの開発組織にとっては、Python (Agent) と TypeScript (UI) を適材適所で使い分けられるメリットは大きいので、同様のニーズと覚悟をお持ちの方の参考になれば幸いです。

READYFORテックブログ

Discussion