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.
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 tokenCe 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.
// 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 chancesEn 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.
{
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.
{
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.
{
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.
{
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 :
{
"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 :
{
"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..."- 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
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.
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 :
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() :
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 :
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.
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
import { chat } from "./agent";
console.log(await chat("Lance un d6 !"));
console.log(await chat("Lance un d20 !"));npx tsx src/main.tsTu 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 boucleCette 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'histoireAttention
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
À lire ensuite
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 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.
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.