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

  1. 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.

  2. Usar sum() para el camino paralelo, o max() 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.

  3. Olvidar sumar surrounding_calls en 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.

  4. 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.

  5. 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_rounds generaliza 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

  1. 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.
  2. 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.
  3. Python — Funciones integradas sum() y max() — Las dos funciones que distinguen por completo el modelo de costo secuencial del paralelo en fanout_rounds.
  4. Python 3.14 — What's New — La versión con la que se ejecutó cada línea de esta medición.