LLM pour les devs : du token au function calling

Comment fonctionne un LLM : tokens, température, types de messages et function calling. On construit ensuite un jeu de dés en TypeScript avec Mistral Small.

Publié le Mis à jour le , mis à jour le 16 min de lecture

Les LLM sont partout, et pourtant leur fonctionnement reste flou pour pas mal de devs, moi le premier quand j'ai commencé. Cet article s'adresse à toi si tu n'as jamais appelé l'API d'un LLM, ou si tu l'as fait sans trop savoir ce qui se passait derrière. Il pose les bases : les tokens, les messages, et surtout le function calling, le mécanisme qui permet à un LLM d'interagir avec l'extérieur. C'est le premier article de la série IA pour les développeurs.

Pour que ça reste concret, on va construire un jeu de dés en TypeScript avec Mistral Small. L'exemple est minuscule, mais il touche à tout ce dont tu auras besoin ensuite.

Un LLM prédit le prochain token, rien de plus

Un LLM (Large Language Model) est un modèle entraîné à prédire le prochain token. À partir d'une séquence d'entrée, il calcule une distribution de probabilités sur tous les tokens possibles et en sélectionne un. Puis il recommence avec la séquence allongée d'un token, jusqu'à la fin de la réponse.

Quand tu envoies "Bonjour, comment", le modèle évalue les probabilités de chaque suite possible et en génère une vraisemblable : "allez", "vas-tu", etc.

Le token est l'unité de base

Un LLM ne manipule ni caractères, ni mots, mais des tokens : des fragments de texte issus d'un vocabulaire fixe, en général 32k à 128k entrées selon le modèle.

La tokenisation s'appuie sur des algorithmes comme BPE (Byte Pair Encoding). Les mots fréquents restent entiers, les mots rares sont découpés en morceaux.

"Bonjour"      → [Bonjour]           → 1 token
"développeur"  → [dével, oppeur]     → 2 tokens
"API"          → [API]               → 1 token

Ce découpage est donné pour l'idée : chaque modèle a son propre tokenizer et coupe à sa façon. En moyenne, 1 token ≈ 4 caractères en anglais, un peu moins en français.

Les tokens fixent le coût et la limite de contexte

D'abord le coût : tu paies par token, entrée et sortie séparément, et les tokens de sortie coûtent en général 3 à 5 fois plus cher que ceux d'entrée.

Ensuite la context window : chaque modèle a une limite de tokens par requête, qui inclut ton prompt, l'historique de conversation et la réponse générée. Si tu la dépasses, l'API refuse la requête.

ModèleContext window
Mistral Small 4256k tokens
Claude Sonnet 5.51M tokens
GPT-6.1 Sol1,05M tokens

Ces chiffres datent de novembre 2026 et bougent à chaque génération : vérifie la doc du modèle que tu utilises.

Et la performance, enfin : plus ton prompt est long, plus l'inférence est lente et chère. Une fenêtre d'un million de tokens ne veut pas dire qu'il faut la remplir.

La température dose le hasard

Le LLM calcule une probabilité pour chaque token. Entre "bonjour" à 70% et "salut" à 20%, il faut bien en choisir un.

C'est le rôle de la température, le paramètre qui dose le hasard dans la sélection :

  • À 0, le modèle prend toujours le token le plus probable. Les réponses sont quasi déterministes.
  • À 1, il échantillonne selon les probabilités calculées. Plus de variété, mais aussi plus de risque d'incohérence.
  • Au-delà de 1, les probabilités s'aplatissent et les tokens improbables remontent. Ça devient créatif, parfois n'importe quoi.
TypeScript
// Exemple simplifié
const probabilities = { "bonjour": 0.7, "salut": 0.2, "coucou": 0.1 };

// Température 0 → toujours "bonjour"
// Température 1 → "bonjour" 70% du temps, "salut" 20%, "coucou" 10%
// Température 2 → distribution plus uniforme, "coucou" a plus de chances

En pratique, 0.7 est un bon compromis par défaut. Pour du code ou des réponses factuelles, je descends vers 0. Pour de la génération d'histoires, comme dans le jeu de dés plus bas, je monte vers 1.

D'autres paramètres existent comme top_p (nucleus sampling) ou top_k, mais la température reste le plus utilisé.

Le modèle invente quand il ne sait pas

Le LLM génère le token le plus vraisemblable, pas le plus vrai. Il n'a aucune notion de vérité : il s'appuie sur les régularités statistiques apprises pendant l'entraînement. Quand il produit une réponse fausse avec aplomb, on parle d'hallucination.

Demande-lui le nom du CEO d'une startup obscure :

Toi : Qui est le CEO de TechStartup42 ?
LLM : Le CEO de TechStartup42 est Marc Lefebvre, un entrepreneur...

Ce "Marc Lefebvre" n'existe probablement pas. Le modèle a généré une séquence de tokens plausible dans ce contexte (prénom + nom français après "Le CEO de X est").

Les hallucinations sont plus fréquentes quand :

  • Le sujet est peu représenté dans les données d'entraînement
  • La question demande des faits précis (dates, chiffres, noms)
  • Le modèle est poussé à répondre même sans certitude

Un LLM seul n'est donc pas fiable sur du factuel. Il lui faut un accès à des sources externes, ce que permet justement le function calling, qu'on verra plus bas.

Une conversation est une liste de messages

Quand tu discutes avec un LLM via l'API, tu envoies une liste de messages. L'API ne garde rien d'un appel à l'autre : à chaque requête, tu renvoies toute la conversation. Chaque message a un rôle.

system : les instructions de base

Le message système définit le comportement du LLM. Il est lu en premier et influence toute la conversation.

TypeScript
{
  role: "system",
  content: "Tu es un maître de jeu pour un jeu de dés. Tu es enthousiaste et dramatique dans tes descriptions."
}

user : tes messages

Ce que l'utilisateur (toi ou ton application) envoie au LLM.

TypeScript
{
  role: "user",
  content: "Lance un d6 !"
}

assistant : les réponses du LLM

Les réponses précédentes du LLM. C'est en les renvoyant que tu maintiens le contexte de la conversation.

TypeScript
{
  role: "assistant",
  content: "Tu lances les dés... ils roulent sur la table..."
}

tool : les résultats de function calling

Quand le LLM appelle une fonction, tu lui renvoies le résultat avec ce rôle. On détaille ce message juste après.

TypeScript
{
  role: "tool",
  content: "4"
}

Un LLM seul ne peut pas agir

Un LLM reçoit du texte et génère du texte. Il n'a accès ni à Internet (pas de recherche, pas d'appel d'API), ni au système de fichiers, ni à l'heure qu'il est. Il n'a même pas de générateur de nombres aléatoires, et c'est ça qui va t'occuper dans la suite.

Demande-lui de lancer un dé :

Toi : Lance un d6
LLM : Tu obtiens un 4 !

Ce "4" n'est pas aléatoire. Le LLM a simplement généré un token qui lui semblait plausible dans ce contexte. Relance la même requête avec une température à 0 et tu retomberas sur le même chiffre.

Les autres limites découlent de la même cause. Le modèle est figé à sa date d'entraînement, donc il ignore les événements récents et les nouvelles versions de tes frameworks. Et il n'a aucun effet de bord possible : pas d'email envoyé, pas de fichier créé, pas de base de données modifiée.

Le function calling : le LLM demande, ton code exécute

Le function calling (ou "tools") donne au LLM un moyen d'interagir avec l'extérieur. Point important, que j'ai mis un moment à bien intégrer : le LLM n'exécute rien lui-même. Il demande l'exécution d'une fonction, et c'est ton code qui l'exécute.

Tu décris des fonctions, le modèle choisit

Quand tu appelles l'API, tu peux fournir une liste de tools : des descriptions de fonctions que le LLM a le droit d'utiliser. Chaque tool a un name (roll_dice, get_weather, send_email...), une description en langage naturel, et des parameters sous forme de JSON Schema, un format standard pour décrire la forme d'un objet JSON.

Le LLM lit la demande de l'utilisateur. S'il estime avoir besoin d'un tool, au lieu de générer du texte, il génère un appel de fonction structuré. Voici ce que renvoie l'API dans ce cas :

JSON
{
  "choices": [{
    "message": {
      "role": "assistant",
      "content": "",
      "tool_calls": [{
        "id": "call_abc123",
        "type": "function",
        "function": {
          "name": "roll_dice",
          "arguments": "{\"sides\": 6}"
        }
      }]
    },
    "finish_reason": "tool_calls"
  }]
}

Quelques détails qui comptent : content est vide parce que le LLM n'a pas généré de texte, il demande un tool. finish_reason vaut tool_calls, c'est ton signal pour continuer la boucle. tool_calls est une liste, il peut y en avoir plusieurs. Et arguments est du JSON stringifié, à parser côté code.

Une fois le tool exécuté, tu renvoies le résultat au LLM avec un message de type tool :

JSON
{
  "role": "tool",
  "tool_call_id": "call_abc123",
  "name": "roll_dice",
  "content": "18"
}

Le tool_call_id doit correspondre à l'id reçu, sinon le modèle ne sait pas à quelle demande ce résultat répond. Le content est une string, même quand ton résultat est un nombre.

Le LLM reçoit ce résultat et génère sa réponse finale, ou redemande un autre tool si besoin. Tout ça passe en JSON structuré : le modèle a été entraîné à produire des appels valides selon le schéma que tu lui fournis.

Le flux complet tient en deux requêtes

sequenceDiagram
    participant App as Ton code
    participant LLM as LLM (Mistral)
    participant Fn as Fonction

    App->>LLM: "Lance un d20" + liste des tools
    Note over LLM: Analyse la demande
    LLM-->>App: tool_call: roll_dice({ sides: 20 })
    App->>Fn: rollDice({ sides: 20 })
    Fn-->>App: 18
    App->>LLM: Résultat: 18
    LLM-->>App: "18 ! Tu brandis ton épée..."
  1. Tu envoies le message de l'utilisateur + la liste des tools disponibles
  2. Le LLM analyse et décide s'il a besoin d'un tool
  3. Si oui, il retourne un tool call au lieu de texte
  4. Ton code exécute la vraie fonction
  5. Tu renvoies le résultat au LLM
  6. Le LLM génère sa réponse finale en utilisant le résultat

Ton code garde la main

Le LLM ne peut pas exécuter de code arbitraire : il demande, et c'est toi qui décides d'exécuter ou non. Tu peux le brancher sur n'importe quoi (base de données, API, système de fichiers) sans toucher au modèle. Et comme tout passe par ton code, tu valides les arguments, tu logges les appels, tu appliques des rate limits et tu gères les erreurs comme tu l'entends.

Mise en pratique : un jeu de dés

On va écrire cinq petits fichiers : le schéma des arguments, la déclaration du tool, la fonction qui lance le dé, la boucle qui parle au modèle, et un point d'entrée.

Installation

Il te faut Node.js (les exemples tournent sur la version 22) et une clé d'API Mistral, à créer dans la console Mistral.

Terminal
mkdir jeu-de-des && cd jeu-de-des
npm init -y
npm pkg set type=module
npm install @mistralai/mistralai zod
npm install -D tsx typescript @types/node
export MISTRAL_API_KEY="ta-clé"

Le SDK Mistral est publié en ES Modules uniquement, d'où le type=module. tsx exécute les fichiers TypeScript directement, sans étape de build.

Définir le schéma avec Zod

Zod permet de décrire la forme des arguments une seule fois, puis d'en tirer à la fois un type TypeScript, une validation à l'exécution et un JSON Schema pour l'API :

src/schemas.ts
import { z } from "zod";

export const rollDiceSchema = z.object({
  sides: z
    .number()
    .int()
    .positive()
    .default(6)
    .describe("Nombre de faces du dé (ex: 6 pour un d6, 20 pour un d20). Par défaut: 6."),
});

export type RollDiceInput = z.infer<typeof rollDiceSchema>;

Si l'utilisateur dit simplement "Lance un dé" sans préciser, le LLM peut omettre sides et Zod appliquera la valeur par défaut (6). La description du paramètre aide aussi le LLM à comprendre qu'un d6 est le choix standard.

Déclarer le tool

Zod 4 sait convertir un schéma en JSON Schema avec z.toJSONSchema() :

src/tools.ts
import { z } from "zod";
import { rollDiceSchema } from "./schemas";

export const tools = [
  {
    type: "function" as const,
    function: {
      name: "roll_dice",
      description: "Lance un dé et retourne le résultat. L'utilisateur peut préciser le type de dé (d6, d20, etc.).",
      parameters: z.toJSONSchema(rollDiceSchema, { io: "input" }),
    },
  },
];

Note

L'option { io: "input" } décrit ce que le schéma accepte en entrée. Sans elle, Zod décrit ce qu'il produit après validation, et un champ avec .default() y apparaît comme obligatoire. Beaucoup de tutoriels, dont la première version de celui-ci, passent par le paquet zod-to-json-schema : avec Zod 4, il n'est plus nécessaire.

Implémenter la fonction

C'est du TypeScript ordinaire, sans rien qui touche au LLM :

src/dice.ts
import type { RollDiceInput } from "./schemas";

export function rollDice({ sides }: RollDiceInput): number {
  return Math.floor(Math.random() * sides) + 1;
}

Écrire la boucle agentique

Reste la pièce centrale : une boucle qui exécute les tools demandés et relance le modèle, jusqu'à ce qu'il réponde en texte. On l'appelle la boucle agentique.

src/agent.ts
import { Mistral } from "@mistralai/mistralai";
import type { ChatCompletionRequest } from "@mistralai/mistralai/models/components";
import { tools } from "./tools";
import { rollDice } from "./dice";
import { rollDiceSchema } from "./schemas";

const client = new Mistral({ apiKey: process.env.MISTRAL_API_KEY });

// Le type d'un message, tel que le SDK l'attend
type Message = ChatCompletionRequest["messages"][number];

// Mapping nom de fonction → schéma + implémentation
const toolImplementations = {
  roll_dice: {
    schema: rollDiceSchema,
    execute: rollDice,
  },
};

export async function chat(userMessage: string): Promise<string> {
  const messages: Message[] = [
    {
      role: "system",
      content: `Tu es un conteur créatif. Quand l'utilisateur te demande de lancer un dé, utilise la fonction roll_dice puis invente une courte histoire (2-3 phrases) inspirée par le résultat obtenu. Plus le chiffre est élevé, plus l'histoire est épique et positive. Plus il est bas, plus elle est dramatique ou comique.`,
    },
    {
      role: "user",
      content: userMessage,
    },
  ];

  // Boucle agentique : continue tant que le LLM veut utiliser des tools
  while (true) {
    const response = await client.chat.complete({
      model: "mistral-small-latest",
      messages,
      tools,
    });

    const choice = response.choices[0];
    const assistantMessage = choice?.message;
    if (!assistantMessage) throw new Error("Pas de réponse");

    // Cas 1 : le LLM veut utiliser des tools
    if (choice.finishReason === "tool_calls" && assistantMessage.toolCalls) {
      // Ajouter le message de l'assistant (avec les tool calls)
      messages.push({ ...assistantMessage, role: "assistant" });

      // Exécuter chaque tool appelé
      for (const toolCall of assistantMessage.toolCalls) {
        const functionName = toolCall.function.name as keyof typeof toolImplementations;
        const tool = toolImplementations[functionName];
        if (!tool) {
          throw new Error(`Fonction inconnue: ${functionName}`);
        }

        // Les arguments arrivent en JSON stringifié
        const rawArgs = toolCall.function.arguments;
        const parsedArgs = typeof rawArgs === "string" ? JSON.parse(rawArgs) : rawArgs;

        // Valider les arguments avec Zod
        const args = tool.schema.parse(parsedArgs);
        console.log(`🎲 Appel de ${functionName}:`, args);

        // Exécuter la fonction
        const result = tool.execute(args);
        console.log(`📊 Résultat:`, result);

        // Ajouter le résultat pour le LLM
        // Le SDK est en camelCase (toolCallId), l'API REST en snake_case (tool_call_id)
        messages.push({
          role: "tool",
          name: functionName,
          content: JSON.stringify(result),
          toolCallId: toolCall.id,
        });
      }

      // Continuer la boucle pour que le LLM traite les résultats
      continue;
    }

    // Cas 2 : le LLM a fini (pas de tool call)
    return typeof assistantMessage.content === "string" ? assistantMessage.content : "";
  }
}

Deux choses à remarquer. Les arguments passent par tool.schema.parse() avant d'atteindre rollDice : ce que produit un LLM se valide comme n'importe quelle entrée utilisateur. Et le message de l'assistant qui contient les toolCalls est remis dans l'historique avant les résultats, sinon l'API ne sait pas à quoi ces résultats répondent.

Lancer le jeu

src/main.ts
import { chat } from "./agent";

console.log(await chat("Lance un d6 !"));
console.log(await chat("Lance un d20 !"));
Terminal
npx tsx src/main.ts

Tu devrais voir passer l'appel du tool, le résultat du dé, puis l'histoire. Ça donne ce genre de sortie :

🎲 Appel de roll_dice: { sides: 6 }
📊 Résultat: 2
Le dé roule et s'arrête sur 2. Un gobelin surgit de l'ombre,
te vole ta bourse et disparaît en ricanant. Mauvais début de journée.

🎲 Appel de roll_dice: { sides: 20 }
📊 Résultat: 18
18 ! Tu brandis ton épée et tranches le dragon d'un coup magistral.
La foule acclame ton nom. Les bardes chanteront cet exploit.

La boucle fait un aller-retour par tool

La boucle while (true) peut surprendre. Voici ce qui se passe à chaque tour :

Requête 1 : "Lance un d20"
  → LLM retourne : tool_call(roll_dice, { sides: 20 })
  → On exécute rollDice({ sides: 20 }) → 18
  → On ajoute le résultat aux messages

Requête 2 : (avec le résultat du tool)
  → LLM retourne : "18 ! Tu brandis ton épée..."
  → Pas de tool_call → on sort de la boucle

Cette boucle permet aussi au LLM d'enchaîner plusieurs tools. Imagine un second tool get_character_stats :

"Lance un d20 et dis-moi si je réussis mon jet de force"
  → tool_call(get_character_stats) → { force: 14 }
  → tool_call(roll_dice, { sides: 20 }) → 12
  → LLM compare et génère l'histoire

Attention

Rien n'arrête ce while (true) si le modèle redemande un tool à chaque tour. Pour un jeu de dés, le risque est faible. Dès que tu ajoutes des tools, mets une limite d'itérations. C'est l'un des garde-fous de la partie 2IA pour les développeurs · partie 224 minAgents IA : boucle agentique et pattern ReActConstruis un agent IA autonome en TypeScript avec Mistral : boucle agentique, pattern ReAct, planification, mémoire et garde-fous. Code complet inclus.Lire l'articleNouvel ongletUn bandeau t'y proposera de reprendre ici, section 6..

Aller plus loin

À partir de là, il suffit d'ajouter des tools. Avec un inventaire, des stats et un système de combat, tu obtiens un jeu de rôle complet. Branché sur la météo, ton calendrier ou ta base de données, ça devient un assistant. Et avec des tools pour chercher sur le web ou écrire des fichiers, tu tiens un agent autonome, celui qu'on construit dans l'article suivant sur les agents IA autonomesIA pour les développeurs · partie 224 minAgents IA : boucle agentique et pattern ReActConstruis un agent IA autonome en TypeScript avec Mistral : boucle agentique, pattern ReAct, planification, mémoire et garde-fous. Code complet inclus.Lire l'articleNouvel ongletUn bandeau t'y proposera de reprendre ici, section 7..

Dans cet article, les tools vivent dans ton projet : si tu veux roll_dice ailleurs, tu le recopies. MCP (Model Context Protocol) répond à ça en sortant les tools dans un serveur que n'importe quel client compatible peut utiliser. C'est le sujet de la partie 3, MCP pour les développeursIA pour les développeurs · partie 310 minMCP pour les devs : relier tes LLM au monde réelMCP (Model Context Protocol) évite d'écrire un connecteur par client LLM et par source de données. Son architecture, puis un premier serveur MCP en TypeScript.Lire l'articleNouvel ongletUn bandeau t'y proposera de reprendre ici, section 7..

Ce que je retiens

Avec le function calling, le LLM demande et ton code agit. C'est la nuance que je garde en tête. Le jeu de dés est minuscule, mais l'architecture reste la même quand on vise plus gros : le LLM raisonne, appelle des fonctions, continue avec les résultats.

Si tu veux t'amuser un peu, ajoute un deuxième tool (par exemple get_character_stats) et regarde comment le modèle les combine. C'est en faisant ça que j'ai vraiment compris la boucle.

IA pour les développeurs · partie 1 sur 3

La suite, partie 2Agents IA : boucle agentique et pattern ReAct

Construis un agent IA autonome en TypeScript avec Mistral : boucle agentique, pattern ReAct, planification, mémoire et garde-fous. Code complet inclus.

24 min de lecture

Voir en Markdown

À lire ensuite

IA pour les développeurs · 3

MCP pour les devs : relier tes LLM au monde réel

MCP (Model Context Protocol) évite d'écrire un connecteur par client LLM et par source de données. Son architecture, puis un premier serveur MCP en TypeScript.

Cité dans cet article, à propos de MCP pour les développeurs

MCP en pratique · 1

MCP remote : quand stdio ne suffit plus

Stdio suffit sur ta machine, pas au-delà. Streamable HTTP sans session depuis la spec de juillet 2026, un serveur MCP remote en TypeScript, puis OAuth 2.1.

Projet

Inkfate : quand l'IA devient maître de jeu

Inkfate est un jeu de rôle solo où une IA tient le rôle du maître de jeu. Les règles du Year Zero Engine, et le pipeline LangGraph et Mistral derrière le MJ.

Alexandre Simonet, développeur fullstack.
J'écris ici sur les serveurs MCP, les agents et l'infra pour les faire tourner.