# 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.

Source : https://simotech.dev/blog/initiation-llm-function-calling

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](/series/ia-pour-devs).

Pour que ça reste concret, on va construire un jeu de dés en TypeScript avec [Mistral Small](https://docs.mistral.ai/getting-started/models/models_overview/). 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èle | Context window |
|--------|----------------|
| Mistral Small 4 | 256k tokens |
| Claude Sonnet 5.5 | 1M tokens |
| GPT-6.1 Sol | 1,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.

```ts
// 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.

```ts
{
  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.

```ts
{
  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.

```ts
{
  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.

```ts
{
  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](https://json-schema.org/), 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

```mermaid
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](https://console.mistral.ai/).

```bash
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](https://zod.dev/) 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 :

```ts title="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()`](https://zod.dev/json-schema) :

```ts title="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 :

```ts title="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**.

```ts title="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

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

console.log(await chat("Lance un d6 !"));
console.log(await chat("Lance un d20 !"));
```

```bash
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
```

> [!WARNING]
> 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 2](/blog/agents-autonomes-llm).

## 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 autonomes](/blog/agents-autonomes-llm).

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éveloppeurs](/blog/mcp-pour-les-developpeurs).

## 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.
