AIエージェント向け / ADK

Google ADK で店番AIを動かす

このストアの「店番に相談する」は、Google ADK(Agent Development Kit)で動かせます。 同じ仕組みを自店でも作りたい方向けに、ゼロから組む手順をまとめました。コードはすべてこのストアで実際に動いているものです。

全体の構成

上から下へ、ブラウザの入力がサーバを通ってモデルに届き、道具の実行だけがデータに触れます。金額と在庫はモデルに計算させず、必ずサーバ側の関数で確定させるのが要です。

ブラウザ

src/components/Concierge.tsx

会話のUI。返答はMarkdownとして整形して出す

POST /api/chat

APIルート

src/app/api/chat/route.ts

レート制限(60秒20回)・履歴の長さ制限・cookieから会員を特定

runConcierge(messages, shopper)

経路の選択とフォールバック

src/lib/concierge.ts

接客方針(システムプロンプト)を組み立て、経路を選ぶ。失敗したら検索ベースの応答に退避

ADK 経路

src/lib/ai/adk.ts

LlmAgent + InMemoryRunner。ツールの実行ループはADKが持つ

直接SDK 経路

src/lib/ai/gemini.ts / anthropic.ts

自前でループを回す。切り替え可能

どちらも同じ道具の定義を使う

道具の定義(1か所)

src/lib/ai-tools.ts

JSON Schema で書く。ADKへは jsonSchemaToZod() でZodに写す

道具の実行だけがデータに触れる

サーバ側の確定処理

catalog.ts / pricing.ts / orders.ts

検索・見積り・在庫・注文。ここが唯一の真実

ADK と 直接SDK、どちらを選ぶか

単一の店番エージェントを動かすだけなら、直接SDKのほうが依存は軽く済みます。 ADKが効いてくるのは、エージェントを増やしたり、実行の様子を観測したくなってからです。

観点直接SDK(@google/genai)Google ADK(@google/adk)
ツールのループ自前で書く(約40行)ADKが持つ
会話の履歴毎回すべて送り直すセッションに積む
道具の定義JSON Schema をそのままZod スキーマ
依存の重さSDK 1つexpress・OpenTelemetry 等を連れてくる
向く場面単一エージェントの接客マルチエージェント・A2A・観測を足すとき
この店は両方を残しています。 道具の定義を共有しているので、経路を切り替えても店番の振る舞いは変わりません。 環境変数1つで戻せる状態にしておくと、ADKの更新で動かなくなったときに店を止めずに済みます。

構築の手順

既存のNext.jsアプリに後付けする前提で、7段階に分けています。 手順3までで「モデルが道具を呼ぶ」ところまで動きます。

01

鍵を用意して、依存を入れる

Google AI Studio でAPIキーを発行します。ADKが読むのは GEMINI_API_KEY GOOGLE_GENAI_API_KEY だけです。

npm i @google/adk zod

ADKは express や OpenTelemetry を含むので、Nextのバンドルに載せずに実行時読み込みにします。

// next.config.ts
const nextConfig = {
  serverExternalPackages: ["@google/adk"],
};

注意

クライアントに混ざると確実にビルドが壊れます。ADKを使うファイルは サーバ側から動的import でのみ読み込んでください。
02

道具を1か所で定義する

モデルに渡す道具は、経路ごとに書かず1つの配列にまとめます。 こうしておくと、チャット・MCP・ADKで二重管理になりません。chatagent の 旗で、どこに出すかを分けています。

// src/lib/ai-tools.ts
export type AiTool = {
  name: string;
  description: string;
  inputSchema: Record<string, unknown>; // JSON Schema
  chat: boolean;   // 接客チャットに出すか
  agent: boolean;  // 外部AIエージェント(MCP)に出すか
  run: (input: Record<string, unknown>) => Promise<unknown>;
};

export const AI_TOOLS: AiTool[] = [
  {
    name: "search_products",
    description: "取扱商品を検索する。お客さんの要望を聞いたらまずこれを呼ぶこと。",
    chat: true,
    agent: true,
    inputSchema: {
      type: "object",
      properties: {
        q: { type: "string", description: "キーワード" },
        max_price: { type: "number", description: "上限価格(円・税込)" },
      },
      additionalProperties: false,
    },
    run: async (input) => searchProducts({ q: str(input.q), maxPrice: num(input.max_price) }),
  },
];
説明文(description)はプロンプトの一部です。「いつ呼ぶか」を書くと呼び忘れが減ります。 「商品を検索する」より「要望を聞いたらまずこれを呼ぶこと」のほうが効きます。
03

JSON Schema を Zod に写す

ADKの FunctionTool は Zod スキーマを取ります。 道具の定義はMCPと共有したいのでJSON Schemaのまま持ち、機械的に変換します。 実際に使う範囲だけ対応すれば十分です。

// src/lib/ai/json-schema-to-zod.ts
export function jsonSchemaToZod(schema: JsonSchema): z.ZodObject<z.ZodRawShape> {
  if (schema.type !== "object") throw new Error("道具の引数はオブジェクトで定義すること");
  const required = new Set(schema.required ?? []);

  const shape = Object.fromEntries(
    Object.entries(schema.properties ?? {}).map(([key, value]) => {
      const field = toZod(value);
      return [key, required.has(key) ? field : field.optional()];
    }),
  );
  return z.object(shape);
}
04

エージェントを組む

道具をADKの FunctionTool に写して、LlmAgent に渡します。instruction が接客方針(システムプロンプト)です。

// src/lib/ai/adk.ts
import { LlmAgent, FunctionTool, InMemoryRunner } from "@google/adk";

function buildTools(trace: AdkTrace): FunctionTool[] {
  return chatTools().map(
    (tool) =>
      new FunctionTool({
        name: tool.name,
        description: tool.description,
        parameters: jsonSchemaToZod(tool.inputSchema),
        execute: async (input) => {
          const output = await tool.run(input ?? {});
          collect(trace, tool.name, input ?? {}, output); // 副作用を回収する
          return output;
        },
      }),
  );
}

export function createAgent(system: string, trace: AdkTrace): LlmAgent {
  return new LlmAgent({
    name: "tenban",
    // env() は空文字列を「未設定」として扱う。詳しくは下の「つまずきやすい点」
    model: env("GEMINI_MODEL") ?? "gemini-flash-latest",
    description: "ひびのば STORE の店番",
    instruction: system,
    tools: buildTools(trace),
  });
}
05

Runner で1往復動かす

過去のやりとりはセッションに積み、最後の発言だけを新規メッセージとして渡します。 道具の往復(tool ロール)は積み直しません。

const runner = new InMemoryRunner({ agent, appName: "hibinoba" });
const session = await runner.sessionService.createSession({ appName: "hibinoba", userId });

const history = [...messages];
const latest = history.pop();

for (const message of history) {
  if (message.role === "tool") continue;
  await runner.sessionService.appendEvent({
    session,
    event: {
      author: message.role === "user" ? "user" : agent.name,
      content: {
        role: message.role === "user" ? "user" : "model",
        parts: [{ text: message.text }],
      },
    },
  });
}

let text = "";
for await (const event of runner.runAsync({
  userId,
  sessionId: session.id,
  newMessage: { role: "user", parts: [{ text: latest.text }] },
})) {
  for (const part of event.content?.parts ?? []) {
    if (part.text) text += part.text;
  }
}
InMemoryRunner はプロセスが死ぬとセッションも消えます。 会話をまたいで覚えさせたい場合は、永続版のセッションサービスに差し替えます。 その日のために userId は 会員ごとに分けておくのがおすすめです。
06

道具の副作用を回収する

ADKが返すのは最終的な文章だけです。 「どの商品に触れたか」「かごに何を入れる提案をしたか」はexecute の中で自分で拾っておきます。 このストアではカートの提案カードをこれで組み立てています。

export type AdkTrace = {
  touchedSlugs: Set<string>;
  proposal: { lines: CartLine[]; reason: string; total: number; itemCount: number } | null;
};

// execute の中で
if (tool.name === "propose_cart") {
  const q = await quote(toCartLines(input?.lines)); // 金額はサーバで確定させる
  trace.proposal = {
    lines: q.lines.map((l) => ({ slug: l.slug, qty: l.qty })),
    reason: String(input?.reason ?? ""),
    total: q.total,
    itemCount: q.itemCount,
  };
}

注意

モデルが言った金額をそのまま画面に出さないでください。 引数として受け取るのは商品と数量だけにし、 合計額はサーバ側の見積り関数で計算し直します。
07

止まらないようにする

ADKは鍵が不正でも例外を投げず、空文字を返すことがあります。 空の返答をそのまま店の言葉として出さないよう、明示的に落とします。

const { text, trace, model } = await runWithAdk(system, messages);

// 空の返答を店の言葉として出さない
if (!text) throw new Error("ADK から本文が返りませんでした");

呼び出し側では、例外を検索ベースの応答に退避させます。 キーが未設定でも、モデルが落ちていても、店として黙り込まない状態を作ります。

if (selected === "adk") {
  try {
    return await converseWithAdk(messages, shopper);
  } catch (e) {
    console.error("[concierge] adk", e);
    return fallbackConcierge(messages); // キーワード検索で答える
  }
}

会員の購入履歴を踏まえて接客する

ログイン中の会員には、購入履歴を引く道具を1本だけ追加で生やしています。 「前に買ったやつ」「いつものを補充したい」に答えられるようになります。

// 会員IDは cookie から確定させ、リクエスト本文からは受け取らない
const user = await currentUser();
const shopper = user ? { id: user.id, name: user.name } : undefined;
return Response.json(await runConcierge(messages, shopper));

注意

道具の引数に会員IDを取らせないでください。引数にすると、会話に紛れ込ませた指示でモデルが他人のIDを渡してしまう余地が生まれます。 サーバで確定したIDをクロージャで束ね、モデルが指定できるのは件数だけにします。 この道具はMCP(外部AIエージェント)には公開していません。
function purchaseHistoryTool(shopper: Shopper): AiTool {
  return {
    name: "get_purchase_history",
    description: "いま話しているお客さん本人の購入履歴を取得する。他人の履歴は引けない。",
    chat: true,
    agent: false, // 外部エージェントには出さない
    inputSchema: {
      type: "object",
      properties: { limit: { type: "number" } }, // userId は受け取らない
      additionalProperties: false,
    },
    run: async (input) => listOrdersForUser(shopper.id, Math.min(num(input.limit) ?? 5, 20)),
  };
}

環境変数

変数既定説明
GEMINI_API_KEYAI Studio のキー。未設定なら検索ベースの応答に落ちる
AI_PROVIDER自動adk / gemini / anthropic
GEMINI_MODELgemini-flash-latest接客は速さが効くので既定は flash 系
GOOGLE_API_KEY別名。ADKは読まないので内部で橋渡ししている
Vertex AI 経由にする場合は GOOGLE_GENAI_USE_VERTEXAI=true GOOGLE_CLOUD_PROJECT / GOOGLE_CLOUD_LOCATION を使います(APIキーではなくADC認証)。

動作確認

アプリ全体を起動する前に、ADK単体で疎通を見ておくと切り分けが楽です。 鍵が不正でも例外にならないことがあるので、道具が呼ばれたかどうかまで確認します。

node --env-file-if-exists=.env.local adk-smoke.mjs

そのあと店を起動して、店番に話しかけます。返答の下にカートの提案カードが出れば、 道具の実行と副作用の回収まで通っています。

AI_PROVIDER=adk npm run dev

つまずきやすい点

返答が空になる/店が黙る
鍵が不正なときにADKが例外ではなく空文字を返しています。本文が空なら例外にして、 検索ベースの応答へ退避させてください。
ビルドが壊れる・クライアントで落ちる
ADKがクライアントバンドルに混ざっています。serverExternalPackages の指定と、動的importを確認してください。
2ターン目で 400 エラーになる(Gemini直接SDK)
思考署名(thoughtSignature)を落として履歴を組み立て直しています。 提供元が返した生の応答をそのまま履歴に戻してください。ADK経路ではADKが面倒を見ます。
No model found for <エージェント名> と出る
モデル名が空文字列で渡っています。docker compose の GEMINI_MODEL: $${GEMINI_MODEL:-} は、ホスト側が 未設定でも変数を省略せず空文字列を渡しますprocess.env.X ?? 既定値 ?? は null と undefined しか見ないので、 空文字列がそのまま通ってモデル未指定になります。空文字を未設定として扱う小さなヘルパ (src/lib/env.ts)を挟んでください。 同じ壊れ方は署名鍵やAPIキーでも起き、そちらは動いているように見えるぶん厄介です。
モデルが道具を呼んでくれない
description に「いつ呼ぶか」が書かれていないことが多いです。 方針側にも「金額を答えるときは必ず見積りの道具で計算する。暗算しない」のように書きます。
在庫を超える提案をしてくる
方針に書くだけでは防げません。注文を確定する処理の側で弾いてください。 プロンプトは案内、サーバは検問、と役割を分けます。

道具の一覧と、外部AIエージェント向けの入口は /for-agents で公開しています。実際の会話は 店のトップ から試せます。

UCP(AIエージェント向けのレジ)については UCP 対応の作り方をご覧ください。