Files
novi-lessons/Les12-Tool-Calling/Les12-Lesstof.md
2026-05-21 08:52:47 +02:00

538 lines
16 KiB
Markdown

# 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
1. [Het schaalprobleem dat we oplossen](#1-het-schaalprobleem)
2. [Wat is Tool Calling?](#2-wat-is-tool-calling)
3. [Anatomie van een tool](#3-anatomie-van-een-tool)
4. [Multi-step met stopWhen](#4-multi-step-met-stopwhen)
5. [Refactor: chat-route met tools](#5-refactor-chat-route)
6. [Tool-invocations in de UI](#6-tool-invocations-in-de-ui)
7. [Edge cases & error handling](#7-edge-cases--error-handling)
8. [Tool Calling vs context-all — vergelijking](#8-tool-calling-vs-context-all)
9. [Best practices](#9-best-practices)
10. [Wat komt hierna? Agents teaser](#10-wat-komt-hierna)
11. [Bronnen](#11-bronnen)
---
## 1. Het schaalprobleem
In Les 11 stuurden we **alle 500 bands** mee als tekst-context bij elke chat-request:
```typescript
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.
```typescript
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 tekst
- `z.enum([...])` — vaste keuze (AI mag alleen een van deze)
- `z.number().min(1).max(100)` — getal met bounds
- `z.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:
```typescript
// ❌ 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)`.)
```typescript
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:
```typescript
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.
```tsx
"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
```typescript
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:
1. **Descriptions**: "Alleen gebruiken als gebruiker expliciet vraagt om X toe te voegen"
2. **Confirmation UI**: laat user nog een keer bevestigen voor de write definitief gaat
3. **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
```typescript
// ❌
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 `enum` voor 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
```typescript
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:
```typescript
// 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.
```typescript
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:
1. Vraag user smaakprofiel (Hip-Hop + Indie)
2. searchBands per dag + genre
3. Filtert op overlap-vermijding
4. addFavorite per geselecteerde band
5. listFavorites om finaal schema te tonen
6. Wijst conflicten aan in tijdsloten
7. 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
### Supabase JS
- Query builder: https://supabase.com/docs/reference/javascript/select
- Filters: https://supabase.com/docs/reference/javascript/using-filters