Pydantic + OpenAI: la forma más limpia de obtener resultados estructurados de los LLM

En mi última publicación sobre resultados estructurados, los tres enfoques principales para obtener respuestas legibles por máquina de un LLM. Estos son el modo JSON, la llamada a funciones y las salidas estructuradas de OpenAI. Si aún no has leído esa publicación, vale la pena leerla rápidamente antes de esta, ya que construiremos directamente sobre ella.

Entonces, hoy iremos un paso más allá y hablaremos sobre algo que cambia la forma en que se sienten los resultados estructurados en la práctica. Eso es Pydantic. Más específicamente, si bien la función de Salidas Estructuradas de OpenAI garantiza que el modelo devuelva un JSON válido y conforme al esquema, todavía tenemos que hacer cosas con ese JSON en el lado de Python. En particular, necesitamos analizarlo, validar los tipos de datos, acceder a los campos, manejar cualquier valor inesperado, etc. Y ahí es donde entra Pydantic.

En mi opinión, la combinación de Pydantic y los resultados estructurados de OpenAI es la configuración más limpia disponible en este momento para crear aplicaciones confiables basadas en LLM en Python. Al final de esta publicación, verá exactamente por qué.

¿Qué pasa con Pydantic?

Pydantic es una biblioteca de Python para la validación de datos mediante anotaciones de tipo. Esto significa que le permite definir la forma y los tipos de sus datos como una clase de Python y luego valida que cualquier dato que pase realmente se ajuste a esa descripción. Si no es así, Pydantic genera un error claro y descriptivo en lugar de permitir que los datos incorrectos se propaguen silenciosamente a través de su sistema.

Aquí está el modelo Pydantic más simple posible:

de pydantic import BaseModel clase PersonInfo (BaseModel): nombre: str edad: int ciudad: str

De esta manera, ahora tenemos un esquema que exige que el nombre y la ciudad siempre sean una cadena, mientras que la edad es siempre un número entero. Si alguien intenta crear una PersonInfo con age="thirty-two", Pydantic lo detectará inmediatamente y nos dirá exactamente qué salió mal.

¿Pero no es esto justo lo que ya estábamos haciendo con las llamadas a funciones y las salidas estructuradas? Sí, pero con una diferencia muy importante.

Por un lado, con el modo JSON o la llamada a función, el modelo puede devolver o no una respuesta que coincida con el esquema que teníamos en mente. Incluso cuando obtiene el esquema correcto, aún devuelve una cadena simple de JSON, lo que nos permite analizar campos, convertir tipos y validar valores manualmente en el lado de Python.

Por otro lado, Structured Outputs mejora esto al garantizar que el JSON devuelto siempre se ajuste a nuestro esquema definido, gracias a la decodificación restringida a nivel de modelo. No obstante, sigue siendo solo una cadena de JSON que necesitamos manejar nosotros mismos en Python.

Con Pydantic, definimos nuestro esquema como una clase de Python, y la integración con la API de OpenAI significa que obtenemos un objeto Python adecuado, no un diccionario, con todos los campos escritos y validados automáticamente. El esquema JSON que necesita la API se genera a partir de nuestro modelo Pydantic detrás de escena. Nunca tenemos que escribirlo nosotros mismos. En otras palabras, Pydantic es la capa de definición de esquema que se encuentra entre nuestro código Python y la API de OpenAI, lo que hace que toda la experiencia de salida estructurada sea más limpia, segura y fácil de mantener.

🍨 DataCream es un boletín sobre inteligencia artificial, datos y tecnología. Si estás interesado en estos temas, ¡suscríbete aquí!

Pero veamos en la práctica qué ganamos al usar Pydantic en comparación con simplemente usar Salidas Estructuradas.

Así es como era obtener resultados estructurados antes de la integración de Pydantic:

from openai import OpenAI import json client = OpenAI(api_key="your_api_key") # define el esquema como un diccionario sin formato herramientas = [ { "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 } } } ] respuesta = client.chat.completions.create( model="gpt-4o-mini", tools=tools, tool_choice={"type": "function", "function": {"name": "extract_person_info"}}, mensajes=[ {"role": "user", "content": "Extract información de: 'Maria tiene 32 años y vive en Atenas'".} ] ) # analiza manualmente: obtenemos un resultado de diccionario simple = json.loads(response.choices[0].message.tool_calls[0].function.arguments) print(resultado["nombre"]) # "María" – pero ¿qué pasa si falta la clave? Error de clave. print(resultado["edad"]) # podría ser "32" en lugar de 32; tipo no garantizado

Y aquí ocurre lo mismo con Pydantic:

from openai import OpenAI from pydantic import BaseModel client = OpenAI(api_key="your_api_key") class PersonInfo(BaseModel): nombre: str edad: int ciudad: str respuesta = client.beta.chat.completions.parse( model="gpt-4o-mini", mensajes=[ {"role": "user", "content": "Extrae información de: 'Maria tiene 32 años y vive en Atenas.'"} ], respuesta_formato=PersonInfo ) # obtenemos un objeto Python adecuado, completamente tipificado y validado resultado = respuesta.opciones[0].message.parsed print(result.name) # "Maria" — siempre una cadena print(result.age) # 32 — siempre un número entero, nunca una cadena print(result.city) # "Atenas" — siempre una cadena

Obviamente, la versión de Pydantic es más corta, más legible y más segura. Observe cómo pasamos nuestro modelo Pydantic a respuesta_formato, y el SDK de OpenAI, que se encarga de generar el esquema JSON, simplemente realiza la llamada API con estricto: Verdadero y analiza la respuesta nuevamente en un objeto PersonInfo adecuado. De esta manera, obtenemos acceso a los campos con seguridad de tipos, validación y notación de puntos.

Además de esto, observe también el método .parse(), en lugar del habitual .create(). Esto se debe a que .parse() es un método en el SDK de OpenAI diseñado específicamente para funcionar con modelos de Pydantic, manejando todo el proceso de salida estructurado de principio a fin.

construyendo con Pydantic

1. modelos y listas anidados

Uno de los lugares donde realmente brilla Pydantic son las estructuras de datos anidadas. Es complicado escribir un esquema JSON sin formato para objetos anidados, pero cuando se usa Pydantic, solo se trata de definir clases de Python:

de pydantic import BaseModel al escribir import Lista clase Dirección(BaseModel): calle: str ciudad: str país: str clase ContactInfo(BaseModel): nombre: str correo electrónico: str dirección: Dirección números de teléfono: Lista[str] respuesta = client.beta.chat.completions.parse( model="gpt-4o-mini", mensajes=[ { "role": "user", "content": """Extraer información de contacto de: 'Maria Mouschoutzi, [email protected], Ermou 15, Atenas, Grecia Teléfono: +30 210 1234567, +30 697 8901234'""" } ], Response_format=ContactInfo ) contact = Response.choices.[0].message.parsed print(contacto.nombre) # "Maria Mouschoutzi" print(contacto.dirección.ciudad) # "Atenas" print(contacto.phone_numbers[0]) # "+30 210 1234567"

El modelo de dirección está anidado dentro de ContactInfo de forma natural y Pydantic maneja la validación completa de la estructura anidada. Luego podemos acceder a campos con notación de puntos limpia, como por ejemplo, contacto.dirección.ciudad en lugar de resultado["dirección"]["ciudad"], lo que puede generar KeyError si falta alguna clave intermedia. Entonces, una de las tareas en las que Pydantic resulta realmente útil es cuando se necesitan estructuras de datos anidadas.

2. validación con el campo Pydantic

Otra tarea en la que Pydantic resulta muy útil es la validación de los datos devueltos. Esta validación incluye la verificación de los tipos de datos, pero también va más allá, ya que Pydantic también permite aplicar instrucciones claras sobre el contenido real de los valores devueltos. Más específicamente, podemos agregar restricciones de validación explícitas usando Field, dando al modelo instrucciones sobre qué valores son aceptables y cuáles no.

Por ejemplo, supongamos que tenemos una configuración que devuelve datos sobre reseñas de productos:

de pydantic import BaseModel, campo de escribir import Clase opcional ProductReview(BaseModel): nombre_producto: str = Campo(descripción="El nombre del producto que se está revisando") calificación: int = Campo(ge=1, le=5, descripción="Calificación de 1 a 5 estrellas") sentimiento: str = Campo(descripción="Uno de: positivo, neutral, negativo") resumen: str = Campo(max_length=200, descripción="Un breve resumen de la revisión") verificado_compra: Opcional[bool] = Field(default=None, descripción="Si se trata de una compra verificada") respuesta = client.beta.chat.completions.parse( model="gpt-4o-mini", mensajes=[ { "role": "user", "content": """Extraiga una reseña estructurada de: '¡Me encanta esta máquina de café! Hace un espresso perfecto en todo momento. 5 estrellas, se lo recomendaría a cualquiera.'"" " } ], respuesta_formato=ProductReview ) revisión = respuesta.opciones[0].message.parsed print(review.product_name) # "máquina de café" print(review.rating) # 5 print(review.sentiment) # "positivo" print(review.summary) # "Hace un espresso perfecto en todo momento".

En particular, ge=1, le=5 en el campo de calificación le dice al modelo que las calificaciones válidas están entre 1 y 5. max_length=200 en el resumen garantiza que nunca obtengamos una respuesta de más de 200 caracteres.

Además, los campos de descripción actúan como instrucciones para el modelo, ayudándolo a comprender exactamente qué debe contener cada campo. Esto es esencialmente el equivalente a los campos de descripción que escribimos manualmente en los esquemas de llamada a funciones, guiando al modelo sobre qué poner en cada campo.

3. negativas

Otra cosa que vale la pena destacar es que al usar .parse(), el SDK de OpenAI también maneja con elegancia los rechazos de modelos. Más específicamente, en el caso de que el modelo se niegue a completar una solicitud (por ejemplo, porque el contenido viola las políticas del modelo), el campo analizado será Ninguno y el campo de rechazo contendrá el motivo. Esto nos permite obtener un resultado estructurado, incluso en caso de rechazo, informando al menos al usuario de la aplicación del motivo por el cual se rechazó su solicitud. Esto es de particular importancia para las aplicaciones impulsadas por IA que se ejecutan en producción, donde realmente no podemos predecir qué cosas extrañas puede solicitar el usuario.

Entonces, así es como se desarrollaría un rechazo del modelo con Pydantic:

respuesta = client.beta.chat.completions.parse( model="gpt-4o-mini", mensajes=[{"role": "usuario", "content": "Extraiga información de la publicación de trabajo anterior."}], respuesta_format=JobPosting ) mensaje = respuesta.choices[0].message if message.refusal: print(f"Modelo rechazado: {message.refusal}") else: trabajo = mensaje.parsed print(f"Extraído: {job.job_title} en {job.company_name}")

Un ejemplo del mundo real: extracción de información de documentos

Juntemos todo con un ejemplo más realista. Imaginemos que estamos construyendo un canal que procesa ofertas de trabajo y extrae información estructurada de ellas. Este es exactamente el tipo de tarea donde brillan los resultados estructurados: entrada no estructurada, esquema de salida bien definido y un sistema posterior que necesita insertar el resultado en una base de datos.

de pydantic import BaseModel, Campo de escribir lista de importación, Opcional de openai import Cliente OpenAI = OpenAI(api_key="your_api_key") class SalaryRange(BaseModel): min_salary: Opcional[int] = Campo(default=Ninguno, descripción="Salario mínimo en USD por año") max_salary: Opcional[int] = Campo(default=Ninguno, descripción="Salario máximo en USD por año") moneda: str = Field(default="USD", descripción="Código de moneda") class JobPosting(BaseModel): job_title: str = Field(description="El puesto de trabajo o nombre de la función") company_name: str = Field(description="El nombre de la empresa contratante") ubicación: str = Field(description="Ubicación del trabajo, por ejemplo, 'Atenas, Grecia' o 'Remoto'") emplea_type: str = Field(description="Uno de: tiempo completo, tiempo parcial, contrato, autónomo") habilidades_requeridas: Lista[str] = Campo(descripción="Lista de habilidades técnicas requeridas") años_de_experiencia: Opcional[int] = Campo(default=Ninguno, descripción="Años mínimos de experiencia requeridos") salario: Opcional[SalaryRange] = Campo(default=Ninguno, descripción="Rango de salario si se menciona") remoto_friendly: bool = Campo(descripción="Si se permite el trabajo remoto") job_post_text = """ Ingeniero senior de Python en pialgorithms (Atenas, Grecia / Remoto) Buscamos un desarrollador de Python con experiencia para unirse a nuestro equipo de IA. Trabajará en nuestra plataforma de gestión de documentos, creando funciones inteligentes de búsqueda y extracción utilizando LLM y canalizaciones RAG. Requisitos: – Más de 5 años de experiencia en Python – Sólido conocimiento de FastAPI, LangChain y bases de datos vectoriales – Experiencia con OpenAI API y Pydantic – Familiaridad con Docker y la implementación en la nube (AWS/GCP) Salario: 60.000 €. – 85.000 € al año Puesto de tiempo completo. """ respuesta = client.beta.chat.completions.parse( model="gpt-4o-mini", mensajes=[ { "role": "system", "content": "Eres un experto en extraer información estructurada de ofertas de trabajo." }, { "role": "user", "content": f"Extrae toda la información relevante de este trabajo. publicación:nn{job_post_text}" } ], respuesta_formato=JobPosting ) trabajo = respuesta.opciones[0].message.parsed print(f"Título: {job.job_title}") print(f"Empresa: {job.company_name}") print(f"Ubicación: {job.location}") print(f"Remoto: {job.remote_friendly}") print(f"Habilidades: {', '.join(job.required_skills)}") print(f"Experiencia: {job.years_of_experience} años") if trabajo.salario: print(f"Salario: {trabajo.salario.min_salario} – {trabajo.salario.max_salario} {trabajo.salario.moneda}")

Y el resultado se parece a esto:

Título: Ingeniero senior de Python Empresa: pialgorithms Ubicación: Atenas, Grecia / Remoto Remoto: True Habilidades: Python, FastAPI, LangChain, bases de datos vectoriales, API OpenAI, Pydantic, Docker, AWS, GCP Experiencia: 5 años Salario: 60000 – 85000 EUR

Este es un código de extracción listo para producción. El modelo SalaryRange anidado maneja la información salarial opcional de forma limpia, los campos opcionales tienen el valor predeterminado Ninguno correctamente cuando falta información y cada campo de la respuesta se escribe y valida automáticamente. El resultado se puede insertar directamente en una base de datos o pasar al siguiente paso de una canalización sin ningún procesamiento adicional.

en mi mente

Lo que encuentro más satisfactorio acerca de la combinación Pydantic + OpenAI es lo bien que coincide con la forma en que ya piensa un desarrollador de Python. Define estructuras de datos como clases, utiliza sugerencias de tipo y detecta errores de tipo temprano y en voz alta. Todas estas son tradicionalmente las partes más impredecibles de cualquier aplicación de IA, principalmente porque tuvimos que hacerlas todas nosotros mismos manualmente en el lado Python de la aplicación de IA. Pydantic funciona como una conexión mejor y más sólida entre el modelo de IA y el resto de nuestro código Python.

✨ ¡Gracias por leer! ✨

Si llegó hasta aquí, es posible que le resulten útiles los pialgoritmos: una plataforma que hemos estado creando y que ayuda a los equipos a gestionar de forma segura el conocimiento organizacional en un solo lugar.

¿Te encantó esta publicación? Únase a mí en 💌 Substack y 💼 LinkedIn

Todas las imágenes del autor, salvo que se indique lo contrario.