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_sequential — pricing_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 tressearch_docsdepolicy_agentsobre preguntas distintas—, el modelo (concepto) simplemente las pide todas en el mismo turno, ydispatch_parallellas 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
historial —
booking_agentcotizando mientraspolicy_agentresponde una pregunta de política—, hace falta el mecanismo de las lecciones 03-04: cada agente corre su propiorun_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
-
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.
-
Intentar envolver
pricing_agentenrun_fanout_sequentialcuando no hace falta. Si las tres cotizaciones ya van en el mismo turno del mismo agente, agregarSubTaskyrun_fanout_sequentialencima 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. -
Confundir "tres tool_use en el mismo turno" con "tres agentes trabajando". Los tres
tool_usede 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. -
Pensar que
dispatch_parallelyrun_fanout_sequentialcompiten por la misma responsabilidad. No —dispatch_parallelreparte tool calls dentro de un turno;run_fanout_sequential(y su extensión conThreadPoolExecutoren la lección 06) reparte agentes completos. Uno vive dentro del loop de un agente; el otro vive alrededor de varios agentes. -
Olvidar la garantía de orden que ya tenía
dispatch_parallel. El Módulo 1, lección 06, ya estableció quezip(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 aresults, 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_agentcomparando tres salas —re-ejecutado sin ningún cambio del Módulo 2, lección 05— es fan-out dentro de un solo agente: trestool_useen el mismo turno, un solo historial, ydispatch_parallel(ya construido enagent-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_sequentialproduce 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
- Python —
concurrent.futures— El módulo detrás dedispatch_parallel, reusado sin cambios desdeagent-fundamentalsM5 — la misma base técnica que la lección 06 escala a agentes completos. - Anthropic — Tool use (function calling) overview — La forma de varios
tool_useen el mismo turno, la pieza del protocolo que hace posible el fan-out dentro de un agente. - 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.
- Python — Diccionarios — La estructura detrás de
PRICING_TOOLS, idéntica a la del Módulo 2, lección 05.