< BACK 暖かいオーバーヘッドライトに照らされた絡み合った銅線のヴィンテージ電話交換機を35mmフィルムで撮影した写真

Vercel AI SDKとSupabaseを使ってAIエージェントを構築する

3週間前、エディンバラを拠点とするSaaSの創業者であるクライアントから電話がかかってきて、彼の製品データベースについての質問に答えたり、過去の会話を記憶したり、サポートチケットの作成などのアクションを実行できる「スマートアシスタント」を構築できるかどうか尋ねられました。予算は限定的で、納期はさらに迫っていました。Vercel AI SDKについてしっかり学んでみたいと思っていたので、このタイミングがまさに絶好の機会だと感じました。

その後の3日間は実際の開発に費やしました。洗練された部分もあれば、恥ずかしい失敗もあり、ソースコードを読み込む作業もたくさんありました。このポストは、最初から欲しかった実践的なチュートリアルです。

---

実際に構築するもの

AIエージェントです。チャットボットではありません。この区別は多くの人が思っているよりも重要です。

チャットボットは入力を受け取って出力を生成します。一方、エージェントはそれを行った上で、どのツールをどの順序で呼び出すかを判断し、何か失敗した場合にはループバックできます。セッション間でメモリを持ちます。応答するだけでなく、行動を起こすことができます。

私たちのエージェントは以下を実行します:

  • ユーザーからの自然言語の質問を受け取る
  • ツール呼び出しを使用して Supabase Postgres データベースをクエリする
  • 会話履歴をセッション間で記憶する(Supabase に永続化)
  • 構造化された、根拠のある回答を返す

スタックは Next.js (App Router)、Vercel AI SDK、データベースと認証の両方に Supabase、そしてモデルとして OpenAI の gpt-4o です。OpenAI を Anthropic または Mistral に置き換えることもできます。変更は約 10 行で済み、SDK はプロバイダーをきれいに抽象化します。

---

プロジェクトセットアップ

新しい Next.js プロジェクトから始めます。

`` npx create-next-app@latest ai-agent --typescript --app --tailwind cd ai-agent ``

実際に必要な依存関係をインストールします:

`` npm install ai @ai-sdk/openai @supabase/supabase-js @supabase/ssr zod ``

Zod はツール入力に対するスキーマ検証を行います。これがなければ、モデルが正気な引数を渡してくれることを信じるしかありません。通常はうまくいきますが、うまくいかないこともあります。

.env.local に環境変数を設定します:

`` OPENAI_API_KEY=sk-... NEXT_PUBLIC_SUPABASE_URL=https://your-project.supabase.co NEXT_PUBLIC_SUPABASE_ANON_KEY=eyJ... SUPABASE_SERVICE_ROLE_KEY=eyJ... ``

サービスロールキーはサーバーサイドのみで使用してください。anonキーはクライアントサイドの認証フローで問題ありません。これらを混同してはいけません。(2022年の本番環境前のステージング環境で私がこれをやってしまい、すべてのユーザーにクライアントの CRM への管理者レベルの読み取りアクセス権を一時的に与えてしまいました。楽しくない金曜日の午後でしたね。)

---

Supabase のセットアップ

Supabase から必要なものは 2 つあります:実際のデータ用のテーブルと、会話履歴用のテーブルです。

データテーブル

このチュートリアルでは、商品カタログの上に構築していると考えましょう。これをSupabase SQLエディタで実行してください:

``sql create table products ( id uuid primary key default gen_random_uuid(), name text not null, description text, price_gbp numeric(10, 2), category text, in_stock boolean default true, created_at timestamptz default now() ); ``

20~30行のデータを入力してください。現実的なデータはテストを大幅に改善します。私は常にMockarooを使用しています。「string1、string2」といったナンセンスではなく、ドメイン固有の値を生成してくれるからです。

メモリテーブル

ここがセッション間の会話履歴が保存される場所です。

```sql create table conversation_messages ( id uuid primary key default gen_random_uuid(), session_id text not null, role text not null check (role in ('user', 'assistant', 'tool')), content jsonb not null, created_at timestamptz default now() );

create index on conversation_messages (session_id, created_at); ```

contentのjsonb型は意図的です。AI SDKはメッセージコンテンツを構造化されたオブジェクト(テキスト部分、ツール呼び出し部分、ツール結果部分)として渡し、プレーンテキストではありません。テキストとして保存してから解析し直すのは、必要のない面倒な作業です。

---

コアエージェントルート

app/api/agent/route.ts を作成します。ここが実際のロジックが存在する場所です。

```typescript import { openai } from '@ai-sdk/openai'; import { streamText, tool } from 'ai'; import { createClient } from '@supabase/supabase-js'; import { z } from 'zod';

const supabase = createClient( process.env.NEXT_PUBLIC_SUPABASE_URL!, process.env.SUPABASE_SERVICE_ROLE_KEY! );

export async function POST(req: Request) { const { messages, sessionId } = await req.json();

// Supabaseから履歴を読み込む const { data: history } = await supabase .from('conversation_messages') .select('role, content') .eq('session_id', sessionId) .order('created_at', { ascending: true }) .limit(40);

const priorMessages = (history ?? []).map((row) => ({ role: row.role, content: row.content, }));

const allMessages = [...priorMessages...messages];

const result = await streamText({ model: openai('gpt-4o'), system: You are a helpful product assistant. You have access to a product database. Always use the queryProducts tool when the user asks about products, pricing, or availability. Never guess at product details. If the tool returns no results, say so plainly., messages: allMessages, tools: { queryProducts: tool({ description: 'Query the product catalogue by category, name, or stock status.', parameters: z.object({ category: z.string().optional().describe('Product category to filter by'), searchTerm: z.string().optional().describe('Name or keyword to search'), inStockOnly: z.boolean().optional().describe('Filter to in-stock products only'), }), execute: async ({ category, searchTerm, inStockOnly }) => { let query = supabase.from('products').select('*');

if (category) query = query.eq('category', category); if (inStockOnly) query = query.eq('in_stock', true); if (searchTerm) query = query.ilike('name', %${searchTerm}%);

const { data, error } = await query.limit(10);

if (error) return { error: error.message }; return { products: data ?? [] }; }, }), }, maxSteps: 5, onFinish: async ({ response }) => { // 新しいメッセージを保存する const newMessages = response.messages.map((msg) => ({ session_id: sessionId, role: msg.role, content: msg.content, }));

await supabase.from('conversation_messages').insert(newMessages); }, });

return result.toDataStreamResponse(); } ```

ここで注意しておくべきいくつかのポイントがあります。

maxSteps: 5 はエージェントループです。SDK はツールを呼び出し、結果をモデルにフィードバックしることを最大5回繰り返してから、最終的な回答を強制します。この値を低く設定しすぎるとエージェントがタスク途中で諦めます。高く設定しすぎると、混乱したモデルが API コストを素早く増加させます。5は多くのタスクに対する妥当なデフォルト値です。

onFinish コールバックはメモリを永続化する場所です。ストリームが完了する前にメッセージを保存しようとしないでください。完全なツール呼び出し/結果ペアをまだ取得していません。

---

フロントエンドの構築

シンプルに保ちます。AI SDK の useChat フックがほぼすべての重い作業を行います。

```typescript // app/page.tsx 'use client';

import { useChat } from 'ai/react'; import { useState } from 'react';

export default function AgentPage() { const [sessionId] = useState(() => crypto.randomUUID());

const { messages, input, handleInputChange, handleSubmit, isLoading } = useChat({ api: '/api/agent', body: { sessionId }, });

return ( <div className="max-w-2xl mx-auto p-6"> <div className="space-y-4 mb-6"> {messages.map((m) => ( <div key={m.id} className={m.role === 'user' ? 'text-right' : 'text-left'}> <span className="inline-block bg-gray-100 rounded px-3 py-2 text-sm"> {typeof m.content === 'string' ? m.content : '[tool interaction]'} </span> </div> ))} </div> <form onSubmit={handleSubmit} className="flex gap-2"> <input value={input} onChange={handleInputChange} className="flex-1 border rounded px-3 py-2 text-sm" placeholder="Ask about products..." disabled={isLoading} /> <button type="submit" disabled={isLoading} className="bg-black text-white px-4 py-2 rounded text-sm"> Send </button> </form> </div> ); } ```

sessionIdはページマウント時に一度だけ生成されます。実際のアプリケーションでは、これをあなたの認証ユーザーのIDまたは永続化されたセッションクッキーに紐付けてください。そうしなければ、ページをリフレッシュするたびにメモリがリセットされます。

---

エージェントを実際に役立つものにする

ここが重要です。データベースをクエリするだけの基本的なエージェントはデモに過ぎません。役立つエージェントはエッジケースに対応します。具体的には以下の通りです:

ツールエラーを優雅に処理する

execute関数がエラーを投げる場合、SDKはそれをキャッチしてエラー文字列をモデルに渡します。モデルは通常、そのエラーをユーザーに説明しようとします。それで問題ありませんが、execute関数はエラーをスロー(throw)するのではなくデータとして返すようにしたいなら、モデルは私の経験では捕捉された例外よりも返されたエラーオブジェクトをうまく処理します。

グラウンディングとハルシネーション

システムプロンプトは極めて重要です。「製品の詳細を推測しない」というのはたんなるお飾りではなく、その指示がなければ、gpt-4oはクエリが何も返さないときに製品仕様を時々でっち上げます。本当の話ですが、ガードなしでテストして、存在しない製品について質問したら、モデルはもっともらしい価格と説明を発明しました。素晴らしく、そして完全に役に立たない。

履歴の長さを制御する

履歴から40メッセージをロード(上記の.limit(40))は妥当な上限です。それ以上だと、ほとんど役に立たない古いコンテキストにトークンを消費することになります。長時間実行されるエージェントの場合は、サマリー化を検討してください。30メッセージごとに、モデルにこれまでの会話を要約するよう求めて、その結果を単一の「サマリー」メッセージとして保存します。

Vercel AI SDKのマルチステップツールコールに関するドキュメントは、ツール結果パーツの構造化方法について、さらに詳しく説明しています。掘り下げたい場合は参照してください。

---

Vercelにデプロイする

App Routerを使用していると仮定すると、本当にシンプルです。

  1. GitHubにプッシュします。
  2. Vercel ダッシュボードでリポジトリをインポートします。
  3. プロジェクト設定で環境変数を追加します。
  4. デプロイします。

注意点が1つあります。ストリーミングレスポンスには Web Streams をサポートするランタイムが必要です。Vercel の App Router はこれをそのままサポートしています。Pages Router を使用していて古い Express スタイルの API の場合は、設定を変更する必要があります。正直に言うと、App Router を使ってください。

Supabase のコネクションプーリングもチェックする価値があります。無料枠の Supabase プロジェクトはコネクション数が約 60 に制限されています。エージェントのリクエストごとに複数クエリでデータベースにアクセスしている場合、負荷がかかるとその制限に予想より早く達する可能性があります。本番デプロイメントでは Supabase のコネクションプーラー(PgBouncer)をトランザクションモードで使用してください。

---

次回やることを変えるなら

Edinburgh のクライアントのためにこれをリリースしたとき、いくつか問題がありました。

  • 最初、メッセージコンテンツを Supabase に text として保存していました。jsonb ではなく。文字列から構造化されたツールメッセージを復元するのは本当に大変で、午後のほとんどを無駄にしました。
  • ルートにレート制限を追加していませんでした。クライアントのチームは UAT 中すぐに長くて複雑なクエリでそれをハンマリングし始めました。何か Upstash Rate Limit のようなものを追加してから、人間に渡してください。
  • 初期テストで maxSteps を 10 に設定しました。文言の悪いクエリの場合、モデルは製品が存在しないと判断するまでに 6 回のツール呼び出しをループしました。それは 6 回のデータベース往復と大量のトークンです。5 がほぼ常に十分です。

ただし、コアアーキテクチャは問題なく機能しました。Vercel AI SDK により、独自のストリーミングパーサーとツール呼び出し状態機械の記述を避けることができ、それだけで依存関係の価値があります。

---

FAQ

Vercel AI SDK で OpenAI 以外のどのモデルが動作しますか?

SDK は Anthropic(Claude 3.5 Sonnet ほか)、Google(Gemini)、Mistral、Cohere などを、@ai-sdk/anthropic のようなプロバイダパッケージ経由でサポートしています。ほとんどの場合、プロバイダの切り替えは本当に 1 行で済みます。openai('gpt-4o') を anthropic('claude-3-5-sonnet-20241022') に置き換えるだけで、ストリーミング、ツール呼び出し、useChat フックはすべて同じように動作します。一部のプロバイダはツール呼び出しサポートに関する癖があるため、コミットする前に SDK 互換性テーブルを確認してください。

エージェントは Supabase に書き込むことができますか、読み取るだけではなく?

はい、これはエージェントが興味深く、同時に危険になるポイントです。queryProducts を書いたのと同じ方法で、createSupportTicket または updateProductStock ツールを記述できます。execute 関数は select の代わりに insert または update を実行するだけです。エージェントが書き込むことができるテーブルに対してはロウレベルセキュリティポリシーを強くお勧めします。また、破壊的な操作(delete)は、UI に確認ステップがない限り、ツールセットから完全に除外してください。

ユーザーが自分のデータのみを表示するように認証を処理するにはどうすればよいですか?

最もクリーンなアプローチ: サービスロールキーではなく、ユーザーの JWT(Cookie または Authorization ヘッダーから)を使用してルートハンドラー内部で Supabase クライアントを生成します。そうすることで Supabase のロウレベルセキュリティポリシーが自動的に適用されます。@supabase/ssr パッケージには Next.js Cookie からセッションを取得するヘルパーがあります。ユーザー ID をプレーンパラメータとしてエージェントに渡さないでください。システムプロンプトが完璧でない場合、モデルは別のユーザーのデータをクエリするようにだまされるかもしれません。

Vercel AI SDKはプロダクションレディですか?

条件付きでプロダクションレディと言えます。積極的にメンテナンスされており、Vercelは高速でフィーチャーをリリースし、ストリーミングとツール呼び出しの基本要素は安定しています。より頻繁に変わる部分は実験的機能(generateObjectと複雑なユニオンスキーマなど)です。バージョンをピンして、アップグレード前にチェンジログを読んでください。Seahawk Mediaは現在2つのライブクライアントプロジェクトで問題なく運用中ですが、アグレッシブにバージョンピンしています。

RailwayのPostgresやPlanetScaleではなく、なぜSupabaseなのか?

SupabaseはPostgresに加えて、型付きクライアント、認証、リアルタイム、ストレージ、エッジ関数を1つの屋根の下に提供します。このようなプロジェクトでは、認証統合とSQLエディタによる迅速なイテレーションが本当に時間を節約します。ただし、実際のエージェントコードは任意のPostgres互換データベースで動作します。SupabaseクライアントをpgやDrizzle ORMに置き換えても、構造的には何も変わりません。

---

正直に言うと、このスタックがゼロから動作する、メモリに永続化するエージェントへどれだけ高速に到達できるかに驚きました。Vercel AI SDKは目に見えない作業をたくさんやってくれます:ストリーミングプロトコル、ツール呼び出しのシリアライゼーション、プロバイダーの抽象化です。Supabaseは永続化と認証を、あなたに大きな負担をかけずに処理します。残された複雑さ(誰も抽象化できない部分)は、モデルが実際に動作するようなシステムプロンプトを書くことです。その部分はイテレーションが必要です。厳密に始めて、徐々に緩和し、最悪の言い回しの質問でテストしてください。

関連記事:2026年のAI検索キーワードリサーチ:その概要、従来型SEOとの違い、技術的SEO、およびAI検索。

< BACK