Les LLM sont partout, mais leur fonctionnement reste flou pour beaucoup de développeurs. Cet article pose les bases : tokens, prompts, et surtout le function calling, la fonctionnalité qui permet aux LLM d'interagir avec l'extérieur.
Pour illustrer ces concepts, on va implémenter un jeu de dés en TypeScript avec Mistral Small. Un exemple simple, mais qui couvre tous les mécanismes essentiels.
Comment fonctionne un LLM ?
Un LLM (Large Language Model) est fondamentalement 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.
Quand tu envoies "Bonjour, comment", le modèle évalue les probabilités de chaque token suivant et génère le plus probable : "allez", "vas-tu", etc.
Les tokens : l'unité de base
Un LLM ne manipule ni caractères, ni mots, mais des tokens. Un token est un fragment de texte issu d'un vocabulaire fixe (généralement 32k à 128k tokens selon les modèles).
La tokenisation utilise des algorithmes comme BPE (Byte Pair Encoding) qui découpent le texte de manière optimale : les mots fréquents restent entiers, les mots rares sont fragmentés.
1"Bonjour" → [Bonjour] → 1 token
2"développeur" → [dével, oppeur] → 2 tokens
3"API" → [API] → 1 token
4"🎲" → [🎲] → 1 tokenEn moyenne, 1 token ≈ 4 caractères en anglais, un peu moins en français.
Pourquoi c'est important ?
Coût : tu paies par token, entrée et sortie séparément. Les tokens de sortie coûtent généralement 3 à 5 fois plus cher que ceux d'entrée.
Context window : chaque modèle a une limite de tokens par requête. Cette limite inclut ton prompt, l'historique de conversation, ET la réponse générée. Si tu dépasses, le modèle tronque ou refuse.
| Modèle | Context window |
|---|---|
| Mistral Small | 32k tokens |
| GPT-4o | 128k tokens |
| Claude Sonnet | 200k tokens |
Performance : plus ton prompt est long, plus l'inférence est lente et coûteuse. Les tokens ne sont pas tous égaux : un prompt de 1000 tokens avec beaucoup de code sera traité différemment d'un texte narratif.
Température et sampling
Le LLM calcule une probabilité pour chaque token. Mais entre "bonjour" à 70% et "salut" à 20%, lequel choisir ?
C'est le rôle de la température, un paramètre qui contrôle le "hasard" dans la sélection :
- Température = 0 : le modèle choisit toujours le token le plus probable. Réponses déterministes et répétables.
- Température = 1 : le modèle échantillonne selon les probabilités calculées. Plus de variété, mais aussi plus de risques d'incohérence.
- Température > 1 : les probabilités sont "aplaties", les tokens moins probables ont plus de chances d'être choisis. Réponses plus créatives, voire chaotiques.
1// Exemple simplifié
2const probabilities = { "bonjour": 0.7, "salut": 0.2, "coucou": 0.1 };
3
4// Température 0 → toujours "bonjour"
5// Température 1 → "bonjour" 70% du temps, "salut" 20%, "coucou" 10%
6// Température 2 → distribution plus uniforme, "coucou" a plus de chancesEn pratique, on utilise souvent 0.7 pour un bon équilibre. Pour du code ou des réponses factuelles, on descend vers 0. Pour de la créativité, on 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 problème des hallucinations
Le LLM génère le token le plus vraisemblable, pas le plus vrai. Il n'a aucune notion de vérité, seulement des patterns statistiques appris pendant l'entraînement.
Demande-lui le nom du CEO d'une startup obscure :
1Toi : Qui est le CEO de TechStartup42 ?
2LLM : 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"). Pour lui, c'est juste de la prédiction de texte.
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
C'est une limitation fondamentale : un LLM seul n'est pas fiable pour des informations factuelles. Il lui faut un accès à des sources externes — et c'est précisément ce que permet le function calling, qu'on verra plus bas.
Les types de messages
Quand tu discutes avec un LLM via l'API, tu envoies une liste de messages. 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.
1{
2 role: "system",
3 content: "Tu es un maître de jeu pour un jeu de dés. Tu es enthousiaste et dramatique dans tes descriptions."
4}user : Tes messages
Ce que l'utilisateur (toi ou ton application) envoie au LLM.
1{
2 role: "user",
3 content: "Lance un d6 !"
4}assistant : Les réponses du LLM
Les réponses précédentes du LLM. Utile pour maintenir le contexte de la conversation.
1{
2 role: "assistant",
3 content: "Tu lances les dés... ils roulent sur la table..."
4}tool : Les résultats de function calling
Quand le LLM appelle une fonction, tu lui renvoies le résultat avec ce rôle.
1{
2 role: "tool",
3 content: "4"
4}Le problème : un LLM ne peut pas agir
Un LLM est stateless et isolé. Il reçoit du texte, génère du texte, point. Il n'a pas accès à :
- Internet : impossible de chercher une info récente ou appeler une API
- Le système de fichiers : impossible de lire ou écrire des fichiers
- L'heure actuelle : il ne sait pas quel jour on est
- Le hasard : pas de générateur de nombres aléatoires
Demande-lui de lancer un dé :
1Toi : Lance un d6
2LLM : 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. Si tu relances la même requête avec une température à 0, tu obtiendras toujours le même résultat.
Les conséquences
Hallucinations : on a vu plus haut pourquoi le LLM invente des réponses — sans source externe, il ne peut que deviner.
Données obsolètes : le modèle est figé à sa date d'entraînement. Il ne connaît pas les événements récents ni les nouvelles versions de tes frameworks.
Pas d'effet de bord : il ne peut pas envoyer d'email, créer de fichier, ou modifier une base de données. Il ne fait que générer du texte.
La solution : le Function Calling (Tools)
Le function calling (ou "tools") permet de donner au LLM la capacité d'interagir avec l'extérieur. Mais attention : 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.
Le principe
Quand tu appelles l'API, tu peux fournir une liste de tools : des descriptions de fonctions que le LLM peut utiliser. Chaque tool contient :
- name : identifiant de la fonction (
roll_dice,get_weather,send_email...) - description : explication en langage naturel de ce que fait la fonction
- parameters : un JSON Schema décrivant les arguments attendus
Le LLM analyse 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 à quoi ressemble une réponse de l'API quand le LLM décide d'utiliser un tool :
1{
2 "choices": [{
3 "message": {
4 "role": "assistant",
5 "content": "",
6 "tool_calls": [{
7 "id": "call_abc123",
8 "type": "function",
9 "function": {
10 "name": "roll_dice",
11 "arguments": "{\"sides\": 6}"
12 }
13 }]
14 },
15 "finish_reason": "tool_calls"
16 }]
17}À retenir :
- content est vide : le LLM n'a pas généré de texte, il demande un tool
- finish_reason: "tool_calls" : indique que le LLM attend le résultat d'un tool
- tool_calls : liste des fonctions à appeler (peut en contenir plusieurs)
- arguments : 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 :
1{
2 "role": "tool",
3 "tool_call_id": "call_abc123",
4 "name": "roll_dice",
5 "content": "18"
6}Ce qu'il faut noter :
- tool_call_id : doit correspondre à l'id du tool call reçu
- name : nom de la fonction exécutée
- content : résultat sous forme de string
Le LLM reçoit ce résultat et peut alors générer sa réponse finale, ou demander un autre tool si nécessaire.
C'est du JSON structuré, pas du texte libre. Le LLM a été entraîné à produire des appels valides selon le schéma que tu lui as fourni.
Le flux complet
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..."
- Tu envoies le message de l'utilisateur + la liste des tools disponibles
- Le LLM analyse et décide s'il a besoin d'un tool
- Si oui, il retourne un tool call au lieu de texte
- Ton code exécute la vraie fonction
- Tu renvoies le résultat au LLM
- Le LLM génère sa réponse finale en utilisant le résultat
Pourquoi cette architecture ?
Sécurité : le LLM ne peut pas exécuter de code arbitraire. Il ne fait que demander, et c'est toi qui décides d'exécuter ou non.
Flexibilité : tu peux connecter le LLM à n'importe quoi (base de données, API, système de fichiers) sans modifier le modèle.
Contrôle : tu peux valider les arguments, logger les appels, appliquer des rate limits, gérer les erreurs.
Mise en pratique : le jeu de dés
Installation
1npm install @mistralai/mistralai zod zod-to-json-schemaDéfinir le schéma avec Zod
Zod permet de définir des schémas typés et de les convertir en JSON Schema pour l'API :
1// schemas.ts
2import { z } from "zod";
3
4export const rollDiceSchema = z.object({
5 sides: z
6 .number()
7 .int()
8 .positive()
9 .default(6)
10 .describe("Nombre de faces du dé (ex: 6 pour un d6, 20 pour un d20). Par défaut: 6."),
11});
12
13export 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éfinir le tool
On utilise zodToJsonSchema pour convertir le schéma Zod en JSON Schema compatible avec l'API Mistral :
1// tools.ts
2import { zodToJsonSchema } from "zod-to-json-schema";
3import { rollDiceSchema } from "./schemas";
4
5export const tools = [
6 {
7 type: "function" as const,
8 function: {
9 name: "roll_dice",
10 description: "Lance un dé et retourne le résultat. L'utilisateur peut préciser le type de dé (d6, d20, etc.).",
11 parameters: zodToJsonSchema(rollDiceSchema),
12 },
13 },
14];Implémenter la fonction
1// dice.ts
2import type { RollDiceInput } from "./schemas";
3
4export function rollDice({ sides }: RollDiceInput): number {
5 return Math.floor(Math.random() * sides) + 1;
6}La boucle agentique (Agentic Loop)
Voici le cœur du système : une boucle qui gère automatiquement les appels de tools jusqu'à ce que le LLM ait fini.
1// agent.ts
2import { Mistral } from "@mistralai/mistralai";
3import { tools } from "./tools";
4import { rollDice } from "./dice";
5import { rollDiceSchema } from "./schemas";
6
7const client = new Mistral({ apiKey: process.env.MISTRAL_API_KEY });
8
9// Mapping nom de fonction → schéma + implémentation
10const toolImplementations = {
11 roll_dice: {
12 schema: rollDiceSchema,
13 execute: rollDice,
14 },
15};
16
17export async function chat(userMessage: string): Promise<string> {
18 const messages: any[] = [
19 {
20 role: "system",
21 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.`,
22 },
23 {
24 role: "user",
25 content: userMessage,
26 },
27 ];
28
29 // Boucle agentique : continue tant que le LLM veut utiliser des tools
30 while (true) {
31 const response = await client.chat.complete({
32 model: "mistral-small-latest",
33 messages,
34 tools,
35 });
36
37 const choice = response.choices?.[0];
38 if (!choice) throw new Error("Pas de réponse");
39
40 const assistantMessage = choice.message;
41
42 // Cas 1 : Le LLM veut utiliser des tools
43 if (choice.finishReason === "tool_calls" && assistantMessage.toolCalls) {
44 // Ajouter le message de l'assistant (avec les tool calls)
45 messages.push(assistantMessage);
46
47 // Exécuter chaque tool appelé
48 for (const toolCall of assistantMessage.toolCalls) {
49 const functionName = toolCall.function.name as keyof typeof toolImplementations;
50 const rawArgs = JSON.parse(toolCall.function.arguments);
51
52 const tool = toolImplementations[functionName];
53 if (!tool) {
54 throw new Error(`Fonction inconnue: ${functionName}`);
55 }
56
57 // Valider les arguments avec Zod
58 const args = tool.schema.parse(rawArgs);
59 console.log(`🎲 Appel de ${functionName}:`, args);
60
61 // Exécuter la fonction
62 const result = tool.execute(args);
63 console.log(`📊 Résultat:`, result);
64
65 // Ajouter le résultat pour le LLM
66 // Note : le SDK utilise camelCase (toolCallId), l'API REST utilise snake_case (tool_call_id)
67 messages.push({
68 role: "tool",
69 name: functionName,
70 content: JSON.stringify(result),
71 toolCallId: toolCall.id,
72 });
73 }
74
75 // Continuer la boucle pour que le LLM traite les résultats
76 continue;
77 }
78
79 // Cas 2 : Le LLM a fini (pas de tool call)
80 return assistantMessage.content as string;
81 }
82}Utilisation
1// main.ts
2import { chat } from "./agent";
3
4async function main() {
5 console.log(await chat("Lance un d6 !"));
6 // 🎲 Appel de roll_dice: { sides: 6 }
7 // 📊 Résultat: 2
8 // "Le dé roule et s'arrête sur 2. Un gobelin surgit de l'ombre,
9 // te vole ta bourse et disparaît en ricanant. Mauvais début de journée."
10
11 console.log(await chat("Lance un d20 !"));
12 // 🎲 Appel de roll_dice: { sides: 20 }
13 // 📊 Résultat: 18
14 // "18 ! Tu brandis ton épée et tranches le dragon d'un coup magistral.
15 // La foule acclame ton nom. Les bardes chanteront cet exploit."
16}
17
18main();Comprendre la boucle agentique
La boucle while (true) est cruciale. Voici pourquoi :
1Requête 1 : "Lance un d20"
2 → LLM retourne : tool_call(roll_dice, { sides: 20 })
3 → On exécute rollDice({ sides: 20 }) → 18
4 → On ajoute le résultat aux messages
5
6Requête 2 : (avec le résultat du tool)
7 → LLM retourne : "18 ! Tu brandis ton épée..."
8 → Pas de tool_call → on sort de la boucleCette boucle permet aussi au LLM d'enchaîner plusieurs tools si nécessaire. Imagine un tool supplémentaire get_character_stats :
1"Lance un d20 et dis-moi si je réussis mon jet de force"
2 → tool_call(get_character_stats) → { force: 14 }
3 → tool_call(roll_dice, { sides: 20 }) → 12
4 → LLM compare et génère l'histoireAller plus loin
Le function calling ouvre des possibilités infinies :
- Jeu de rôle complet : ajoute des tools pour gérer l'inventaire, les stats, le combat
- Assistant connecté : tools pour météo, calendrier, base de données
- Agent autonome : tools pour lire/écrire des fichiers, naviguer sur le web
Tu peux aussi enchaîner plusieurs tools, ou laisser le LLM décider de la stratégie.
MCP : un standard pour les tools
Tu as peut-être entendu parler de MCP (Model Context Protocol). C'est un protocole open source créé par Anthropic qui standardise la façon dont les LLM interagissent avec des sources de données et des outils externes.
Concrètement, MCP définit :
- Un format standard pour déclarer des tools
- Un protocole de communication entre le client (ton app) et les serveurs MCP
- Une architecture client/serveur qui permet de réutiliser des tools entre différentes applications
L'idée : au lieu de réimplémenter les mêmes tools pour chaque projet, tu peux utiliser des serveurs MCP existants ou créer les tiens une fois pour toutes.
1┌─────────────┐ ┌─────────────┐ ┌─────────────┐
2│ Ton app │────▶│ Client MCP │────▶│ Serveur MCP │
3│ + LLM │ │ │ │ (tools) │
4└─────────────┘ └─────────────┘ └─────────────┘Il existe déjà des serveurs MCP pour :
- Accès au filesystem
- Bases de données (PostgreSQL, SQLite...)
- APIs (GitHub, Slack, Google Drive...)
- Navigation web
MCP est supporté par Claude Desktop, Cursor, et d'autres clients. Si tu construis des agents IA, c'est un standard à connaître.
Conclusion
Un LLM seul ne fait que prédire du texte. Avec le function calling, il devient capable d'agir — ou plutôt de demander à ton code d'agir pour lui.
Le jeu de dés qu'on a construit est minimal, mais l'architecture est la même pour des systèmes plus ambitieux : le LLM raisonne, appelle des fonctions, et continue avec les résultats.
Si tu veux aller plus loin, essaie d'ajouter un deuxième tool (par exemple get_character_stats) et observe comment le LLM les combine.