Módulo 4: Fan-out paralelo y agregación
Midiendo el ahorro de latencia en rondas
Descripción
Las lecciones 03 a 06 dejaron dos caminos completos para la misma petición compuesta:
run_fanout_sequential, que despacha una sub-tarea después de la otra, y run_fanout_parallel, que
las despacha a la vez con ThreadPoolExecutor. Esta lección responde la pregunta que sostiene la
existencia de la lección 06: ¿cuánto se ahorra, de verdad, corriendo en paralelo?
La respuesta tiene una parte que sorprende si vienes del Módulo 3: el fan-out no ahorra ninguna
llamada al modelo. Las mismas llamadas —el split, las internas de cada especialista, la
síntesis— pasan en los dos caminos, exactamente las mismas. Lo que cambia es cuántas rondas
hacen falta para completarlas: en secuencial, las rondas se suman; en paralelo, quedan
acotadas por la sub-tarea que más necesita, porque las demás avanzan al mismo tiempo. Esta
lección mide esa diferencia con un conteo de rondas —nunca con time.time() ni ningún reloj real—,
y construye una fórmula que generaliza el resultado a cualquier número de sub-tareas.
Conexión con el módulo
Esta lección reusa los números ya producidos en la lección 04 (llamadas internas de cada
especialista) y el mecanismo de la lección 06 (run_fanout_parallel), sin modificar ninguno de los
dos. Lo nuevo es el modelo de costo —fanout_rounds— que convierte esos números en una comparación
de rondas. La lección 08 (mini-proyecto) aplica esta misma fórmula a escenarios nuevos, incluido uno
de tres agentes.
Analogía: tres tareas de cocina, una sola cocina o tres cocinas
Imagina preparar tres platos que no dependen entre sí — una ensalada, una sopa, un postre— cada uno con sus propios pasos. Con una sola cocina (secuencial), preparas la ensalada completa, después la sopa completa, después el postre completo: el tiempo total es la suma de los tres. Con tres cocinas (paralelo), pones a alguien en cada una a la vez: el tiempo total ya no es la suma — es el tiempo del plato más lento de los tres, porque los otros dos ya estarían listos cuando ese termine. Si la ensalada toma 2 pasos, la sopa 2 pasos y el postre 2 pasos, con una cocina son 6 pasos en total; con tres cocinas, son 2 pasos — acotado por el más lento, que en este caso son los tres por igual.
Ejemplo trabajado: rondas secuencial vs. paralelo, con números reales
import concurrent.futures
from dataclasses import dataclass
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 = {
"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']}"
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,
},
},
"policy_agent": {"tools": {"search_docs": search_docs}},
}
def run_specialist(name, task, model_script):
tools = SPECIALISTS[name]["tools"]
return run_agent_parallel(task, model_script, tools)
def count_model_calls(history):
return sum(1 for m in history if m["role"] == "assistant")
@dataclass
class SubTask:
agent: str
task: str
def run_fanout_sequential(subtasks, model_scripts):
ordered = sorted(subtasks, key=lambda s: s.agent)
results = {}
for sub in ordered:
final, history = run_specialist(sub.agent, sub.task, model_scripts[sub.agent])
results[sub.agent] = {"task": sub.task, "output": final["content"][0]["text"], "history": history}
return results
subtasks = [
SubTask(agent="booking_agent", task="Cotiza Focus pro 3h."),
SubTask(agent="policy_agent", task="¿Cuál es la política de cancelación?"),
]
model_scripts = {
"booking_agent": [
{"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."}]},
],
"policy_agent": [
{"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."
)}]},
],
}
results = run_fanout_sequential(subtasks, model_scripts)
call_counts = {a: count_model_calls(results[a]["history"]) for a in results}
print("--- llamadas internas por especialista (idénticas en ambos caminos) ---")
for a in sorted(call_counts):
print(f" {a:15} -> {call_counts[a]} llamadas al modelo")
FANOUT_SPLIT_CALLS = 1 # concepto: el supervisor reconoce las sub-tareas -- pasa UNA vez, antes de dispatchar
COMPOSE_CALLS = 1 # concepto: la síntesis final -- pasa UNA vez, DESPUÉS de que ambas terminen
HOPS_PER_SPECIALIST = 2 # convención M2 L06 / M3 L05: ida y vuelta a cada especialista
def fanout_rounds(call_counts_list, surrounding_calls):
"""Una 'ronda' es un paso de ida y vuelta con el modelo -- NUNCA tiempo
real de reloj (no hay time.time() en esta guía). Secuencial: cada
especialista espera a que el anterior termine, así que las rondas se
SUMAN. Paralelo: los especialistas corren a la vez, así que las rondas
quedan acotadas por el que MÁS necesita -- max(), no sum()."""
sequential = sum(call_counts_list) + surrounding_calls
parallel = max(call_counts_list) + surrounding_calls
return sequential, parallel
counts_list = list(call_counts.values())
seq_rounds, par_rounds = fanout_rounds(counts_list, FANOUT_SPLIT_CALLS + COMPOSE_CALLS)
total_calls = sum(counts_list) + FANOUT_SPLIT_CALLS + COMPOSE_CALLS # el TOTAL de llamadas es igual en los dos caminos
total_hops = HOPS_PER_SPECIALIST * len(counts_list) # los hops tampoco cambian
print()
print(f"{'':32}{'fan-out secuencial':>20}{'fan-out paralelo':>20}")
print(f"{'llamadas al modelo TOTAL':32}{total_calls:>20}{total_calls:>20}")
print(f"{'hops entre agentes':32}{total_hops:>20}{total_hops:>20}")
print(f"{'rondas de coordinación':32}{seq_rounds:>20}{par_rounds:>20}")
rounds_saved = seq_rounds - par_rounds
pct_saved = round(100 * rounds_saved / seq_rounds)
print()
print(f"diferencia: MISMAS {total_calls} llamadas al modelo y MISMOS {total_hops} hops en los dos "
f"caminos -- lo único que cambia son las RONDAS: {rounds_saved} menos con concurrencia real "
f"({pct_saved}% menos), porque booking_agent y policy_agent avanzan sus 2 rondas internas "
f"a la vez, en vez de una detrás de la otra.")
Qué esperar:
--- llamadas internas por especialista (idénticas en ambos caminos) ---
booking_agent -> 2 llamadas al modelo
policy_agent -> 2 llamadas al modelo
fan-out secuencial fan-out paralelo
llamadas al modelo TOTAL 6 6
hops entre agentes 4 4
rondas de coordinación 6 4
diferencia: MISMAS 6 llamadas al modelo y MISMOS 4 hops en los dos caminos -- lo único que cambia son las RONDAS: 2 menos con concurrencia real (33% menos), porque booking_agent y policy_agent avanzan sus 2 rondas internas a la vez, en vez de una detrás de la otra.
Ahí está el resultado central de la lección: 6 = 6 llamadas al modelo, 4 = 4 hops — ninguno
de los dos caminos ahorra ni una sola llamada ni un solo hop, porque las mismas decisiones tienen
que tomarse de todos modos. La única columna que difiere es la de rondas: 6 en secuencial (el
split, más las 2+2 llamadas internas sumadas, más la síntesis), 4 en paralelo (el split, más
max(2, 2) = 2 porque las dos corren a la vez, más la síntesis).
De dónde sale cada número
Secuencial:
1 split -- pasa una vez, antes de que nada se despache
2 + 2 = 4 -- booking_agent y policy_agent, UNO DESPUÉS DEL OTRO (se suman)
1 síntesis -- pasa una vez, después de que la última termine
------------------------------------------------------------
6 RONDAS
Paralelo:
1 split -- IDÉNTICO -- sigue pasando una vez, antes del despacho
max(2, 2) = 2 -- booking_agent y policy_agent avanzan SUS 2 rondas A LA VEZ
1 síntesis -- IDÉNTICO -- sigue esperando a que AMBOS terminen
------------------------------------------------------------
4 RONDAS
El split y la síntesis no se paralelizan con nada — el split tiene que pasar antes de que exista
ninguna sub-tarea que despachar, y la síntesis tiene que esperar a que todas las sub-tareas
hayan terminado (la lección 04 ya lo confirmó con el KeyError de compose_fanout_response sobre
resultados incompletos). Por eso surrounding_calls se suma igual en los dos caminos — lo único
que el max() en vez de sum() cambia es la parte que sí corre a la vez: las llamadas internas de
los especialistas.
Por qué el fan-out no ahorra llamadas (a diferencia del pipeline)
Vale la pena comparar esto con el Módulo 3, lección 05, que sí medía un ahorro de llamadas: ahí, un pipeline ahorraba exactamente una llamada de ruteo por etapa, porque el orden fijo eliminaba una decisión que un supervisor repetido sí tendría que pagar. Acá no hay ninguna decisión que eliminar — el split (reconocer que hay dos sub-tareas independientes) tiene que pasar de todos modos, tanto si las despachas en secuencia como en paralelo. El fan-out no reduce cuánto trabajo de decisión hace falta; reduce cuánto tiempo de reloj-conceptual (rondas) toma completar ese mismo trabajo, porque parte de él deja de esperar en fila.
Errores comunes
-
Buscar un ahorro de llamadas al modelo donde no lo hay. El resultado de esta lección no es "el fan-out es más barato" — es "el fan-out es más rápido, al mismo costo". Confundir los dos lleva a esperar un número que este patrón, por diseño, no produce.
-
Usar
sum()para el camino paralelo, omax()para el secuencial. Es el error de fórmula más directo — invertirlos produce números sin sentido (un paralelo "más lento" que el secuencial, por ejemplo). La regla es fija: secuencial suma, paralelo acota por el máximo. -
Olvidar sumar
surrounding_callsen los dos lados por igual. El split y la síntesis no desaparecen en ningún camino — omitirlos de uno de los dos lados de la comparación exagera el ahorro real. -
Generalizar el 33% de este ejemplo a cualquier fan-out. El porcentaje depende de cuántas llamadas internas tiene cada sub-tarea y de cuántas sub-tareas hay. Lo que sí generaliza es la fórmula (
sum()vs.max()) — el Ejercicio 1 la aplica a un caso de tres sub-tareas con un resultado distinto. -
Pensar que el ahorro en rondas es lo mismo que un ahorro de tiempo real de reloj. No lo es — esta guía nunca mide
time.time(). "Rondas" es un conteo discreto de pasos de coordinación, útil para comparar patrones entre sí, no una medición de latencia de una API real.
Ejercicios
Ejercicio 1: Fan-out de tres agentes, cada uno con 2 llamadas internas (Fácil)
Usando fanout_rounds, calcula las rondas secuencial y paralelo para un fan-out de tres sub-tareas,
cada una con 2 llamadas internas (como booking_agent, policy_agent y pricing_agent en el
mini-proyecto de la lección 08), con los mismos surrounding_calls = 2 del ejemplo trabajado.
Ver solución
seq_3, par_3 = fanout_rounds([2, 2, 2], surrounding_calls=2)
print(f"rondas secuencial: {seq_3} | rondas paralelo: {par_3} | ahorradas: {seq_3 - par_3}")
Salida esperada:
rondas secuencial: 8 | rondas paralelo: 4 | ahorradas: 4
Explicación: secuencial suma las tres (2+2+2=6) más los 2 surrounding_calls = 8. Paralelo
toma el máximo (max(2,2,2)=2) más los mismos 2 surrounding_calls = 4. El ahorro creció de 2
(con dos sub-tareas) a 4 (con tres) — cuantas más sub-tareas corran a la vez, mayor el ahorro en
rondas, mientras todas tengan un costo interno parecido.
Ejercicio 2: Dos agentes con costos internos DISTINTOS (Medio)
Calcula las rondas secuencial y paralelo para dos sub-tareas con costos internos de 2 y 3 llamadas
respectivamente (imagina que policy_agent necesitó un paso extra de refinamiento en su
búsqueda), con surrounding_calls = 2.
Ver solución
seq_2, par_2 = fanout_rounds([2, 3], surrounding_calls=2)
print(f"rondas secuencial: {seq_2} | rondas paralelo: {par_2} | ahorradas: {seq_2 - par_2}")
print("nota: el paralelo queda ACOTADO por el especialista más lento (3), no por el promedio")
Salida esperada:
rondas secuencial: 7 | rondas paralelo: 5 | ahorradas: 2
nota: el paralelo queda ACOTADO por el especialista más lento (3), no por el promedio
Explicación: el paralelo (5 = max(2,3) + 2) queda determinado por la sub-tarea más lenta
(3 llamadas), no por un promedio de las dos (que sería 2.5). Esto es importante para el diseño real:
agregar sub-tareas rápidas a un fan-out donde ya hay una lenta no reduce el tiempo total — solo
agregar trabajo que corra en el tiempo que la más lenta ya estaba usando es "gratis" en rondas.
Ejercicio 3: Ahorro diario sobre 500 peticiones compuestas (Difícil)
Reservo recibe 500 peticiones compuestas por día, cada una con la misma forma del ejemplo trabajado
(dos sub-tareas de 2 llamadas cada una). Calcula el ahorro de rondas por día si TODAS se despacharan
con run_fanout_parallel en vez de run_fanout_sequential.
Ver solución
DAILY_REQUESTS = 500
seq_daily, par_daily = fanout_rounds([2, 2], surrounding_calls=2)
savings_per_request = seq_daily - par_daily
print(f"ahorro por petición: {savings_per_request} rondas")
print(f"ahorro diario: {DAILY_REQUESTS * savings_per_request} rondas")
Salida esperada:
ahorro por petición: 2 rondas
ahorro diario: 1000 rondas
Explicación: 1000 rondas por día es un ahorro real y medible en latencia agregada — el mismo tipo de cálculo que ya usó el Módulo 3, lección 05, para el ahorro de llamadas de un pipeline. La diferencia de fondo: acá ninguna llamada se elimina —Reservo sigue pagando exactamente el mismo costo de coordinación por día—, lo que se recupera es tiempo: 1000 rondas menos de espera acumulada para los socios que mandan peticiones compuestas, sin que el sistema haga ni un solo trabajo de menos.
Resumen y siguiente paso
- El fan-out no ahorra llamadas al modelo ni hops — las mismas decisiones pasan en los dos caminos, secuencial y paralelo, en la misma cantidad exacta.
- Lo que sí ahorra son rondas de coordinación: secuencial las suma (cada sub-tarea espera a la anterior); paralelo las acota por el máximo (todas avanzan a la vez, limitadas solo por la más lenta).
- Para la petición del ejemplo trabajado: 6 rondas secuencial, 4 rondas paralelo — un ahorro de 2 rondas (33%), con las mismas 6 llamadas al modelo y los mismos 4 hops en ambos caminos.
- La fórmula
fanout_roundsgeneraliza a cualquier número de sub-tareas y a costos internos distintos entre ellas — el paralelo siempre queda acotado por la sub-tarea más lenta, nunca por un promedio.
Siguiente lección: 08 — Mini-proyecto: fan-out en Reservo. Aplicamos el patrón completo — identificar independencia, despachar (secuencial y paralelo), agregar, medir el ahorro— a tres escenarios nuevos, incluido uno que NO es fan-out entre agentes.
Recursos adicionales
- Anthropic — Building effective agents — El principio de que paralelizar sub-tareas independientes reduce la latencia percibida sin cambiar el trabajo total — el resultado exacto que mide esta lección.
- Anthropic — Multi-agent research system — El reporte de Anthropic mide explícitamente que la latencia de un sistema multi-agente está acotada por el sub-agente más lento cuando el trabajo se reparte en paralelo — la misma propiedad de
max()que confirma esta lección. - Python — Funciones integradas
sum()ymax()— Las dos funciones que distinguen por completo el modelo de costo secuencial del paralelo enfanout_rounds. - Python 3.14 — What's New — La versión con la que se ejecutó cada línea de esta medición.