Saltar al contenido principal

El patrón enrutado en profundidad: clasificar y derivar en Python

básico 8 min lectura
Implementa el patrón de enrutado en Python: clasificación con salida estructurada, un umbral de confianza y un ejemplo completo y ejecutable.

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:

  1. Entender en qué consiste el patrón de enrutado y su ruta de fallback a un humano.
  2. Forzar que el router devuelva una decisión estructurada: departamento, confianza y motivo.
  3. Escribir la función de clasificación y la función que activa al especialista elegido.
  4. Implementar el umbral de confianza mínima que decide cuándo es más seguro derivar a un humano que arriesgarse.
  5. 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.

Persona humanaresponder_como_especialista()OpenAI APIatender_consulta()Persona humanaresponder_como_especialista()OpenAI APIatender_consulta()alt[confianza >= confianza_minima][confianza < confianza_minima]clasificar(mensaje) — responses.parseDecision(departamento, confianza, motivo)responder_como_especialista(departamento, mensaje)responses.createrespuestarespuestaderiva la conversación"Voy a pasarte con un compañero..."

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 DEPARTAMENTOS y al Literal de Decision.
  • El umbral de confianza es un parámetro, no una regla fija. confianza_minima=0.7 es 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 Literal para 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.

Comprueba tu comprensión

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"?

agentes patrones router python openai structured-outputs