Una persona pregunta cuántas plazas quedan para un taller. Queremos que el modelo consulte el catálogo de nuestra aplicación y que devuelva una ficha con el nombre del taller y las plazas disponibles. La aplicación necesita leer esos datos sin volver a interpretar una explicación en prosa.
Podemos definir el resultado con Pydantic, ofrecer una herramienta de consulta y entregar a esa herramienta el catálogo que debe utilizar. Pydantic AI reúne esas piezas y lleva el intercambio con el modelo: petición de herramienta, ejecución, devolución del dato y obtención de la respuesta final.
Los tipos nos ayudarán a describir qué datos utiliza la herramienta y qué resultado aceptamos. Las plazas seguirán saliendo del catálogo; el modelo se encargará de interpretar la petición y utilizar la operación disponible.
Vamos a seguir un caso completo antes de entrar en el código. También dejaremos una salida para pedir una aclaración cuando no se pueda identificar el taller.
El taller tiene su repositorio, workshop-seats-agent, con el código, las pruebas y una imagen de Docker. Su README explica cómo ejecutarlo. Sin clave de IA, el modo --demo sustituye al modelo por unas reglas fijas que solicitan la herramienta y construyen la ficha; el agente, la consulta al catálogo y las validaciones sí se ejecutan.
Consultar las plazas y recibir una ficha
Nuestra aplicación tiene este catálogo ficticio en memoria:
| Taller | Plazas disponibles |
|---|---|
| Fotografía nocturna | 3 |
| Cerámica | 0 |
Para consultarlo, la aplicación ofrece al modelo una herramienta llamada get_available_seats. Es una función de nuestro programa: recibe el nombre de un taller, lee sus plazas en el catálogo y devuelve un número. No reserva plazas ni modifica el catálogo.
El modelo no ve el código de la función ni el catálogo. Recibe una descripción con el nombre de la herramienta, un texto que explica para qué sirve y el argumento que admite: workshop_name, que solo puede valer «Fotografía nocturna» o «Cerámica». Con esa descripción decide si necesita la herramienta y con qué valor.
La persona pregunta «¿Cuántas plazas quedan para Fotografía nocturna?». El recorrido esperado es este:
- El modelo recibe la pregunta y la descripción de
get_available_seats. - Solicita esa herramienta con
workshop_name="Fotografía nocturna". - Pydantic AI llama a nuestra función. La función consulta el catálogo de la aplicación y devuelve
3. - El modelo recibe ese resultado y propone la ficha final. Pydantic AI comprueba sus datos y entrega el objeto a nuestro código.
- Nuestra aplicación compara la ficha con el catálogo antes de mostrarla.
La ficha adecuada para este caso contiene:
{
"workshop_name": "Fotografía nocturna",
"available_seats": 3
}
workshop_name guarda el nombre concreto del taller y available_seats, su cantidad de plazas disponibles. Los utilizaremos para presentar el resultado. Con un modelo real, la solicitud y la ficha pueden variar; la consulta al catálogo y las comprobaciones son código nuestro.
Si la pregunta fuera «¿Cuántas plazas quedan?», el modelo podría responder con una aclaración como «¿De qué taller quieres consultar las plazas?». Admitiremos esa respuesta de texto para no obligarlo a inventar un taller. Ninguno de los dos recorridos reserva plazas ni modifica el catálogo.
La herramienta recibe datos de la aplicación
La función necesita conocer el catálogo, pero no hace falta incluirlo entero en el mensaje del modelo. Se lo entregaremos como una dependencia: un objeto que nuestra aplicación prepara y que la herramienta puede consultar durante esa ejecución.
Pydantic AI proporciona un RunContext para acceder a esas dependencias. El modelo propone el nombre del taller; la biblioteca aporta el contexto a la función. El catálogo no es un argumento que el modelo tenga que construir.
La documentación de dependencias explica esta separación entre los datos de la aplicación y los argumentos de las herramientas. Una dependencia podría contener un servicio o una conexión; aquí basta con el objeto que guarda el catálogo.
El diagrama muestra la consulta de Fotografía nocturna. El tiempo avanza hacia abajo y las flechas discontinuas representan resultados. La columna del catálogo agrupa nuestra función y sus datos, dentro del mismo programa:

Ver el diagrama a tamaño completo.
El modelo recibe el resultado de la consulta. La aplicación conserva el catálogo y las operaciones que pueden ejecutarse sobre él.
Cómo se reparte el proyecto
El repositorio separa lo que pertenece a nuestra aplicación de lo que depende de Pydantic AI:
| Carpeta o módulo | Qué contiene |
|---|---|
domain/ |
WorkshopCatalog, que guarda las plazas y responde consultas. |
application/ |
CheckAvailability, el caso de uso que pide la respuesta y la compara con el catálogo. |
adapters/pydantic_ai/ |
La ficha Availability, la herramienta, el agente y el modelo de demostración. |
adapters/workshop_name.py |
Los nombres de taller que puede enviar el modelo. |
bootstrap.py |
La entrada de consola: elige el modelo y conecta las piezas. |
El catálogo y el caso de uso no importan Pydantic AI; las pruebas de arquitectura del repositorio lo comprueban. Así, si cambiamos de biblioteca, la regla «las plazas salen del catálogo» sigue en el mismo sitio. El repositorio lo aprovecha: adapters/langgraph/ resuelve la misma consulta con LangChain y LangGraph, y se elige con --engine langgraph. En este artículo usamos la opción por defecto, Pydantic AI.
Definir el catálogo y el resultado
Empezamos por el catálogo, en domain/workshop_catalog.py. Recibe las plazas de cada taller y las copia en un diccionario de solo lectura:
class WorkshopCatalog:
"""Read-only availability for the workshops offered by the application."""
def __init__(self, seats_by_workshop: Mapping[str, int]) -> None:
"""Copy the catalog after checking its names and seat counts."""
if not seats_by_workshop:
raise ValueError("The catalog needs at least one workshop.")
for name, seats in seats_by_workshop.items():
if not name.strip():
raise ValueError("Workshop names cannot be blank.")
if isinstance(seats, bool) or seats < 0:
raise ValueError(f"Seats for {name} must be zero or more.")
self._seats_by_workshop = MappingProxyType(dict(seats_by_workshop))
def seats_for(self, workshop_name: str) -> int:
"""Return the available seats without reserving any of them."""
if workshop_name not in self._seats_by_workshop:
raise UnknownWorkshopError(workshop_name)
return self._seats_by_workshop[workshop_name]
El constructor rechaza un catálogo vacío, un nombre en blanco o unas plazas negativas. seats_for devuelve las plazas de un taller; si el nombre no está en el catálogo, produce UnknownWorkshopError en lugar de devolver cero. Para «Fotografía nocturna» devuelve 3.
La ficha que queremos recibir del agente vive en adapters/pydantic_ai/. Los nombres de taller que admite vienen de adapters/workshop_name.py, que comparten las dos versiones del repositorio:
WorkshopName = Literal["Fotografía nocturna", "Cerámica"]
Y la ficha, en availability.py:
class Availability(BaseModel):
"""Availability for a supported workshop."""
model_config = ConfigDict(extra="forbid", strict=True)
workshop_name: WorkshopName
available_seats: int = Field(ge=0)
El nombre debe pertenecer a los dos talleres admitidos y las plazas deben ser un entero mayor o igual que cero. El modo estricto evita aceptar texto como "3" en lugar del entero, y extra="forbid" rechaza campos que no hemos pedido. Cero es un resultado válido: significa que no quedan plazas.
WorkshopName repite los nombres del catálogo para que el modelo reciba la lista de valores admitidos. Una prueba del repositorio comprueba que ambas listas coinciden.
Escribir la herramienta
Ahora escribimos la herramienta que presentamos al principio, en seat_tools.py. Recibe dos datos: ctx, que da acceso al catálogo de esta ejecución, y workshop_name, el taller que propone el modelo. Devuelve las plazas de ese taller:
def get_available_seats(
ctx: RunContext[WorkshopCatalog],
workshop_name: WorkshopName,
) -> int:
"""Read available seats for a supported workshop without reserving."""
return ctx.deps.seats_for(workshop_name)
Cuando registremos la función en el agente, Pydantic AI preparará a partir de ella la descripción que recibe el modelo: el nombre get_available_seats, el texto de su docstring y el argumento workshop_name con sus dos valores admitidos. También la utilizará para comprobar los argumentos que solicite el modelo antes de ejecutar la función.
ctx no forma parte de esa descripción. El modelo aporta workshop_name; ctx lo proporciona la biblioteca, y ctx.deps es la instancia de WorkshopCatalog que entregaremos al ejecutar el agente. La consulta la hace seats_for, el mismo método que usa el resto de la aplicación.
Configurar y ejecutar el agente
El agente vive en pydantic_ai_assistant.py, dentro de la clase PydanticAiAssistant. Su constructor recibe el modelo y crea el agente con la herramienta:
INSTRUCTIONS = (
"Use get_available_seats before reporting workshop availability. "
"Return Availability with the selected workshop and returned count. "
"If the workshop is missing, unsupported, or ambiguous, ask for "
"clarification in Spanish as plain text. Never claim a reservation."
)
DEFAULT_USAGE_LIMITS = UsageLimits(request_limit=3, tool_calls_limit=2)
class PydanticAiAssistant:
"""Let the model consult the catalog and validate its final answer."""
def __init__(
self,
model: Model | str,
usage_limits: UsageLimits = DEFAULT_USAGE_LIMITS,
) -> None:
"""Configure the agent, its tool and the usage limits per question."""
self._agent: Agent[WorkshopCatalog, Availability | str] = Agent(
model,
deps_type=WorkshopCatalog,
output_type=[Availability, str],
instructions=INSTRUCTIONS,
tools=[get_available_seats],
model_settings={"timeout": 20.0},
retries=0,
)
self._usage_limits = usage_limits
tools=[get_available_seats] registra la herramienta. deps_type indica el tipo de dependencia que espera el agente; esta anotación ayuda al análisis de tipos y no envía el catálogo al modelo.
output_type=[Availability, str] admite dos resultados: la ficha validada o una respuesta de texto. La documentación de salidas explica cómo se utilizan estos tipos para obtener y validar la respuesta. retries=0 desactiva nuevos intentos de corrección por fallos de validación; no convierte un dato inválido en una respuesta aceptable.
El método ask ejecuta el agente con una pregunta y el catálogo como dependencia:
def ask(
self,
question: str,
catalog: WorkshopCatalog,
) -> AvailabilityReport | ClarificationRequest:
"""Run the agent once and translate its validated output."""
result = self._agent.run_sync(
question,
deps=catalog,
usage_limits=self._usage_limits,
)
output = result.output
if isinstance(output, str):
return ClarificationRequest(message=output)
return AvailabilityReport(
workshop_name=output.workshop_name,
available_seats=output.available_seats,
)
run_sync espera a que termine el recorrido y devuelve su resultado. El límite permite como máximo tres peticiones al modelo y dos ejecuciones de la herramienta. Si result.output es texto, lo devolvemos como una aclaración; si es una ficha Availability, copiamos sus dos campos en AvailabilityReport, el tipo que entiende el resto de la aplicación. La distinción es necesaria porque hemos admitido dos tipos de salida: no todas las respuestas tienen available_seats.
Pydantic AI realiza los intercambios de herramienta dentro de la ejecución. Los límites de uso detienen la continuación cuando se superaría alguno de los máximos configurados. Si falla una llamada, no se obtiene una salida válida o se alcanza un límite, Pydantic AI produce un error; la aplicación lo muestra y termina, sin presentarlo como cero plazas o como una petición de aclaración.
Qué garantiza el tipo y qué comprobamos después
Una ficha con available_seats=-1 incumple nuestras reglas y Pydantic la rechaza. Una ficha con available_seats=30 tiene la forma correcta, pero no refleja las tres plazas del catálogo. La validación de tipos no demuestra que el modelo haya copiado bien el dato.
Esa comprobación la hace el caso de uso CheckAvailability, en application/check_availability.py. Pide la respuesta al asistente y compara las plazas con las del catálogo:
def execute(
self,
question: str,
) -> AvailabilityReport | ClarificationRequest:
"""Return a verified report or the assistant's clarification."""
if not question.strip():
raise ValueError("The question cannot be blank.")
answer = self._assistant.ask(question, self._catalog)
if isinstance(answer, ClarificationRequest):
return answer
catalog_seats = self._catalog.seats_for(answer.workshop_name)
if answer.available_seats != catalog_seats:
raise InconsistentReportError(answer, catalog_seats)
return answer
Para Fotografía nocturna, el catálogo dice 3: una ficha con 3 pasa y una con 30 produce InconsistentReportError. Una aclaración se devuelve tal cual, porque no afirma ningún número. El caso de uso no sabe qué biblioteca hay detrás: recibe el asistente a través de un puerto, AvailabilityAssistant.
bootstrap.py conecta las piezas: crea el catálogo, elige el modelo de demostración con --demo o el de Anthropic, y pasa el asistente al caso de uso. Para el modelo real lee ANTHROPIC_MODEL y ANTHROPIC_API_KEY; si falta alguna, avisa sin llamar al proveedor:
def _configured_model_name() -> str | None:
"""Return the Anthropic model name when the key is also configured."""
model_name = os.environ.get("ANTHROPIC_MODEL", "").strip()
if not model_name or not os.environ.get("ANTHROPIC_API_KEY"):
return None
return model_name
Pydantic AI recibe ese nombre con el prefijo del proveedor: PydanticAiAssistant(f"anthropic:{model_name}").
Con la imagen construida, el modo demostración responde la pregunta del artículo:
docker run --rm workshop-seats-agent --demo "¿Cuántas plazas quedan para Fotografía nocturna?"
Taller: Fotografía nocturna
Plazas disponibles: 3
Con «¿Quedan plazas para Cerámica?» devuelve cero plazas, y con «¿Cuántas plazas quedan?» devuelve la aclaración. El README incluye la construcción de la imagen y la ejecución con un modelo de Anthropic.
La misma consulta con LangGraph
Hasta aquí, el modelo decide cuándo consultar el catálogo y escribe la ficha final; después, el caso de uso comprueba que el número cuadre.
El repositorio incluye una segunda versión que reparte el trabajo de otra manera: el modelo solo dice de qué taller se habla, y el resto del recorrido lo decide nuestro código. LangChain se encarga de la llamada al modelo y LangGraph organiza los pasos en un grafo. En el artículo de LangChain y LangGraph se explican estas herramientas con calma; aquí vemos cómo encajan en el taller.
Con «¿Cuántas plazas quedan para Fotografía nocturna?», el recorrido es este:
- El paso
identifyenvía al modelo unas instrucciones con los dos talleres admitidos y la pregunta. El modelo devuelve una selección conworkshop_name="Fotografía nocturna", o conNonesi no puede identificar el taller. - Una función nuestra,
choose_next_step, mira ese campo y elige el paso siguiente. Como hay un taller, va aanswer. answerlee las plazas en el catálogo y construye unAvailabilityReportcon3. Si no había taller, el pasoclarifydevuelve una aclaración fija: «Indica el taller: Fotografía nocturna o Cerámica.».
El modelo ya no escribe el número: lo copia el código desde el catálogo. La selección que devuelve también es un modelo de Pydantic, en adapters/langgraph/workshop_selection.py, y reutiliza los mismos nombres de taller:
class WorkshopSelection(BaseModel):
"""A supported workshop name, or no selection."""
model_config = ConfigDict(extra="forbid", strict=True)
workshop_name: WorkshopName | None
LangChain pide al modelo una respuesta con esa forma mediante with_structured_output(WorkshopSelection). Si propone un taller que no está en la lista, la validación lo rechaza igual que con Availability.
Los tres pasos se conectan en el constructor de LangGraphAssistant, en adapters/langgraph/lang_graph_assistant.py. Cada nodo es una función, y la arista condicional usa choose_next_step para elegir entre answer y clarify:
builder = StateGraph(WorkshopState, context_schema=WorkflowContext)
builder.add_node("identify", self._identify_workshop)
builder.add_node("answer", answer_availability)
builder.add_node("clarify", ask_for_workshop)
builder.add_edge(START, "identify")
builder.add_conditional_edges(
"identify",
choose_next_step,
{"answer": "answer", "clarify": "clarify"},
)
builder.add_edge("answer", END)
builder.add_edge("clarify", END)
self._workflow = builder.compile()
El catálogo llega a los pasos como contexto de cada ejecución, dentro de WorkflowContext, igual que en Pydantic AI llegaba mediante deps. answer lo lee con runtime.context.catalog.seats_for(...).
El resto del taller no cambia. LangGraphAssistant implementa el mismo puerto, AvailabilityAssistant, y devuelve un AvailabilityReport o una ClarificationRequest. CheckAvailability sigue comparando las plazas con el catálogo, aunque en este camino no deberían fallar nunca, y la consola presenta el resultado igual. Para usar esta versión, basta con añadir --engine langgraph:
docker run --rm workshop-seats-agent --engine langgraph --demo "¿Cuántas plazas quedan para Fotografía nocturna?"
Taller: Fotografía nocturna
Plazas disponibles: 3
En el modo --demo, una regla fija con RunnableLambda sustituye solo a la selección del taller: busca en la pregunta el nombre de uno de los dos talleres. El grafo, la decisión y la lectura del catálogo se ejecutan de verdad. Las pruebas del repositorio usan la misma sustitución.
Probar sin depender del modelo
En la versión con Pydantic AI, las pruebas sustituyen el modelo por un FunctionModel de Pydantic AI, una función nuestra que decide cada respuesta. Así comprueban, sin red ni clave, la consulta al catálogo, la descripción de la herramienta que recibe el modelo, la aclaración, el rechazo de plazas negativas, el límite de llamadas y la ficha que contradice el catálogo. La guía de pruebas de Pydantic AI explica este tipo de sustitutos.
El modo --demo usa esa misma técnica con reglas fijas: busca en la pregunta el nombre de un taller, solicita la herramienta y construye la ficha con el número devuelto. No interpreta el lenguaje, así que no mide las decisiones de un modelo real. Para eso necesitamos casos con resultados esperados, incluidas consultas ambiguas y talleres sin plazas.
En nuestro caso, los tipos conectan las piezas con un contrato reconocible: la herramienta recibe el catálogo de la aplicación y la respuesta validada contiene un taller y una cantidad. El caso de uso añade la comprobación que los tipos no pueden hacer, y las plazas siguen siendo datos consultados, no una reserva ejecutada.