Intelligence Artificielle angularforall.com

- Function Calling OpenAI : sorties JSON structurées fiables

Openai Function-Calling Gpt-4O Structured-Outputs Json-Schema Tool-Use Llm Ia-Generative Typescript Zod Node-Js Parallel-Tool-Calls Prompt-Engineering Api Agents-Ia
Function Calling OpenAI : sorties JSON structurées fiables

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.

Trois cas d'usage typiques : (1) connecter le LLM à votre métier — base de données, API externe, recherche ; (2) extraire des données structurées depuis du texte libre ; (3) router une requête utilisateur vers le bon sous-système (intent classification fiable).

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}'
//   }
// }]
tool_choice : '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èglePourquoiExemple correct
Tous les champs dans requiredPas de champs optionnels en mode strictUtiliser type: ['string', 'null'] à la place
additionalProperties: falseEmpêche les clés inventéesObligatoire sur chaque objet
Pas de oneOf / anyOf mixtesLimite l'ambiguïtéPréférer enum ou union typée
Profondeur max 5 niveauxLimite la complexitéAplatir les structures imbriquées
Max 100 propriétés au totalPerformance 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
};
Astuce pratique : Pour un champ "optionnel", utilisez 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;
}
Quand désactiver ? Quand l'ordre compte (par exemple : créer un dossier puis y déposer un fichier). Passer 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èreFunction callingStructured Outputs
Sortietool_calls à exécuterRéponse finale parsée
CycleItératif (boucle agent)Un seul tour
UsageAction sur le systèmeExtraction/classification
Mode strictstrict: true dans la fonctionActivé par défaut avec json_schema
DisponibleTous les modèles récentsgpt-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);
}
Coût d'un retry : Un seul retry suffit dans 95 % des cas — le modèle utilise le message d'erreur Zod pour corriger. Au-delà, c'est probablement le schéma ou le prompt qui sont mal calibrés. Limiter à 1 retry évite les boucles infinies coûteuses.

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-mini pour les outils simples (CRUD, lookup), gpt-4o pour 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.
Sécurité critique : Les arguments d'un 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.

Partager