+200 XP

Function calling et structured outputs

Le moyen le plus rapide de transformer un chatbot en système consiste à donner au modèle un ensemble de fonctions qu'il peut appeler et à exiger que ses réponses reviennent sous forme de JSON strict auquel votre code peut faire confiance. Cette leçon creuse les deux mécanismes de l'API OpenAI : le tool calling (la boucle où le modèle demande à votre code d'exécuter quelque chose) et les Structured Outputs (la garantie que la réponse du modèle correspond à votre schéma). Ils résolvent des problèmes différents et se combinent très bien.

Nous utiliserons la Responses API, l'interface principale actuelle d'OpenAI. Chat Completions fonctionne toujours et repose sur des concepts quasi identiques, mais c'est vers Responses que va l'écosystème.

Deux mécanismes, deux rôles

Gardez-les bien distincts :

  • Function (tool) calling : le modèle décide qu'il a besoin de données externes ou d'une action, et émet une requête structurée pour appeler l'une de *vos* fonctions. Votre code l'exécute, renvoie le résultat, et le modèle continue. C'est ainsi que le modèle atteint ce qui est hors de son contexte : bases de données, votre API, une calculatrice, la météo.
  • Structured Outputs : vous forcez la *réponse finale* du modèle à se conformer exactement à un JSON Schema. Pas de prose, pas de balises markdown, pas de « Bien sûr, voici votre JSON ». Juste des données valides et parsables, à chaque fois.

On utilise souvent les deux : les tools pour collecter les faits, les Structured Outputs pour emballer le résultat.

Définir un tool

Un tool est une description JSON d'une fonction : un nom, une description et un schéma de paramètres. La description n'est pas décorative. **Le modèle la lit pour décider *quand* et *comment* appeler la fonction**, alors écrivez-la comme une documentation destinée à un développeur junior.

python
from openai import OpenAI

client = OpenAI()

tools = [{
    "type": "function",
    "name": "get_weather",
    "description": "Get the current temperature for a city in Celsius.",
    "parameters": {
        "type": "object",
        "properties": {
            "city": {"type": "string", "description": "City name, e.g. 'Lisbon'"}
        },
        "required": ["city"],
        "additionalProperties": False
    }
}]

Deux détails comptent. required liste les arguments que le modèle doit fournir, et additionalProperties: False empêche le modèle d'inventer des champs supplémentaires. Les deux poussent vers des appels prévisibles.

La boucle de tool calling

Voici le point sur lequel les gens se trompent : le modèle n'exécute pas votre fonction. Il renvoie une *demande* de l'appeler. Vous exécutez la fonction, renvoyez le résultat, et rappelez le modèle. Cet aller-retour est la boucle, et elle vous appartient.

Le déroulé :

  1. Envoyez le message utilisateur avec vos définitions de tools.
  2. Le modèle répond avec un ou plusieurs éléments function_call (ou une réponse normale si aucun tool n'est nécessaire).
  3. Votre code exécute chaque appel et ajoute un function_call_output avec le résultat.
  4. Vous rappelez le modèle avec l'input mis à jour. Il rédige alors la réponse finale.
python
import json

def get_weather(city):
    # Imaginons que cela appelle une vraie API.
    return {"city": city, "temp_c": 19}

input_list = [{"role": "user", "content": "What's the weather in Lisbon?"}]

response = client.responses.create(
    model="gpt-4.1",
    tools=tools,
    input=input_list,
)

# On reporte la sortie du modèle dans la conversation.
input_list += response.output

for item in response.output:
    if item.type == "function_call":
        args = json.loads(item.arguments)
        result = get_weather(**args)
        input_list.append({
            "type": "function_call_output",
            "call_id": item.call_id,
            "output": json.dumps(result),
        })

final = client.responses.create(
    model="gpt-4.1",
    tools=tools,
    input=input_list,
)
print(final.output_text)

Notez le call_id. Le modèle peut demander plusieurs appels de tools dans un même tour (parallel tool calling), et chaque sortie doit être rattachée à son appel par id. Ajoutez tous les résultats avant de faire la requête suivante.

Règles pratiques pour la boucle

  • Bouclez, ne supposez pas un seul tour. Après avoir renvoyé les sorties des tools, le modèle peut appeler un autre tool. Encapsulez l'étape requête/exécution dans une boucle while qui continue jusqu'à ce qu'aucun élément function_call ne revienne. Plafonnez-la (disons 8 itérations) pour qu'un modèle désorienté ne tourne pas indéfiniment.
  • Validez les arguments avant d'exécuter. Le schéma contraint le modèle, mais traitez les arguments de tools comme n'importe quelle entrée non fiable. Ne les passez jamais directement dans un shell, une chaîne SQL ou un chemin de fichier.
  • Gardez les fonctions étroites. get_order_status(order_id) vaut mieux qu'une méga-fonction avec un flag mode. Les tools étroits sont plus faciles à choisir correctement pour le modèle et plus faciles à sécuriser pour vous.
  • Contrôlez le choix si nécessaire. Utilisez tool_choice="auto" (par défaut), "required" pour forcer l'usage d'*un* tool, ou nommez un tool précis pour forcer exactement celui-là. Mettez parallel_tool_calls=False si vos tools doivent s'exécuter en séquence.

Structured Outputs : arrêtez de parser de la prose

Le tool calling gère les *actions*. Les Structured Outputs gèrent la *forme de la réponse*. Quand vous mettez strict: true et fournissez un schéma, l'API contraint la génération de sorte que la sortie corresponde de façon prouvée à votre schéma. C'est plus solide que la vieille astuce de prompt « réponds en JSON », qui produisait du JSON valide la plupart du temps et cassait à 2 h du matin.

En Python, la voie la plus propre consiste à définir votre schéma comme un modèle Pydantic et à laisser le SDK le parser pour vous.

python
from pydantic import BaseModel

class CalendarEvent(BaseModel):
    name: str
    date: str
    participants: list[str]

response = client.responses.parse(
    model="gpt-4.1",
    input=[
        {"role": "system", "content": "Extract the event details."},
        {"role": "user", "content": "Standup with Ana and Rui on Friday."},
    ],
    text_format=CalendarEvent,
)

event = response.output_parsed
print(event.participants)  # ['Ana', 'Rui']

output_parsed vous rend un objet typé, pas une chaîne sur laquelle il faut faire un json.loads en priant. Si vous n'êtes pas en Python, vous passez un JSON Schema brut dans text.format avec "type": "json_schema" et "strict": true, et vous récupérez un texte JSON garanti conforme.

Concevoir des schémas qui fonctionnent vraiment

  • Chaque propriété est de fait obligatoire. Le mode strict traite toutes les clés comme requises. Pour rendre un champ « optionnel », donnez-lui une union avec null, par exemple Optional[str] en Pydantic, puis vérifiez la valeur null dans votre code.
  • Utilisez des enums pour contraindre les choix. Si status ne peut valoir que open, pending ou closed, définissez-le comme un enum. Le modèle ne pourra pas renvoyer in-progress et vous surprendre en aval.
  • Les descriptions guident les valeurs. Les descriptions de champs dans le schéma orientent *ce qui* va dans chaque champ, pas seulement les types. Servez-vous-en.
  • Attention au sous-ensemble supporté. Les Structured Outputs prennent en charge une portion définie de JSON Schema. Des motifs comme minimum, maximum et certaines contraintes format peuvent ne pas être appliqués. Consultez le guide Structured Outputs avant de vous appuyer sur un mot-clé.

OpenAI Function Calling and Structured Outputs Explained

Watch on YouTube

Combiner les deux : le pattern réaliste

La plupart des fonctionnalités en production utilisent les deux ensemble. Imaginez un assistant support qui répond à une question de remboursement :

  1. Le modèle appelle lookup_order(order_id) (tool calling) pour récupérer des données réelles.
  2. Vous renvoyez l'enregistrement de la commande.
  3. Le modèle produit une réponse finale contrainte par un schéma RefundDecision (Structured Outputs) : un booléen eligible, un enum reason et une chaîne customer_message.

Le code de votre application ne parse jamais de texte libre. Il lit decision.eligible et branche. C'est tout l'intérêt : le modèle gère le langage et le jugement, votre code gère le flux de contrôle, et la frontière entre les deux est un contrat typé.

Une subtilité à connaître : les *paramètres* de tools et les Structured *Outputs* sont deux emplacements de schéma distincts. L'un façonne l'appel qui entre, l'autre la réponse qui sort. Vous pouvez utiliser l'un seul ou les deux à la fois dans la même requête.

Vérification des acquis

1. Quelle est la différence fondamentale entre le function (tool) calling et les Structured Outputs ?

2. Dans la boucle de tool calling, que se passe-t-il réellement quand le modèle « appelle » une fonction ?

3. Pourquoi la leçon insiste-t-elle sur le fait d'écrire la « description » d'un tool comme une documentation destinée à un développeur junior ?

CHOIX MULTIPLES

4. Sélectionnez TOUTES les affirmations correctes sur les champs du schéma de paramètres des tools abordés dans la leçon.

Sélectionnez toutes les réponses correctes.

CHOIX MULTIPLES

5. Sélectionnez TOUS les scénarios qui reflètent correctement les usages prévus décrits dans la leçon.

Sélectionnez toutes les réponses correctes.

Erreurs, coûts et modes de défaillance

Les vrais systèmes cassent de façons précises. Anticipez-les.

  • Le modèle hallucine un tool qui n'existe pas, ou de mauvais arguments. Avec des schémas de tools stricts, c'est rare, mais si une fonction échoue réellement, renvoyez l'erreur *comme sortie du tool* (par exemple {"error": "order not found"}) plutôt que de lever une exception. Le modèle peut la lire et se rattraper, en demandant à l'utilisateur un identifiant de commande correct.
  • Refus. Les Structured Outputs peuvent malgré tout refuser pour des raisons de sécurité. Le SDK le remonte, donc vérifiez le champ de refus avant de faire confiance à output_parsed. Traitez-le explicitement au lieu de considérer un refus comme des données mal formées.
  • Troncature. Si le modèle atteint la limite de tokens de sortie en plein JSON, vous obtenez des données incomplètes même en mode strict, car la contrainte garantit une *structure valide*, pas une *complétion*. Vérifiez si le statut de la réponse est incomplete et augmentez votre max_output_tokens pour les gros objets.
  • Latence à la première utilisation d'un nouveau schéma. La toute première requête avec un schéma strict inédit peut être plus lente, le temps que l'API prépare la contrainte. Les schémas répétés sont rapides. Réutilisez les schémas plutôt que de les générer dynamiquement à chaque requête.
  • Tokens. Les définitions de tools et les schémas vivent dans la fenêtre de contexte et comptent comme tokens d'entrée à chaque appel. Des schémas longs et verbeux sur de nombreux tools s'additionnent. Gardez les descriptions serrées et n'attachez que les tools dont le modèle pourrait plausiblement avoir besoin pour cette requête.

Où cela se situe dans l'écosystème

Vous avez vu la mécanique brute. Les produits OpenAI de plus haut niveau sont construits exactement sur ces primitives :

  • Les GPT Actions dans les Custom GPTs sont du function calling piloté par une spec OpenAPI. Vous décrivez votre API une fois, et le GPT l'appelle via le même pattern requête/exécution, simplement géré par ChatGPT plutôt que par votre code.
  • L'Agents SDK encapsule la boucle de tools, les retries et les handoffs pour que vous cessiez d'écrire la boucle while à la main. Quand votre orchestration de tools devient complexe (plusieurs agents, guardrails, tracing), passez-y plutôt que de maintenir du code de boucle sur mesure. Voir la documentation de l'Agents SDK.
  • Les tools intégrés comme la recherche web et la recherche de fichiers sont des function calls que l'API exécute côté serveur. Vous les activez dans tools sans écrire l'exécuteur.

Comprendre d'abord la boucle nue fait qu'aucun de ces éléments ne ressemble à de la magie. Ce sont des commodités posées sur le contrat que vous venez d'apprendre.

Points clés

  • Le tool calling est une boucle qui vous appartient. Le modèle *demande* une fonction ; votre code l'exécute, renvoie la sortie indexée par call_id, et rappelle le modèle. Bouclez jusqu'à ce qu'il ne reste plus d'appels de tools, avec un plafond d'itérations strict.
  • Utilisez des Structured Outputs en `strict: true` pour toute réponse que votre code parse. Cela garantit la forme et élimine le JSON fragile obtenu au petit bonheur du prompt. En Python, définissez un modèle Pydantic et lisez output_parsed.
  • Concevez les schémas de façon défensive. Traitez tous les champs comme requis (utilisez des unions avec null pour l'optionnel), contraignez les choix avec des enums, écrivez des descriptions de champs, et vérifiez que vos mots-clés font partie du sous-ensemble supporté.
  • Renvoyez les erreurs de tools comme des données, et vérifiez les refus et la troncature. Laissez le modèle se rattraper après un appel échoué ; vérifiez le statut de la réponse avant de faire confiance au payload.
  • Passez à l'Agents SDK ou aux GPT Actions quand la boucle grossit. Ils sont construits sur ces primitives exactes, donc le modèle mental se transpose directement.

À faire, tiré de cette leçon

Ces actions sont compilées dans le plan d'action du rôle.

  • Démarrez tout nouveau code sur l'API Responses et lisez response.usage dès le premier jour
Voir le plan d'action complet →