Módulo 4: Fan-out paralelo y agregación
Identificando sub-tareas independientes
Descripción
Antes de repartir cualquier trabajo, hace falta confirmar que de verdad se puede repartir. Esta lección construye esa confirmación, ejecutada: dado un texto compuesto como "Cotiza Focus pro 3h y dime la política de cancelación", vas a separar las dos preguntas, correrlas en dos órdenes distintos —primero cotizar y después la política, y también al revés—, y comprobar que el resultado de cada una no cambia sin importar en qué orden corrieron. Esa invariancia al orden es, en los hechos, la definición operativa de "independiente" que usa el resto del módulo.
El contraste que cierra la lección es igual de importante: vas a tomar la etapa confirm del
pipeline de Reservo (Módulo 3) e intentar armar su tarea sin que la etapa quote haya corrido
antes. Ahí sí falla — con un KeyError real, no una opinión —, porque confirm necesita un dato
que solo quote produce. Esa falla es la prueba, por el lado contrario, de que el pipeline SÍ tiene
una dependencia real, mientras que las dos sub-tareas de este módulo no.
Conexión con el módulo
Esta lección no construye ningún mecanismo nuevo de despacho todavía —eso empieza en la lección 03—. Construye el criterio que justifica todo lo que sigue: sin esta prueba, "reparte y agrega" sería una receta sin fundamento. La lección 03 asume, sin volver a probarlo, que las sub-tareas que llegan ya pasaron por este criterio.
Analogía: la orden de trabajo no cambia si la lees de atrás para adelante
Retoma la orden de trabajo física de la lección 03 del Módulo 3 — la que viaja con la pieza en la línea de ensamblaje, con las medidas exactas del corte. Esa orden solo tiene sentido en un orden: leerla de atrás para adelante (soldar antes de que exista el corte) no produce nada útil, porque el dato que soldar necesita todavía no existe.
Ahora imagina dos formularios sueltos, sin relación entre sí: uno pide cotizar una sala, el otro pide una política de cancelación. Da exactamente lo mismo cuál llenes primero — ninguno necesita ver el contenido del otro para completarse. Leerlos "de atrás para adelante" (el de política primero, el de cotización después) produce el mismo resultado que leerlos en el orden original. Esa prueba de lectura invertida es, literalmente, lo que ejecuta esta lección.
Ejemplo trabajado: dos órdenes, un mismo resultado
import concurrent.futures
import reservo_tools as rt
def dispatch_parallel(tool_use_blocks, tools):
with concurrent.futures.ThreadPoolExecutor(max_workers=len(tool_use_blocks)) as pool:
futures = [pool.submit(tools[b["name"]], **b["input"]) for b in tool_use_blocks]
results = [f.result() for f in futures]
return [
{"type": "tool_result", "tool_use_id": b["id"], "content": str(r)}
for b, r in zip(tool_use_blocks, results)
]
def run_agent_parallel(question, model_script, tools, max_iterations=10):
messages = [{"role": "user", "content": question}]
for step in range(max_iterations):
turn = model_script[step]
messages.append({"role": "assistant", "content": turn["content"]})
if turn["stop_reason"] != "tool_use":
return turn, messages
tool_result_blocks = dispatch_parallel(turn["content"], tools)
messages.append({"role": "user", "content": tool_result_blocks})
raise RuntimeError(f"max_iterations alcanzado ({max_iterations})")
POLICY_DOCS = {
"no-show-policy": (
"Si un miembro no se presenta a una reserva confirmada y no cancela "
"con al menos 2 horas de anticipación, Reservo cobra el 50% del "
"precio cotizado como cargo por no-presentación."
),
"cancellation-policy": (
"Las reservas se pueden cancelar sin cargo hasta 2 horas antes del "
"horario reservado. Cancelaciones dentro de esas 2 horas aplican "
"el cargo de no-presentación."
),
}
def search_docs(query):
q = query.lower()
if "cancela" in q:
return f"[cancellation-policy] {POLICY_DOCS['cancellation-policy']}"
if "no" in q and ("present" in q or "show" in q):
return f"[no-show-policy] {POLICY_DOCS['no-show-policy']}"
return "No se encontró una política relevante para esa pregunta."
SPECIALISTS = {
"booking_agent": {
"tools": {
"list_rooms": rt.list_rooms, "get_quote": rt.get_quote,
"book_room": rt.book_room, "cancel_booking": rt.cancel_booking,
},
"expertise": "cotizar, reservar y cancelar salas",
},
"policy_agent": {
"tools": {"search_docs": search_docs},
"expertise": "responder preguntas de política (cancelación, no-presentación)",
},
}
def run_specialist(name, task, model_script):
tools = SPECIALISTS[name]["tools"]
return run_agent_parallel(task, model_script, tools)
COMPOUND_REQUEST = "Cotiza Focus pro 3h y dime la política de cancelación."
SUBTASK_BOOKING = "Cotiza Focus pro 3h."
SUBTASK_POLICY = "¿Cuál es la política de cancelación?"
model_script_booking = [
{"stop_reason": "tool_use", "content": [
{"type": "tool_use", "id": "toolu_01", "name": "get_quote",
"input": {"room": "Focus", "tier": "pro", "hours": 3}}]},
{"stop_reason": "end_turn", "content": [
{"type": "text", "text": "Focus pro 3h cuesta 6000 centavos."}]},
]
model_script_policy = [
{"stop_reason": "tool_use", "content": [
{"type": "tool_use", "id": "toolu_01", "name": "search_docs",
"input": {"query": "política de cancelación"}}]},
{"stop_reason": "end_turn", "content": [
{"type": "text", "text": (
"Puedes cancelar sin cargo hasta 2 horas antes del horario "
"reservado. Después de eso aplica el cargo de no-presentación."
)}]},
]
print("--- petición compuesta ---")
print(repr(COMPOUND_REQUEST))
print("sub-tarea A (booking_agent):", repr(SUBTASK_BOOKING))
print("sub-tarea B (policy_agent): ", repr(SUBTASK_POLICY))
print()
print("--- orden A: booking_agent primero, policy_agent después ---")
final_b1, hist_b1 = run_specialist("booking_agent", SUBTASK_BOOKING, model_script_booking)
final_p1, hist_p1 = run_specialist("policy_agent", SUBTASK_POLICY, model_script_policy)
print("booking_agent ->", final_b1["content"][0]["text"])
print("policy_agent ->", final_p1["content"][0]["text"])
print()
print("--- orden B: policy_agent primero, booking_agent después (invertido) ---")
final_p2, hist_p2 = run_specialist("policy_agent", SUBTASK_POLICY, model_script_policy)
final_b2, hist_b2 = run_specialist("booking_agent", SUBTASK_BOOKING, model_script_booking)
print("policy_agent ->", final_p2["content"][0]["text"])
print("booking_agent ->", final_b2["content"][0]["text"])
same = (
final_b1["content"][0]["text"] == final_b2["content"][0]["text"]
and final_p1["content"][0]["text"] == final_p2["content"][0]["text"]
)
print()
print("¿mismo resultado sin importar el orden en que corrieron?", same)
Qué esperar:
--- petición compuesta ---
'Cotiza Focus pro 3h y dime la política de cancelación.'
sub-tarea A (booking_agent): 'Cotiza Focus pro 3h.'
sub-tarea B (policy_agent): '¿Cuál es la política de cancelación?'
--- orden A: booking_agent primero, policy_agent después ---
booking_agent -> Focus pro 3h cuesta 6000 centavos.
policy_agent -> Puedes cancelar sin cargo hasta 2 horas antes del horario reservado. Después de eso aplica el cargo de no-presentación.
--- orden B: policy_agent primero, booking_agent después (invertido) ---
policy_agent -> Puedes cancelar sin cargo hasta 2 horas antes del horario reservado. Después de eso aplica el cargo de no-presentación.
booking_agent -> Focus pro 3h cuesta 6000 centavos.
¿mismo resultado sin importar el orden en que corrieron? True
run_specialist es exactamente el de los Módulos 2 y 3, sin ningún cambio — la novedad de esta
lección no está en el runner, está en el patrón de la prueba: correr las mismas dos sub-tareas en
las dos secuencias posibles y confirmar, con ==, que el texto de cada una es idéntico
independientemente de cuál corrió primero. Ni booking_agent ni policy_agent leyeron nada del
resultado del otro — cada uno solo vio su propia sub-tarea (SUBTASK_BOOKING o SUBTASK_POLICY),
un string fijo que no cambia según lo que haya pasado antes.
El contraste: la etapa confirm de un pipeline SÍ depende del orden
print()
print("--- contraste: la etapa 'confirm' de un pipeline (M3) SÍ depende del orden ---")
def build_stage_task_confirm(payload):
"""Igual que en el Módulo 3: la etapa 'confirm' necesita price_cents,
que solo existe si la etapa 'quote' ya corrió antes."""
return (f"Reserva {payload['room']} {payload['tier']} {payload['hours']}h "
f"-- cotizado en {payload['price_cents']} centavos.")
payload_sin_cotizar = {"room": "Focus", "tier": "pro", "hours": 3}
try:
build_stage_task_confirm(payload_sin_cotizar)
except KeyError as e:
print(f"KeyError capturado: {e!r} -- 'confirm' necesita el resultado de 'quote'")
Qué esperar:
--- contraste: la etapa 'confirm' de un pipeline (M3) SÍ depende del orden ---
KeyError capturado: KeyError('price_cents') -- 'confirm' necesita el resultado de 'quote'
Ahí está la diferencia, confirmada con dos fallas de tipo opuesto. Las sub-tareas de fan-out de esta
lección corrieron en los dos órdenes sin ningún error y con el mismo resultado exacto. La
etapa confirm del pipeline, en cambio, ni siquiera pudo armar su tarea sin el dato que dejó la
etapa anterior — un KeyError inmediato, porque payload['price_cents'] no existe todavía. Esa es,
en código real y no en intuición, la prueba de que "cotizar y confirmar" (M3) es una dependencia
genuina, mientras que "cotizar y preguntar por la política" (este módulo) no lo es.
La regla operativa: ¿cómo saber si dos sub-tareas son independientes?
Antes de repartir cualquier petición compuesta en sub-tareas, aplica esta prueba:
- ¿La tarea de la sub-tarea B menciona algún dato que solo produce la sub-tarea A? Si
build_stage_task(o el texto que armes a mano) necesita leerpayload['algo']que otra sub-tarea todavía no generó, no es independiente — es una dependencia, y el patrón correcto es el pipeline (M3), no el fan-out. - ¿El resultado de B cambia si corre antes o después de A? Si no cambia —como demostró el ejemplo trabajado—, las dos son candidatas genuinas a fan-out.
- ¿Las dos sub-tareas van al MISMO especialista, con pasos que se acumulan? "Cotiza Focus pro
3h y resérvala" es un solo agente (
booking_agent) con dos pasos donde el segundo (book_room) necesita el precio del primero (get_quote) — dependencia real, dentro de un mismo agente. El Ejercicio 3 de esta lección lo confirma ejecutando.
Errores comunes
-
Confundir "tiene una 'y' en el medio" con "es independiente". "Cotiza Focus pro 3h y resérvala para Ana" también tiene una "y", pero la segunda mitad depende de la primera. La conjunción no dice nada sobre independencia — solo el contenido real de cada sub-tarea lo dice.
-
Probar la independencia corriendo las sub-tareas UNA sola vez, en UN solo orden. Correr en un solo orden nunca prueba nada sobre independencia — podrías tener suerte y que el resultado "parezca" correcto aunque hubiera una dependencia oculta. La prueba real exige el orden invertido, como hizo el ejemplo trabajado.
-
Pensar que el
KeyErrordel contraste es un error del código, no una prueba intencional. Esa excepción está ahí a propósito — es la evidencia de queconfirmSÍ depende dequote. Si en cambio no fallara nada, la conclusión sería la contraria: que la etapa no tiene, en realidad, ninguna dependencia real (un caso que valdría la pena revisar en un pipeline de verdad). -
Aplicar el criterio de independencia a nivel de PALABRAS, no de DATOS. Dos sub-tareas pueden compartir palabras (ambas mencionan "Focus") sin compartir ningún dato real entre sí. Lo que importa es si una necesita leer algo que la otra produjo, no si suenan parecidas.
-
Olvidar que la independencia se prueba ANTES de construir el mecanismo de despacho. Las lecciones 03 y 04 de este módulo asumen, sin volver a probarlo, que las sub-tareas que reciben ya pasaron por este criterio — repartir sub-tareas que en realidad dependen entre sí produciría resultados incorrectos sin que el código lo detecte.
Ejercicios
Ejercicio 1: Aplica el criterio sin ejecutar nada (Fácil)
Para cada una de estas tres peticiones, decide si sus dos partes son independientes (candidatas a fan-out) o dependientes (necesitan un pipeline): (a) "¿Cuánto cuesta Studio pro 2h y qué salas hay disponibles?"; (b) "Reserva Boardroom pro 4h para Sofía y después cancélala."; (c) "Dime la política de no-presentación y compara Focus contra Studio, pro, 3h."
Ver solución
(a) Independientes. "Cuánto cuesta Studio pro 2h" (una cotización) y "qué salas hay disponibles"
(list_rooms) no comparten ningún dato — ninguna necesita el resultado de la otra. Candidata a
fan-out entre dos sub-tareas de booking_agent.
(b) Dependientes. No puedes cancelar una reserva que todavía no existe — "cancélala" necesita el
booking_id que "reserva... para Sofía" recién va a producir. Es exactamente el patrón del
Ejercicio 3: un pipeline de dos etapas dentro del mismo agente, no fan-out.
(c) Independientes. La política de no-presentación y la comparación de precios entre Focus y
Studio son preguntas completamente separadas, a especialistas distintos (policy_agent y
pricing_agent) — ninguna necesita nada de la otra.
Ejercicio 2: Prueba la independencia de una nueva petición compuesta (Medio)
Repite el patrón del ejemplo trabajado —correr en los dos órdenes y comparar— para la petición "Cotiza Boardroom pro 2h y dime la política de cancelación.".
Ver solución
SUBTASK_BOOKING = "Cotiza Boardroom pro 2h."
SUBTASK_POLICY = "¿Cuál es la política de cancelación?"
model_script_booking = [
{"stop_reason": "tool_use", "content": [
{"type": "tool_use", "id": "toolu_01", "name": "get_quote",
"input": {"room": "Boardroom", "tier": "pro", "hours": 2}}]},
{"stop_reason": "end_turn", "content": [
{"type": "text", "text": "Boardroom pro 2h cuesta 12800 centavos."}]},
]
model_script_policy = [
{"stop_reason": "tool_use", "content": [
{"type": "tool_use", "id": "toolu_01", "name": "search_docs",
"input": {"query": "política de cancelación"}}]},
{"stop_reason": "end_turn", "content": [
{"type": "text", "text": "Puedes cancelar sin cargo hasta 2 horas antes del horario reservado."}]},
]
final_b1, _ = run_specialist("booking_agent", SUBTASK_BOOKING, model_script_booking)
final_p1, _ = run_specialist("policy_agent", SUBTASK_POLICY, model_script_policy)
final_p2, _ = run_specialist("policy_agent", SUBTASK_POLICY, model_script_policy)
final_b2, _ = run_specialist("booking_agent", SUBTASK_BOOKING, model_script_booking)
print("orden [booking, policy]:", final_b1["content"][0]["text"], "|", final_p1["content"][0]["text"])
print("orden [policy, booking]:", final_p2["content"][0]["text"], "|", final_b2["content"][0]["text"])
print("Boardroom pro 2h a mano:", 8000 * 2 * 80 // 100)
print("¿coinciden sin importar el orden?",
final_b1["content"][0]["text"] == final_b2["content"][0]["text"]
and final_p1["content"][0]["text"] == final_p2["content"][0]["text"])
Salida esperada:
orden [booking, policy]: Boardroom pro 2h cuesta 12800 centavos. | Puedes cancelar sin cargo hasta 2 horas antes del horario reservado.
orden [policy, booking]: Puedes cancelar sin cargo hasta 2 horas antes del horario reservado. | Boardroom pro 2h cuesta 12800 centavos.
Boardroom pro 2h a mano: 12800
¿coinciden sin importar el orden? True
Explicación: 12800 = 8000 * 2 * 80 // 100, el mismo cálculo de siempre para pro. El
resultado confirma, con datos distintos a los del ejemplo trabajado, que el criterio de
independencia no depende de qué sala ni de qué precio se pida — depende únicamente de si las
sub-tareas comparten un dato real.
Ejercicio 3: Confirma que "cotiza y resérvala" NO es fan-out (Difícil)
Ejecuta "Cotiza Focus pro 3h y resérvala para Ana." como una sola tarea de booking_agent (dos tool
calls en secuencia, no dos agentes). Explica por qué esto demuestra que la sub-tarea de reservar
depende genuinamente de la de cotizar, y por qué NO deberías intentar repartir esta petición como
fan-out entre dos invocaciones separadas.
Ver solución
TASK_NOT_INDEPENDENT = "Cotiza Focus pro 3h y resérvala para Ana."
model_script_dependent = [
{"stop_reason": "tool_use", "content": [
{"type": "tool_use", "id": "toolu_01", "name": "get_quote",
"input": {"room": "Focus", "tier": "pro", "hours": 3}}]},
{"stop_reason": "tool_use", "content": [
{"type": "tool_use", "id": "toolu_02", "name": "book_room",
"input": {"room": "Focus", "tier": "pro", "hours": 3, "member": "Ana"}}]},
{"stop_reason": "end_turn", "content": [
{"type": "text", "text": "Focus pro 3h cuesta 6000 centavos. Reservé la sala para Ana (confirmación #1)."}]},
]
final_dep, hist_dep = run_specialist("booking_agent", TASK_NOT_INDEPENDENT, model_script_dependent)
print("tarea:", repr(TASK_NOT_INDEPENDENT))
print("respuesta:", final_dep["content"][0]["text"])
print("¿cuántos agentes distintos hicieron falta?", 1, "-- booking_agent, con DOS tool calls EN SECUENCIA")
print("¿book_room pudo correr ANTES que get_quote?", "No -- necesita el price_cents que get_quote calculó")
Salida esperada:
tarea: 'Cotiza Focus pro 3h y resérvala para Ana.'
respuesta: Focus pro 3h cuesta 6000 centavos. Reservé la sala para Ana (confirmación #1).
¿cuántos agentes distintos hicieron falta? 1 -- booking_agent, con DOS tool calls EN SECUENCIA
¿book_room pudo correr ANTES que get_quote? No -- necesita el price_cents que get_quote calculó
Explicación: el guion de model_script_dependent NO tiene dos tool_use en el mismo turno
(como sí tenía pricing_agent comparando tres salas) — tiene dos turnos separados, uno después
del otro, porque book_room internamente reutiliza get_quote (Módulo agent-fundamentals,
lección de las cuatro tools) y su resultado depende de que la cotización ya haya corrido. Repartir
esto como fan-out —invocar book_room y get_quote en hilos separados, esperando que ambos
terminen "a la vez"— no ahorraría nada y podría, en un caso real con datos que sí cambian entre
corridas, producir una reserva con el precio equivocado. El patrón correcto para esta petición es
una secuencia dependiente dentro de un mismo agente, exactamente lo que ya resuelve
agent-fundamentals, no un fan-out de este módulo.
Resumen y siguiente paso
- La prueba operativa de independencia es ejecutar las sub-tareas en los dos órdenes posibles y confirmar que el resultado de cada una no cambia — si cambia, o si una falla por falta de un dato, no son independientes.
- El contraste con la etapa
confirmdel pipeline de Reservo lo confirmó por el lado opuesto: unKeyErrorreal, porque esa etapa SÍ necesita el resultado de la etapaquote. - "Cotiza Focus pro 3h y resérvala" no es fan-out — es una dependencia real dentro de un mismo
agente, el mismo patrón que ya resuelve
agent-fundamentalssin necesitar este módulo. - Con el criterio de independencia confirmado y ejecutado, el resto del módulo puede asumir, sin volver a probarlo, que las sub-tareas que recibe ya lo pasaron.
Siguiente lección: 03 — El caso base: orden fijo enumerado. Construimos SubTask y
run_fanout_sequential, el mecanismo que despacha sub-tareas independientes una después de la
otra, en un orden fijo y determinista.
Recursos adicionales
- Anthropic — Building effective agents — El patrón "parallelization" exige, como primer paso, identificar sub-tareas que se puedan dividir sin dependencias — exactamente el criterio que ejecuta esta lección.
- Python —
ast.literal_evaly el mecanismo de payload — El módulodataclasses, reusado aquí para pensar en el payload de un pipeline como el contraejemplo de independencia. - Anthropic — Tool use (function calling) overview — El protocolo que
run_specialistsigue respetando, sin cambios, en cualquiera de los dos órdenes de esta lección. - Python — Excepciones (
KeyError) — La excepción que confirma, en código real, la dependencia de la etapaconfirmdel pipeline.