Erstellung von mehrstufigen UIs für KI-Agenten mit Next.js und dem AI SDK
Eine UI für einen KI-Agenten in Next.js mit dem AI SDK: vorgefertigte Tools, mehrstufige Schleifen sowie streambasierte generative UI-Komponenten, die während der Arbeit des Agenten in TypeScript dargestellt werden.
Die meisten Anleitungen zu KI-Systemen enden genau dort, wo es wirklich wichtig wird. Man schreibt einen kleinen Script, sendet eine Anfrage an ein Modell und beobachtet, wie Tokens in einer Terminalansicht angezeigt werden – und das wirkt bereits wie Fortschritt.
Die eigentliche Schwierigkeit zeigt sich erst, wenn man versucht, daraus ein echtes Produkt zu machen.
Dann wird klar, dass ein Geschäftsanwender kein Interesse an einem rohen Block aus Markdown-Text hat. Er erwartet interaktive Datentabellen, eine Bestätigungsstufe vor jeder zerstörerischen Aktion sowie einen reibungslosen Stream-Ausgabe-Verlauf, der nicht dazu führt, dass die Seite ständig springt.
Was er tatsächlich braucht, ist ein Modell, das mehrere Toolaufrufe nacheinander koordinieren kann, während die Benutzeroberfläche für jede Phase dieses Prozesses ordnungsgemäß formatierte React-Komponenten anzeigt.
Das Entwerfen einer Schnittstelle für einen Agenten bedeutet, das Modell als aktiven Ausführungsmotor und nicht als einfachen Textgenerator zu betrachten. Im Folgenden finden Sie einen Entwurf zur Erstellung einer für die Produktion geeigneten Agentenschnittstelle in Next.js mit dem Vercel AI SDK – einer Schnittstelle, die mehrstufige Aufrufe von Tools, stark typisierte Datenpakete sowie dynamisch generierte UI-Elemente unterstützt, sobald die Antworten eintreffen.
Die Architektur: Jenseits einfacher Textströme
Ein robuster Agentenpipeline sieht überhaupt nicht wie ein einfacher Chatbot aus:
[ Client: useChat() ]
│ ▲
│ │ Data Stream (Tokens + Typed Tool Invocations)
▼ │
[ Next.js Route Handler: streamText() ]
│ ▲
│ │ Model generates tool arguments
▼ │
[ Tool Execution (e.g. DB Query / Stripe API) ]
│
└──> (Loop back to LLM if more steps are required)
- Stateful Transport: Der Client sendet die Eingaben des Benutzers an einen Route-Handler, der auf dem Edge- oder Node-Backend läuft.
- Ausführung von Tools auf Serverseite: Das Modell prüft, ob es bereits genügend Informationen hat, um zu antworten, oder ob es ein vordefiniertes Tool aufriefen muss, das über ein Zod-Schema validiert wurde, um externe Daten abzurufen.
1. Definition der Agent-API mit typisierten Tools
Beginnen Sie damit, die API-Route mit dem App Router zu erstellen. Ziel ist ein Agent, der aktuelle Kontoinformationen abrufen und transaktionale Aktionen auslösen kann, wie beispielsweise die Erstellung einer Rechnung.
Installieren Sie die erforderlichen Pakete:
npm install ai @ai-sdk/openai @ai-sdk/react zod
Dann definieren Sie den Route-Handler in app/api/agent/route.ts:
import { openai } from '@ai-sdk/openai';
import { streamText, tool } from 'ai';
import { z } from 'zod';
// Prevent function timeouts on long agent loops
export const maxDuration = 60;
export async function POST(req: Request) {
const { messages } = await req.json();
const result = streamText({
model: openai('gpt-4o'),
messages,
// System instructions establish agent personality and boundaries
system: `You are an internal operations assistant.
Use the available tools to answer queries and perform operations.
Always confirm parameters before invoking actions that write data.`,
// Allow the model to execute a tool, inspect output, and take subsequent steps
maxSteps: 5,
tools: {
getAccountMetrics: tool({
description: 'Fetch real-time metrics for a customer account',
parameters: z.object({
accountId: z.string().describe('The alphanumeric account identifier'),
timeframe: z.enum(['7d', '30d', '90d']),
}),
execute: async ({ accountId, timeframe }) => {
// Replace with real database or external API call
await new Promise((r) => setTimeout(r, 800)); // Simulating latency
return {
accountId,
timeframe,
revenue: 24500,
activeUsers: 342,
churnRisk: 'low',
};
},
}),
generateInvoiceDraft: tool({
description: 'Prepare an invoice draft for customer review',
parameters: z.object({
recipientEmail: z.string().email(),
amount: z.number().positive(),
currency: z.string().default('USD'),
}),
execute: async ({ recipientEmail, amount, currency }) => {
// Simulating invoice creation
return {
invoiceId: `inv_${Math.random().toString(36).substring(2, 9)}`,
status: 'draft',
recipientEmail,
amount,
currency,
createdAt: new Date().toISOString(),
};
},
}),
},
});
return result.toDataStreamResponse();
}
Warum maxSteps wichtig ist
Ältere Ansätze für mehrstufiges Agenten-Reasoning setzten auf umständliche while-Schleifen, manuelle Änderungen am Nachrichtenarray sowie getrennte Anfragen an den LLM für jeden Schritt.
Durch die Einstellung von maxSteps: 5 kann das SDK diesen rekursiven Zyklus zur Auflösung von Tools auf dem Server übernehmen und jeden Zwischenzustand direkt an den Client streamen, sobald er entsteht.
2. Aufbau des Clients: Streaming von typisierten Komponenteninseln
Auf der Frontend-Seite teilen moderne Agenten-Interfaces jede eingehende Antwort in Teile auf, anstatt sie als einen einzigen Textblock zu behandeln. Eine einzelne Antwort kann normale Textabschnitte, noch ausstehende Toolaufrufe sowie bereits abgeschlossene Toolergebnisse enthalten.
Richten Sie den Chat-Container in app/page.tsx ein:
'use client';
import { useChat } from '@ai-sdk/react';
import { useState } from 'react';
import { MetricsCard } from '@/components/tools/MetricsCard';
import { InvoiceCard } from '@/components/tools/InvoiceCard';
export default function AgentConsole() {
const [input, setInput] = useState('');
const { messages, append, status } = useChat({
api: '/api/agent',
});
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
if (!input.trim()) return;
append({ role: 'user', content: input });
setInput('');
};
const isLoading = status === 'streaming' || status === 'submitted';
return (
<main className="max-w-4xl mx-auto py-10 px-4 flex flex-col h-screen">
<header className="border-b pb-4 mb-6">
<h1 className="text-xl font-semibold tracking-tight">Operations Intelligence Agent</h1>
<p className="text-sm text-neutral-500">Autonomous workflow orchestration and metrics exploration.</p>
</header>
{/* Message Feed */}
<div className="flex-1 overflow-y-auto space-y-6 pr-2">
{messages.map((m) => (
<div
key={m.id}
className={`flex flex-col ${
m.role === 'user' ? 'items-end' : 'items-start'
}`}
>
<div
className={`rounded-2xl px-4 py-3 max-w-[85%] text-sm ${
m.role === 'user'
? 'bg-blue-600 text-white'
: 'bg-neutral-100 text-neutral-900 border border-neutral-200'
}`}
>
{/* Handle granular message parts */}
{m.parts?.map((part, index) => {
if (part.type === 'text') {
return <p key={index} className="whitespace-pre-wrap">{part.text}</p>;
}
// Handle tool rendering based on typed part identifier
if (part.type === 'tool-invocation') {
const { toolName, state, args } = part.toolInvocation;
if (state !== 'result') {
return (
<div key={index} className="my-2 py-2 px-3 bg-neutral-50 rounded border border-dashed border-neutral-300 text-xs text-neutral-500 animate-pulse">
Executing tool: <code className="font-mono">{toolName}</code> with {JSON.stringify(args)}...
</div>
);
}
const { result } = part.toolInvocation;
// Render specialized components for each tool
if (toolName === 'getAccountMetrics') {
return <MetricsCard key={index} data={result} />;
}
if (toolName === 'generateInvoiceDraft') {
return <InvoiceCard key={index} data={result} />;
}
}
return null;
}) ?? m.content}
</div>
</div>
))}
</div>
{/* Input Form */}
<form onSubmit={handleSubmit} className="mt-4 flex gap-2">
<input
value={input}
onChange={(e) => setInput(e.target.value)}
placeholder="Ask for metrics or draft an invoice..."
disabled={isLoading}
className="flex-1 rounded-lg border border-neutral-300 px-4 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-neutral-900 disabled:opacity-50"
/>
<button
type="submit"
disabled={isLoading}
className="bg-neutral-900 text-white rounded-lg px-5 py-2 text-sm font-medium hover:bg-neutral-800 disabled:opacity-50 transition"
>
{isLoading ? 'Thinking...' : 'Send'}
</button>
</form>
</main>
);
}
3. Darstellung generativer UI-Komponenten
Anstatt rohes JSON auszugeben, werden die Ergebnisse des Schemas direkt auf Präsentationskomponenten abgebildet.
Hier ist components/tools/MetricsCard.tsx:
interface MetricsCardProps {
data: {
accountId: string;
timeframe: string;
revenue: number;
activeUsers: number;
churnRisk: string;
};
}
export function MetricsCard({ data }: MetricsCardProps) {
return (
<div className="my-3 bg-white border border-neutral-200 rounded-xl p-4 shadow-sm text-neutral-800">
<div className="flex items-center justify-between pb-2 border-b border-neutral-100 mb-3">
<span className="font-mono text-xs text-neutral-400">ID: {data.accountId}</span>
<span className="bg-emerald-50 text-emerald-700 text-xs px-2 py-0.5 rounded-full font-medium">
Window: {data.timeframe}
</span>
</div>
<div className="grid grid-cols-3 gap-4 text-center">
<div>
<p className="text-xs text-neutral-400 uppercase font-semibold">Revenue</p>
<p className="text-lg font-bold">${data.revenue.toLocaleString()}</p>
</div>
<div>
<p className="text-xs text-neutral-400 uppercase font-semibold">Users</p>
<p className="text-lg font-bold">{data.activeUsers}</p>
</div>
<div>
<p className="text-xs text-neutral-400 uppercase font-semibold">Churn Risk</p>
<p className="text-lg font-bold capitalize text-emerald-600">{data.churnRisk}</p>
</div>
</div>
</div>
);
}
Und components/tools/InvoiceCard.tsx:
interface InvoiceCardProps {
data: {
invoiceId: string;
status: string;
recipientEmail: string;
amount: number;
currency: string;
createdAt: string;
};
}
export function InvoiceCard({ data }: InvoiceCardProps) {
return (
<div className="my-3 bg-white border border-neutral-200 rounded-xl p-4 shadow-sm text-neutral-800">
<div className="flex justify-between items-center mb-2">
<h4 className="text-xs uppercase font-bold tracking-wider text-neutral-400">Invoice Draft Created</h4>
<span className="text-xs bg-amber-100 text-amber-800 px-2 py-0.5 rounded font-mono font-medium">
{data.status}
</span>
</div>
<p className="text-xl font-bold font-mono my-1">
${data.amount} <span className="text-xs font-sans text-neutral-400">{data.currency}</span>
</p>
<p className="text-xs text-neutral-500">Recipient: <span className="text-neutral-700">{data.recipientEmail}</span></p>
<div className="mt-3 flex gap-2">
<button
onClick={() => alert(`Invoice ${data.invoiceId} sent!`)}
className="w-full bg-neutral-900 hover:bg-neutral-800 text-white text-xs font-semibold py-1.5 rounded transition"
>
Approve & Send
</button>
</div>
</div>
);
}
4. Erfahrene Lektionen aus der Produktion
Das Übertragen einer solchen Schnittstelle von localhost auf eine Live-Deployment führt oft schnell zu Randfällen. Behalten Sie die folgenden Schutzmaßnahmen im Hinterkopf.
1. Zeitlimits für Serveranfragen auf Vercel festlegen
Agentenschleifen, die mehrere Toolaufrufe miteinander verknüpfen, können leicht das Standardzeitlimit für serverlose Funktionen überschreiten (in der Regel 10 bis 15 Sekunden). Stellen Sie sicher, dass Sie in Ihrer App Router-Route eine explizite Dauerangabe festlegen:
export const maxDuration = 60; // Set according to your deployment plan limits
2. Idempotenz bei Toolausführungen
Falls ein Modell seine eigene Ausgabe falsch interpretiert oder aufgrund unklarer Anweisungen einen Schritt erneut ausführt, kann es dazu kommen, dass ein Mutationstool mehrmals aufgerufen wird. Jedes Tool, das Daten schreibt – beispielsweise eines, das eine Zahlungsmethode abruft oder ein neues Kontoinformationen erstellt – sollte eine Idempotenzschlüssel benötigen, der entweder direkt aus der Anfrage selbst stammt oder aus dem Sitzungsidentifikator abgeleitet wird.
3. Bestätigung durch einen Menschen
Ein Agent sollte niemals autonom irreversible Operationen durchführen dürfen, wie zum Beispiel das Löschen von Datensätzen, das Versenden echter E-Mails oder das Überweisen von Geldern.
- Lassen Sie den Agenten stattdessen einen Entwurfszustand erstellen, der mit etwas wie
status: 'pending_approval'markiert ist. - Ziehen Sie eine interaktive Karte hinzu, die einen manuellen Bestätigungsbutton enthält.
- Erfordern Sie, dass der Benutzer die Aktion explizit genehmigt und über eine standardmäßige Next.js Server Action auslöst.
Der neue Standard für KI-Interfaces
Einfache Chat-Vervollständigungsfelder gehören allmählich der Vergangenheit an. Die heutigen Nutzer suchen keinen abstrakten Gesprächspartner; sie wollen Tools, die Reibungsverluste im Arbeitsablauf verringern, ohne ihnen die Kontrolle zu entziehen.
Durch die Kombination von Next.js App Router Streaming mit dem typisierten Tool-Runtime des AI SDKs verwandeln Sie Sprachmodelle in zuverlässige funktionale Bausteine anstelle von unvorhersehbaren Textgeneratoren.
Das Ergebnis ist ein System, das die Vorhersehbarkeit stark typisierter TypeScript-Komponenten mit der flexiblen Denkfähigkeit von Foundation-Modellen verbindet – genau das Gleichgewicht, das Unternehmenssoftware benötigt.