538 lines
16 KiB
Markdown
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
|