Módulo 3: Agents con create_agent

Structured Output en Agentes

Descripción de la cápsula

Hasta ahora, cuando tu agente termina su trabajo — después de llamar tools, analizar resultados, y razonar sobre la respuesta — te entrega un AIMessage con texto libre. Algo como "RAG es una técnica que combina búsqueda con generación...". Texto natural, legible para humanos, pero inútil para sistemas downstream.

¿Qué pasa si necesitas que el agente retorne un JSON con campos específicos? ¿Un reporte con topic, summary, sources y confidence? ¿Un objeto que puedas guardar en una base de datos, enviar a una API, o pasar a otro agente?

Para eso existe el parámetro response_format en create_agent. Le pasas un modelo Pydantic, y después de que el agente termina su loop de tools, se hace una llamada final al modelo con structured output que extrae los datos en el formato exacto que definiste. El resultado queda en result["structured_response"] — un objeto Pydantic tipado, validado, listo para usar en tu sistema.


El problema: la respuesta del agente es texto libre

Veamos qué retorna un agente estándar sin structured output:

from dotenv import load_dotenv
load_dotenv()

from langchain.chat_models import init_chat_model
from langchain.agents import create_agent
from langchain_core.tools import tool

@tool
def search(query: str) -> str:
    """Busca información sobre un tema."""
    return f"RAG (Retrieval-Augmented Generation) fue propuesto por Lewis et al. en 2020. Combina un retriever con un generador para mejorar la precisión factual."

model = init_chat_model("openai:gpt-4.1-mini")
agent = create_agent(model, [search])

result = agent.invoke({"messages": [("user", "Investiga qué es RAG")]})

last_message = result["messages"][-1]
print(type(last_message))
print(last_message.content)
# Output esperado:
# <class 'langchain_core.messages.ai.AIMessage'>
# RAG (Retrieval-Augmented Generation) es una técnica propuesta por Lewis et al. en 2020
# que combina un componente de recuperación (retriever) con un modelo generativo para
# mejorar la precisión factual de las respuestas...

El resultado es un AIMessage con content como string. Si quieres extraer el tema, un resumen, las fuentes, y un nivel de confianza, tendrías que parsear el texto manualmente — frágil, propenso a errores, y no escalable.


response_format: structured output en agentes

El parámetro response_format en create_agent acepta un modelo Pydantic que define la estructura exacta de la respuesta.

Ejemplo básico: reporte de investigación

from dotenv import load_dotenv
load_dotenv()

from pydantic import BaseModel, Field
from langchain.chat_models import init_chat_model
from langchain.agents import create_agent
from langchain_core.tools import tool

class ResearchReport(BaseModel):
    topic: str = Field(description="Tema investigado")
    summary: str = Field(description="Resumen de los hallazgos")
    sources: list[str] = Field(description="Fuentes consultadas")
    confidence: float = Field(description="Nivel de confianza del 0 al 1")

@tool
def search(query: str) -> str:
    """Busca información sobre un tema."""
    return f"RAG (Retrieval-Augmented Generation) fue propuesto por Lewis et al. en 2020. Combina búsqueda de documentos con generación de texto. Fuente: arxiv.org/abs/2005.11401"

model = init_chat_model("openai:gpt-4.1-mini")
agent = create_agent(
    model,
    [search],
    prompt="Investiga el tema solicitado y genera un reporte completo.",
    response_format=ResearchReport
)

result = agent.invoke({"messages": [("user", "¿Qué es RAG?")]})

report = result["structured_response"]
print(f"Tema: {report.topic}")
print(f"Resumen: {report.summary}")
print(f"Fuentes: {report.sources}")
print(f"Confianza: {report.confidence}")
print(f"\nTipo: {type(report)}")
# Output esperado:
# Tema: RAG (Retrieval-Augmented Generation)
# Resumen: RAG es una técnica propuesta por Lewis et al. en 2020 que combina la búsqueda de documentos relevantes con la generación de texto para mejorar la precisión factual de los modelos de lenguaje.
# Fuentes: ['arxiv.org/abs/2005.11401']
# Confianza: 0.9
#
# Tipo: <class '__main__.ResearchReport'>

result["structured_response"] contiene un objeto Pydantic tipado. Puedes acceder a .topic, .summary, .sources, .confidence directamente, sin parsear texto.


Cómo funciona internamente

Cuando usas response_format, el agente sigue un proceso de dos fases:

  1. Fase de tools (igual que siempre): el agente ejecuta su loop ReAct — llama tools, analiza resultados, itera hasta tener suficiente información
  2. Fase de extracción: después de que el loop termina, se hace una llamada adicional al modelo con structured output, pasándole toda la conversación y pidiéndole que extraiga los datos en el formato del modelo Pydantic
from dotenv import load_dotenv
load_dotenv()

from pydantic import BaseModel, Field
from langchain.chat_models import init_chat_model
from langchain.agents import create_agent
from langchain_core.tools import tool
from langchain_core.messages import AIMessage, ToolMessage

class CityInfo(BaseModel):
    city: str = Field(description="Nombre de la ciudad")
    weather: str = Field(description="Clima actual")
    recommendation: str = Field(description="Recomendación para el visitante")

@tool
def get_weather(city: str) -> str:
    """Obtiene el clima actual de una ciudad."""
    weathers = {
        "Madrid": "Soleado, 28°C, humedad 35%",
        "Londres": "Lluvioso, 12°C, humedad 85%",
    }
    return weathers.get(city, f"Clima no disponible para {city}")

model = init_chat_model("openai:gpt-4.1-mini")
agent = create_agent(
    model,
    [get_weather],
    prompt="Consulta el clima y da una recomendación al visitante.",
    response_format=CityInfo
)

result = agent.invoke({"messages": [("user", "¿Cómo está el clima en Madrid?")]})

print("=== Mensajes del agente ===")
for msg in result["messages"]:
    if isinstance(msg, AIMessage) and msg.tool_calls:
        print(f"  [Agent] Tool calls: {[tc['name'] for tc in msg.tool_calls]}")
    elif isinstance(msg, ToolMessage):
        print(f"  [Tool] {msg.content}")
    elif isinstance(msg, AIMessage):
        print(f"  [Agent] {msg.content[:80]}")

print("\n=== Structured Response ===")
info = result["structured_response"]
print(f"  Ciudad: {info.city}")
print(f"  Clima: {info.weather}")
print(f"  Recomendación: {info.recommendation}")
# Output esperado:
# === Mensajes del agente ===
#   [Agent] Tool calls: ['get_weather']
#   [Tool] Soleado, 28°C, humedad 35%
#   [Agent] Madrid tiene un clima soleado con 28°C...
#
# === Structured Response ===
#   Ciudad: Madrid
#   Clima: Soleado, 28°C, humedad 35%
#   Recomendación: Lleva ropa ligera y protector solar. Perfecto para pasear al aire libre.

El result contiene tanto los messages (toda la conversación incluyendo tool calls) como el structured_response (el objeto Pydantic extraído).


Diseñando modelos Pydantic efectivos para agentes

La calidad del structured output depende directamente de cómo diseñes tu modelo Pydantic. Los Field(description=...) son instrucciones para el modelo.

Modelos simples vs complejos

from pydantic import BaseModel, Field

class SimpleAnswer(BaseModel):
    answer: str = Field(description="Respuesta directa a la pregunta")
    confidence: float = Field(description="Confianza del 0 al 1")

class DetailedAnalysis(BaseModel):
    topic: str = Field(description="Tema principal analizado")
    key_points: list[str] = Field(description="Puntos clave encontrados, máximo 5")
    pros: list[str] = Field(description="Ventajas o aspectos positivos")
    cons: list[str] = Field(description="Desventajas o aspectos negativos")
    recommendation: str = Field(description="Recomendación final en una oración")
    confidence: float = Field(description="Confianza del 0 al 1")

class ProductComparison(BaseModel):
    class Product(BaseModel):
        name: str = Field(description="Nombre del producto")
        price: str = Field(description="Precio o rango de precios")
        best_for: str = Field(description="Para qué tipo de usuario es mejor")

    query: str = Field(description="Consulta original del usuario")
    products: list[Product] = Field(description="Productos comparados")
    winner: str = Field(description="Producto recomendado")
    reasoning: str = Field(description="Razón de la recomendación")

Reglas para descripciones efectivas

  • ✅ Sé específico: "Resumen en máximo 2 oraciones" en lugar de "Resumen"
  • ✅ Indica formato: "Precio en formato USD, e.g. '$29.99'" en lugar de "Precio"
  • ✅ Define rango: "Confianza del 0 al 1, donde 1 es certeza absoluta"
  • ❌ No seas vago: "Datos relevantes" no le dice nada al modelo
  • ❌ No contradigas el prompt: si el prompt pide un análisis, no definas un campo "one_word_answer"

Ejemplo completo: agente con tools + structured output

Este es el patrón más poderoso: las tools hacen el trabajo pesado (buscar, calcular, consultar APIs) y el structured output formatea la respuesta final.

from dotenv import load_dotenv
load_dotenv()

from pydantic import BaseModel, Field
from langchain.chat_models import init_chat_model
from langchain.agents import create_agent
from langchain_core.tools import tool

class TravelReport(BaseModel):
    destination: str = Field(description="Ciudad de destino")
    weather: str = Field(description="Clima actual")
    attractions: list[str] = Field(description="Principales atracciones turísticas")
    estimated_budget: str = Field(description="Presupuesto estimado por día en USD")
    best_season: str = Field(description="Mejor temporada para visitar")

@tool
def get_weather(city: str) -> str:
    """Obtiene el clima actual de una ciudad."""
    weathers = {
        "Barcelona": "Soleado, 26°C, perfecto para la playa",
        "Tokio": "Templado, 20°C, ideal para caminar",
        "Nueva York": "Fresco, 15°C, lleva una chaqueta ligera",
    }
    return weathers.get(city, f"Clima no disponible para {city}")

@tool
def get_attractions(city: str) -> str:
    """Obtiene las principales atracciones turísticas de una ciudad."""
    attractions = {
        "Barcelona": "Sagrada Familia, Park Güell, La Rambla, Casa Batlló, Barceloneta",
        "Tokio": "Shibuya Crossing, Senso-ji, Tokyo Tower, Akihabara, Tsukiji Market",
        "Nueva York": "Central Park, Statue of Liberty, Times Square, Brooklyn Bridge, MoMA",
    }
    return attractions.get(city, f"Atracciones no disponibles para {city}")

@tool
def get_budget(city: str) -> str:
    """Obtiene el presupuesto estimado diario para un turista."""
    budgets = {
        "Barcelona": "Presupuesto medio: $80-120/día (hostel), $150-250/día (hotel)",
        "Tokio": "Presupuesto medio: $100-150/día (hostel), $200-350/día (hotel)",
        "Nueva York": "Presupuesto medio: $120-180/día (hostel), $250-400/día (hotel)",
    }
    return budgets.get(city, f"Presupuesto no disponible para {city}")

model = init_chat_model("openai:gpt-4.1-mini")
agent = create_agent(
    model,
    [get_weather, get_attractions, get_budget],
    prompt="Eres un agente de viajes. Usa todas las herramientas disponibles para recopilar información completa sobre el destino.",
    response_format=TravelReport
)

result = agent.invoke({
    "messages": [("user", "Quiero viajar a Barcelona, dame toda la info")]
})

report = result["structured_response"]
print(f"🏙️  Destino: {report.destination}")
print(f"🌤️  Clima: {report.weather}")
print(f"🎯 Atracciones:")
for attraction in report.attractions:
    print(f"   - {attraction}")
print(f"💰 Presupuesto: {report.estimated_budget}")
print(f"📅 Mejor temporada: {report.best_season}")
# Output esperado:
# 🏙️  Destino: Barcelona
# 🌤️  Clima: Soleado, 26°C, perfecto para la playa
# 🎯 Atracciones:
#    - Sagrada Familia
#    - Park Güell
#    - La Rambla
#    - Casa Batlló
#    - Barceloneta
# 💰 Presupuesto: $80-250/día dependiendo del tipo de alojamiento
# 📅 Mejor temporada: Primavera (abril-junio) o principios de otoño (septiembre-octubre)

El agente llamó las 3 tools automáticamente, recopiló toda la información, y la formateó exactamente según el modelo TravelReport.


Accediendo a structured_response vs messages

El resultado de agent.invoke() con response_format contiene dos cosas:

CampoTipoContenido
result["messages"]list[BaseMessage]Toda la conversación: HumanMessage, AIMessages, ToolMessages
result["structured_response"]Tu modelo PydanticRespuesta estructurada extraída
from dotenv import load_dotenv
load_dotenv()

from pydantic import BaseModel, Field
from langchain.chat_models import init_chat_model
from langchain.agents import create_agent
from langchain_core.tools import tool

class QuickAnswer(BaseModel):
    question: str = Field(description="Pregunta original")
    answer: str = Field(description="Respuesta concisa")
    tool_used: bool = Field(description="Si se usó alguna herramienta")

@tool
def search(query: str) -> str:
    """Busca información."""
    return f"Resultado: {query} es un concepto de IA generativa."

model = init_chat_model("openai:gpt-4.1-mini")
agent = create_agent(model, [search], response_format=QuickAnswer)

result = agent.invoke({"messages": [("user", "¿Qué es RAG?")]})

print(f"Total de mensajes: {len(result['messages'])}")
print(f"Structured response: {result['structured_response']}")
print(f"Tipo: {type(result['structured_response'])}")
print(f"\nAcceso directo:")
print(f"  .question = {result['structured_response'].question}")
print(f"  .answer = {result['structured_response'].answer}")
print(f"  .tool_used = {result['structured_response'].tool_used}")
# Output esperado:
# Total de mensajes: 4
# Structured response: question='¿Qué es RAG?' answer='RAG es una técnica de IA generativa que combina búsqueda con generación de texto.' tool_used=True
# Tipo: <class '__main__.QuickAnswer'>
#
# Acceso directo:
#   .question = ¿Qué es RAG?
#   .answer = RAG es una técnica de IA generativa que combina búsqueda con generación de texto.
#   .tool_used = True

Streaming con structured output

Puedes combinar streaming con structured output. El streaming muestra el progreso de las tools, y al final obtienes la respuesta estructurada.

from dotenv import load_dotenv
load_dotenv()

from pydantic import BaseModel, Field
from langchain.chat_models import init_chat_model
from langchain.agents import create_agent
from langchain_core.tools import tool
from langchain_core.messages import AIMessage, ToolMessage

class Summary(BaseModel):
    topic: str = Field(description="Tema principal")
    summary: str = Field(description="Resumen en 1-2 oraciones")
    key_terms: list[str] = Field(description="Términos clave, máximo 5")

@tool
def search(query: str) -> str:
    """Busca información sobre un tema."""
    return f"{query}: framework open-source para aplicaciones con LLMs, creado por Harrison Chase en 2022."

model = init_chat_model("openai:gpt-4.1-mini")
agent = create_agent(model, [search], response_format=Summary)

last_step = None
for step in agent.stream(
    {"messages": [("user", "¿Qué es LangChain?")]},
    stream_mode="updates"
):
    last_step = step
    for node_name, update in step.items():
        for msg in update.get("messages", []):
            if isinstance(msg, AIMessage) and msg.tool_calls:
                for tc in msg.tool_calls:
                    print(f"⏳ Llamando: {tc['name']}...")
            elif isinstance(msg, ToolMessage):
                print(f"✅ Resultado recibido")
            elif isinstance(msg, AIMessage) and msg.content:
                print(f"💬 Respuesta del agente recibida")

result = agent.invoke({"messages": [("user", "¿Qué es LangChain?")]})
summary = result["structured_response"]
print(f"\n📋 Structured Output:")
print(f"  Tema: {summary.topic}")
print(f"  Resumen: {summary.summary}")
print(f"  Términos: {summary.key_terms}")
# Output esperado:
# ⏳ Llamando: search...
# ✅ Resultado recibido
# 💬 Respuesta del agente recibida
#
# 📋 Structured Output:
#   Tema: LangChain
#   Resumen: LangChain es un framework open-source para construir aplicaciones con modelos de lenguaje (LLMs), creado por Harrison Chase en 2022.
#   Términos: ['LangChain', 'LLM', 'framework', 'open-source', 'Harrison Chase']

Modelos Pydantic con validación avanzada

Puedes usar las capacidades completas de Pydantic para validar y restringir los datos que el agente retorna.

from dotenv import load_dotenv
load_dotenv()

from pydantic import BaseModel, Field, field_validator
from langchain.chat_models import init_chat_model
from langchain.agents import create_agent
from langchain_core.tools import tool

class SentimentAnalysis(BaseModel):
    text_analyzed: str = Field(description="Texto que se analizó")
    sentiment: str = Field(description="Sentimiento: 'positivo', 'negativo', o 'neutro'")
    score: float = Field(description="Puntuación de sentimiento de -1.0 a 1.0", ge=-1.0, le=1.0)
    key_phrases: list[str] = Field(description="Frases clave que determinaron el sentimiento, máximo 3")

    @field_validator("sentiment")
    @classmethod
    def validate_sentiment(cls, v):
        valid = ["positivo", "negativo", "neutro"]
        if v.lower() not in valid:
            raise ValueError(f"Sentimiento debe ser uno de {valid}")
        return v.lower()

@tool
def analyze_text(text: str) -> str:
    """Analiza un texto para determinar su sentimiento."""
    positive_words = ["excelente", "genial", "increíble", "fantástico", "maravilloso"]
    negative_words = ["terrible", "horrible", "pésimo", "malo", "desastroso"]
    text_lower = text.lower()
    pos = sum(1 for w in positive_words if w in text_lower)
    neg = sum(1 for w in negative_words if w in text_lower)
    if pos > neg:
        return f"Análisis: texto positivo ({pos} palabras positivas, {neg} negativas)"
    elif neg > pos:
        return f"Análisis: texto negativo ({pos} palabras positivas, {neg} negativas)"
    return f"Análisis: texto neutro ({pos} palabras positivas, {neg} negativas)"

model = init_chat_model("openai:gpt-4.1-mini")
agent = create_agent(
    model,
    [analyze_text],
    prompt="Analiza el sentimiento del texto proporcionado usando la herramienta disponible.",
    response_format=SentimentAnalysis
)

result = agent.invoke({
    "messages": [("user", "Analiza: 'El servicio fue excelente, la comida increíble y el ambiente genial'")]
})

analysis = result["structured_response"]
print(f"Texto: {analysis.text_analyzed}")
print(f"Sentimiento: {analysis.sentiment}")
print(f"Score: {analysis.score}")
print(f"Frases clave: {analysis.key_phrases}")
# Output esperado:
# Texto: El servicio fue excelente, la comida increíble y el ambiente genial
# Sentimiento: positivo
# Score: 0.9
# Frases clave: ['excelente', 'increíble', 'genial']

Comparación: response_format vs extracción manual

Aspectoresponse_formatExtracción manual con prompts
ConfiguraciónUna línea: response_format=MyModelPrompt largo describiendo el formato JSON
ValidaciónAutomática (Pydantic)Manual (parsear JSON, validar campos)
TipadoObjeto Pydantic tipadodict sin tipos
ConfiabilidadAlta (structured output nativo)Media (el modelo puede cambiar formato)
Campos adicionalesImposible (schema fijo)Posible (el modelo agrega info extra)
Costo+1 llamada al modeloIncluido en la respuesta

Cuándo usar cada uno

  • response_format — Cuando necesitas datos tipados para sistemas downstream (APIs, DBs, otros agentes)
  • response_format — Cuando la estructura es fija y conocida de antemano
  • ✅ Extracción manual — Cuando necesitas respuestas flexibles o exploratorias
  • ⚠️ response_format tiene un costo extra (una llamada adicional al modelo para la extracción)

Modelos Pydantic con tipos opcionales

No siempre el agente tendrá toda la información. Usa Optional para campos que pueden faltar.

from dotenv import load_dotenv
load_dotenv()

from typing import Optional
from pydantic import BaseModel, Field
from langchain.chat_models import init_chat_model
from langchain.agents import create_agent
from langchain_core.tools import tool

class CompanyInfo(BaseModel):
    name: str = Field(description="Nombre de la empresa")
    founded: Optional[int] = Field(description="Año de fundación, null si no se encontró", default=None)
    ceo: Optional[str] = Field(description="CEO actual, null si no se encontró", default=None)
    industry: str = Field(description="Industria principal")
    description: str = Field(description="Descripción en 1-2 oraciones")

@tool
def search_company(name: str) -> str:
    """Busca información sobre una empresa."""
    companies = {
        "langchain": "LangChain Inc, fundada en 2022 por Harrison Chase. Industria: AI/ML. Provee herramientas open-source para aplicaciones con LLMs.",
        "openai": "OpenAI, fundada en 2015 por Sam Altman et al. CEO: Sam Altman. Industria: AI Research. Creadores de GPT y ChatGPT.",
    }
    return companies.get(name.lower(), f"No se encontró información sobre {name}")

model = init_chat_model("openai:gpt-4.1-mini")
agent = create_agent(model, [search_company], response_format=CompanyInfo)

result = agent.invoke({"messages": [("user", "Busca información sobre LangChain")]})
info = result["structured_response"]
print(f"Empresa: {info.name}")
print(f"Fundada: {info.founded}")
print(f"CEO: {info.ceo}")
print(f"Industria: {info.industry}")
print(f"Descripción: {info.description}")
# Output esperado:
# Empresa: LangChain
# Fundada: 2022
# CEO: Harrison Chase
# Industria: AI/ML
# Descripción: LangChain provee herramientas open-source para construir aplicaciones que utilizan modelos de lenguaje (LLMs).

Conexión con el proyecto

En el Proyecto del módulo (Cápsula 08), combinarás structured output con streaming y múltiples tools para construir un agente de investigación completo. El agente buscará información, la procesará, y entregará un ResearchReport estructurado que podrías almacenar en una base de datos o enviar a otro sistema.


Troubleshooting

Problema 1: structured_response no aparece en el resultado

Causa: No pasaste response_format a create_agent. Solución: Verifica que incluiste el parámetro:

agent = create_agent(model, tools, response_format=MyModel)
result = agent.invoke(input_data)
print(result["structured_response"])

Problema 2: Error de validación Pydantic en la respuesta

Causa: El modelo generó datos que no pasan la validación del modelo Pydantic (e.g., un float fuera de rango). Solución: Haz las descripciones más explícitas y usa valores por defecto:

class MyModel(BaseModel):
    score: float = Field(
        description="Puntuación del 0.0 al 1.0. Usa 0.5 si no estás seguro.",
        ge=0.0, le=1.0
    )

Problema 3: El modelo no llena todos los campos correctamente

Causa: Las descripciones de los Field son demasiado vagas, o la información no estaba disponible en las tools. Solución: Mejora las descripciones y considera usar Optional:

class Report(BaseModel):
    summary: str = Field(description="Resumen en exactamente 2-3 oraciones")
    sources: Optional[list[str]] = Field(
        description="URLs de fuentes consultadas. Null si no hay URLs específicas.",
        default=None
    )

Problema 4: El structured output ignora la información de las tools

Causa: El prompt del agente no instruye al modelo a usar la información recopilada. Solución: Sé explícito en el prompt:

agent = create_agent(
    model, tools,
    prompt="Usa TODAS las herramientas disponibles para recopilar información. Basa tu respuesta EXCLUSIVAMENTE en los datos obtenidos de las herramientas.",
    response_format=MyModel
)

Problema 5: Costo elevado por la llamada extra de extracción

Causa: response_format siempre agrega una llamada adicional al modelo. Solución: Esto es esperado. Si el costo es problema, considera usar un modelo más económico o evalúa si realmente necesitas structured output para tu caso de uso.


Ejercicios

Ejercicio 1: Structured output básico (Fácil)

Crea un modelo Pydantic MovieReview con campos title, rating (1-10), y review. Crea un agente con una tool de búsqueda que retorne datos de películas, y extrae una reseña estructurada.

Ver solución
from dotenv import load_dotenv
load_dotenv()

from pydantic import BaseModel, Field
from langchain.chat_models import init_chat_model
from langchain.agents import create_agent
from langchain_core.tools import tool

class MovieReview(BaseModel):
    title: str = Field(description="Título de la película")
    rating: int = Field(description="Calificación del 1 al 10", ge=1, le=10)
    review: str = Field(description="Reseña en 2-3 oraciones")

@tool
def search_movie(title: str) -> str:
    """Busca información sobre una película."""
    movies = {
        "inception": "Inception (2010). Director: Christopher Nolan. Un ladrón que roba secretos del subconsciente recibe una tarea imposible: implantar una idea. Considerada una obra maestra del sci-fi. IMDb: 8.8/10.",
        "the matrix": "The Matrix (1999). Director: Wachowski. Un programador descubre que la realidad es una simulación. Revolucionó el cine de acción y ciencia ficción. IMDb: 8.7/10.",
    }
    return movies.get(title.lower(), f"Película '{title}' no encontrada en la base de datos.")

model = init_chat_model("openai:gpt-4.1-mini")
agent = create_agent(
    model,
    [search_movie],
    prompt="Busca información sobre la película y genera una reseña.",
    response_format=MovieReview
)

result = agent.invoke({"messages": [("user", "Reseña de Inception")]})
review = result["structured_response"]
print(f"Título: {review.title}")
print(f"Rating: {review.rating}/10")
print(f"Reseña: {review.review}")
# Output esperado:
# Título: Inception
# Rating: 9/10
# Reseña: Inception es una obra maestra del cine de ciencia ficción dirigida por Christopher Nolan. La película explora el concepto de robo de ideas dentro del subconsciente con una narrativa compleja y visualmente impresionante.

Explicación: El modelo Pydantic define la estructura exacta. El agente busca la info con la tool y la extrae en el formato definido.

Ejercicio 2: Modelo con campos opcionales (Fácil)

Crea un modelo PersonProfile con name, age (Optional), occupation, y fun_fact (Optional). Crea un agente que busque info sobre personas famosas — algunos datos pueden no estar disponibles.

Ver solución
from dotenv import load_dotenv
load_dotenv()

from typing import Optional
from pydantic import BaseModel, Field
from langchain.chat_models import init_chat_model
from langchain.agents import create_agent
from langchain_core.tools import tool

class PersonProfile(BaseModel):
    name: str = Field(description="Nombre completo de la persona")
    age: Optional[int] = Field(description="Edad actual, null si no se conoce", default=None)
    occupation: str = Field(description="Ocupación principal")
    fun_fact: Optional[str] = Field(description="Dato curioso, null si no hay uno conocido", default=None)

@tool
def search_person(name: str) -> str:
    """Busca información sobre una persona."""
    people = {
        "guido van rossum": "Guido van Rossum, creador de Python. Nacido en 1956 en Países Bajos. Trabajó en Google y Dropbox. Dato curioso: Python se nombró por Monty Python.",
        "linus torvalds": "Linus Torvalds, creador de Linux y Git. Nacido en 1969 en Finlandia. Trabaja en la Linux Foundation.",
    }
    return people.get(name.lower(), f"No se encontró información sobre {name}")

model = init_chat_model("openai:gpt-4.1-mini")
agent = create_agent(model, [search_person], response_format=PersonProfile)

result = agent.invoke({"messages": [("user", "Cuéntame sobre Guido van Rossum")]})
profile = result["structured_response"]
print(f"Nombre: {profile.name}")
print(f"Edad: {profile.age if profile.age else 'No disponible'}")
print(f"Ocupación: {profile.occupation}")
print(f"Dato curioso: {profile.fun_fact if profile.fun_fact else 'No disponible'}")
# Output esperado:
# Nombre: Guido van Rossum
# Edad: 69
# Ocupación: Creador de Python / Ingeniero de software
# Dato curioso: Python fue nombrado en honor a Monty Python, el grupo de comedia británico.

Explicación: Los campos Optional con default=None permiten que el modelo deje campos vacíos cuando la información no está disponible, sin causar errores de validación.

Ejercicio 3: Múltiples tools con structured output (Medio)

Crea 3 tools (search_price, search_specs, search_reviews) y un modelo ProductAnalysis. El agente debe usar todas las tools y combinar la información en un reporte estructurado.

Ver solución
from dotenv import load_dotenv
load_dotenv()

from pydantic import BaseModel, Field
from langchain.chat_models import init_chat_model
from langchain.agents import create_agent
from langchain_core.tools import tool

class ProductAnalysis(BaseModel):
    product: str = Field(description="Nombre del producto")
    price_range: str = Field(description="Rango de precio en USD")
    key_specs: list[str] = Field(description="Especificaciones principales, máximo 4")
    user_rating: float = Field(description="Rating promedio de usuarios del 0 al 5", ge=0, le=5)
    verdict: str = Field(description="Veredicto final en una oración")

@tool
def search_price(product: str) -> str:
    """Busca el precio de un producto."""
    return f"Precio de {product}: $999 - $1,299 USD dependiendo de la configuración."

@tool
def search_specs(product: str) -> str:
    """Busca especificaciones técnicas de un producto."""
    return f"Specs de {product}: Chip M4, 16GB RAM, pantalla Liquid Retina XDR 14\", batería 18h, SSD desde 512GB."

@tool
def search_reviews(product: str) -> str:
    """Busca reseñas de usuarios sobre un producto."""
    return f"Reseñas de {product}: 4.7/5 estrellas (2,340 reseñas). Usuarios destacan rendimiento y batería. Críticas: precio elevado."

model = init_chat_model("openai:gpt-4.1-mini")
agent = create_agent(
    model,
    [search_price, search_specs, search_reviews],
    prompt="Investiga el producto usando TODAS las herramientas disponibles antes de generar el análisis.",
    response_format=ProductAnalysis
)

result = agent.invoke({"messages": [("user", "Analiza el MacBook Pro M4")]})
analysis = result["structured_response"]
print(f"Producto: {analysis.product}")
print(f"Precio: {analysis.price_range}")
print(f"Specs:")
for spec in analysis.key_specs:
    print(f"  - {spec}")
print(f"Rating: {analysis.user_rating}/5")
print(f"Veredicto: {analysis.verdict}")
# Output esperado:
# Producto: MacBook Pro M4
# Precio: $999 - $1,299 USD
# Specs:
#   - Chip M4
#   - 16GB RAM
#   - Pantalla Liquid Retina XDR 14"
#   - Batería de 18 horas
# Rating: 4.7/5
# Veredicto: Excelente laptop profesional con rendimiento de primer nivel y batería excepcional, aunque el precio puede ser elevado para algunos usuarios.

Explicación: El prompt "Investiga usando TODAS las herramientas" le indica al agente que no se salte ninguna tool. La información de las 3 tools se combina en un solo modelo Pydantic.

Ejercicio 4: Modelos Pydantic anidados (Medio)

Crea un modelo TeamReport que contenga una lista de TeamMember (modelo anidado). Cada miembro tiene name, role y contribution. El agente debe buscar info de un equipo y estructurarla.

Ver solución
from dotenv import load_dotenv
load_dotenv()

from pydantic import BaseModel, Field
from langchain.chat_models import init_chat_model
from langchain.agents import create_agent
from langchain_core.tools import tool

class TeamMember(BaseModel):
    name: str = Field(description="Nombre del miembro del equipo")
    role: str = Field(description="Rol o posición en el equipo")
    contribution: str = Field(description="Contribución principal al proyecto")

class TeamReport(BaseModel):
    project: str = Field(description="Nombre del proyecto")
    team: list[TeamMember] = Field(description="Miembros del equipo")
    total_members: int = Field(description="Número total de miembros")
    status: str = Field(description="Estado del proyecto: 'activo', 'completado', o 'pausado'")

@tool
def search_project(name: str) -> str:
    """Busca información sobre un proyecto y su equipo."""
    return f"""Proyecto: {name}
    Equipo:
    - Harrison Chase: Fundador y CEO, diseñó la arquitectura original del framework
    - Ankush Gola: Co-fundador y CTO, lideró la integración con proveedores de LLMs
    - Nuno Campos: Ingeniero principal, creó LangGraph y el sistema de streaming
    Estado: Activo, con actualizaciones semanales"""

model = init_chat_model("openai:gpt-4.1-mini")
agent = create_agent(model, [search_project], response_format=TeamReport)

result = agent.invoke({"messages": [("user", "Dame info del equipo de LangChain")]})
report = result["structured_response"]
print(f"Proyecto: {report.project}")
print(f"Estado: {report.status}")
print(f"Total miembros: {report.total_members}")
print(f"\nEquipo:")
for member in report.team:
    print(f"  👤 {member.name} ({member.role})")
    print(f"     → {member.contribution}")
# Output esperado:
# Proyecto: LangChain
# Estado: activo
# Total miembros: 3
#
# Equipo:
#   👤 Harrison Chase (Fundador y CEO)
#      → Diseñó la arquitectura original del framework
#   👤 Ankush Gola (Co-fundador y CTO)
#      → Lideró la integración con proveedores de LLMs
#   👤 Nuno Campos (Ingeniero Principal)
#      → Creó LangGraph y el sistema de streaming

Explicación: Los modelos Pydantic anidados (TeamMember dentro de TeamReport) permiten representar estructuras complejas. El modelo extrae la información jerárquica automáticamente.

Ejercicio 5: Structured output con validación custom (Difícil)

Crea un modelo CodeReview con validadores que verifiquen que severity sea uno de ["low", "medium", "high", "critical"] y que issues tenga al menos un elemento. El agente analiza código y retorna la revisión.

Ver solución
from dotenv import load_dotenv
load_dotenv()

from pydantic import BaseModel, Field, field_validator
from langchain.chat_models import init_chat_model
from langchain.agents import create_agent
from langchain_core.tools import tool

class CodeIssue(BaseModel):
    line: int = Field(description="Número de línea aproximado del problema")
    description: str = Field(description="Descripción del problema encontrado")
    fix: str = Field(description="Sugerencia para corregirlo")

class CodeReview(BaseModel):
    file_name: str = Field(description="Nombre del archivo analizado")
    severity: str = Field(description="Severidad general: 'low', 'medium', 'high', o 'critical'")
    issues: list[CodeIssue] = Field(description="Lista de problemas encontrados, al menos 1")
    overall_quality: float = Field(description="Calidad general del 0 al 10", ge=0, le=10)
    summary: str = Field(description="Resumen de la revisión en 1-2 oraciones")

    @field_validator("severity")
    @classmethod
    def validate_severity(cls, v):
        valid = ["low", "medium", "high", "critical"]
        if v.lower() not in valid:
            raise ValueError(f"Severity debe ser: {valid}")
        return v.lower()

    @field_validator("issues")
    @classmethod
    def validate_issues(cls, v):
        if len(v) < 1:
            raise ValueError("Debe haber al menos 1 issue")
        return v

@tool
def analyze_code(code: str) -> str:
    """Analiza un fragmento de código Python para encontrar problemas."""
    return """Análisis del código:
    - Línea 3: Variable 'x' no tiene type hint
    - Línea 7: Bloque except genérico (bare except), debería capturar excepciones específicas
    - Línea 12: String hardcodeado que debería ser una constante o variable de entorno
    - Calidad general: aceptable pero necesita mejoras de seguridad"""

model = init_chat_model("openai:gpt-4.1-mini")
agent = create_agent(
    model,
    [analyze_code],
    prompt="Analiza el código proporcionado y genera una revisión detallada.",
    response_format=CodeReview
)

result = agent.invoke({
    "messages": [("user", """Revisa este código:
def process(x):
    try:
        result = x * 2
        db_url = "postgresql://admin:password123@localhost/db"
        return result
    except:
        return None
""")]
})

review = result["structured_response"]
print(f"Archivo: {review.file_name}")
print(f"Severidad: {review.severity}")
print(f"Calidad: {review.overall_quality}/10")
print(f"\nProblemas encontrados:")
for issue in review.issues:
    print(f"  Línea {issue.line}: {issue.description}")
    print(f"    Fix: {issue.fix}")
print(f"\nResumen: {review.summary}")
# Output esperado:
# Archivo: process.py
# Severidad: high
# Calidad: 4.0/10
#
# Problemas encontrados:
#   Línea 3: Variable 'x' sin type hint
#     Fix: Agregar type hint: def process(x: int) -> Optional[int]
#   Línea 7: Bloque except genérico captura todas las excepciones
#     Fix: Usar except específico: except (ValueError, TypeError) as e
#   Línea 12: Credenciales de base de datos hardcodeadas en el código
#     Fix: Usar variables de entorno: os.environ["DB_URL"]
#
# Resumen: El código tiene problemas de seguridad críticos (credenciales expuestas) y malas prácticas (except genérico, sin type hints).

Explicación: Los field_validator aseguran que la respuesta del modelo cumple reglas de negocio. Si el modelo retorna una severidad inválida, Pydantic lanza un error. Los modelos anidados (CodeIssue dentro de CodeReview) permiten representar listas de objetos complejos.


Resumen

En esta cápsula aprendiste:

  • response_format en create_agent acepta un modelo Pydantic y fuerza al agente a retornar datos estructurados
  • Después del loop de tools, se hace una llamada adicional al modelo para extraer los datos en el formato definido
  • El resultado contiene result["structured_response"] — un objeto Pydantic tipado, validado y listo para usar
  • Las descripciones en Field() actúan como instrucciones para el modelo — sé específico
  • Puedes combinar tools + structured output: las tools recopilan datos, el structured output los formatea
  • Usa Optional para campos que pueden no tener información disponible
  • Los validadores de Pydantic (field_validator, ge, le) aseguran calidad de datos en la respuesta
  • El streaming funciona con structured output — puedes mostrar progreso de tools antes de obtener la respuesta final

Próxima cápsula: Legacy vs Moderno: mapeo de APIs — cómo traducir código que usa LLMChain, AgentExecutor y otras APIs legacy a las APIs modernas de LangChain.


Recursos adicionales

  1. Structured Output — LangChain Docs — Guía conceptual de structured output
  2. How to return structured output from an agent — Tutorial oficial
  3. create_agent API Reference — Parámetro response_format
  4. Pydantic Documentation — Modelos, validadores y Field
  5. How to get structured output from a model — Structured output a nivel de modelo
  6. BaseModel API — Pydantic — Referencia completa de BaseModel

Módulo 3 — LangChain & LangGraph: From Chains to Agents