
Pydantic + OpenAI : le moyen le plus propre d’obtenir des résultats structurés à partir des LLM
Dans mon dernier article sur les résultats structurés, les trois principales approches pour obtenir des réponses lisibles par machine à partir d’un LLM. Il s’agit du mode JSON, de l’appel de fonction et des sorties structurées d’OpenAI. Si vous n’avez pas encore lu cet article, cela vaut la peine de le lire rapidement avant celui-ci, puisque nous allons construire directement dessus.
Aujourd’hui, nous allons donc aller plus loin et parler de quelque chose qui change la façon dont les résultats structurés se sentent dans la pratique. C’est Pydantique. Plus précisément, alors que Résultats structurés d’OpenAI La fonctionnalité garantit que le modèle renvoie un JSON valide et conforme au schéma, nous devons encore faire des choses avec ce JSON du côté Python. En particulier, nous devons l’analyser, valider les types de données, accéder aux champs, gérer les valeurs inattendues, etc. Et c’est là qu’intervient Pydantic.
À mon avis, la combinaison des sorties structurées de Pydantic et d’OpenAI est la configuration la plus propre disponible actuellement pour créer des applications fiables basées sur LLM en Python. À la fin de cet article, vous comprendrez exactement pourquoi.
et Pydantic ?
Pydantique est une bibliothèque Python pour la validation des données à l’aide d’annotations de type. Cela signifie qu’il vous permet de définir la forme et les types de vos données en tant que classe Python, puis de valider que toutes les données que vous transmettez sont réellement conformes à cette description. Si ce n’est pas le cas, Pydantic génère une erreur descriptive claire plutôt que de laisser les mauvaises données se propager silencieusement dans votre système.
Voici le modèle Pydantic le plus simple possible :
from pydantic import BaseModel
class PersonInfo(BaseModel):
name: str
age: int
city: str
De cette façon, nous avons maintenant un schéma qui applique cela name et city seront toujours une chaîne, alors queage est toujours un entier. Si quelqu’un essaie de créer un PersonInfo avec age="thirty-two" Pydantic le détectera immédiatement et nous dira exactement ce qui n’a pas fonctionné.
Mais n’est-ce pas exactement ce que nous faisions déjà avec les appels de fonctions et les sorties structurées ? Oui, mais avec une différence très importante.
D’un côté, avec le mode JSON ou l’appel de fonction, le modèle peut ou non renvoyer une réponse qui correspond au schéma que nous avions en tête. Même lorsqu’il obtient le bon schéma, il renvoie toujours une chaîne simple de JSON, nous laissant analyser manuellement les champs, convertir les types et valider les valeurs du côté Python.
D’un autre côté, Structured Outputs améliore cela en garantissant que le JSON renvoyé est toujours conforme à notre schéma défini, grâce à un décodage contraint au niveau du modèle. Néanmoins, il ne s’agit encore que d’une chaîne de JSON que nous devons gérer nous-mêmes en Python.
Avec Pydantic, nous définissons notre schéma comme une classe Python, et l’intégration avec l’API d’OpenAI nous permet de revenir un objet Python approprié, pas un dictionnaireavec tous les champs saisis et validés automatiquement. Le schéma JSON dont l’API a besoin est généré à partir de notre modèle Pydantic en coulisses. Nous n’avons jamais besoin de l’écrire nous-mêmes. Autrement dit, Pydantic est la couche de définition de schéma qui se situe entre notre code Python et l’API d’OpenAI.rendant l’ensemble de l’expérience de sortie structurée plus propre, plus sûre et plus maintenable.
🍨 Crème de données est une newsletter sur l’IA, les données et la technologie. Si ces sujets vous intéressent, abonnez-vous ici!
Mais voyons en pratique ce que nous gagnons en utilisant Pydantic par rapport à la simple utilisation des sorties structurées.

Voici à quoi ressemblait l’obtention d’une sortie structurée avant l’intégration de Pydantic :
from openai import OpenAI
import json
client = OpenAI(api_key="your_api_key")
# define schema as a raw dictionary
tools = [
{
"type": "function",
"function": {
"name": "extract_person_info",
"strict": True,
"parameters": {
"type": "object",
"properties": {
"name": {"type": "string"},
"age": {"type": "integer"},
"city": {"type": "string"}
},
"required": ["name", "age", "city"],
"additionalProperties": False
}
}
}
]
response = client.chat.completions.create(
model="gpt-4o-mini",
tools=tools,
tool_choice={"type": "function", "function": {"name": "extract_person_info"}},
messages=[
{"role": "user", "content": "Extract info from: 'Maria is 32 years old and lives in Athens.'"}
]
)
# parse manually — we get back a plain dictionary
result = json.loads(response.choices[0].message.tool_calls[0].function.arguments)
print(result["name"]) # "Maria" — but what if the key is missing? KeyError.
print(result["age"]) # might be "32" instead of 32 — type not guaranteed
Et voici la même chose avec Pydantic :
from openai import OpenAI
from pydantic import BaseModel
client = OpenAI(api_key="your_api_key")
class PersonInfo(BaseModel):
name: str
age: int
city: str
response = client.beta.chat.completions.parse(
model="gpt-4o-mini",
messages=[
{"role": "user", "content": "Extract info from: 'Maria is 32 years old and lives in Athens.'"}
],
response_format=PersonInfo
)
# we get back a proper Python object, fully typed and validated
result = response.choices[0].message.parsed
print(result.name) # "Maria" — always a string
print(result.age) # 32 — always an integer, never a string
print(result.city) # "Athens" — always a string
Évidemment, la version Pydantic est plus courte, plus lisible et plus sûre. Remarquez comment nous transmettons notre modèle Pydantic à response_formatet le SDK OpenAI, qui gère la génération du schéma JSON, effectue simplement l’appel API avec strict: Trueet analyse la réponse dans un format approprié PersonInfo objet. De cette façon, nous obtenons un accès à la sécurité des types, à la validation et à la notation par points aux champs.
En plus de cela, notez également le .parse() méthode, au lieu de la méthode habituelle .create(). C’est parce que .parse() est une méthode du SDK OpenAI spécialement conçue pour fonctionner avec les modèles Pydantic, gérant de bout en bout le pipeline de sortie structuré complet.
construire avec Pydantic
1. modèles et listes imbriqués
L’un des domaines où Pydantic brille vraiment est celui des structures de données imbriquées. Le schéma JSON brut pour les objets imbriqués est difficile à écrire, mais lors de l’utilisation de Pydantic, il s’agit simplement de définir des classes Python :
from pydantic import BaseModel
from typing import List
class Address(BaseModel):
street: str
city: str
country: str
class ContactInfo(BaseModel):
name: str
email: str
address: Address
phone_numbers: List[str]
response = client.beta.chat.completions.parse(
model="gpt-4o-mini",
messages=[
{
"role": "user",
"content": """Extract contact info from:
'Maria Mouschoutzi, [email protected],
Ermou 15, Athens, Greece.
Phone: +30 210 1234567, +30 697 8901234'"""
}
],
response_format=ContactInfo
)
contact = response.choices[0].message.parsed
print(contact.name) # "Maria Mouschoutzi"
print(contact.address.city) # "Athens"
print(contact.phone_numbers[0]) # "+30 210 1234567"
Le Address le modèle est imbriqué à l’intérieur ContactInfo naturellement, et Pydantic gère la validation complète de la structure imbriquée. Nous pouvons alors accéder à des champs avec une notation par points propres, comme par exemple, contact.address.city plutôt que result["address"]["city"]ce qui peut augmenter KeyError s’il manque une clé intermédiaire. Ainsi, l’une des tâches pour lesquelles Pydantic s’avère très utile est celle qui nécessite des structures de données imbriquées.
2. validation avec le champ Pydantic
Une autre tâche sur laquelle Pydantic est très utile est la validation des données renvoyées. Cette validation inclut la vérification des types de données, mais va également au-delà, puisque Pydantic permet également d’appliquer des instructions claires sur le contenu réel des valeurs renvoyées. Plus précisément, nous pouvons ajouter des contraintes de validation explicites en utilisant Fielddonnant au modèle des instructions sur les valeurs acceptables et celles qui ne le sont pas.
Par exemple, supposons que nous ayons une configuration renvoyant des données sur les avis sur les produits :
from pydantic import BaseModel, Field
from typing import Optional
class ProductReview(BaseModel):
product_name: str = Field(description="The name of the product being reviewed")
rating: int = Field(ge=1, le=5, description="Rating from 1 to 5 stars")
sentiment: str = Field(description="One of: positive, neutral, negative")
summary: str = Field(max_length=200, description="A brief summary of the review")
verified_purchase: Optional[bool] = Field(default=None, description="Whether this is a verified purchase")
response = client.beta.chat.completions.parse(
model="gpt-4o-mini",
messages=[
{
"role": "user",
"content": """Extract a structured review from:
'Absolutely love this coffee machine!
Makes perfect espresso every time.
5 stars, would recommend to anyone.'"""
}
],
response_format=ProductReview
)
review = response.choices[0].message.parsed
print(review.product_name) # "coffee machine"
print(review.rating) # 5
print(review.sentiment) # "positive"
print(review.summary) # "Makes perfect espresso every time."
En particulier, le ge=1, le=5 sur le rating Le champ indique au modèle que les notes valides sont comprises entre 1 et 5. Le champ max_length=200 sur le summary garantit que nous n’obtiendrons jamais de réponse de plus de 200 caractères.
De plus, le description les champs agissent comme des instructions pour le modèle, l’aidant à comprendre exactement ce que chaque champ doit contenir. C’est essentiellement l’équivalent du description champs que nous avons écrits manuellement dans les schémas d’appel de fonctions, guidant le modèle sur ce qu’il faut mettre dans chaque champ.
3. refus
Une autre chose qui mérite d’être soulignée est qu’en utilisant .parse()le SDK OpenAI gère également les refus de modèle avec élégance. Plus précisément, dans le cas où le modèle refuse de répondre à une demande (par exemple, parce que le contenu viole les politiques du modèle), le parsed le champ sera None et le refusal le champ contiendra la raison. Cela nous permet d’obtenir un résultat structuré, même en cas de refus, en informant au moins l’utilisateur de l’application de la raison pour laquelle sa demande a été refusée. Ceci est particulièrement important pour les applications basées sur l’IA exécutées en production, où nous ne pouvons pas vraiment prédire quelle chose étrange l’utilisateur pourrait demander.
Voici donc comment se déroulerait un refus de modèle avec Pydantic :
response = client.beta.chat.completions.parse(
model="gpt-4o-mini",
messages=[{"role": "user", "content": "Extract info from the job posting above."}],
response_format=JobPosting
)
message = response.choices[0].message
if message.refusal:
print(f"Model refused: {message.refusal}")
else:
job = message.parsed
print(f"Extracted: {job.job_title} at {job.company_name}")
Un exemple concret : l’extraction d’informations sur un document
Rassemblons le tout avec un exemple plus réaliste. Imaginez que nous construisons un pipeline qui traite les offres d’emploi et en extrait des informations structurées. C’est exactement le genre de tâche dans laquelle les sorties structurées brillent : une entrée non structurée, un schéma de sortie bien défini et un système en aval qui doit insérer le résultat dans une base de données.
from pydantic import BaseModel, Field
from typing import List, Optional
from openai import OpenAI
client = OpenAI(api_key="your_api_key")
class SalaryRange(BaseModel):
min_salary: Optional[int] = Field(default=None, description="Minimum salary in USD per year")
max_salary: Optional[int] = Field(default=None, description="Maximum salary in USD per year")
currency: str = Field(default="USD", description="Currency code")
class JobPosting(BaseModel):
job_title: str = Field(description="The job title or role name")
company_name: str = Field(description="The name of the hiring company")
location: str = Field(description="Job location, e.g. 'Athens, Greece' or 'Remote'")
employment_type: str = Field(description="One of: full-time, part-time, contract, freelance")
required_skills: List[str] = Field(description="List of required technical skills")
years_of_experience: Optional[int] = Field(default=None, description="Minimum years of experience required")
salary: Optional[SalaryRange] = Field(default=None, description="Salary range if mentioned")
remote_friendly: bool = Field(description="Whether remote work is allowed")
job_post_text = """
Senior Python Engineer at pialgorithms (Athens, Greece / Remote)
We are looking for an experienced Python developer to join our AI team.
You will work on our document management platform, building intelligent
search and extraction features using LLMs and RAG pipelines.
Requirements:
- 5+ years of Python experience
- Strong knowledge of FastAPI, LangChain, and vector databases
- Experience with OpenAI API and Pydantic
- Familiarity with Docker and cloud deployment (AWS/GCP)
Salary: €60,000 - €85,000 per year
Full-time position. Remote-friendly.
"""
response = client.beta.chat.completions.parse(
model="gpt-4o-mini",
messages=[
{
"role": "system",
"content": "You are an expert at extracting structured information from job postings."
},
{
"role": "user",
"content": f"Extract all relevant information from this job posting:\n\n{job_post_text}"
}
],
response_format=JobPosting
)
job = response.choices[0].message.parsed
print(f"Title: {job.job_title}")
print(f"Company: {job.company_name}")
print(f"Location: {job.location}")
print(f"Remote: {job.remote_friendly}")
print(f"Skills: {', '.join(job.required_skills)}")
print(f"Experience: {job.years_of_experience} years")
if job.salary:
print(f"Salary: {job.salary.min_salary} - {job.salary.max_salary} {job.salary.currency}")
Et le résultat ressemble à ceci :
Title: Senior Python Engineer
Company: pialgorithms
Location: Athens, Greece / Remote
Remote: True
Skills: Python, FastAPI, LangChain, vector databases, OpenAI API, Pydantic, Docker, AWS, GCP
Experience: 5 years
Salary: 60000 - 85000 EUR
Il s’agit d’un code d’extraction prêt pour la production. L’imbriqué SalaryRange le modèle gère proprement les informations facultatives sur le salaire, Optional champs par défaut None gracieusement lorsque des informations sont manquantes, et chaque champ de la réponse est saisi et validé automatiquement. Le résultat peut être inséré directement dans une base de données ou transmis à l’étape suivante d’un pipeline sans aucun traitement supplémentaire.
Dans mon esprit
Ce que je trouve le plus satisfaisant dans la combinaison Pydantic + OpenAI, c’est à quel point elle correspond à la façon dont pense déjà un développeur Python. Il définit les structures de données en tant que classes, utilise des indices de type et détecte les erreurs de type tôt et haut dans le sens. Toutes ces parties sont traditionnellement les plus imprévisibles de toute application d’IA, principalement parce que nous avons dû tout faire nous-mêmes manuellement du côté Python de l’application d’IA. Pydantic fonctionne comme une connexion meilleure et plus robuste entre le modèle d’IA et le reste de notre code Python.
✨ Merci d’avoir lu ! ✨
Si tu es arrivé jusqu’ici, les pialgorithmes pourraient vous être utiles: une plateforme que nous avons créée qui aide les équipes à gérer en toute sécurité les connaissances organisationnelles en un seul endroit.
Vous avez adoré cet article ? Rejoignez-moi sur 💌 Sous-pile et 💼 LinkedIn
Toutes les images sont de l’auteur, sauf indication contraire.



