Maîtrisez le function calling OpenAI : déclarer des outils, garantir un JSON strict avec Structured Outputs, gérer les parallel tool calls et valider avec Zod.
Pourquoi le function calling plutôt qu'un prompt JSON
Demander un JSON dans le prompt fonctionne 80 % du temps : "Réponds en JSON strict : { titre, tags, score }". Les 20 % restants ruinent une fonctionnalité en production : guillemets typographiques, virgules orphelines, commentaires // ajoutés par le modèle, ou un préambule "Voici le JSON :" avant l'objet.
Le function calling change la nature du contrat. Vous déclarez des fonctions avec un schéma JSON, et le modèle ne décide pas seulement quoi dire — il décide quelle fonction appeler et avec quels arguments. L'API retourne un objet tool_calls typé. Plus de parsing fragile : si le modèle déclenche un outil, ses arguments sont garantis conformes au schéma quand vous activez le mode strict.
Setup du SDK et premier outil
Le SDK officiel openai couvre Node.js, Deno, Bun et les runtimes edge. La clé reste exclusivement serveur ; ne passez jamais dangerouslyAllowBrowser: true en production.
# Installation du SDK officiel et de Zod pour la validation
npm install openai zod
# Optionnel : tiktoken pour estimer les coûts avant l'appel
npm install gpt-tokenizer
// lib/openai.ts - Client singleton cote serveur
import OpenAI from 'openai';
if (!process.env.OPENAI_API_KEY) {
throw new Error('OPENAI_API_KEY manquante dans .env');
}
const openai = new OpenAI({
apiKey: process.env.OPENAI_API_KEY,
maxRetries: 3, // retry automatique sur 429 et 5xx
timeout: 30_000, // 30 secondes par appel
});
export default openai;
Premier outil : recherche d'articles
// search-tool.ts - Declaration d'un outil avec schema JSON
import openai from './lib/openai';
// 1. Definition de l'outil cote API
const tools = [
{
type: 'function' as const,
function: {
name: 'search_articles',
description: 'Recherche des articles techniques par mot-cle.',
parameters: {
type: 'object',
properties: {
query: {
type: 'string',
description: 'Termes de recherche en francais ou anglais.'
},
limit: {
type: 'integer',
description: 'Nombre maximum de resultats (1 a 10).',
minimum: 1,
maximum: 10
}
},
required: ['query', 'limit'],
additionalProperties: false // requis pour le mode strict
},
strict: true // mode strict OpenAI - garantit le schema
}
}
];
// 2. Premier appel - le modele decide s'il invoque l'outil
const response = await openai.chat.completions.create({
model: 'gpt-4o-2024-08-06',
messages: [
{ role: 'user', content: 'Trouve-moi 3 articles sur Angular Signals.' }
],
tools,
tool_choice: 'auto' // 'auto' | 'required' | 'none' | { name: '...' }
});
console.log(response.choices[0].message.tool_calls);
// [{
// id: 'call_xxx',
// type: 'function',
// function: {
// name: 'search_articles',
// arguments: '{"query":"Angular Signals","limit":3}'
// }
// }]
'auto' laisse le modèle décider, 'required' force un appel d'outil (utile pour extraire des données), { type: 'function', function: { name: 'search_articles' } } force cet outil précis (idéal pour les classifications fermées).
Schéma JSON strict : règles et anti-patterns
Le mode strict: true contraint le modèle au niveau du sampling (decode time) : il ne peut littéralement pas générer un token qui violerait le schéma. En contrepartie, OpenAI impose des règles strictes sur la forme du schéma.
| Règle | Pourquoi | Exemple correct |
|---|---|---|
Tous les champs dans required | Pas de champs optionnels en mode strict | Utiliser type: ['string', 'null'] à la place |
additionalProperties: false | Empêche les clés inventées | Obligatoire sur chaque objet |
Pas de oneOf / anyOf mixtes | Limite l'ambiguïté | Préférer enum ou union typée |
| Profondeur max 5 niveaux | Limite la complexité | Aplatir les structures imbriquées |
| Max 100 propriétés au total | Performance et fiabilité | Découper en plusieurs outils |
// schema-strict.ts - Exemples corrects et incorrects
// CORRECT en mode strict
const goodSchema = {
type: 'object',
properties: {
title: { type: 'string' },
// null autorise au lieu de "champ optionnel"
description: { type: ['string', 'null'] },
priority: { type: 'string', enum: ['low', 'medium', 'high'] },
tags: {
type: 'array',
items: { type: 'string' }
}
},
required: ['title', 'description', 'priority', 'tags'],
additionalProperties: false
};
// INCORRECT - rejete par l'API en mode strict
const badSchema = {
type: 'object',
properties: {
title: { type: 'string' },
description: { type: 'string' } // pas dans required
},
required: ['title']
// additionalProperties manquant
};
type: ['string', 'null'] et incluez-le dans required. Le modèle retournera null explicitement quand la donnée manque, ce qui est plus propre qu'un champ absent côté code applicatif.
Cycle complet : tool_calls → exécution → réponse
Le function calling est itératif. Le modèle peut enchaîner plusieurs tours d'outils avant de produire une réponse finale. Votre code applicatif orchestre la boucle.
// agent-loop.ts - Boucle complete avec plusieurs outils
import openai from './lib/openai';
// 1. Catalogue d'outils metiers
const tools = [
{
type: 'function' as const,
function: {
name: 'get_user_profile',
description: 'Retourne le profil complet d\'un utilisateur a partir de son id.',
parameters: {
type: 'object',
properties: {
userId: { type: 'string', description: 'UUID v4 de l\'utilisateur.' }
},
required: ['userId'],
additionalProperties: false
},
strict: true
}
},
{
type: 'function' as const,
function: {
name: 'list_recent_orders',
description: 'Liste les commandes des 30 derniers jours.',
parameters: {
type: 'object',
properties: {
userId: { type: 'string' },
status: {
type: 'string',
enum: ['paid', 'pending', 'cancelled', 'refunded']
}
},
required: ['userId', 'status'],
additionalProperties: false
},
strict: true
}
}
];
// 2. Implementations reelles - jamais cote modele
async function executeTool(name: string, args: Record<string, any>): Promise<string> {
switch (name) {
case 'get_user_profile':
// Appel a votre repository - PAS d'eval, PAS de SQL direct depuis args
return JSON.stringify(await usersRepo.findById(args.userId));
case 'list_recent_orders':
return JSON.stringify(await ordersRepo.list(args.userId, args.status));
default:
return JSON.stringify({ error: 'Outil inconnu' });
}
}
// 3. Boucle agent - orchestration multi-tours
async function runAgent(userMessage: string): Promise<string> {
const messages: any[] = [
{
role: 'system',
content: 'Tu es un assistant support. Utilise les outils disponibles ' +
'pour repondre, ne devine jamais les donnees.'
},
{ role: 'user', content: userMessage }
];
// Garde-fou : maximum 6 tours pour eviter les boucles infinies
for (let step = 0; step < 6; step++) {
const response = await openai.chat.completions.create({
model: 'gpt-4o-2024-08-06',
messages,
tools,
tool_choice: 'auto'
});
const message = response.choices[0].message;
messages.push(message);
// Pas de tool_calls = reponse finale
if (!message.tool_calls || message.tool_calls.length === 0) {
return message.content ?? '';
}
// Executer tous les tool_calls (parallele) et ajouter les resultats
const results = await Promise.all(
message.tool_calls.map(async (tc) => {
const args = JSON.parse(tc.function.arguments);
const output = await executeTool(tc.function.name, args);
return {
role: 'tool' as const,
tool_call_id: tc.id,
content: output
};
})
);
messages.push(...results);
}
throw new Error('Boucle agent : trop d\'iterations.');
}
// Usage
const reponse = await runAgent('Trouve les commandes payees de user-abc.');
console.log(reponse);
Parallel tool calls et ordonnancement
Depuis novembre 2023, GPT-4o et GPT-4-turbo peuvent retourner plusieurs tool_calls dans une seule réponse. Si l'utilisateur demande "compare la météo à Paris et Rome", le modèle renvoie deux tool_calls de get_weather avec des arguments différents. Exécuter en parallèle divise la latence par deux.
// parallel-calls.ts - Mise en evidence des Promise.all
async function compareWeather() {
const messages: any[] = [
{ role: 'system', content: 'Tu es un assistant meteo concis.' },
{ role: 'user', content: 'Compare la meteo a Paris et Rome ce soir.' }
];
const response = await openai.chat.completions.create({
model: 'gpt-4o-2024-08-06',
messages,
tools: weatherTools,
parallel_tool_calls: true // active par defaut sur GPT-4o
});
const message = response.choices[0].message;
messages.push(message);
// Le modele renvoie probablement 2 tool_calls
console.log(message.tool_calls?.length); // 2
// Promise.all = appels Paris et Rome en parallele
const startedAt = Date.now();
const results = await Promise.all(
(message.tool_calls ?? []).map(async (tc) => ({
role: 'tool' as const,
tool_call_id: tc.id,
content: await callWeatherApi(JSON.parse(tc.function.arguments))
}))
);
console.log(`Parallel duration : ${Date.now() - startedAt} ms`);
messages.push(...results);
// Tour final pour la synthese
const final = await openai.chat.completions.create({
model: 'gpt-4o-2024-08-06',
messages
});
return final.choices[0].message.content;
}
parallel_tool_calls: false oblige le modèle à enchaîner les appels en séquence — un appel, un résultat, puis le suivant.
Structured Outputs : JSON strict garanti
Function calling déclare des outils. Mais si vous voulez juste extraire des données structurées sans aller chercher d'information externe, utilisez response_format avec un json_schema strict. La sortie respecte le schéma au caractère près.
// structured-output.ts - Extraction avec response_format strict
import openai from './lib/openai';
import { z } from 'zod';
import { zodResponseFormat } from 'openai/helpers/zod';
// 1. Schema Zod -> JSON Schema automatique
const Ticket = z.object({
summary: z.string().min(5).describe('Resume du ticket en une phrase.'),
severity: z.enum(['low', 'medium', 'high', 'critical']),
components: z.array(z.string()).max(5).describe('Composants impactes.'),
actionable: z.boolean().describe('Reproductible et clair pour l\'equipe ?'),
assigneeHint: z.string().nullable().describe('Equipe suggeree ou null.')
});
type Ticket = z.infer<typeof Ticket>;
async function extractTicket(rawText: string): Promise<Ticket> {
const response = await openai.beta.chat.completions.parse({
model: 'gpt-4o-2024-08-06',
messages: [
{ role: 'system', content: 'Extrais un ticket support depuis le texte utilisateur.' },
{ role: 'user', content: rawText }
],
// zodResponseFormat genere automatiquement le json_schema strict
response_format: zodResponseFormat(Ticket, 'support_ticket'),
temperature: 0
});
// .parsed est typé Ticket - jamais null si pas d'erreur
const ticket = response.choices[0].message.parsed;
if (!ticket) throw new Error('Parsing impossible');
return ticket;
}
// Usage
const ticket = await extractTicket(`
Le bouton "Enregistrer" du formulaire client renvoie un 500 depuis hier 22h.
Reproductible sur Chrome et Firefox. Bloque toute l'equipe commerciale.
`);
console.log(ticket);
// {
// summary: 'Bouton "Enregistrer" renvoie 500 sur formulaire client.',
// severity: 'critical',
// components: ['form-client', 'api-customers'],
// actionable: true,
// assigneeHint: 'backend'
// }
Différence pratique avec function calling
| Critère | Function calling | Structured Outputs |
|---|---|---|
| Sortie | tool_calls à exécuter | Réponse finale parsée |
| Cycle | Itératif (boucle agent) | Un seul tour |
| Usage | Action sur le système | Extraction/classification |
| Mode strict | strict: true dans la fonction | Activé par défaut avec json_schema |
| Disponible | Tous les modèles récents | gpt-4o-2024-08-06+ |
Validation Zod et retry sur erreur
Même avec strict: true, certaines contraintes métier ne sont pas exprimables en JSON Schema : "l'email doit appartenir au tenant de l'utilisateur", "la date doit être un jour ouvré". Zod est le filet de sécurité applicatif.
// zod-safety-net.ts - Double validation et retry cible
import { z } from 'zod';
import openai from './lib/openai';
const Booking = z.object({
when: z.string().refine(
s => {
// Doit etre un jour ouvre (lundi-vendredi)
const d = new Date(s);
return !isNaN(d.getTime()) && d.getDay() >= 1 && d.getDay() <= 5;
},
{ message: 'Doit etre un jour ouvre.' }
),
attendees: z.array(z.string().email()).min(1).max(20),
duration: z.number().int().min(15).max(180)
});
async function extractBooking(text: string, attempt = 0): Promise<z.infer<typeof Booking>> {
const messages: any[] = [
{ role: 'system', content: 'Extrais une demande de reunion. Jours ouvres uniquement.' },
{ role: 'user', content: text }
];
const response = await openai.beta.chat.completions.parse({
model: 'gpt-4o-2024-08-06',
messages,
response_format: zodResponseFormat(Booking, 'booking'),
temperature: 0
});
const parsed = response.choices[0].message.parsed;
if (!parsed) throw new Error('Sortie OpenAI invalide');
// Validation metier - peut echouer meme avec strict mode
const result = Booking.safeParse(parsed);
if (result.success) return result.data;
// Une seule tentative de correction avec le feedback Zod
if (attempt >= 1) throw new Error('Booking invalide : ' + result.error.message);
// Renvoie l'erreur au modele pour qu'il se corrige
messages.push(
{ role: 'assistant', content: JSON.stringify(parsed) },
{
role: 'user',
content: `Erreur de validation : ${result.error.message}. Corrige et renvoie.`
}
);
return extractBooking(text, attempt + 1);
}
Streaming avec tool calls progressifs
En streaming, les tool_calls arrivent eux aussi par morceaux : nom de la fonction d'abord, puis arguments token par token. Il faut accumuler par tool_call_id avant d'exécuter.
// streaming-tools.ts - Accumuler les tool_calls fragmentes
import openai from './lib/openai';
async function streamingWithTools(userMessage: string) {
const stream = await openai.chat.completions.create({
model: 'gpt-4o-2024-08-06',
messages: [{ role: 'user', content: userMessage }],
tools,
stream: true
});
// Accumulateur : un tool_call par index
const toolCalls: Record<number, {
id: string;
name: string;
args: string;
}> = {};
let text = '';
for await (const chunk of stream) {
const delta = chunk.choices[0]?.delta;
if (!delta) continue;
// Texte normal stream classique
if (delta.content) {
text += delta.content;
process.stdout.write(delta.content);
}
// Tool calls fragmentes : reconstruction par index
for (const tc of delta.tool_calls ?? []) {
const idx = tc.index;
toolCalls[idx] ??= { id: '', name: '', args: '' };
if (tc.id) toolCalls[idx].id = tc.id;
if (tc.function?.name) toolCalls[idx].name = tc.function.name;
if (tc.function?.arguments) toolCalls[idx].args += tc.function.arguments;
}
// Fin de stream
if (chunk.choices[0]?.finish_reason === 'tool_calls') {
// Tous les tool_calls sont complets - parse et execute
for (const tc of Object.values(toolCalls)) {
const args = JSON.parse(tc.args);
console.log(`Execute ${tc.name}`, args);
// ... appel reel a executeTool(tc.name, args)
}
}
}
return { text, toolCalls };
}
Production : observabilité et coûts
Le function calling consomme des tokens en input (schéma des outils) et en output (arguments + raisonnement). Sans observabilité, la facture dérape vite — surtout en boucle agent.
// telemetry.ts - Wrapper avec metriques et garde-fous coût
import openai from './lib/openai';
import type { ChatCompletionCreateParamsNonStreaming } from 'openai/resources/chat';
interface CallStats {
promptTokens: number;
completionTokens: number;
latencyMs: number;
toolCalls: number;
model: string;
}
const stats: CallStats[] = [];
async function callTracked(params: ChatCompletionCreateParamsNonStreaming) {
const startedAt = Date.now();
const response = await openai.chat.completions.create(params);
stats.push({
promptTokens: response.usage?.prompt_tokens ?? 0,
completionTokens: response.usage?.completion_tokens ?? 0,
latencyMs: Date.now() - startedAt,
toolCalls: response.choices[0].message.tool_calls?.length ?? 0,
model: params.model
});
return response;
}
// Calcul du cout estime (en centimes de dollar)
function estimateCost(s: CallStats): number {
const tarifs: Record<string, { in: number; out: number }> = {
'gpt-4o-2024-08-06': { in: 2.50, out: 10.00 },
'gpt-4o-mini': { in: 0.15, out: 0.60 }
};
const t = tarifs[s.model] ?? tarifs['gpt-4o-2024-08-06'];
return (s.promptTokens * t.in + s.completionTokens * t.out) / 10_000;
}
// Garde-fou anti-explosion en boucle agent
function assertBudget(maxCents = 50) {
const total = stats.reduce((acc, s) => acc + estimateCost(s), 0);
if (total > maxCents) {
throw new Error(`Budget depasse : ${total.toFixed(2)} cents`);
}
}
- Cache le catalogue d'outils : avec Prompt Caching, les schémas longs (1024+ tokens) sont mis en cache automatiquement (-50 % coût input).
- Limite les itérations agent à 6-10 tours max, avec compteur dans le message system si nécessaire.
- Choix du modèle :
gpt-4o-minipour les outils simples (CRUD, lookup),gpt-4opour la planification multi-étapes. - Loggue chaque tool call : nom, arguments, durée, résultat tronqué — indispensable pour le debug et l'audit.
- Sandbox les fonctions sensibles : pas de
delete, pas de paiement, pas d'envoi d'email sans confirmation utilisateur. - Alerte budget dans le dashboard OpenAI + assertion soft côté code pour stopper les boucles incontrôlées.
tool_call proviennent du LLM, qui lui-même reçoit potentiellement des entrées utilisateur. Traitez ces arguments exactement comme un input utilisateur non sûr : validation Zod, échappement SQL, RBAC sur l'utilisateur courant, jamais d'eval() ni d'exécution shell directe.
Conclusion
Le function calling transforme GPT en orchestrateur fiable de votre métier : déclaration de schéma strict, parsing garanti, parallel tool calls pour la latence, et Structured Outputs quand vous voulez juste de la donnée propre. Avec Zod en filet de sécurité et un wrapper de télémétrie, vous obtenez un contrat aussi solide qu'un endpoint REST typé.
Pour démarrer : commencez par un seul outil avec strict: true sur gpt-4o-2024-08-06, validez les sorties avec Zod, ajoutez du monitoring tokens dès le premier déploiement. N'ouvrez le parallel tool calling et la boucle agent qu'une fois ce socle stable — c'est là que la complexité (et la facture) explose le plus vite.