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

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

  1. Het schaalprobleem dat we oplossen
  2. Wat is Tool Calling?
  3. Anatomie van een tool
  4. Multi-step met stopWhen
  5. Refactor: chat-route met tools
  6. Tool-invocations in de UI
  7. Edge cases & error handling
  8. Tool Calling vs context-all — vergelijking
  9. Best practices
  10. Wat komt hierna? Agents teaser
  11. 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 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:

// ❌ 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:

  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

// ❌
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

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:

  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

Underlying providers

Zod

Supabase JS