Vor drei Wochen hat mich ein Klient angerufen, ein SaaS-Gründer aus Edinburgh, und gefragt, ob ich ihm einen „intelligenten Assistenten" bauen könnte, der Fragen zu seiner Produktdatenbank beantworten, vergangene Gespräche im Gedächtnis behalten und Maßnahmen wie das Erstellen von Support-Tickets durchführen könnte. Das Budget war knapp, die Frist noch knapper. Ich hatte mir vorgenommen, mich ordentlich mit dem Vercel AI SDK auseinanderzusetzen, und das war der richtige Moment.
Es folgte dann drei Tage tatsächliches Bauen: einige elegante Bits, einige peinliche Fehler, viel Quellcode-Lesen. Dieser Beitrag ist das funktionierende Tutorial, das ich mir am Anfang gewünscht hätte.
---
Was wir eigentlich bauen
Ein AI-Agent. Kein Chatbot. Der Unterschied ist wichtiger, als die meisten Leute denken.
Ein Chatbot nimmt Input entgegen und produziert Output. Ein Agent tut das und entscheidet, welche Tools er aufrufen soll, in welcher Reihenfolge, und kann zu sich selbst zurückkehren, wenn etwas fehlschlägt. Er hat ein Gedächtnis über Sessions hinweg. Er kann handeln, nicht nur antworten.
Unser Agent wird:
- Natürlichsprachliche Fragen von Nutzern entgegennehmen
- Eine Supabase-Postgres-Datenbank mittels Tool-Aufrufen abfragen
- Konversationsverlauf über Sessions hinweg speichern (persistent in Supabase)
- Strukturierte, fundierte Antworten zurückgeben
Der Stack ist Next.js (App Router), Vercel AI SDK, Supabase sowohl für die Datenbank als auch für Auth, und OpenAI gpt-4o als das Modell. Du könntest OpenAI durch Anthropic oder Mistral ersetzen mit etwa zehn Zeilen Änderungen – das SDK abstrahiert den Provider sauber.
---
Projektsetup
Beginne mit einem frischen Next.js-Projekt.
`` npx create-next-app@latest ai-agent --typescript --app --tailwind cd ai-agent ``
Installieren Sie nur die Dependencies, die Sie tatsächlich benötigen:
`` npm install ai @ai-sdk/openai @supabase/supabase-js @supabase/ssr zod ``
Zod validiert die Schema-Eingaben für Ihre Tools. Ohne das verlassen Sie sich darauf, dass das Modell sinnvolle Argumente übergibt – was es normalerweise tut, bis es das nicht mehr tut.
Legen Sie Ihre Umgebungsvariablen in .env.local fest:
`` OPENAI_API_KEY=sk-... NEXT_PUBLIC_SUPABASE_URL=https://your-project.supabase.co NEXT_PUBLIC_SUPABASE_ANON_KEY=eyJ... SUPABASE_SERVICE_ROLE_KEY=eyJ... ``
Verwenden Sie den Service-Role-Schlüssel nur server-seitig. Der Anon-Schlüssel ist für clientseitige Auth-Flows völlig in Ordnung. Verwechseln Sie diese nie. (Ich habe das 2022 in einer Staging-Umgebung getan und habe kurzzeitig jedem Benutzer Admin-Lesezugriff auf die CRM eines Kunden gegeben. Das war kein angenehmer Freitagnachmittag.)
---
Supabase einrichten
Sie benötigen von Supabase zwei Dinge: eine Tabelle für Ihre tatsächlichen Daten und eine Tabelle für die Conversation-History.
Die Datentabelle
In diesem Tutorial nehmen wir an, du baust auf einem Produktkatalog auf. Führe dies im Supabase SQL-Editor aus:
``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() ); ``
Befülle es mit 20–30 Zeilen. Realistische Daten machen Tests deutlich besser, ich nutze dafür immer Mockaroo, weil es domänenspezifische Werte generiert statt „string1, string2"-Unsinn.
Die Nachrichtentabelle
Hier wird die Gesprächshistorie zwischen Sessions gespeichert.
```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); ```
Der jsonb-Typ bei content ist absichtlich. Das AI SDK übergibt Nachrichteninhalte als strukturierte Objekte (Textteile, Tool-Call-Teile, Tool-Result-Teile), nicht als einfache Strings. Es als Text zu speichern und dann später zurückzuparschen ist ein Kopfschmerz, den du dir sparen kannst.
---
The Core Agent Route
Erstelle app/api/agent/route.ts. Hier befindet sich die eigentliche Logik.
```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();
// Lade Verlauf aus 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: Du bist ein hilfsbereiter Produktassistent. Du hast Zugriff auf eine Produktdatenbank. Nutze immer das queryProducts-Tool, wenn der Nutzer nach Produkten, Preisen oder Verfügbarkeit fragt. Spekuliere nie über Produktdetails. Falls das Tool keine Ergebnisse liefert, teile das deutlich mit., messages: allMessages, tools: { queryProducts: tool({ description: 'Durchsuche den Produktkatalog nach Kategorie, Name oder Bestandsstatus.', parameters: z.object({ category: z.string().optional().describe('Produktkategorie zum Filtern'), searchTerm: z.string().optional().describe('Name oder Suchbegriff'), inStockOnly: z.boolean().optional().describe('Nur verfügbare Produkte anzeigen'), }), 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 }) => { // Die neuen Nachrichten persistieren 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(); } ```
Ein paar Dinge, die es hier hervorzuheben gilt.
maxSteps: 5 ist die Agent Loop. Das SDK wird weiterhin Tools aufrufen und Ergebnisse an das Modell zurückfüttern, bis zu fünfmal, bevor es eine abschließende Antwort erzwingt. Setzen Sie dies zu niedrig an, und der Agent gibt die Aufgabe auf halbem Weg auf. Setzen Sie es zu hoch an, und ein verwirrtes Modell kann schnell API-Kosten anhäufen. Fünf ist für die meisten Aufgaben ein sinnvoller Standard.
Der onFinish-Callback ist der Ort, an dem Sie das Gedächtnis persistieren. Versuchen Sie nicht, Nachrichten zu speichern, bevor der Stream abgeschlossen ist, Sie haben noch nicht die vollständigen Tool-Call/Result-Paare.
---
Frontend bauen
Halten Sie dies einfach. Ein useChat Hook vom AI SDK erledigt fast die ganze Arbeit.
```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> ); } ```
Die sessionId wird einmalig beim Laden der Seite generiert. Binde sie in einer echten App an die ID deines authentifizierten Nutzers oder an einen persistenten Session-Cookie. Andernfalls wird der Speicher bei jedem Seiten-Refresh gelöscht.
---
Den Agent tatsächlich nützlich machen
Hier ist der Punkt: Ein einfacher Agent, der eine Datenbank abfragt, ist eine Demo. Ein nützlicher Agent behandelt Edge Cases. Konkret:
Tool-Fehler elegant handhaben
Wenn deine execute-Funktion einen Fehler wirft, fängt das SDK diesen ab und gibt die Fehlerzeichenkette an das Modell zurück. Das Modell wird normalerweise versuchen, den Fehler dem Benutzer zu erklären, was in Ordnung ist. Aber du möchtest, dass deine execute-Funktionen Fehler als Daten zurückgeben und nicht werfen – das Modell geht mit zurückgegebenen Fehlerobjekten besser um als mit abgefangenen Exceptions, nach meiner Erfahrung.
Grounding und Halluzination
Der System-Prompt ist enorm wichtig. „Never guess at product details" ist kein Füllwerk – ohne diese Anweisung wird gpt-4o gelegentlich Produktspezifikationen erfinden, wenn eine Abfrage nichts zurückgibt. Ehrlich gesagt habe ich das ohne die Schutzmaßnahme getestet, nach einem nicht existierenden Produkt gefragt, und das Modell hat einen plausibel klingenden Preis und eine Beschreibung erfunden. Beeindruckend und völlig unbrauchbar.
Steuerung der Verlaufslänge
Das Laden von 40 Nachrichten aus der Historie (das `.limit(40)` oben) ist eine vernünftige Obergrenze. Darüber hinaus verschwendest du Token für alten Kontext, der selten hilft. Für länger laufende Agents solltest du Zusammenfassungen nutzen: Frag das Modell alle 30 Nachrichten, die Konversation bis dahin zusammenzufassen, und speichere das als eine einzelne „Summary"-Nachricht.
Die Vercel AI SDK-Dokumentation zu Multi-Step Tool Calls geht tiefer auf das Nachrichtenformat ein, wenn du verstehen möchtest, wie Tool-Result-Parts strukturiert sind.
---
Bereitstellung auf Vercel
Unter der Annahme, dass du den App Router verwendest, ist das wirklich einfach.
- Push zu GitHub.
- Importiere das Repository im Vercel Dashboard.
- Füge deine Umgebungsvariablen in den Projekteinstellungen hinzu.
- Stellen bereit.
Die eine Tücke: Streaming-Antworten erfordern eine Runtime, die Web Streams unterstützt. Der App Router auf Vercel handhabt das standardmäßig. Wenn du mit Pages Router und einer älteren Express-ähnlichen API arbeitest, musst du die Dinge anders konfigurieren. Ehrlich gesagt, nutze einfach den App Router.
Es lohnt sich auch, die Supabase-Verbindungspooling zu überprüfen. Supabase-Projekte im kostenlosen Plan haben ein Verbindungslimit von etwa 60. Wenn du bei jeder Agent-Anfrage mit mehreren Abfragen auf die Datenbank zugreifst, könntest du dieses Limit unter Last schneller erreichen als erwartet. Verwende Supabase's Connection Pooler (PgBouncer) im Transaction Mode für produktive Deployments.
---
Was ich beim nächsten Mal anders machen würde
Als ich das für den Edinburgh-Client veröffentlichte, gab es einige Probleme:
- Ich speicherte anfangs Message-Inhalte als Text in Supabase, nicht als jsonb. Strukturierte Tool-Messages aus einem String zu rekonstruieren war wirklich schmerzhaft und ich habe einen großen Teil eines Nachmittags dafür verschwendet.
- Ich habe kein Rate Limiting auf der Route hinzugefügt. Das Team des Clients begann sofort, sie während UAT mit langen, komplexen Abfragen zu bombardieren. Füge etwas wie Upstash Rate Limit hinzu, bevor du etwas an Menschen übergibst.
- Ich habe maxSteps in den ersten Tests auf 10 gesetzt. Bei einer schlecht formulierten Anfrage durchlief das Modell sechs Tool-Aufrufe, bevor es zu dem Ergebnis kam, dass das Produkt nicht existiert. Das sind sechs Datenbankabfragen und viele Token. Fünf reicht fast immer aus.
Die Kernarchitektur hat sich aber gut bewährt. Vercel AI SDK hat mich davor bewahrt, meinen eigenen Streaming-Parser und eine Tool-Call-State-Machine zu schreiben, was allein die Abhängigkeit wert war.
---
FAQ
Welche Modelle funktionieren mit dem Vercel AI SDK außer OpenAI?
Das SDK unterstützt Anthropic (Claude 3.5 Sonnet und andere), Google (Gemini), Mistral, Cohere und weitere über Provider-Pakete wie @ai-sdk/anthropic . Der Provider-Wechsel ist genuinely nur eine Zeile in den meisten Fällen, ersetze openai('gpt-4o') durch anthropic('claude-3-5-sonnet-20241022') und das Streaming, Tool-Calling und useChat Hooks funktionieren alle identisch. Einige Provider haben Besonderheiten beim Tool-Calling-Support, daher überprüfe die SDK-Kompatibilitätstabelle, bevor du dich festlegst.
Kann der Agent in Supabase schreiben und nicht nur lesen?
Ja, und genau hier wird es interessant und gefährlich zugleich. Du kannst ein createSupportTicket oder updateProductStock Tool auf die gleiche Weise schreiben wie queryProducts . Die execute Funktion führt einfach ein insert oder update statt ein select aus. Ich würde dir dringend Row-Level-Security-Policies auf allen Tabellen empfehlen, in die der Agent schreiben kann, und halte destruktive Operationen (delete) völlig aus dem Tool-Set heraus, es sei denn, du hast einen Bestätigungsschritt in deiner UI.
Wie handhabe ich die Authentifizierung, damit Nutzer nur ihre eigenen Daten sehen?
Der sauberste Ansatz: Generiere den Supabase-Client im Route Handler mit dem JWT des Nutzers (aus einem Cookie oder Authorization Header) statt mit dem Service-Role-Key. Auf diese Weise werden Supabase's Row-Level-Security-Policies automatisch angewendet. Das @supabase/ssr Paket hat Helfer zum Abrufen der Session aus Next.js Cookies. Übergebe keine Nutzer-IDs als plain Parameter an den Agent, das Modell könnte dazu verleitet werden, die Daten eines anderen Nutzers abzufragen, wenn der System Prompt nicht wasserdicht ist.
Ist das Vercel AI SDK produktionsreif?
Ich würde sagen, es ist produktionsreif mit Einschränkungen. Es wird aktiv gewartet, Vercel liefert Features in schnellem Tempo, und die Core-Primitives für Streaming und Tool-Calling sind stabil. Das, was sich mehr bewegt, sind die experimentellen Features (wie generateObject mit komplexen Union-Schemas). Fixiere deine Version und lies das Changelog vor Updates. Seahawk hat derzeit zwei Live-Kundenprojekte darauf laufen ohne Probleme, aber wir pinnen die Versionen aggressiv.
Warum Supabase konkret und nicht Postgres auf Railway oder PlanetScale?
Supabase gibt dir Postgres plus einen typisierten Client, Auth, Real-time, Storage und Edge Functions unter einem Dach. Für ein Projekt wie dieses sind die Auth-Integration und der SQL-Editor für schnelle Iterationen wirklich zeitsparend. Das gesagt, der eigentliche Agent-Code funktioniert mit jeder Postgres-kompatiblen Datenbank. Ersetze den Supabase-Client durch pg oder Drizzle ORM und strukturell ändert sich nichts.
---
Ehrlich gesagt hat mich dieser Stack überrascht, wie schnell man von null zu einem funktionierenden, speicherpersistenten Agent kommt. Das Vercel AI SDK macht viel unsichtbare Arbeit: Streaming-Protokoll, Tool-Call-Serialisierung, Provider-Abstraktion. Supabase kümmert sich um Persistierung und Auth ohne viel von dir zu verlangen. Die Komplexität, die bleibt – und das ist der Part, den niemand für dich abstrahieren kann – ist das Schreiben eines System-Prompts, der das Modell wirklich zum Verhalten bringt. Das braucht Iteration. Fang streng an, lockere graduell auf, und teste mit den schlechtesten formulierten Fragen, die du dir vorstellen kannst.
Weiterführende Lektüre: AI-Suchschlüsselwort-Recherche 2026: was es ist, warum traditionelle, technisches SEO und AI-Suche.
