El patrón enrutado en profundidad: clasificar y derivar en Python
En este capítulo vas a implementar el patrón de enrutado de verdad: una primera llamada que solo clasifica el mensaje y, en función del resultado, una segunda llamada que activa un único especialista —o ninguna, si lo mejor es derivar a una persona—. Al terminar este capítulo serás capaz de:
- Entender en qué consiste el patrón de enrutado y su ruta de fallback a un humano.
- Forzar que el router devuelva una decisión estructurada: departamento, confianza y motivo.
- Escribir la función de clasificación y la función que activa al especialista elegido.
- Implementar el umbral de confianza mínima que decide cuándo es más seguro derivar a un humano que arriesgarse.
- Encadenar todo en una función completa que atiende una consulta real.
El patrón de enrutado
El router analiza la petición de entrada y decide a qué especialista delegarla. Lo veremos con un ejemplo concreto: un chat de soporte con tres departamentos —facturación, técnico y devoluciones— y una ruta de fallback a un humano cuando la confianza de la clasificación es baja.
Preparar el proyecto
Si vienes siguiendo los capítulos anteriores, el proyecto ya tiene lo necesario. Si empiezas directamente aquí, sigue primero Preparar el proyecto: herramientas y entorno y luego instala lo que usa este capítulo:
uv add openai pydantic
El router también necesita salida estructurada
Igual que en el pipeline, aquí la salida del primer paso alimenta directamente al segundo: si el modelo no devolviera un departamento válido y un número de confianza, no habría forma fiable de decidir hacia dónde enrutar. Por eso el router también se apoya en salida estructurada, esta vez con un campo restringido a un conjunto cerrado de valores:
import os
from typing import Literal
from openai import OpenAI
from pydantic import BaseModel, Field
DEFAULT_MODEL = os.environ.get("OPENAI_MODEL", "gpt-5.1")
class Decision(BaseModel):
departamento: Literal["facturacion", "tecnico", "devoluciones"]
confianza: float = Field(ge=0, le=1)
motivo: str
Literal[...] obliga a que departamento sea exactamente uno de esos tres valores —no un texto libre que luego haya que normalizar— y Field(ge=0, le=1) mantiene la confianza dentro de un rango de 0 a 1.
Paso 1: clasificar el mensaje
def clasificar(client: OpenAI, mensaje: str) -> Decision:
respuesta = client.responses.parse(
model=DEFAULT_MODEL,
instructions=(
"Clasifica el mensaje de un cliente de una tienda online "
"en el departamento adecuado e indica tu confianza (0 a 1)."
),
input=mensaje,
text_format=Decision,
)
decision = respuesta.output_parsed
if decision is None:
raise ValueError("El modelo no devolvió una decisión válida")
return decision
Fíjate en que esta función no responde al cliente: solo clasifica. Separar “decidir a dónde va” de “generar la respuesta final” es lo que permite insertar el umbral de confianza en medio, antes de comprometerse con un especialista.
Paso 2: responder con el especialista elegido
Cada departamento es, de nuevo, un prompt distinto sobre la misma función:
DEPARTAMENTOS = {
"facturacion": (
"Eres del equipo de facturación de una tienda online. "
"Responde con empatía y explica el siguiente paso concreto."
),
"tecnico": (
"Eres soporte técnico de una tienda online. Da una posible "
"causa y una solución paso a paso, sin jerga."
),
"devoluciones": (
"Eres del equipo de devoluciones. Explica el proceso de "
"devolución en tres pasos numerados."
),
}
def responder_como_especialista(
client: OpenAI, departamento: str, mensaje: str
) -> str:
respuesta = client.responses.create(
model=DEFAULT_MODEL,
instructions=DEPARTAMENTOS[departamento],
input=mensaje,
)
return respuesta.output_text
El umbral de confianza: cuándo derivar a un humano
def atender_consulta(
mensaje: str, client: OpenAI | None = None, confianza_minima: float = 0.7
) -> dict:
client = client or OpenAI()
print("El router clasifica el mensaje…")
decision = clasificar(client, mensaje)
print(f" Departamento: {decision.departamento}")
print(f" Confianza: {decision.confianza}")
print(f" Motivo: {decision.motivo}")
if decision.confianza < confianza_minima:
print("Confianza baja: se deriva a un agente humano")
return {
"departamento": "humano",
"confianza": decision.confianza,
"respuesta": (
"Voy a pasarte con un compañero para asegurarnos de "
"resolverlo bien. Un momento, por favor."
),
}
print(f"Solo se activa el especialista de {decision.departamento}")
respuesta = responder_como_especialista(client, decision.departamento, mensaje)
return {
"departamento": decision.departamento,
"confianza": decision.confianza,
"respuesta": respuesta,
}
Ejecutarlo
if __name__ == "__main__":
resultado = atender_consulta(
"Me habéis cobrado dos veces el mismo pedido, ¿qué hago?"
)
print(f"\nRespuesta de {resultado['departamento']}")
print(resultado["respuesta"])
Ejecútalo con uv run main.py: verás primero la clasificación (departamento, confianza y motivo) y después, solo si la confianza supera el umbral, la respuesta del especialista correspondiente.
Qué te llevas de este ejemplo
- Clasificar y responder son dos llamadas distintas, no una. Eso es lo que permite insertar el umbral de confianza entre medias, antes de comprometerse con una respuesta.
- Añadir un departamento nuevo no toca la lógica del router. Basta con añadir una entrada al diccionario
DEPARTAMENTOSy alLiteraldeDecision. - El umbral de confianza es un parámetro, no una regla fija.
confianza_minima=0.7es una decisión de negocio que puedes ajustar según cuánto te cueste equivocarte de departamento. - El router puede ir delante de cualquier otro patrón. El mismo
clasificar()que aquí decide un departamento podría decidir, en su lugar, qué pipeline ejecutar o qué especialistas activar en un planificador-ejecutor.
Resumen
- El patrón de enrutado separa clasificar de responder: una llamada decide a qué especialista delegar, otra genera la respuesta final.
- La salida estructurada (aquí con un
Literalpara el departamento) evita depender de que el modelo escriba el nombre exacto del departamento en texto libre. - Un umbral de confianza mínima decide cuándo es más seguro derivar a un humano que arriesgarse a una clasificación incorrecta.
- Añadir un departamento nuevo es solo una entrada más en un diccionario, sin tocar la lógica del router.
En el siguiente capítulo volvemos al patrón planificador-ejecutor, esta vez con concurrencia real: varios agentes especialistas trabajando en paralelo de verdad con asyncio.
Autoevaluación
Antes de continuar, comprueba que los conceptos clave han quedado claros.
1. ¿Por qué el router separa clasificar() de responder_como_especialista() en dos llamadas distintas?
2. ¿Qué papel juega el campo Literal["facturacion", "tecnico", "devoluciones"] en la clase Decision?
3. Si confianza_minima vale 0.7 y el router clasifica un mensaje con confianza 0.5, ¿qué ocurre?
4. ¿Qué hay que tocar para añadir un cuarto departamento, por ejemplo "envios"?