Retour aux series

De Dev Web a Ingenieur IA

De Dev Web à Ingénieur IA #4 — Appel de Fonctions & Outils

Définir des outils, boucle itérative LLM↔outil : la base des agents

Intro

L’article précédent nous a appris à obtenir du JSON typé d’un LLM avec format: json + Zod. On avait un pipeline linéaire : prompt → LLM → JSON → parsing.

Aujourd’hui on change de paradigme. Au lieu de demander au LLM de répondre directement, on va lui donner des outils (tools) qu’il peut appeler. C’est la base des agents : le LLM décide lui-même d’utiliser un outil, on exécute, on renvoie le résultat, et le LLM formule la réponse finale.

C’est ce qu’on appelle le tool calling (ou function calling). C’est le mécanisme qui permet à ChatGPT de calculer, de chercher sur le web, ou de consulter une base de données.

Si tu viens de l’article 3, copie le dossier :

cp -r article-03-structured-output article-04-tool-calling
cd article-04-tool-calling && rm -rf node_modules package-lock.json
npm install

Si tu démarres ici, voir l’article 1 pour le setup initial.

Note sur le modèle : le tool calling nécessite un modèle qui supporte cette fonctionnalité. qwen3:4b le fait très bien. Si tu n’as pas encore ce modèle :

ollama pull qwen3:4b

Tool calling setup


Le concept : LLM → tool → LLM

Sans tool calling, le flow est simple :

User → LLM → Réponse texte

Avec tool calling, le LLM peut décider de déléguer une partie du travail :

User → LLM → tool_calls → notre code exécute → on renvoie le résultat → LLM → Réponse finale

Le LLM ne peut pas exécuter de code, mais il peut décider quel outil appeler et avec quels arguments. C’est nous qui exécutons. C’est une séparation claire : décision = LLM, exécution = nous.


Le code

import { z } from "zod";

const MODEL = "qwen3:4b";
const OLLAMA_BASE = process.env.OLLAMA_HOST ?? "http://localhost:11434";

// ─── Types ─────────────────────────────────────────────

interface ToolDef {
  type: "function";
  function: {
    name: string;
    description: string;
    parameters: Record<string, unknown>;
  };
}

interface ToolCall {
  type: "function";
  function: { name: string; arguments: string | Record<string, unknown> };
}

interface Message {
  role: "system" | "user" | "assistant" | "tool";
  content: string | null;
  tool_calls?: ToolCall[];
}

interface ChatResponse {
  message: Message;
}

// ─── Nos outils ────────────────────────────────────────

const TOOLS: ToolDef[] = [
  {
    type: "function",
    function: {
      name: "add",
      description: "Additionne deux nombres",
      parameters: {
        type: "object",
        properties: {
          a: { type: "number", description: "Le premier nombre" },
          b: { type: "number", description: "Le second nombre" },
        },
        required: ["a", "b"],
      },
    },
  },
  {
    type: "function",
    function: {
      name: "multiply",
      description: "Multiplie deux nombres",
      parameters: {
        type: "object",
        properties: {
          a: { type: "number", description: "Le premier nombre" },
          b: { type: "number", description: "Le second nombre" },
        },
        required: ["a", "b"],
      },
    },
  },
  {
    type: "function",
    function: {
      name: "get_current_time",
      description: "Retourne l'heure actuelle à Paris",
      parameters: { type: "object", properties: {} },
    },
  },
  {
    type: "function",
    function: {
      name: "get_weather",
      description: "Retourne la météo pour une ville",
      parameters: {
        type: "object",
        properties: {
          city: { type: "string", description: "Nom de la ville" },
        },
        required: ["city"],
      },
    },
  },
];

// ─── Validation des arguments (Zod) ────────────────────

const AddArgs = z.object({ a: z.number(), b: z.number() });
const MultiplyArgs = z.object({ a: z.number(), b: z.number() });
const WeatherArgs = z.object({ city: z.string() });

// ─── Exécution d'un tool ───────────────────────────────

function normalizeArgs(raw: string | Record<string, unknown>): Record<string, unknown> {
  return typeof raw === "string" ? JSON.parse(raw) : raw;
}

function executeToolCall(tc: ToolCall): string {
  const { name, arguments: raw } = tc.function;
  const args = normalizeArgs(raw);

  switch (name) {
    case "add": {
      const { a, b } = AddArgs.parse(args);
      return String(a + b);
    }
    case "multiply": {
      const { a, b } = MultiplyArgs.parse(args);
      return String(a * b);
    }
    case "get_current_time": {
      return new Date().toLocaleTimeString("fr-FR", {
        timeZone: "Europe/Paris",
      });
    }
    case "get_weather": {
      const { city } = WeatherArgs.parse(args);
      const temp = 15 + Math.floor(Math.random() * 15);
      const conditions = ["ensoleillé", "nuageux", "pluvieux"];
      const sky = conditions[Math.floor(Math.random() * conditions.length)];
      return `Il fait ${temp}°C et le ciel est ${sky} à ${city}.`;
    }
    default:
      throw new Error(`Tool inconnu: ${name}`);
  }
}

// ─── Helper Ollama ─────────────────────────────────────

async function chat(
  model: string,
  messages: Message[],
  tools?: ToolDef[],
): Promise<ChatResponse> {
  const body: Record<string, unknown> = { model, messages, stream: false, keep_alive: 0 };
  if (tools) body.tools = tools;

  const res = await fetch(`${OLLAMA_BASE}/api/chat`, {
    method: "POST",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify(body),
  });
  if (!res.ok) throw new Error(`HTTP ${res.status}: ${await res.text()}`);
  return res.json() as Promise<ChatResponse>;
}

// ─── Boucle LLM ↔ tool (réutilisée par toutes les sections) ──

const MAX_TOOL_ITERATIONS = 5;

async function toolLoop(messages: Message[], label: string): Promise<string> {
  for (let i = 0; i < MAX_TOOL_ITERATIONS; i++) {
    const response = await withSpinner(label, () =>
      chat(MODEL, messages, TOOLS),
    );

    if (response.message.tool_calls) {
      for (const tc of response.message.tool_calls) {
        const { name, arguments: raw } = tc.function;
        const argsStr = typeof raw === "string" ? raw : JSON.stringify(raw);
        console.log(`  🛠  ${name}(${argsStr})`);
        const result = executeToolCall(tc);
        console.log(`  → ${result}\n`);
        messages.push({
          role: "assistant",
          content: null,
          tool_calls: [tc],
        });
        messages.push({ role: "tool", content: result });
      }
    } else {
      const content = response.message.content ?? "";
      console.log(`  ${content}\n`);
      return content;
    }
  }
  throw new Error(`Boucle infinie : le LLM appelle des outils sans fin après ${MAX_TOOL_ITERATIONS} tours`);
}

// ─── Spinner ───────────────────────────────────────────

async function withSpinner<T>(label: string, fn: () => Promise<T>): Promise<T> {
  const frames = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
  let i = 0;
  const interval = setInterval(() => {
    process.stdout.write(`\r${frames[i]} ${label}`);
    i = (i + 1) % frames.length;
  }, 80);

  try {
    const result = await fn();
    process.stdout.write(`\r✓ ${label}\n`);
    return result;
  } catch (e) {
    process.stdout.write(`\r✗ ${label}\n`);
    throw e;
  } finally {
    clearInterval(interval);
  }
}

// ─── Main — chaque section illustre un cas ─────────────

async function main() {
  // ── 1. SANS TOOL ──
  console.log("");
  console.log("=== 1. Sans tool — le LLM calcule de mémoire ===\n");

  const r1 = await withSpinner("Appel sans outils...", () =>
    chat(MODEL, [
      {
        role: "user",
        content: "Combien font 123 * 456 ? Réponds uniquement par le nombre.",
      },
    ]),
  );
  console.log(`  ${r1.message.content}\n`);
  console.log("---\n");

  // ── 2. AVEC TOOL — appel simple ──
  console.log("=== 2. Avec tool — multiplication ===\n");

  const msg2: Message[] = [
    { role: "user", content: "Combien font 123 * 456 ?" },
  ];
  await toolLoop(msg2, "Multiplication...");
  console.log("---\n");

  // ── 3. APPELS MULTIPLES ──
  console.log("=== 3. Appels multiples — (10 + 5) × 2 ===\n");

  const msg3: Message[] = [
    { role: "user", content: "Calcule (10 + 5) * 2 étape par étape. Réponds en texte simple, sans LaTeX." },
  ];
  await toolLoop(msg3, "Calcul étape par étape...");
  console.log("---\n");

  // ── 4. TOOLS NON MATHÉMATIQUES ──
  console.log("=== 4. Heure et météo ===\n");

  const msg4: Message[] = [
    {
      role: "user",
      content: "Quelle heure est-il et quel temps fait-il à Paris ?",
    },
  ];
  await toolLoop(msg4, "Heure et météo...");
  console.log("---\n");
}

main().catch((err) => {
  console.error(`\nErreur : ${err.message}`);
  process.exit(1);
});

Exécution

npm start

Voici le résultat complet du script :

=== 1. Sans tool — le LLM calcule de mémoire ===

✓ Appel sans outils...
  56088

---

=== 2. Avec tool — multiplication ===

✓ Multiplication...
  🛠  multiply({"a":123,"b":456})
  → 56088

  123 × 456 = 56088

---

=== 3. Appels multiples — (10 + 5) × 2 ===

✓ Calcul étape par étape...
  Première étape : 10 + 5 = 15. Deuxième étape : 15 * 2 = 30.

---

=== 4. Heure et météo ===

✓ Heure et météo...
  🛠  get_current_time({})
  → 09:44:45

  🛠  get_weather({"city":"Paris"})
  → Il fait 25°C et le ciel est nuageux à Paris.

✓ Heure et météo...
  Il est 09:44:45 à Paris et il fait 25°C avec un ciel nuageux.

Analyse test par test

Test 1 — Sans tool : on pose une multiplication au LLM sans lui donner d’outil. Le modèle répond 56088 — c’est correct ! Mais c’est parce que 123 × 456 est un calcul courant dans les données d’entraînement. Si on lui demande 157 × 893, il risque de se tromper. Un LLM n’est pas une calculatrice.

Test 2 — Avec tool multiply : on donne au LLM un outil multiply. Il décide de l’appeler avec {"a": 123, "b": 456}. On exécute la fonction JavaScript et on renvoie 56088. Le LLM formule la réponse finale. Résultat garanti correct.

Test 3 — Appels multiples : ici le LLM a choisi de ne pas utiliser les outils et a répondu de mémoire. (10 + 5) × 2 est suffisamment simple pour que le modèle n’estime pas nécessaire de déléguer. C’est une leçon importante : le LLM décide souverainement d’utiliser ou non un outil — il n’est pas obligé de s’en servir même s’ils sont disponibles. Les modèles récents (dont les versions à jour de qwen3) sont plus confiants et n’appellent des outils que quand ils jugent que leur connaissance ne suffit pas.

Test 4 — Tools variés : le LLM combine deux outils de natures différentes (get_current_time et get_weather) pour répondre à une question composée. Chaque tool retourne un résultat textuel que le LLM assemble dans une réponse cohérente.

Pour aller plus loin — forcer l’appel d’outils : si tu veux que le LLM utilise les outils même pour des calculs triviaux, ajoute une consigne explicite dans le prompt : "Calcule (10 + 5) * 2 étape par étape en utilisant les outils mis à ta disposition." Le mot-clé “outils” est essentiel : c’est lui qui active le tool calling. Sans consigne explicite, le modèle peut répondre de mémoire pour les cas simples.

Comment ça marche

La clé du tool calling, c’est le champ tools dans la requête Ollama :

const body = { model, messages, tools: TOOLS, stream: false, keep_alive: 0 };

Chaque tool est défini avec :

  • name — le nom que le LLM utilisera pour l’appeler

  • description — cruciale : c’est elle qui guide le LLM dans le choix du tool

  • parameters — en JSON Schema : types, descriptions, champs requis

Quand le LLM décide d’utiliser un outil, la réponse contient tool_calls :

if (response.message.tool_calls) {
  for (const tc of response.message.tool_calls) {
    const { name, arguments: raw } = tc.function;
    const argsStr = typeof raw === "string" ? raw : JSON.stringify(raw);
    console.log(`  🛠  ${name}(${argsStr})`);
    const result = executeToolCall(tc);
    console.log(`  → ${result}\n`);
    messages.push({ role: "assistant", tool_calls: [tc] });
    messages.push({ role: "tool", content: result });
  }
}

On pousse deux messages dans l’historique :

  1. Le message assistant avec les tool_calls (pour que le LLM sache ce qu’il a demandé)

  2. Le message tool avec le résultat (pour que le LLM puisse l’utiliser)

La boucle continue tant que le LLM appelle des outils. Dès qu’il répond sans tool_calls, on a la réponse finale.

Pourquoi normalizeArgs ? Le LLM renvoie parfois arguments en string JSON ('{"a":1,"b":2}'), parfois déjà parsé en objet ({a:1, b:2}). C’est le même genre d’imprévisibilité qu’avec extractJSON() dans l’article 3. normalizeArgs() uniformise le format avant de le passer à Zod.

Pourquoi Zod ? On réutilise Zod pour valider les arguments des outils avant de les exécuter. Même si le LLM est censé respecter le schéma JSON, il peut arriver qu’il produise des types incorrects. AddArgs.parse(args) lève une erreur claire si a ou b ne sont pas des nombres.

Pendant ce temps, sous le capot

Pendant que le script tourne, voici ce que consomme Ollama sur un MacBook Air 16 Go (Activity Monitor, onglet CPU) :

llama-server  1.6% CPU  10.45% MEM  90.3% GPU
  • 1.6% CPU — quasi rien, le modèle ne sollicite pas le CPU

  • 10.45% MEM — ~1.6 Go, c’est le poids de qwen3:4b chargé en RAM (que tu poses une question ou non)

  • 90.3% GPU — l’inférence tourne sur le GPU Apple Silicon via Metal. C’est pour ça que le MacBook Air reste fluide et ne chauffe pas : le GPU gère l’essentiel du calcul

Tu peux vérifier toi-même avec Activity Monitor (cmd+espace → “Activity Monitor” → onglet CPU → coche %GPU dans le menu Vue → Colonnes).

Consommation Ollama dans Activity Monitor

Le tableau récapitulatif :

Situation Résultat
LLM sans outil (calcul) Parfois faux
LLM + outil multiply Toujours correct
LLM + étapes multiples Raisonnement guidé par les outils
LLM + outils variés Composition de sources différentes

Ce qu’on a appris

  • Tool calling — un LLM peut décider d’appeler des fonctions. Il choisit le nom et les arguments, on exécute.

  • Définition des tools — chaque outil a un nom, une description, un schéma JSON. La description est essentielle : c’est elle qui guide le LLM.

  • Boucle LLM ↔ tool — on alterne appels LLM et exécution d’outils jusqu’à réponse finale. C’est le cœur des agents.

  • Validation avec Zod — on valide les arguments générés par le LLM avant exécution. Sécurité et erreurs claires.

  • tool_calls dans l’historique — on pousse à la fois l’appel tool et son résultat dans les messages, pour que le LLM ait tout le contexte.


Prochain article

RAG Fondamentaux — comment donner accès à nos propres documents au LLM. On va construire un pipeline d’ingestion, vectoriser du texte, et faire une première query RAG.