Módulo 2: El patrón supervisor/router

Ruteo por decisión del modelo

Descripción

La lección 03 terminó con un caso incómodo: una petición donde route_deterministic no devolvió None —el "no sé" honesto—, sino una respuesta segura y equivocada. "Necesito cancelar porque no voy a poder llegar a mi reserva de mañana, ¿me cobran algo?" contiene "cancela" y "reserva" —dos palabras que las reglas asocian con confianza a booking_agent—, pero la intención real del socio es preguntar por un cargo, un dominio que vive en policy_agent. Ninguna palabra clave de la lección 03 puede resolver esto, porque el problema no es de vocabulario — es de sentido.

Esta lección construye la "recepcionista humana" de la analogía del Módulo 2: el supervisor le pasa la petición cruda, junto con la descripción de los tres especialistas, a claude-sonnet-5, y el modelo —leyendo la frase completa, no palabras sueltas— decide correctamente. La decisión en sí es concepto, como en toda esta guía; pero el mecanismo para extraer esa decisión de la respuesta del modelo —un bloque tool_use con una tool de ruteo— sí se ejecuta de verdad, con el mismo patrón tool_use/tool_result que ya conoces de agent-fundamentals.

Conexión con el módulo

Esta lección resuelve el mismo Paso 2 del supervisor (lección 02), pero con el mecanismo opuesto al de la lección 03: en vez de una función determinista, una llamada real al modelo (concepto). La lección 07 pone las dos formas lado a lado, mide su costo con números, y construye un router híbrido que usa la más barata cuando alcanza y la más cara cuando hace falta.


Analogía retomada: la recepcionista, con la petición completa en la mano

El cartel de reglas de la lección 03 solo puede leer palabras sueltas. La recepcionista humana lee la frase entera, entiende que "¿me cobran algo?" es la parte que importa, y que "cancela" y "reserva" son solo el contexto de la pregunta, no la pregunta en sí. Esa comprensión tiene un costo real —en un edificio, el sueldo de la persona; acá, una llamada al modelo— pero también resuelve casos que ningún cartel, por más reglas que tenga, puede anticipar todas.


La decisión como una tool: route_to_agent

En vez de pedirle al modelo un párrafo de texto libre que después haya que interpretar, el supervisor le da una tool de ruteo —una forma estructurada de declarar su elección, con el mismo protocolo tool_use que ya usan get_quote o search_docs—. La diferencia es que esta tool no toca ningún dato de Reservo: solo declara una decisión.

from dataclasses import dataclass


@dataclass
class RoutingDecision:
    target: str
    reason: str


ROUTE_TOOL = {
    "name": "route_to_agent",
    "description": (
        "Elige el especialista de Reservo mejor calificado para resolver "
        "la petición del socio."
    ),
    "input_schema": {
        "type": "object",
        "properties": {
            "agent": {"type": "string",
                       "enum": ["booking_agent", "policy_agent", "pricing_agent"]},
            "reason": {"type": "string"},
        },
        "required": ["agent", "reason"],
    },
}

ROUTE_TOOL sigue exactamente el mismo formato de contrato (name/description/input_schema) que las cuatro tools canónicas de Reservo — el enum de agent es lo que le impide al modelo inventar un nombre de especialista que no existe, el mismo rol que cumplía el enum de room en get_quote.


Ejemplo trabajado: la petición ambigua, resuelta por decisión del modelo

AMBIGUOUS_REQUEST = (
    "Necesito cancelar porque no voy a poder llegar a mi reserva de "
    "mañana, ¿me cobran algo?"
)

# Turno CONCEPTO (claude-sonnet-5): el supervisor le pasa la petición cruda
# más la descripción de los tres especialistas, y el modelo, leyendo el
# SENTIDO completo de la frase -- no solo sus palabras sueltas -- decide.
# Esto NO se ejecuta contra una API real; es un guion escrito a mano.
concept_turn = {
    "stop_reason": "tool_use",
    "content": [
        {"type": "tool_use", "id": "toolu_01", "name": "route_to_agent",
         "input": {
             "agent": "policy_agent",
             "reason": (
                 "el socio pregunta si le van a cobrar algo -- es una "
                 "pregunta sobre el cargo de no-presentación, no una orden "
                 "de cancelar ahora mismo"
             ),
         }},
    ],
}


def extract_routing_decision(turn):
    """ESTO SÍ se ejecuta de verdad: parsea el bloque tool_use del turno del
    modelo (concepto, ya escrito arriba) y arma la RoutingDecision -- el
    mismo mecanismo de extracción que ya usa dispatch_parallel para leer
    `block["name"]`/`block["input"]`, solo que acá el resultado no dispara
    una tool de Reservo, dispara la elección del supervisor."""
    block = turn["content"][0]
    assert block["type"] == "tool_use" and block["name"] == "route_to_agent"
    return RoutingDecision(target=block["input"]["agent"], reason=block["input"]["reason"])


print("--- petición ambigua ---")
print(repr(AMBIGUOUS_REQUEST))

print()
print("--- ruteo determinista (lección 03), sobre la misma petición ---")
POLICY_KEYWORDS = ("política", "no-show", "no me presento", "no llego", "cargo por")
BOOKING_KEYWORDS = ("cotiza", "reserva", "resérva", "agenda", "cancela")
t = AMBIGUOUS_REQUEST.lower()
det_result = "policy_agent" if any(k in t for k in POLICY_KEYWORDS) else (
    "booking_agent" if any(k in t for k in BOOKING_KEYWORDS) else None)
print(f"route_deterministic -> {det_result!r}  (matchea 'cancela'/'reserva', ninguna palabra de política)")

print()
print("--- ruteo por decisión del modelo (concepto), extracción ejecutada ---")
decision = extract_routing_decision(concept_turn)
print(f"target: {decision.target!r}")
print(f"reason: {decision.reason!r}")

print()
print("--- costo de cada camino, para ESTA petición ---")
ROUTE_CALLS_DETERMINISTIC = 0
ROUTE_CALLS_LLM = 1
print(f"ruteo determinista: {ROUTE_CALLS_DETERMINISTIC} llamadas al modelo, resultado: {det_result!r} (incorrecto)")
print(f"ruteo por decisión: {ROUTE_CALLS_LLM} llamada al modelo, resultado: {decision.target!r} (correcto)")

Qué esperar:

--- petición ambigua ---
'Necesito cancelar porque no voy a poder llegar a mi reserva de mañana, ¿me cobran algo?'

--- ruteo determinista (lección 03), sobre la misma petición ---
route_deterministic -> 'booking_agent'  (matchea 'cancela'/'reserva', ninguna palabra de política)

--- ruteo por decisión del modelo (concepto), extracción ejecutada ---
target: 'policy_agent'
reason: 'el socio pregunta si le van a cobrar algo -- es una pregunta sobre el cargo de no-presentación, no una orden de cancelar ahora mismo'

--- costo de cada camino, para ESTA petición ---
ruteo determinista: 0 llamadas al modelo, resultado: 'booking_agent' (incorrecto)
ruteo por decisión: 1 llamada al modelo, resultado: 'policy_agent' (correcto)

El contraste queda lado a lado: la misma petición, dos mecanismos, dos resultados distintos. El determinista es gratis y se equivoca; el del modelo cuesta una llamada y acierta. Ninguno de los dos es "el ganador" en abstracto — la lección 07 muestra que la respuesta depende de qué proporción de tus peticiones reales se parece a esta, y de cuánto cuesta equivocarse.


Qué se ejecuta acá, y qué no

Vale la pena ser preciso, porque esta lección camina cerca del límite de la regla dura de la guía. No se ejecuta: la decisión en sí —el hecho de que el modelo, leyendo la frase, elija policy_agent en vez de booking_agent— es un guion escrito a mano (concept_turn), exactamente como cada model_script de las lecciones anteriores. Sí se ejecuta: la función extract_routing_decision, que toma ese turno (ya escrito) y hace el trabajo mecánico de leer block["input"]["agent"] y armar una RoutingDecision de verdad, en Python real, sin ningún LLM de por medio. Es la misma distinción que separa model_script (concepto) de run_agent_parallel (ejecutado) en toda la guía — acá se aplica a un turno que decide a quién rutear, en vez de a qué tool de Reservo llamar.


Comparación: determinista vs. decisión del modelo

Determinista (lección 03)Decisión del modelo (esta lección)
Costo por petición0 llamadas al modelo1 llamada al modelo
LatenciaInstantáneaLa de una llamada real a la API
Comprensión de sentidoNinguna -- solo palabras sueltasCompleta -- lee la frase entera
Reproducibilidad100% -- misma entrada, misma salida siempreNo garantizada -- dos corridas pueden variar
Auditable/testeableTotalmente -- se puede probar cada reglaParcial -- depende de evals sobre el modelo
CoberturaLimitada al vocabulario anticipadoGeneraliza a frases nunca vistas
Falla típicaSilenciosa (confiada) o NoneRara, pero posible -- no es infalible

Errores comunes

  1. Pensar que el ruteo por decisión del modelo es infalible. No lo es — es mejor en casos de sentido ambiguo, pero sigue siendo una decisión probabilística de un modelo, no una garantía matemática. La regla dura de esta guía existe justamente porque esa decisión nunca se ejecuta de verdad acá: se muestra como concepto, con ejemplos realistas.

  2. Confundir el enum de ROUTE_TOOL con una validación de que la decisión sea correcta. El enum solo impide que el modelo invente un nombre de especialista que no existe (por ejemplo, "shipping_agent") — no impide que elija, dentro de los tres válidos, el que no correspondía.

  3. Usar el ruteo por decisión del modelo para TODAS las peticiones, incluidas las que las reglas ya resuelven bien. Esto es exactamente el costo que la lección 07 mide: pagar una llamada al modelo para rutear una petición que un cartel de reglas ya resolvía gratis y bien es desperdiciar el ahorro que la lección 03 demostró.

  4. Olvidar que extract_routing_decision asume que el modelo SIEMPRE devuelve tool_use. El assert de la función fallaría si el concept_turn tuviera, por ejemplo, stop_reason: "end_turn" con solo texto libre — un caso real tendría que manejar esa posibilidad, no asumirla ausente (fuera del alcance de esta lección, que se concentra en el mecanismo de extracción).

  5. Pensar que esta lección "arregla" el router determinista. No lo arregla — lo complementa. route_deterministic sigue siendo la opción correcta para el vocabulario que sí cubre bien; esta lección construye la alternativa para cuando no alcanza, no un reemplazo total.


Ejercicios

Ejercicio 1: Clasifica tres peticiones nuevas (Fácil)

Sin escribir código, para cada una de estas tres peticiones decide si un router determinista bien diseñado la resolvería sin problema, o si necesitaría el ruteo por decisión del modelo: (a) "Quiero saber el precio de Boardroom pro 2h."; (b) "Che, entre las tres salas, ¿cuál me sale más barata para mañana?"; (c) "No sé bien qué hacer, tengo una reserva pero capaz no llego a tiempo, ¿qué me recomiendas?".

Ver solución

(a) Router determinista, sin problema. "Precio" no está en BOOKING_KEYWORDS tal como está escrito en esta lección, pero la intención es clara y de una sola palabra clave más (por ejemplo, "quiero saber el precio" con una keyword de precio agregada a BOOKING_KEYWORDS) resolvería el caso sin ambigüedad.

(b) Router determinista. "Más barata" es literalmente una de las PRICING_KEYWORDS — el vocabulario coloquial ("che") no afecta la coincidencia de substring, así que el cartel de reglas la resuelve bien.

(c) Necesita el ruteo por decisión del modelo. La frase no tiene una intención clara ni palabras clave confiables — "no sé bien qué hacer" y "¿qué me recomiendas?" no describen ni una transacción ni una pregunta de política de forma directa; hace falta entender el contexto completo (una reserva, una posible tardanza) para inferir que la pregunta real es sobre el cargo de no-presentación.

Ejercicio 2: Extrae la decisión de una nueva petición ambigua (Medio)

Escribe un concept_turn nuevo para la petición "¿Me devuelven la plata si tuve que cancelar por una emergencia?", con route_to_agent decidiendo policy_agent, y ejecuta extract_routing_decision sobre él.

Ver solución
NEW_AMBIGUOUS = "¿Me devuelven la plata si tuve que cancelar por una emergencia?"

concept_turn_2 = {
    "stop_reason": "tool_use",
    "content": [
        {"type": "tool_use", "id": "toolu_01", "name": "route_to_agent",
         "input": {
             "agent": "policy_agent",
             "reason": (
                 "pregunta por reembolso tras una cancelación -- es una "
                 "consulta sobre las condiciones de la política, no una "
                 "acción de reservar ni de comparar precios"
             ),
         }},
    ],
}

decision_2 = extract_routing_decision(concept_turn_2)
print("petición:", repr(NEW_AMBIGUOUS))
print(f"target: {decision_2.target!r}")
print(f"reason: {decision_2.reason!r}")

Salida esperada:

petición: '¿Me devuelven la plata si tuve que cancelar por una emergencia?'
target: 'policy_agent'
reason: 'pregunta por reembolso tras una cancelación -- es una consulta sobre las condiciones de la política, no una acción de reservar ni de comparar precios'

Explicación: igual que el ejemplo trabajado, "cancelar" aparece en la frase pero la intención real —preguntar por un reembolso— vive en policy_agent. extract_routing_decision funciona idéntico sin importar el contenido del turno, siempre que respete el formato tool_use con route_to_agent.

Ejercicio 3: Costo diario, híbrido vs. siempre-modelo (Difícil)

Escribe daily_routing_cost(total_requests, pct_needs_llm, always_llm=False), que devuelve cuántas llamadas al modelo se gastan por día en rutear, si always_llm=True (todas las peticiones pasan por el modelo) o si solo el pct_needs_llm de ellas lo necesita (el resto las resuelve el router determinista gratis). Ejecutala con 100 y con 5000 peticiones diarias, asumiendo que un 15% son ambiguas.

Ver solución
def daily_routing_cost(total_requests, pct_needs_llm, always_llm=False):
    if always_llm:
        return total_requests
    return round(total_requests * pct_needs_llm)


for total in (100, 5000):
    always = daily_routing_cost(total, pct_needs_llm=1.0, always_llm=True)
    hybrid = daily_routing_cost(total, pct_needs_llm=0.15)
    print(f"{total:5} peticiones/día -> siempre-LLM: {always:5} llamadas  |  híbrido (15% ambiguas): {hybrid:5} llamadas  |  ahorro: {always - hybrid}")

Salida esperada:

  100 peticiones/día -> siempre-LLM:   100 llamadas  |  híbrido (15% ambiguas):    15 llamadas  |  ahorro: 85
 5000 peticiones/día -> siempre-LLM:  5000 llamadas  |  híbrido (15% ambiguas):   750 llamadas  |  ahorro: 4250

Explicación: el ahorro escala linealmente con el volumen — a 5000 peticiones diarias, rutear todo por decisión del modelo cuesta 4250 llamadas de más que un híbrido bien diseñado. Este cálculo es exactamente el tipo de cuenta que la lección 07 formaliza en un router híbrido real, no solo estimado: la decisión de "cuándo vale la pena pagar el modelo" depende del volumen real de tu sistema, no de una preferencia abstracta por un mecanismo sobre el otro.


Resumen y siguiente paso

  • Cuando route_deterministic falla con confianza —no con un None—, la alternativa es que el modelo lea la frase completa y decida, usando una tool de ruteo estructurada (route_to_agent) con el mismo protocolo tool_use de siempre.
  • La decisión sigue siendo concepto (claude-sonnet-5, guionada a mano); lo que sí se ejecuta es extract_routing_decision, la función real que parsea el turno y arma la RoutingDecision.
  • Sobre la misma petición ambigua de la lección 03, el ruteo determinista devolvió booking_agent (incorrecto, costo 0); el ruteo por decisión del modelo devolvió policy_agent (correcto, costo 1 llamada).
  • Ninguno de los dos mecanismos es superior en abstracto — la lección 07 mide cuándo cada uno compensa su costo.

Siguiente lección: 05 — Construyendo pricing_agent. Antes de armar el dispatcher completo, construimos el tercer especialista como agente real, ejecutado por primera vez de punta a punta.


Recursos adicionales

  1. Anthropic — Tool use (function calling) overview — El protocolo tool_use/tool_result que ROUTE_TOOL reutiliza para estructurar una decisión de ruteo, no una acción sobre datos de Reservo.
  2. Anthropic — Building effective agents — El patrón "routing" con un clasificador basado en el modelo, la contraparte de las reglas de la lección 03.
  3. Anthropic — Messages API reference — La forma exacta de un bloque tool_use, la estructura que extract_routing_decision parsea.
  4. Python — dataclasses — El módulo detrás de RoutingDecision, reusado sin cambios de la lección 02.