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ón | 0 llamadas al modelo | 1 llamada al modelo |
| Latencia | Instantánea | La de una llamada real a la API |
| Comprensión de sentido | Ninguna -- solo palabras sueltas | Completa -- lee la frase entera |
| Reproducibilidad | 100% -- misma entrada, misma salida siempre | No garantizada -- dos corridas pueden variar |
| Auditable/testeable | Totalmente -- se puede probar cada regla | Parcial -- depende de evals sobre el modelo |
| Cobertura | Limitada al vocabulario anticipado | Generaliza a frases nunca vistas |
| Falla típica | Silenciosa (confiada) o None | Rara, pero posible -- no es infalible |
Errores comunes
-
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.
-
Confundir el
enumdeROUTE_TOOLcon una validación de que la decisión sea correcta. Elenumsolo 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. -
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ó.
-
Olvidar que
extract_routing_decisionasume que el modelo SIEMPRE devuelvetool_use. Elassertde la función fallaría si elconcept_turntuviera, 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). -
Pensar que esta lección "arregla" el router determinista. No lo arregla — lo complementa.
route_deterministicsigue 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_deterministicfalla con confianza —no con unNone—, la alternativa es que el modelo lea la frase completa y decida, usando una tool de ruteo estructurada (route_to_agent) con el mismo protocolotool_usede siempre. - La decisión sigue siendo concepto (
claude-sonnet-5, guionada a mano); lo que sí se ejecuta esextract_routing_decision, la función real que parsea el turno y arma laRoutingDecision. - 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
- Anthropic — Tool use (function calling) overview — El protocolo
tool_use/tool_resultqueROUTE_TOOLreutiliza para estructurar una decisión de ruteo, no una acción sobre datos de Reservo. - 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.
- Anthropic — Messages API reference — La forma exacta de un bloque
tool_use, la estructura queextract_routing_decisionparsea. - Python —
dataclasses— El módulo detrás deRoutingDecision, reusado sin cambios de la lección 02.