7. Salida estructurada con Pydantic
En capítulos anteriores, hemos trabajado con salidas de LLM como cadenas de texto sin procesar. Esto funciona bien para chatbots donde los humanos leen las respuestas, pero al construir agentes de IA donde los programas necesitan analizar e interpretar las salidas del LLM, necesitamos datos estructurados y predecibles. En este capítulo, aprenderás cómo usar esquemas(schemas) Pydantic para hacer que el LLM devuelva objetos Python estructurados.
7.1) ¿Por qué salida estructurada?
El problema con la salida de texto libre del LLM
Comencemos entendiendo por qué las respuestas de texto sin procesar crean problemas en aplicaciones reales. Considera este escenario común:
from langchain_openai import ChatOpenAI
from langchain_core.messages import HumanMessage
llm = ChatOpenAI(model="gpt-4o-mini")
# Pregunta al LLM sobre un producto
message = HumanMessage(content="""
Extrae la información del producto de este texto:
"El UltraWidget Pro cuesta $299.99 y está actualmente en stock."
""")
response = llm.invoke([message])
print(response.content)Salida:
Nombre del Producto: UltraWidget Pro
Precio: $299.99
Disponibilidad: En stockLa salida se ve bien. Pero ahora supón que necesitas usar estos datos en tu aplicación Python. ¿Cómo extraes el precio como un número? ¿Cómo verificas la disponibilidad programáticamente? Podrías intentar análisis de cadenas como esto:
# Enfoque de análisis frágil
text = response.content
price_line = [line for line in text.split('\n') if 'Precio:' in line][0]
price_str = price_line.split('$')[1]
price = float(price_str) # Frágil - ¿qué pasa si el formato cambia?Este análisis parece funcionar. Pero en realidad no funciona. Aquí está el por qué:
Por qué este enfoque falla:
- El LLM podría formatear la respuesta de manera diferente la próxima vez ("Precio: 299.99 USD" o "Precio al por menor: $299.99")
Aquí hay ejemplos de diferentes salidas que pueden ocurrir para el mismo prompt:
# Ejemplo 1
"El producto es UltraWidget Pro, con precio de $299.99, y está disponible."
# Ejemplo 2
"Producto: UltraWidget Pro
Costo: 299.99 dólares
Estado: Disponible"
# Ejemplo 3
"UltraWidget Pro - $299.99 (en stock)"
# Ejemplo 4
"Encontré el UltraWidget Pro. Cuesta $299.99 y está actualmente disponible para compra."Cuando la respuesta del LLM cambia, necesitas lógica de análisis completamente diferente. Esto hace difícil construir aplicaciones confiables.
- Las respuestas del LLM son impredecibles: El mismo prompt puede producir diferentes formatos cada vez
- El análisis de cadenas es más difícil de lo que parece: Necesitas manejar
$, espacios, saltos de línea, comas y más - Sin seguridad de tipos en absoluto: No puedes estar seguro si
pricees un float, string o None - El manejo de errores es difícil: Si el LLM dice "Precio no disponible", tu llamada
float()falla - No mantenible: Cambia el prompt ligeramente y reescribes todo el código de análisis
La idea central: Python necesita contratos, no prosa
Piensa en cuando te comunicas con un servidor API en Python. Cuando llamas a una API REST específica, esperas que devuelva una respuesta JSON definida:
# Esperas esta estructura
{
"product_name": "UltraWidget Pro",
"price": 299.99,
"in_stock": true
}Al construir aplicaciones de IA, necesitas el mismo principio. La salida del LLM debe devolverse como datos con una estructura definida, no como texto de forma libre cada vez.
Prosa vs Contrato:
- Prosa: Texto natural de forma libre. Bueno para que los humanos lo lean, pero difícil de procesar para los programas.
- Contrato: Datos con estructura y tipos definidos. Una promesa de que "estos campos existirán con estos tipos."
Salida estructurada significa definir un contrato: "LLM, necesito exactamente estos campos, con exactamente estos tipos, en exactamente este formato."
Aquí es donde entra Pydantic. Pydantic es la biblioteca de validación de datos más popular de Python, y LangChain la usa para recibir salidas del LLM en forma estructurada.
El cambio de modelo mental:
- Antes: "LLM, cuéntame sobre este producto" → Analizar texto impredecible
- Después: "LLM, responde en un formato definido" → Recibir objeto Python estructurado
Este cambio de prosa a contratos es fundamental para construir agentes de IA confiables. Cuando un agente necesita decidir su próxima acción basándose en respuestas del LLM (por ejemplo, comprar si está en stock, registrarse para notificación si no), debe recibir respuestas en un formato definido.
7.2) Tu primera salida estructurada
En 7.1, aprendimos por qué los LLM deben responder con estructura definida en lugar de texto de forma libre. Ahora veamos cómo implementar esto realmente.
La idea clave: Simplemente pedirle al LLM "por favor responde en este formato" no es suficiente. Necesitas definir la estructura de datos exacta en código Python y hacer que LangChain la pase al LLM. Esta estructura de datos definida se llama esquema(schema).
¿Qué es un esquema?
Un esquema(schema) es un plano que define la estructura de los datos. Especifica:
- Qué campos deben estar presentes
- Qué tipo debe tener cada campo (string, número, booleano, etc.)
- Qué restricciones aplican (opcional vs requerido, rangos válidos, etc.)
En Python, definimos esquemas usando la clase BaseModel de Pydantic. Aquí está el ejemplo más simple posible:
from pydantic import BaseModel
class ProductInfo(BaseModel):
product_name: str
price: float
in_stock: boolEste esquema dice: "Un objeto ProductInfo debe tener exactamente tres campos: un product_name (string), un price (float) y un in_stock (booleano)."
El patrón de tres pasos: Definir, vincular, invocar
Usar salida estructurada es simple. Solo recuerda tres pasos:
- Definir: Crear un esquema con una clase Pydantic
- Vincular: Conectar el esquema al LLM usando
.with_structured_output() - Invocar: Llamar a
.invoke()para obtener un objeto tipado
Esta es la plantilla estándar que usarás para la mayoría de las tareas de extracción estructurada:
from langchain_openai import ChatOpenAI
from langchain_core.messages import HumanMessage
from pydantic import BaseModel, Field
# Paso 1: Definir el esquema
class ProductInfo(BaseModel):
product_name: str = Field(description="El nombre completo del producto")
price: float = Field(description="Precio en USD")
in_stock: bool = Field(description="Si el producto está disponible")
# Paso 2: Vincular esquema al LLM
llm = ChatOpenAI(model="gpt-4o-mini")
structured_llm = llm.with_structured_output(ProductInfo)
# Paso 3: Invocar y obtener objeto tipado
message = HumanMessage(content="""
Extrae información del producto de este texto:
"El UltraWidget Pro cuesta $299.99 y está actualmente en stock."
""")
result = structured_llm.invoke([message])
# result ahora es un objeto ProductInfo, no un string
print(type(result)) # <class '__main__.ProductInfo'>
print(result.product_name) # UltraWidget Pro
print(result.price) # 299.99
print(result.in_stock) # True¿Qué acaba de pasar?
- Definición de esquema: Definimos los campos y tipos que queremos
- Vinculación:
.with_structured_output(ProductInfo)configura el LLM para usar salida estructurada - Invocación y respuesta: Cuando se llama a
.invoke(), LangChain pasa el JSON Schema al LLM, y el LLM responde con JSON que coincide con esa estructura - Conversión automática: LangChain convierte el JSON a un objeto
ProductInfo- no se necesita código de análisis
Sin análisis. Sin conversión de tipos. Sin errores.
Usa este patrón de 3 pasos como tu plantilla. Síguelo siempre que necesites salida estructurada.
Descripciones de campos: La clave para guiar al LLM
En el ejemplo de definición de esquema anterior, usamos Field(description="..."). Esta descripción no es solo documentación. Son instrucciones que el LLM lee y sigue.
En el uso típico de Pydantic, las descripciones de Field son opcionales:
# Pydantic regular - la descripción es documentación para humanos
class User(BaseModel):
name: str = Field(description="Nombre del usuario") # Funciona bien sin ellaPero al trabajar con LLMs, son esenciales:
# Con LLMs - la descripción determina el comportamiento del LLM
class CustomerFeedback(BaseModel):
sentiment: str = Field(
description="Sentimiento general: 'positive', 'negative' o 'neutral'"
)El LLM lee esta descripción y la usa para decidir cómo responder.
Veamos esto en acción:
from langchain_openai import ChatOpenAI
from langchain_core.messages import HumanMessage
from pydantic import BaseModel, Field
class CustomerFeedback(BaseModel):
sentiment: str = Field(
description="Sentimiento general: 'positive', 'negative' o 'neutral'"
)
main_issue: str = Field(
description="La queja o preocupación principal, si existe. Usa 'none' si no se mencionan problemas."
)
urgency: str = Field(
description="Qué tan urgente es el problema: 'low', 'medium' o 'high'"
)
llm = ChatOpenAI(model="gpt-4o-mini")
structured_llm = llm.with_structured_output(CustomerFeedback)
message = HumanMessage(content="""
Analiza este feedback del cliente:
"El producto funciona bien, pero el envío tomó 3 semanas. Ya perdí la fecha límite de mi proyecto. Por favor respondan inmediatamente."
""")
result = structured_llm.invoke([message])
print(result.sentiment) # negative
print(result.main_issue) # Slow shipping
print(result.urgency) # highCómo las descripciones moldean las decisiones del LLM:
- Descripción de
sentiment→ El LLM aprende que los valores válidos son 'positive', 'negative', 'neutral' → El problema de envío causó fecha límite perdida, así que elige 'negative' - Descripción de
main_issue→ El LLM recibe instrucciones de "encontrar la queja principal" → Identifica "envío lento" como el problema - Descripción de
urgency→ El LLM aprende que la urgencia debe ser 'low', 'medium' o 'high' → Ve "Por favor respondan inmediatamente" y elige 'high'
¿Qué pasa sin descripciones?
sentiment: str # Sin descripciónEl LLM podría devolver "negative", "bad", "unsatisfied", "2/5", "disappointed" en formatos impredecibles, haciendo difícil que tu código maneje los valores.
Punto clave: Las descripciones de campos son parte de tu código que controla el comportamiento del LLM. Escríbelas de manera clara y específica.
Campos categóricos: Especificar valores permitidos
En el ejemplo anterior, el campo sentiment solo puede tener tres valores: 'positive', 'negative' o 'neutral'. Los campos que deben ser uno de un conjunto específico de valores se llaman campos categóricos.
Para campos categóricos, lista todos los valores posibles en la descripción:
sentiment: str = Field(
description="Sentimiento: exactamente 'positive', 'negative' o 'neutral' (minúsculas)"
)Al especificar "exactamente" y "(minúsculas)", enfatizamos que el LLM debe responder con precisamente uno de estos tres valores.
Sin embargo, no hay garantía de que el LLM siempre responda con uno de los valores especificados. Por eso debes escribir código defensivo.
Casos donde el LLM devuelve valores inesperados:
result.sentiment = "Positive" # Con mayúscula
result.sentiment = "NEGATIVE" # Todo en mayúsculas
result.sentiment = "good" # Palabra diferente por completoEscribir código defensivo:
allowed = {"positive", "negative", "neutral"}
# Convertir a minúsculas y verificar
sentiment = result.sentiment.lower()
if sentiment not in allowed:
sentiment = "neutral" # Usar valor predeterminado para valores inesperados
# Ahora sentiment está garantizado de ser uno de los valores permitidosConclusión clave:
- Especifica valores permitidos en la descripción → El LLM es más probable que responda correctamente
- Valida en código → Maneja valores inesperados de manera segura
Nota: El Capítulo 18 muestra patrones más fuertes usando enums de Python para aplicación.
Comparación: Análisis manual vs salida estructurada
Comparemos la misma tarea con y sin salida estructurada para ver la diferencia:
Análisis manual:
from langchain_openai import ChatOpenAI
from langchain_core.messages import HumanMessage
llm = ChatOpenAI(model="gpt-4o-mini")
message = HumanMessage(content="""
Extrae el nombre del producto, precio y disponibilidad de:
"El UltraWidget Pro cuesta $299.99 y está actualmente en stock."
Formato: nombre | precio | disponibilidad
""")
response = llm.invoke([message])
text = response.content
# Análisis manual
parts = text.split('|')
product_name = parts[0].strip()
price_str = parts[1].strip().replace('$', '')
price = float(price_str)
availability = parts[2].strip().lower()
in_stock = 'in stock' in availability or 'available' in availability
print(f"Nombre: {product_name}")
print(f"Precio: ${price}")
print(f"En Stock: {in_stock}")Salida estructurada:
from langchain_openai import ChatOpenAI
from langchain_core.messages import HumanMessage
from pydantic import BaseModel, Field
class ProductInfo(BaseModel):
product_name: str = Field(description="El nombre completo del producto")
price: float = Field(description="Precio en USD")
in_stock: bool = Field(description="Si el producto está disponible")
llm = ChatOpenAI(model="gpt-4o-mini")
structured_llm = llm.with_structured_output(ProductInfo)
message = HumanMessage(content="""
Extrae información del producto de:
"El UltraWidget Pro cuesta $299.99 y está actualmente en stock."
""")
result = structured_llm.invoke([message])
print(f"Nombre: {result.product_name}")
print(f"Precio: ${result.price}")
print(f"En Stock: {result.in_stock}")Diferencias clave:
- Sin lógica de análisis: La versión estructurada tiene cero código de análisis
- Seguridad de tipos:
result.priceestá garantizado de ser un float - Código más simple: Sin regex, sin división de cadenas, sin conversión manual de tipos
- Validación: Pydantic asegura que todos los campos requeridos estén presentes
- Mantenibilidad: Cambiar el esquema es más fácil que actualizar la lógica de análisis
7.3) Consideraciones de diseño de esquemas
Ahora que sabes cómo usar salida estructurada, aprendamos cómo diseñar buenos esquemas. Esta sección cubre principios de diseño prácticos para distinguir campos requeridos de opcionales.
Campos requeridos
Por defecto, todos los campos en un modelo Pydantic son requeridos. Esto significa que el LLM debe extraer o inferir un valor para cada campo requerido del prompt del usuario y proporcionarlo en la respuesta.
from pydantic import BaseModel
class ProductInfo(BaseModel):
product_name: str
price: float
in_stock: boolCuando usas este esquema, el LLM intentará encontrar valores para los tres campos (product_name, price, in_stock) en el texto de entrada.
¿Pero qué pasa cuando el prompt carece de información para un campo requerido?
Podríamos esperar el siguiente comportamiento:
- El LLM no puede encontrar la información en el prompt
- El LLM omite ese campo de su respuesta
- LangChain no puede crear una instancia válida de
ProductInfo - Se lanza
ValidationError
Sin embargo, esto no siempre sucede.
La razón es que diferentes LLMs pueden manejar la información faltante de manera diferente.
Algunos LLMs (como los modelos de OpenAI) tienden a generar valores incluso cuando la información requerida no está presente en el prompt. En este caso, ValidationError no ocurre, pero esto puede causar problemas mayores porque tu aplicación Python puede procesar información fabricada como si fuera real.
Abordaremos cómo resolver este problema en la Sección 7.4: Cuando las cosas salen mal.
Por ahora, solo ten en cuenta que no todos los LLMs manejan la información faltante de la misma manera.
Campos opcionales
Puedes necesitar campos que puedan estar legítimamente presentes o ausentes, incluso en casos normales. Por ejemplo, una nota de entrega (delivery_note) puede o no ser proporcionada por el cliente, incluso para un pedido válido.
Cuándo usar Optional:
- Los datos en sí pueden no existir (por ejemplo, cuando se permiten reseñas anónimas, las reseñas anónimas no tienen nombre de revisor)
- Quieres que el LLM indique explícitamente información faltante en lugar de fabricar un valor
Para hacer un campo opcional, usa el tipo Optional de Python del módulo typing:
from typing import Optional
class ProductReview(BaseModel):
rating: int
review_text: str
reviewer_name: Optional[str] = None # Las reseñas anónimas no tienen nombre de revisorNota: Los usuarios de Python 3.10+ pueden usar
str | Noneen lugar deOptional[str].
Cuando un campo es Optional:
- El LLM puede omitirlo de la respuesta si la información no se encuentra en el prompt
- Los campos omitidos se establecen al valor predeterminado (
None)
Aquí hay un ejemplo completo:
from langchain_openai import ChatOpenAI
from langchain_core.messages import HumanMessage
from pydantic import BaseModel, Field
from typing import Optional
class ProductInfo(BaseModel):
product_name: str
price: float
in_stock: bool
discount_percentage: Optional[float] = None
warranty_years: Optional[int] = None
llm = ChatOpenAI(model="gpt-4o-mini")
structured_llm = llm.with_structured_output(ProductInfo)
message = HumanMessage(content="""
Extrae información del producto: "El UltraWidget Pro cuesta $299.99 y está en stock."
""")
result = structured_llm.invoke([message])
print(result.product_name) # UltraWidget Pro
print(result.price) # 299.99
print(result.in_stock) # True
print(result.discount_percentage) # None (no mencionado)
print(result.warranty_years) # None (no mencionado)Lista de verificación de diseño de esquemas
Antes de finalizar tu esquema, pregúntate:
Selección de campos:
- ¿Son los campos requeridos verdaderamente esenciales? (¿Qué pasa si este campo falta en el prompt?)
- ¿Pueden los campos opcionales estar legítimamente ausentes incluso en casos normales?
Especificación de campos:
- ¿Tiene cada campo una descripción clara?
- ¿Están los campos categóricos explícitamente restringidos? (por ejemplo, "debe ser exactamente 'A', 'B' o 'C'")
7.4) Cuando las cosas salen mal
Dos problemas pueden ocurrir al usar salida estructurada:
- El LLM omite valores de campos requeridos → Ocurre ValidationError
- El LLM fabrica información faltante → No ocurre ValidationError, pero tu código procesa datos incorrectos
Esta sección cubre cómo manejar cada uno.
Entender errores de validación
Cuando el prompt del usuario carece de información para campos requeridos definidos en el esquema, el LLM no puede proporcionar valores para esos campos. El programa Python entonces lanza un ValidationError:
from langchain_anthropic import ChatAnthropic
from langchain_core.messages import HumanMessage
from pydantic import BaseModel, ValidationError
class ProductInfo(BaseModel):
product_name: str
price: float
in_stock: bool
llm = ChatAnthropic(model='claude-sonnet-4-5')
structured_llm = llm.with_structured_output(ProductInfo)
# Entrada faltando información requerida
message = HumanMessage(content="""
Extrae información del producto de: "¡El widget es genial! Muy recomendado."
""")
try:
result = structured_llm.invoke([message])
print(result)
except ValidationError as e:
print("Ocurrió ValidationError")Nota: Cuando el prompt carece de información para campos requeridos, algunos LLMs pueden fabricar valores y proporcionarlos en la respuesta. En este caso,
ValidationErrorno ocurrirá, pero surge un problema mayor. Cubriremos esto en la siguiente sección.
Cuando el prompt carece de información para campos requeridos y ocurre ValidationError, esto es realmente útil para tu aplicación Python. La aplicación puede detectar que ocurrió un problema y manejar el error de manera controlada. Las estrategias de recuperación de errores se cubren en el Capítulo 14 (recuperación de errores a nivel de agente) y el Capítulo 17 (lógica de reintento con gestión de estado).
El problema mayor: LLM fabricando información faltante
Como discutimos en la Sección 7.3, algunos LLMs exhiben un comportamiento más peligroso: fabrican valores y los proporcionan en respuestas cuando falta información en el prompt.
Cómo ocurre el problema:
- El prompt está faltando información requerida
- El LLM genera valores de apariencia plausible de todos modos
ValidationErrorNO ocurre- Tu aplicación Python procesa datos fabricados como si fueran reales
Ejemplo:
class ProductInfo(BaseModel):
product_name: str
price: float
in_stock: bool
message = HumanMessage(content="""
Extrae información del producto de: "¡El widget es genial!"
""")
# Cuando algunos LLMs fabrican valores para product_name, price e in_stock
result = structured_llm.invoke([message])
# ¡No se lanza error!
print(result.product_name) # "widget" (extraído del texto)
print(result.price) # 0.0 (¡fabricado!)
print(result.in_stock) # False (¡fabricado!)
# Problema: No puedes saber qué valores son reales vs fabricadosEsto es peor que un ValidationError porque:
- Tu aplicación Python continúa ejecutándose con datos malos
- No sabes qué campos son reales vs fabricados
- La lógica posterior puede tomar decisiones incorrectas basadas en datos falsos
Solución: Usar campos opcionales con validación
La solución es definir todos los campos requeridos como Optional, luego usar un validador para verificar que todos los campos requeridos tengan valores.
from typing import Optional
from pydantic import BaseModel, model_validator, ValidationError
from langchain_openai import ChatOpenAI
from langchain_core.messages import HumanMessage
class ProductInfo(BaseModel):
# Estos son realmente requeridos, pero declarados como Optional
# La validación real ocurre en el validador abajo
product_name: Optional[str] = None
price: Optional[float] = None
in_stock: Optional[bool] = None
@model_validator(mode='after')
def check_required_fields(self):
"""Valida que todos los campos esenciales estén presentes"""
if self.product_name is None or self.price is None or self.in_stock is None:
raise ValueError("Todos los campos (product_name, price, in_stock) deben ser proporcionados")
return self
llm = ChatOpenAI(model="gpt-4o-mini")
structured_llm = llm.with_structured_output(ProductInfo)
# Prueba con datos incompletos
message = HumanMessage(content="""
Extrae información del producto de: "¡El widget es genial!"
""")
try:
result = structured_llm.invoke([message])
# Si llegamos aquí, todos los campos están garantizados de estar presentes
print(f"Producto: {result.product_name}")
print(f"Precio: ${result.price}")
except ValidationError as e:
# Faltan campos requeridos - la extracción falló
print(f"Extracción incompleta: {e}")Qué está pasando aquí:
@model_validatores el decorador de Pydantic que agrega lógica de validación personalizadamode='after'significa que la validación se ejecuta después de que todos los campos han sido analizados- Si algún campo es
None, lanzamosValueErrorpara señalar datos incompletos - Pydantic automáticamente envuelve este
ValueErroren unValidationError
Por qué esto funciona:
Cuando el prompt está faltando información para campos:
- El LLM no fabrica valores y omite esos campos de la respuesta
- En este caso, esos campos se convierten en
None - Si esos campos son realmente requeridos, el validador de Pydantic lanza
ValueError - Pydantic lo envuelve como
ValidationError
De esta manera, tu aplicación Python recibe un error explícito para manejar, en lugar de datos fabricados.
Conclusión clave: Cuando el prompt carece de información para campos requeridos, obtener un ValidationError es perfectamente normal y esperado. El peligro real son los datos fabricados. Usa campos Optional con validadores para evitar que el LLM fabrique información faltante, mientras detectas explícitamente cuándo faltan campos requeridos.
Resumen del capítulo:
En este capítulo, aprendiste cómo transformar la salida del LLM en objetos Python confiables:
- Por qué importa: El análisis de texto libre es frágil; la salida basada en esquemas proporciona seguridad de tipos
- Cómo usarlo: Define esquemas con
BaseModelde Pydantic → Vincula con.with_structured_output() - Principios de diseño: Elige campos requeridos vs opcionales, guía al LLM con descripciones de campos
- Maneja problemas: ValidationError es normal; el peligro real son los datos fabricados