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.tsLlmAgent + InMemoryRunner。ツールの実行ループはADKが持つ
直接SDK 経路
src/lib/ai/gemini.ts / anthropic.ts自前でループを回す。切り替え可能
どちらも同じ道具の定義を使う
道具の定義(1か所)
src/lib/ai-tools.tsJSON 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・観測を足すとき |
構築の手順
既存のNext.jsアプリに後付けする前提で、7段階に分けています。 手順3までで「モデルが道具を呼ぶ」ところまで動きます。
鍵を用意して、依存を入れる
Google AI Studio でAPIキーを発行します。ADKが読むのは GEMINI_API_KEY か GOOGLE_GENAI_API_KEY だけです。
npm i @google/adk zodADKは express や OpenTelemetry を含むので、Nextのバンドルに載せずに実行時読み込みにします。
// next.config.ts
const nextConfig = {
serverExternalPackages: ["@google/adk"],
};注意
クライアントに混ざると確実にビルドが壊れます。ADKを使うファイルは サーバ側から動的import でのみ読み込んでください。道具を1か所で定義する
モデルに渡す道具は、経路ごとに書かず1つの配列にまとめます。 こうしておくと、チャット・MCP・ADKで二重管理になりません。chat と agent の 旗で、どこに出すかを分けています。
// 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) }),
},
];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);
}エージェントを組む
道具を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),
});
}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 は 会員ごとに分けておくのがおすすめです。道具の副作用を回収する
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,
};
}注意
モデルが言った金額をそのまま画面に出さないでください。 引数として受け取るのは商品と数量だけにし、 合計額はサーバ側の見積り関数で計算し直します。止まらないようにする
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_KEY | — | AI Studio のキー。未設定なら検索ベースの応答に落ちる |
AI_PROVIDER | 自動 | adk / gemini / anthropic |
GEMINI_MODEL | gemini-flash-latest | 接客は速さが効くので既定は flash 系 |
GOOGLE_API_KEY | — | 別名。ADKは読まないので内部で橋渡ししている |
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 対応の作り方をご覧ください。