Módulo 4: Fan-out paralelo y agregación

Fan-out dentro de un solo agente: la variante con pricing_agent

Descripción

El Módulo 2, lección 05, ya construyó algo que —recién ahora— podemos nombrar con precisión: pricing_agent comparando Focus, Studio y Boardroom pidió las tres cotizaciones en el mismo turno, porque ninguna depende de las otras dos. Ese "mismo turno" es fan-out — pero un fan-out de un tipo distinto al que construyeron las lecciones 03 y 04 de este módulo. Esta lección no agrega código nuevo de pricing_agent: re-ejecuta exactamente el mismo ejemplo del Módulo 2 y lo pone, lado a lado, contra el fan-out entre agentes que acabas de construir — la distinción explícita que las Errores comunes de esa misma lección del Módulo 2 ya advertían, sin desarrollarla todavía.

El resultado de esta comparación es una regla simple: si el fan-out reparte tool calls dentro del turno de un único agente, no hace falta nada de lo que construyeron las lecciones 03-04 — dispatch_parallel, ya construido en agent-fundamentals, lo resuelve solo—. Si el fan-out reparte agentes completos, cada uno con su propio historial, hace falta run_fanout_sequential (o su extensión con concurrencia real, lección 06). Confundir los dos no rompe nada por sí solo, pero sí lleva a construir mecanismos de más —o a pensar que este módulo "reinventó" algo que agent-fundamentals ya resolvía.

Conexión con el módulo

Esta lección no depende de las lecciones 03-04 en el código —pricing_agent corre exactamente igual que en el Módulo 2, sin SubTask ni run_fanout_sequential de por medio—. La conexión es conceptual: sitúa el mecanismo de esas lecciones en el mapa completo del patrón, distinguiéndolo del mecanismo que ya existía. La lección 08 (mini-proyecto) usa esta distinción para decidir, sin ambigüedad, cuándo un escenario nuevo necesita run_fanout_* y cuándo no.


Analogía: un cajero con tres cheques, contra tres cajeros con un cheque cada uno

Imagina un banco. Un cliente le entrega a un solo cajero tres cheques para depositar a la vez — el cajero los procesa juntos, en el mismo trámite, sin que ninguno de los tres dependa de los otros—. Eso es fan-out dentro de un agente: un cajero, tres operaciones independientes, un solo recibo final.

Ahora imagina un cliente que necesita tres trámites distintos —depositar un cheque, consultar una tasa de interés, y reportar una tarjeta perdida—, y el banco lo manda a tres cajeros distintos, cada uno especializado, trabajando los tres a la vez. Eso es fan-out entre agentes: tres cajeros, tres historiales de atención separados, y alguien que junta los tres resultados en una sola respuesta para el cliente al final. Los dos ahorran tiempo frente a hacerlo todo en fila, uno después del otro — pero el mecanismo que los coordina es distinto, porque en el segundo caso hay tres atenciones completas corriendo, no tres papeles sobre el mismo mostrador.


Ejemplo trabajado: pricing_agent, re-ejecutado, y la comparación lado a lado

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})")


def count_model_calls(history):
    return sum(1 for m in history if m["role"] == "assistant")


def count_tool_calls(history):
    total = 0
    for m in history:
        if isinstance(m["content"], list):
            total += sum(1 for b in m["content"] if b["type"] == "tool_use")
    return total


# pricing_agent NO tiene una tool nueva -- reusa get_quote de booking_agent
# (Módulo 2, lección 05, sin cambios).
PRICING_TOOLS = {"get_quote": rt.get_quote}

TASK_PRICING = "Compara el precio de Focus, Studio y Boardroom, todos pro, 3h."

# Guion (concepto, claude-sonnet-5): las tres cotizaciones son independientes
# entre sí, así que el modelo las pide las TRES en el MISMO turno.
model_script_pricing = [
    {"stop_reason": "tool_use", "content": [
        {"type": "tool_use", "id": "toolu_01", "name": "get_quote",
         "input": {"room": "Focus", "tier": "pro", "hours": 3}},
        {"type": "tool_use", "id": "toolu_02", "name": "get_quote",
         "input": {"room": "Studio", "tier": "pro", "hours": 3}},
        {"type": "tool_use", "id": "toolu_03", "name": "get_quote",
         "input": {"room": "Boardroom", "tier": "pro", "hours": 3}},
    ]},
    {"stop_reason": "end_turn", "content": [
        {"type": "text", "text": (
            "Focus pro 3h: 6000 centavos. Studio pro 3h: 9600 centavos. "
            "Boardroom pro 3h: 19200 centavos. Focus es la opción más "
            "barata de las tres."
        )}]},
]

final, history = run_agent_parallel(TASK_PRICING, model_script_pricing, PRICING_TOOLS)

print("--- fan-out DENTRO de un solo agente (pricing_agent) ---")
print("tarea:", repr(TASK_PRICING))
for i, m in enumerate(history):
    role, content = m["role"], m["content"]
    if isinstance(content, str):
        print(f"  [{i}] {role:<9} pregunta: {content!r}")
        continue
    for block in content:
        if block["type"] == "tool_use":
            print(f"  [{i}] {role:<9} tool_use({block['name']}): {block['input']}")
        elif block["type"] == "tool_result":
            print(f"  [{i}] {role:<9} tool_result: {block['content']}")
        elif block["type"] == "text":
            print(f"  [{i}] {role:<9} texto final: {block['text']!r}")

within_calls = count_model_calls(history)
within_tools = count_tool_calls(history)
within_agents_involved = 1

print()
print("respuesta final:", final["content"][0]["text"])
print(f"llamadas al modelo: {within_calls}")
print(f"llamadas a tools:   {within_tools}")
print(f"agentes involucrados: {within_agents_involved} (pricing_agent, tres veces la MISMA tool)")

print()
print("--- comparación: fan-out DENTRO de un agente vs. ENTRE agentes (este módulo) ---")
rows = [
    ("qué se reparte", "tool_use dentro de UN turno", "agentes completos, cada uno con su loop"),
    ("quién despacha", "dispatch_parallel (agent-fundamentals M5)", "run_fanout_sequential / _parallel (M4)"),
    ("historiales", "UNO -- el de pricing_agent", "uno por agente: booking_agent Y policy_agent"),
    ("presupuesto", "cuenta contra el turno de UN agente", "cada agente gasta el suyo, por separado"),
    ("orden garantizado", "zip(tool_use_blocks, results)", "results ordenado por nombre de agente"),
]
print(f"{'dimensión':20} | {'dentro de UN agente (L05)':44} | {'entre agentes (M4)':44}")
for dim, within, across in rows:
    print(f"{dim:20} | {within:44} | {across:44}")

Qué esperar:

--- fan-out DENTRO de un solo agente (pricing_agent) ---
tarea: 'Compara el precio de Focus, Studio y Boardroom, todos pro, 3h.'
  [0] user      pregunta: 'Compara el precio de Focus, Studio y Boardroom, todos pro, 3h.'
  [1] assistant tool_use(get_quote): {'room': 'Focus', 'tier': 'pro', 'hours': 3}
  [1] assistant tool_use(get_quote): {'room': 'Studio', 'tier': 'pro', 'hours': 3}
  [1] assistant tool_use(get_quote): {'room': 'Boardroom', 'tier': 'pro', 'hours': 3}
  [2] user      tool_result: {'price_cents': 6000}
  [2] user      tool_result: {'price_cents': 9600}
  [2] user      tool_result: {'price_cents': 19200}
  [3] assistant texto final: 'Focus pro 3h: 6000 centavos. Studio pro 3h: 9600 centavos. Boardroom pro 3h: 19200 centavos. Focus es la opción más barata de las tres.'

respuesta final: Focus pro 3h: 6000 centavos. Studio pro 3h: 9600 centavos. Boardroom pro 3h: 19200 centavos. Focus es la opción más barata de las tres.
llamadas al modelo: 2
llamadas a tools:   3
agentes involucrados: 1 (pricing_agent, tres veces la MISMA tool)

--- comparación: fan-out DENTRO de un agente vs. ENTRE agentes (este módulo) ---
dimensión            | dentro de UN agente (L05)                    | entre agentes (M4)                          
qué se reparte       | tool_use dentro de UN turno                  | agentes completos, cada uno con su loop     
quién despacha       | dispatch_parallel (agent-fundamentals M5)    | run_fanout_sequential / _parallel (M4)      
historiales          | UNO -- el de pricing_agent                   | uno por agente: booking_agent Y policy_agent
presupuesto          | cuenta contra el turno de UN agente          | cada agente gasta el suyo, por separado     
orden garantizado    | zip(tool_use_blocks, results)                | results ordenado por nombre de agente

Ni una línea de este código depende de SubTask ni de run_fanout_sequentialpricing_agent corre con el mismo run_agent_parallel de siempre, y dispatch_parallel ya sabía, desde agent-fundamentals Módulo 5, manejar más de un tool_use en el mismo turno. 2 llamadas al modelo, 3 tool calls, 1 solo agente involucrado — números idénticos a los que ya viste en el Módulo 2, lección 05, porque es literalmente el mismo ejemplo.


Por qué las dos formas de fan-out no compiten entre sí

No hay que elegir entre "el mecanismo de agent-fundamentals" y "el mecanismo de este módulo" — se usan en momentos distintos, sobre problemas distintos:

  • Si la independencia está dentro de las tool calls que un mismo agente necesita hacer —tres cotizaciones de pricing_agent, o tres search_docs de policy_agent sobre preguntas distintas—, el modelo (concepto) simplemente las pide todas en el mismo turno, y dispatch_parallel las despacha sin que este módulo tenga que intervenir en nada.
  • Si la independencia está entre agentes distintos, cada uno con su propio rol y su propio historialbooking_agent cotizando mientras policy_agent responde una pregunta de política—, hace falta el mecanismo de las lecciones 03-04: cada agente corre su propio run_specialist, y alguien tiene que reunir los resultados de historiales separados en una sola respuesta.

Un sistema real de Reservo puede necesitar los dos a la vez, en la misma petición — el Escenario C del mini-proyecto (lección 08) es exactamente ese caso: pricing_agent resuelve su parte con fan-out interno (tres cotizaciones en un turno), mientras que ese mismo pricing_agent corre en paralelo con booking_agent y policy_agent en un fan-out entre agentes.


Errores comunes

  1. Pensar que esta lección construye algo nuevo. No — es una relectura del Módulo 2, lección 05, con el vocabulario completo de este módulo ya disponible. El código es idéntico a propósito.

  2. Intentar envolver pricing_agent en run_fanout_sequential cuando no hace falta. Si las tres cotizaciones ya van en el mismo turno del mismo agente, agregar SubTask y run_fanout_sequential encima no cambia el resultado — solo agrega código sin necesidad. El Ejercicio 3 de esta lección confirma que los dos caminos producen exactamente el mismo resultado cuando se aplican al mismo caso de un solo agente.

  3. Confundir "tres tool_use en el mismo turno" con "tres agentes trabajando". Los tres tool_use de este ejemplo cuentan contra el presupuesto de llamadas de un solo agente (pricing_agent); el fan-out entre agentes de las lecciones 03-04 reparte el presupuesto entre varios agentes, cada uno con el suyo.

  4. Pensar que dispatch_parallel y run_fanout_sequential compiten por la misma responsabilidad. No — dispatch_parallel reparte tool calls dentro de un turno; run_fanout_sequential (y su extensión con ThreadPoolExecutor en la lección 06) reparte agentes completos. Uno vive dentro del loop de un agente; el otro vive alrededor de varios agentes.

  5. Olvidar la garantía de orden que ya tenía dispatch_parallel. El Módulo 1, lección 06, ya estableció que zip(tool_use_blocks, results) mantiene el orden de envío, no el de finalización — la misma disciplina de determinismo que este módulo aplica ahora a results, ordenado por nombre de agente en vez de por orden de zip.


Ejercicios

Ejercicio 1: Verifica las anclas a mano (Fácil)

Sin ejecutar nada, calcula get_quote("Studio", "basic", 2) y get_quote("Boardroom", "basic", 2) a mano, usando la fórmula ROOM_RATE_CENTS[room] * hours (sin descuento, porque basic no lo aplica). Confirma tu cálculo ejecutando las dos llamadas reales.

Ver solución

Cálculo a mano: Studio basic 2h = 4000 * 2 = 8000. Boardroom basic 2h = 8000 * 2 = 16000.

print("Studio basic 2h:", rt.get_quote("Studio", "basic", 2))
print("Boardroom basic 2h:", rt.get_quote("Boardroom", "basic", 2))

Salida esperada:

Studio basic 2h: {'price_cents': 8000}
Boardroom basic 2h: {'price_cents': 16000}

Ambos coinciden con el cálculo a mano.

Ejercicio 2: Studio basic vs. Studio pro, 5h (Medio)

Escribe un guion nuevo para pricing_agent que compare "Studio basic 5h" contra "Studio pro 5h" — dos get_quote en el mismo turno, mismo room, distinto tier— y ejecútalo.

Ver solución
task_ex2 = "Compara Studio basic 5h contra Studio pro 5h."
script_ex2 = [
    {"stop_reason": "tool_use", "content": [
        {"type": "tool_use", "id": "toolu_01", "name": "get_quote",
         "input": {"room": "Studio", "tier": "basic", "hours": 5}},
        {"type": "tool_use", "id": "toolu_02", "name": "get_quote",
         "input": {"room": "Studio", "tier": "pro", "hours": 5}},
    ]},
    {"stop_reason": "end_turn", "content": [
        {"type": "text", "text": "Studio basic 5h: 20000 centavos. Studio pro 5h: 16000 centavos. El tier pro ahorra 4000 centavos."}]},
]
final_ex2, hist_ex2 = run_agent_parallel(task_ex2, script_ex2, PRICING_TOOLS)
print("respuesta:", final_ex2["content"][0]["text"])
print("llamadas al modelo:", count_model_calls(hist_ex2), " llamadas a tools:", count_tool_calls(hist_ex2))

Salida esperada:

respuesta: Studio basic 5h: 20000 centavos. Studio pro 5h: 16000 centavos. El tier pro ahorra 4000 centavos.
llamadas al modelo: 2  llamadas a tools: 2

Explicación: 20000 = 4000 * 5 (sin descuento), 16000 = 4000 * 5 * 80 // 100 (con el 20% de descuento de pro). Dos llamadas al modelo, dos tool calls — el mismo patrón de costo del ejemplo trabajado, con un tool_use menos porque acá se comparan dos combinaciones, no tres.

Ejercicio 3: Confirma que run_fanout_sequential con UN agente da lo mismo que llamarlo directo (Difícil)

Envuelve la tarea del Ejercicio 2 en una SubTask de un solo elemento y despáchala con run_fanout_sequential. Confirma que el resultado es idéntico a llamar run_agent_parallel directamente, y explica por qué eso confirma que los dos mecanismos no compiten entre sí.

Ver solución
from dataclasses import dataclass


@dataclass
class SubTask:
    agent: str
    task: str


SPECIALISTS_ONE = {"pricing_agent": {"tools": PRICING_TOOLS}}


def run_specialist_one(name, task, model_script):
    return run_agent_parallel(task, model_script, SPECIALISTS_ONE[name]["tools"])


def run_fanout_sequential_one(subtasks, model_scripts):
    ordered = sorted(subtasks, key=lambda s: s.agent)
    results = {}
    for sub in ordered:
        final, history = run_specialist_one(sub.agent, sub.task, model_scripts[sub.agent])
        results[sub.agent] = {"task": sub.task, "output": final["content"][0]["text"], "history": history}
    return results


subtasks_one = [SubTask(agent="pricing_agent", task=task_ex2)]
model_scripts_one = {"pricing_agent": script_ex2}
results_one = run_fanout_sequential_one(subtasks_one, model_scripts_one)
final_direct, hist_direct = run_agent_parallel(task_ex2, script_ex2, PRICING_TOOLS)
print("vía run_fanout_sequential:", results_one["pricing_agent"]["output"])
print("vía run_agent_parallel directo:", final_direct["content"][0]["text"])
print("¿mismo texto?", results_one["pricing_agent"]["output"] == final_direct["content"][0]["text"])
print("¿mismas llamadas al modelo?",
      count_model_calls(results_one["pricing_agent"]["history"]) == count_model_calls(hist_direct))

Salida esperada:

vía run_fanout_sequential: Studio basic 5h: 20000 centavos. Studio pro 5h: 16000 centavos. El tier pro ahorra 4000 centavos.
vía run_agent_parallel directo: Studio basic 5h: 20000 centavos. Studio pro 5h: 16000 centavos. El tier pro ahorra 4000 centavos.
¿mismo texto? True
¿mismas llamadas al modelo? True

Explicación: envolver una sola sub-tarea en run_fanout_sequential no cambia absolutamente nada del resultado — el mecanismo de las lecciones 03-04 es una generalización que funciona igual de bien con una sub-tarea que con varias, porque sorted() sobre una lista de un solo elemento no reordena nada. Esto confirma que los dos mecanismos —dispatch_parallel dentro de un agente, run_fanout_sequential entre agentes— son complementarios, no rivales: uno resuelve el paralelismo de tool calls; el otro, cuando hace falta, resuelve el paralelismo de agentes completos por encima.


Resumen y siguiente paso

  • pricing_agent comparando tres salas —re-ejecutado sin ningún cambio del Módulo 2, lección 05— es fan-out dentro de un solo agente: tres tool_use en el mismo turno, un solo historial, y dispatch_parallel (ya construido en agent-fundamentals) resolviéndolo sin ninguna pieza nueva.
  • El fan-out que construyeron las lecciones 03-04 de este módulo es distinto: reparte agentes completos, cada uno con su propio historial y su propio presupuesto de llamadas.
  • Los dos mecanismos no compiten — resuelven paralelismo en escalas distintas, y un sistema real puede necesitar los dos a la vez sobre la misma petición.
  • Envolver una sola sub-tarea en run_fanout_sequential produce el mismo resultado que llamarla directo — confirmado ejecutando — porque el mecanismo generaliza sin romper el caso simple.

Siguiente lección: 06 — Concurrencia real con ThreadPoolExecutor. Extendemos el fan-out entre agentes de las lecciones 03-04 con hilos reales, confirmando que la salida agregada nunca depende de qué hilo terminó primero.


Recursos adicionales

  1. Python — concurrent.futures — El módulo detrás de dispatch_parallel, reusado sin cambios desde agent-fundamentals M5 — la misma base técnica que la lección 06 escala a agentes completos.
  2. Anthropic — Tool use (function calling) overview — La forma de varios tool_use en el mismo turno, la pieza del protocolo que hace posible el fan-out dentro de un agente.
  3. Anthropic — Building effective agents — El patrón "parallelization" no distingue explícitamente entre dentro y entre agentes — esta lección hace esa distinción explícita, útil al decidir qué mecanismo aplica a un caso nuevo.
  4. Python — Diccionarios — La estructura detrás de PRICING_TOOLS, idéntica a la del Módulo 2, lección 05.