16 KiB
Les 12 — Tool Calling
Lesstof
Vak: AI-Assisted Development Opleiding: NOVI Hogeschool Utrecht Onderwerp: Tool Calling — AI besluit zelf welke functie aan te roepen Demo: Polderfest 2027 (vervolg op Les 11)
Inhoudsopgave
- Het schaalprobleem dat we oplossen
- Wat is Tool Calling?
- Anatomie van een tool
- Multi-step met stopWhen
- Refactor: chat-route met tools
- Tool-invocations in de UI
- Edge cases & error handling
- Tool Calling vs context-all — vergelijking
- Best practices
- Wat komt hierna? Agents teaser
- Bronnen
1. Het schaalprobleem
In Les 11 stuurden we alle 500 bands mee als tekst-context bij elke chat-request:
const { data: bands } = await supabase.from("bands").select("*");
const context = bands.map((b) => `- ${b.name} (${b.genre}, ...)`).join("\n");
const system = `Hier zijn alle bands:\n${context}\nBeantwoord vragen...`;
Werkt voor 500 records. Werkt niet voor 50.000.
| Records | Tokens per call | Cost (gpt-4o-mini) | Probleem |
|---|---|---|---|
| 500 | ~30.000 | $0.005 | OK |
| 5.000 | ~300.000 | $0.05 | Te traag, context-window grenst aan limit |
| 50.000 | ~3 miljoen | n.v.t. | Past niet in context |
Daarnaast:
- Context is een snapshot — als data verandert tijdens chat, weet AI 't niet
- Geen write-acties mogelijk — alleen lezen
- AI moet zelf "denken" om de juiste records te vinden in een lap tekst
Tool Calling lost dit op.
2. Wat is Tool Calling?
Het idee: in plaats van alle data mee te sturen, geef je AI tools (functies). AI ziet je vraag, kiest welke tool relevant is, roept 'm aan met de juiste parameters, en gebruikt het resultaat om te antwoorden.
Flow
User: "Welke bands spelen vrijdag op de Main Stage?"
↓
AI: kiest tool → searchBands({ day: "Vrijdag", stage: "Main Stage" })
↓
Supabase: 12 bands terug
↓
AI: formuleert antwoord op basis van resultaat
↓
User: "Op vrijdag op de Main Stage spelen: ..."
Wat win je
- Schaalbaar — werkt voor 100 of 10 miljoen records
- Real-time — tool draait elke keer opnieuw, geen snapshot
- Type-safe — parameters via Zod schema, gevalideerd
- Multi-step — meerdere tools combineren voor complexe vragen
- Write-acties — AI kan ook iets in je database zetten (favorieten, votes, notes)
3. Anatomie van een tool
Drie verplichte delen.
import { tool } from "ai";
import { z } from "zod";
const searchBands = tool({
description:
"Zoek bands in de Polderfest line-up. Filter op dag, stage, genre, of tier.",
inputSchema: z.object({
day: z.enum(["Vrijdag", "Zaterdag", "Zondag"]).optional(),
stage: z.string().optional(),
genre: z.string().optional(),
tier: z.enum(["headliner", "mid", "opener"]).optional(),
}),
execute: async ({ day, stage, genre, tier }) => {
let q = supabase.from("bands").select("*");
if (day) q = q.eq("day", day);
if (stage) q = q.eq("stage", stage);
if (genre) q = q.eq("genre", genre);
if (tier) q = q.eq("tier", tier);
const { data, error } = await q.limit(20);
if (error) return { error: error.message };
return data;
},
});
description
Wat doet deze tool en wanneer gebruik je 'm? AI kiest tools op basis van descriptions.
- ❌ Vaag:
"Doe iets met bands" - ✅ Goed:
"Zoek bands op dag, stage, genre of tier. Gebruik dit voor filtervragen."
Schrijf alsof je 't aan een collega uitlegt. Twee zinnen vaak genoeg.
inputSchema
Zod schema. Type-safe. AI weet welke parameters mogen. (Heette parameters in AI SDK v3 — sinds v5/v6 inputSchema.)
z.string()— vrije tekstz.enum([...])— vaste keuze (AI mag alleen een van deze)z.number().min(1).max(100)— getal met boundsz.boolean().optional()— parameter mag weg.describe("...")— extra context voor AI
execute
Async functie. Returnt wat AI moet zien. Belangrijk: errors als data terug returnen, niet als exception throwen:
// ❌ Niet doen — AI ziet de error niet
execute: async (...) => {
const data = await supabase.from(...).select();
if (data.error) throw new Error(data.error.message);
}
// ✅ Wel doen — AI kan dit zelf afhandelen
execute: async (...) => {
const { data, error } = await supabase.from(...).select();
if (error) return { error: error.message };
return data;
}
4. Multi-step met stopWhen
Met stopWhen: stepCountIs(5) geef je AI toestemming om tot 5 keer een tool aan te roepen voordat hij definitief antwoordt. (In AI SDK v3 heette dit maxSteps: 5 — sinds v5/v6 gebruik je stopWhen met een conditie zoals stepCountIs(N).)
const result = streamText({
model: openai("gpt-4o-mini"),
tools: { searchBands, getStats, getBandByName },
stopWhen: stepCountIs(5),
messages,
});
Voorbeeld
User: "Vergelijk de top headliner met de drukst geplande opener."
Stap 1: AI roept searchBands({ tier: "headliner" }) aan
→ 50 bands terug, sorteer op popularity, top 1 gevonden
Stap 2: AI roept searchBands({ tier: "opener" }) aan
→ 100 bands terug, top 1 gevonden
Stap 3: AI vergelijkt + antwoordt
Drie stappen, één request. AI plant zelf de volgorde.
Wanneer welk step-limit?
| Use case | stepCountIs(N) |
|---|---|
| Simpele query ("welke bands op vrijdag?") | 1-2 |
| Vergelijking ("X vs Y") | 3-5 |
| Onderzoek ("vat alle X samen") | 5-10 |
| Agentic ("plan mijn weekend") | 15-30+ (volgende les) |
Default in AI SDK: 1 stap. Dus expliciet stopWhen zetten als je multi-step wilt.
5. Refactor: chat-route met tools
De volledige app/api/chat/route.ts na refactor:
import { streamText, tool } from "ai";
import { openai } from "@ai-sdk/openai";
import { createClient } from "@supabase/supabase-js";
import { z } from "zod";
const supabase = createClient(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,
);
const searchBands = tool({
description: "Zoek bands op dag, stage, genre, of tier.",
inputSchema: z.object({
day: z.enum(["Vrijdag", "Zaterdag", "Zondag"]).optional(),
stage: z.string().optional(),
genre: z.string().optional(),
tier: z.enum(["headliner", "mid", "opener"]).optional(),
}),
execute: async ({ day, stage, genre, tier }) => {
let q = supabase.from("bands").select("*");
if (day) q = q.eq("day", day);
if (stage) q = q.eq("stage", stage);
if (genre) q = q.eq("genre", genre);
if (tier) q = q.eq("tier", tier);
const { data, error } = await q.limit(20);
if (error) return { error: error.message };
return { count: data.length, bands: data };
},
});
const getStats = tool({
description: "Geef verdeling van bands per groep (genre, day, stage, tier).",
inputSchema: z.object({
groupBy: z.enum(["genre", "day", "stage", "tier"]),
}),
execute: async ({ groupBy }) => {
const { data, error } = await supabase.from("bands").select(groupBy);
if (error) return { error: error.message };
const counts: Record<string, number> = {};
for (const row of data) {
const key = row[groupBy as keyof typeof row] as string;
counts[key] = (counts[key] ?? 0) + 1;
}
return { total: data.length, counts };
},
});
const getBandByName = tool({
description: "Haal alle details op van één band bij naam (inclusief bio).",
inputSchema: z.object({ name: z.string() }),
execute: async ({ name }) => {
const { data, error } = await supabase
.from("bands").select("*").ilike("name", name).single();
if (error) return { error: `Band '${name}' niet gevonden.` };
return data;
},
});
export async function POST(req: Request) {
const { messages } = await req.json();
const system = `Je bent een festival-assistent voor Polderfest 2027.
Gebruik de beschikbare tools om vragen te beantwoorden.
Verzin nooit data. Antwoord beknopt en in het Nederlands.`;
const result = streamText({
model: openai("gpt-4o-mini"),
system,
messages,
tools: { searchBands, getStats, getBandByName },
stopWhen: stepCountIs(5),
});
return result.toUIMessageStreamResponse();
}
Wat is veranderd t.o.v. Les 11:
- Geen
select("*")op start meer - Geen grote context-string in system prompt
- Tools-object toegevoegd
stopWhen: stepCountIs(5)voor multi-step- System prompt veel korter — alleen rol + regels
6. Tool-invocations in de UI
useChat returnt messages met parts — een array van delen per bericht. Tekst-parts én tool-invocation-parts.
"use client";
import { useChat } from "@ai-sdk/react";
import { useState } from "react";
export default function ChatPage() {
const { messages, sendMessage, status } = useChat();
const [input, setInput] = useState("");
return (
<main>
{messages.map((m) => (
<div key={m.id}>
<strong>{m.role}:</strong>
{m.parts?.map((part, i) => {
if (part.type === "text") {
return <div key={i}>{part.text}</div>;
}
// In AI SDK v6 zijn tool-parts genaamd `tool-<toolName>`
if (part.type?.startsWith("tool-")) {
const toolName = part.type.replace("tool-", "");
return (
<div key={i} className="bg-yellow-50 p-2 rounded">
🔧 {toolName}({JSON.stringify(part.input)})
{part.state === "output-available" && (
<details>
<summary>Toon resultaat</summary>
<pre>{JSON.stringify(part.output, null, 2)}</pre>
</details>
)}
</div>
);
}
return null;
})}
</div>
))}
<form
onSubmit={(e) => {
e.preventDefault();
if (!input.trim()) return;
sendMessage({ text: input });
setInput("");
}}
>
<input value={input} onChange={(e) => setInput(e.target.value)} />
</form>
</main>
);
}
Waarom tool-invocations tonen?
- Debug — zien wat AI aanroept met welke args
- Vertrouwen — gebruiker ziet "ja, hij heeft echt iets opgezocht"
- Demo — voor presentaties, hackathons, onboarding
In productie kun je dit optioneel verstoppen, voor jullie eindopdracht: wel tonen.
Part states (v6)
| state | Wat |
|---|---|
"input-streaming" |
Tool args worden nog gestreamed |
"input-available" |
Args compleet, tool draait |
"output-available" |
Tool is gerund, resultaat beschikbaar |
"output-error" |
Tool gaf een error |
7. Edge cases & error handling
Ongeldige input (enum-restrictie)
User: "Welke bands spelen op Donderdag?"
AI ziet dat day enum is — ["Vrijdag", "Zaterdag", "Zondag"]. Donderdag past niet. AI weigert tool aan te roepen en legt uit.
Les: gebruik enums voor restricted values. AI respecteert ze.
Lege resultaten
User: "Death metal bands?"
Tool returnt { count: 0, bands: [] }. AI legt uit: "Geen death metal op Polderfest 2027."
Les: lege array is OK, AI handelt 't af.
Database errors
execute: async (...) => {
const { data, error } = await supabase.from(...).select();
if (error) return { error: error.message };
return data;
}
AI ziet { error: "..." }, communiceert dit netjes. Niet een exception throwen — dan crasht de hele chat.
Write-tools en user-intent
Write-tools (insert/update/delete) zijn gevaarlijker — ze veranderen echt iets. Bescherm via:
- Descriptions: "Alleen gebruiken als gebruiker expliciet vraagt om X toe te voegen"
- Confirmation UI: laat user nog een keer bevestigen voor de write definitief gaat
- Permissions: write-tool checked of user ingelogd is via auth.uid()
Voor demo open laten kan. Voor productie: streng.
8. Tool Calling vs context-all
| Aspect | Les 11 (context-all) | Les 12 (tool calling) |
|---|---|---|
| Tokens per call | ~30.000 (500 bands) | ~2.000 (tools + 1 result) |
| Schaal | Tot ~1000 records | Tot duizenden makkelijk |
| Live data | Snapshot bij chat-start | Actueel per call |
| Write operaties | Niet mogelijk | Wel (addFavorite etc.) |
| Multi-step | Beperkt — alleen reasoning | Native (stopWhen + stepCountIs) |
| Cost | Hoger | Lager (kleinere context) |
| Complexiteit | Lager | Iets hoger (tools definiëren) |
Wanneer toch context-all?
- Hele kleine dataset (<100 records)
- Snel prototype
- Geen schaal nodig
Voor productie: bijna altijd Tool Calling.
9. Best practices
Descriptions schrijven
// ❌
description: "zoek dingen"
// ❌ Te vaag
description: "zoek bands"
// ✅
description:
"Zoek bands in de Polderfest line-up. Filter op dag, stage, genre, " +
"of tier. Gebruik dit voor filtervragen zoals 'welke X op vrijdag?'"
Parameter design
- Gebruik
enumvoor categorische waarden (niet string) .optional()voor filter-parameters.describe()op elke parameter — extra context- Houd parameter-sets klein (3-5 max)
Returns
- Returnt JSON-serializable waarden (geen Date-objecten direct, gebruik ISO strings)
- Errors als
{ error: "..." }terug — niet throwen - Beperk grootte van responses (limit 20 records, niet 500)
System prompts
const system = `Je bent een festival-assistent voor Polderfest 2027.
Gebruik de beschikbare tools om vragen te beantwoorden.
Tips:
- Voor "welke bands op X?" → searchBands
- Voor "hoeveel" → getStats
- Voor specifieke band → getBandByName
Verzin nooit data. Als tools een error returnen, leg dat uit aan de gebruiker.`;
Schrijf het system prompt als handleiding voor AI — wanneer welke tool.
Tools registeren
Houd tools in een aparte module als ze veel worden:
// lib/tools.ts
export const searchBands = tool({ ... });
export const getStats = tool({ ... });
export const getBandByName = tool({ ... });
// app/api/chat/route.ts
import * as tools from "@/lib/tools";
streamText({ ..., tools, stopWhen: stepCountIs(5) });
10. Wat komt hierna?
Volgende les: Agents (Les 13)
Tool Calling met stopWhen: stepCountIs(5) is een eerste stap richting agents. Volgende les: AI Agents met stepCountIs(20) en hoger, of zelfs custom stop-condities.
const result = streamText({
model: openai("gpt-4o-mini"),
tools: { ... },
stopWhen: stepCountIs(30),
experimental_telemetry: { isEnabled: true },
messages,
});
Voorbeeld autonome workflow: "Plan mijn volledige Polderfest weekend op basis van mijn smaak."
Agent doet:
- Vraag user smaakprofiel (Hip-Hop + Indie)
- searchBands per dag + genre
- Filtert op overlap-vermijding
- addFavorite per geselecteerde band
- listFavorites om finaal schema te tonen
- Wijst conflicten aan in tijdsloten
- Optimaliseert en herhaalt
30+ tool-calls in één user-request.
Daarna in deze leerlijn
- Les 14: RAG + embeddings — semantic search op grote tekst-corpora
- Les 15-16: Testing + Deployment + Performance
- Les 17-18: Eindopdracht-werkdagen + Pitch
11. Bronnen
Vercel AI SDK
- Tools-documentatie: https://ai-sdk.dev/docs/foundations/tools
- Agents + stopWhen: https://ai-sdk.dev/docs/foundations/agents
- streamText reference: https://ai-sdk.dev/docs/reference/ai-sdk-core/stream-text
- useChat reference: https://ai-sdk.dev/docs/reference/ai-sdk-ui/use-chat
Underlying providers
- OpenAI Function Calling: https://platform.openai.com/docs/guides/function-calling
- Anthropic Tool Use: https://docs.anthropic.com/en/docs/build-with-claude/tool-use
Zod
- Docs: https://zod.dev
- Schema reference: https://zod.dev/?id=primitives