Módulo 4: Fan-out paralelo y agregación
El caso base: orden fijo enumerado
Descripción
Con el criterio de independencia confirmado en la lección 02, esta lección construye el mecanismo
que lo aprovecha: SubTask, una pieza más simple que el PipelineStage del Módulo 3 —no necesita
kind, porque no hay ningún rol que distinguir en una secuencia—, y run_fanout_sequential, el
runner que despacha cada sub-tarea una después de la otra, en un orden fijo que nunca
depende de en qué orden llegaron. Esta es la pieza que la Regla dura de este módulo exige como caso
base, antes de tocar ningún hilo: correr todo en secuencia, con un orden enumerado y reproducible,
para tener un punto de comparación limpio antes de sumar concurrencia real en la lección 06.
También vas a construir el mecanismo de reconocer las sub-tareas: una tool de split
(split_into_subtasks), con su turno de decisión (concepto, claude-sonnet-5) y su función de
extracción (ejecutada) — el mismo patrón exacto de route_to_agent/RoutingDecision del Módulo 2,
lección 04, aplicado ahora a una lista de sub-tareas en vez de a un único destino.
Conexión con el módulo
Esta lección es la primera pieza EJECUTADA del patrón fan-out. Reusa run_specialist de los
Módulos 2 y 3 sin cambios; el mecanismo de extracción (extract_subtasks) es análogo a
extract_routing_decision (M2 L04), aplicado a una lista. La lección 04 toma
run_fanout_sequential de aquí, sin modificarlo, y le agrega la agregación completa con historial
citado y el conteo de costo.
Analogía: la lista de mandados, en el orden en que los anotaste
Retoma los dos mandados de la lección 01: comprar pan y revisar el correo. Antes de repartirlos —eso
viene en la lección 06—, el caso más simple es hacerlos los dos, uno después del otro, pero en
un orden que decidiste de antemano, no en el orden en que se te ocurrieron mientras hablabas. Anotas
la lista: primero pan, después correo — siempre en ese orden, sin importar si mencionaste primero el
correo al pedirlo. Esa lista escrita, fija, es exactamente lo que hace run_fanout_sequential: un
orden enumerado, decidido una vez, que no cambia según cómo llegó la petición.
Ejemplo trabajado: SubTask, el split, y el despacho en orden fijo
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}},
"pricing_agent": {"tools": {"get_quote": rt.get_quote}},
}
def run_specialist(name, task, model_script):
tools = SPECIALISTS[name]["tools"]
return run_agent_parallel(task, model_script, tools)
@dataclass
class SubTask:
"""Una sub-tarea del fan-out: a QUÉ especialista se despacha (agent) y
QUÉ se le pide (task). A diferencia de un PipelineStage (M3), una
SubTask no tiene `kind` -- no necesita distinguir su ROL en una
secuencia, porque no hay secuencia: todas corren independientes."""
agent: str
task: str
FANOUT_TOOL = {
"name": "split_into_subtasks",
"description": (
"Divide una petición compuesta del socio en sub-tareas independientes, "
"cada una asignada al especialista de Reservo que corresponde. Úsala "
"cuando la petición pide dos o más cosas y NINGUNA necesita el "
"resultado de la otra."
),
"input_schema": {
"type": "object",
"properties": {
"subtasks": {
"type": "array",
"items": {
"type": "object",
"properties": {
"agent": {"type": "string",
"enum": ["booking_agent", "policy_agent", "pricing_agent"]},
"task": {"type": "string"},
},
"required": ["agent", "task"],
},
},
},
"required": ["subtasks"],
},
}
# Turno CONCEPTO (claude-sonnet-5): el supervisor lee la petición compuesta y
# reconoce dos sub-tareas que no dependen entre sí. La decisión EN SÍ no se
# ejecuta -- es un guion escrito a mano, igual que route_to_agent en el
# Módulo 2, lección 04.
concept_turn = {
"stop_reason": "tool_use",
"content": [
{"type": "tool_use", "id": "toolu_01", "name": "split_into_subtasks",
"input": {"subtasks": [
{"agent": "booking_agent", "task": "Cotiza Focus pro 3h."},
{"agent": "policy_agent", "task": "¿Cuál es la política de cancelación?"},
]}},
],
}
def extract_subtasks(turn):
"""ESTO SÍ se ejecuta: parsea el bloque tool_use del turno del modelo
(concepto, ya escrito arriba) y arma la lista de SubTask -- el mismo
mecanismo mecánico que extract_routing_decision en el Módulo 2,
lección 04, aplicado acá a una lista en vez de a un único target."""
block = turn["content"][0]
assert block["type"] == "tool_use" and block["name"] == "split_into_subtasks"
return [SubTask(agent=s["agent"], task=s["task"]) for s in block["input"]["subtasks"]]
def run_fanout_sequential(subtasks, model_scripts):
"""Caso BASE del patrón: corre cada sub-tarea, UNA DESPUÉS DE LA OTRA,
en un orden FIJO -- alfabético por nombre de agente, nunca el orden en
que el modelo (concepto) las haya devuelto. Esto es exactamente la
misma disciplina de determinismo que ya exige el Módulo 1, lección 06:
nunca depender de un orden que no controlas."""
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
COMPOUND_REQUEST = "Cotiza Focus pro 3h y dime 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."
)}]},
],
}
print("--- petición compuesta ---")
print(repr(COMPOUND_REQUEST))
print()
print("--- split (concepto), extracción ejecutada ---")
subtasks = extract_subtasks(concept_turn)
for sub in subtasks:
print(f" {sub.agent:15} <- {sub.task!r}")
print()
print("--- fan-out secuencial, orden fijo (alfabético por agente) ---")
results = run_fanout_sequential(subtasks, model_scripts)
for agent in sorted(results):
print(f"[{agent}] tarea: {results[agent]['task']!r}")
print(f"[{agent}] salida: {results[agent]['output']!r}")
Qué esperar:
--- petición compuesta ---
'Cotiza Focus pro 3h y dime la política de cancelación.'
--- split (concepto), extracción ejecutada ---
booking_agent <- 'Cotiza Focus pro 3h.'
policy_agent <- '¿Cuál es la política de cancelación?'
--- fan-out secuencial, orden fijo (alfabético por agente) ---
[booking_agent] tarea: 'Cotiza Focus pro 3h.'
[booking_agent] salida: 'Focus pro 3h cuesta 6000 centavos.'
[policy_agent] tarea: '¿Cuál es la política de cancelación?'
[policy_agent] salida: 'Puedes cancelar sin cargo hasta 2 horas antes del horario reservado. Después de eso aplica el cargo de no-presentación.'
Fíjate en la asimetría deliberada: extract_subtasks respeta el orden en que el modelo (concepto)
devolvió las sub-tareas dentro de concept_turn["content"][0]["input"]["subtasks"] — en este
ejemplo, booking_agent primero. Pero run_fanout_sequential no confía en ese orden — la
primera línea del cuerpo, sorted(subtasks, key=lambda s: s.agent), reordena alfabéticamente antes
de despachar nada. En este caso concreto los dos órdenes coinciden (booking_agent va antes que
policy_agent alfabéticamente), pero el Ejercicio 2 muestra un caso donde no coinciden — y el
código igual produce el orden fijo, no el que trajo el modelo.
Por qué SubTask no tiene kind
Compara con PipelineStage del Módulo 3, que sí tenía kind —porque un mismo especialista podía
aparecer dos veces en una secuencia, con roles distintos (booking_agent cotizando y luego
confirmando)—. Acá no hace falta: en un fan-out, cada sub-tarea es autónoma, no ocupa una
"posición" en ninguna secuencia que otras sub-tareas necesiten reconocer. Si booking_agent
apareciera dos veces en la misma petición compuesta (por ejemplo, cotizando dos salas distintas
como parte de dos preguntas separadas), cada aparición sería una SubTask independiente más — sin
necesidad de distinguir "cuál cotiza qué" con un campo extra, porque cada una ya trae su propio
task completo.
Por qué el orden fijo es alfabético, y no "el orden en que llegaron"
Podrías pensar que alcanzaría con respetar el orden que trajo concept_turn — después de todo, en
el ejemplo trabajado coincide con el alfabético. La razón para NO hacerlo así es exactamente la
misma que ya estableció la Regla dura del Módulo 1, lección 06, sobre dispatch_parallel: nunca
construir un comportamiento que dependa de un orden que no controlas. El orden en que el modelo
(concepto) decide listar las sub-tareas es, en un sistema real, una decisión probabilística —dos
llamadas con el mismo prompt podrían, en teoría, devolver la lista en órdenes distintos—. Ordenar
siempre por una clave fija (sub.agent, alfabético) elimina esa fuente de variación antes de que
pueda afectar nada.
Errores comunes
-
Confiar en el orden de
concept_turn["..."]["subtasks"]para decidir el orden de ejecución. El Ejercicio 2 de esta lección construye un caso donde el modelo (concepto) devuelve las sub-tareas en un orden distinto al alfabético, y confirma querun_fanout_sequentiallas corre en su propio orden fijo de todos modos. -
Pensar que
run_fanout_sequentialya es el patrón completo del módulo. No — es el caso base, sin concurrencia real. Sirve como punto de comparación limpio antes de la lección 06, que agregaThreadPoolExecutorsin cambiar el resultado agregado. -
Olvidar que
model_scriptses un diccionario keyed por nombre de agente, no una lista posicional. A diferencia demodel_scriptsen el pipeline del Módulo 3 (una lista, una entrada por etapa, en orden), acá es un dict — porque el orden de ejecución puede reordenarse (sorted), y una lista posicional se desalinearía si el orden cambia. -
Usar
extract_subtaskssobre un turno que no tienetool_use. Elassertfallaría — correcto: la función asume, como precondición, que el split concepto siempre termina entool_useconsplit_into_subtasks. Manejar el caso contrario está fuera del alcance de esta lección, igual que ya pasaba conextract_routing_decisionen el Módulo 2. -
Confundir el costo del split (
FANOUT_SPLIT_CALLS, una llamada concepto) con el costo de las sub-tareas. El split reconoce QUÉ despachar; el despacho en sí es lo que cuesta las llamadas internas de cada especialista. La lección 04 separa los dos con precisión.
Ejercicios
Ejercicio 1: Confirma tu propia ejecución (Fácil)
Ejecuta el ejemplo trabajado completo tú mismo y confirma, línea por línea, que tu salida coincide
con el "Qué esperar" de arriba. Presta especial atención al orden en que extract_subtasks lista
las sub-tareas frente al orden en que run_fanout_sequential las despacha.
Ver solución
No hay una única "solución de código" para este ejercicio — es una verificación: si tu salida coincide exactamente con el bloque "Qué esperar" del ejemplo trabajado, tu Reservo desechable arrancó limpio y el split + despacho corrieron sin desvíos.
Ejercicio 2: Agrega una tercera sub-tarea con orden invertido (Medio)
Construye un concept_turn con tres sub-tareas, en un orden que no sea alfabético —por ejemplo,
policy_agent, booking_agent, pricing_agent, en ese orden—. Extráelas, confirma que
extract_subtasks respeta ese orden "de llegada", y despáchalas con run_fanout_sequential,
confirmando que el orden de EJECUCIÓN sí queda alfabético.
Ver solución
concept_turn_3 = {
"stop_reason": "tool_use",
"content": [
{"type": "tool_use", "id": "toolu_01", "name": "split_into_subtasks",
"input": {"subtasks": [
{"agent": "policy_agent", "task": "¿Cuál es la política de cancelación?"},
{"agent": "booking_agent", "task": "Cotiza Focus pro 3h."},
{"agent": "pricing_agent", "task": "Compara Studio y Boardroom, pro, 3h."},
]}},
],
}
subtasks_3 = extract_subtasks(concept_turn_3)
print("orden en que el modelo (concepto) las devolvió:", [s.agent for s in subtasks_3])
ordered_3 = sorted(subtasks_3, key=lambda s: s.agent)
print("orden FIJO en que run_fanout_sequential las corre:", [s.agent for s in ordered_3])
model_scripts_3 = {
"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."}]},
],
"pricing_agent": [
{"stop_reason": "tool_use", "content": [
{"type": "tool_use", "id": "toolu_01", "name": "get_quote",
"input": {"room": "Studio", "tier": "pro", "hours": 3}},
{"type": "tool_use", "id": "toolu_02", "name": "get_quote",
"input": {"room": "Boardroom", "tier": "pro", "hours": 3}},
]},
{"stop_reason": "end_turn", "content": [
{"type": "text", "text": "Studio pro 3h: 9600 centavos. Boardroom pro 3h: 19200 centavos. Studio es más barata."}]},
],
}
results_3 = run_fanout_sequential(subtasks_3, model_scripts_3)
for agent in sorted(results_3):
print(f"[{agent}] {results_3[agent]['output']}")
Salida esperada:
orden en que el modelo (concepto) las devolvió: ['policy_agent', 'booking_agent', 'pricing_agent']
orden FIJO en que run_fanout_sequential las corre: ['booking_agent', 'policy_agent', 'pricing_agent']
[booking_agent] Focus pro 3h cuesta 6000 centavos.
[policy_agent] Puedes cancelar sin cargo hasta 2 horas antes del horario reservado.
[pricing_agent] Studio pro 3h: 9600 centavos. Boardroom pro 3h: 19200 centavos. Studio es más barata.
Explicación: el modelo (concepto) devolvió las tres sub-tareas en el orden
['policy_agent', 'booking_agent', 'pricing_agent'] — el orden en que, en teoría, "se le ocurrieron"
al leer la petición—. run_fanout_sequential ignora completamente ese orden y despacha en el orden
alfabético fijo (booking_agent, policy_agent, pricing_agent), confirmando en código que el
mecanismo de despacho nunca depende de cómo llegó la decisión concepto.
Ejercicio 3: ¿Qué pasa con un agente que no existe en SPECIALISTS? (Difícil)
Construye un concept_turn cuyo split incluya "agent": "shipping_agent" —un especialista que no
existe— y ejecútalo a través de extract_subtasks y run_fanout_sequential. ¿En qué punto exacto
falla, y por qué es preferible a que el fan-out "siguiera de largo" con un especialista inventado?
Ver solución
concept_turn_bad = {
"stop_reason": "tool_use",
"content": [
{"type": "tool_use", "id": "toolu_01", "name": "split_into_subtasks",
"input": {"subtasks": [
{"agent": "shipping_agent", "task": "Envía el contrato de la reserva por correo."},
]}},
],
}
subtasks_bad = extract_subtasks(concept_turn_bad)
print("sub-tarea extraída:", subtasks_bad[0])
try:
run_fanout_sequential(subtasks_bad, {})
except KeyError as e:
print(f"KeyError capturado: {e!r}")
Salida esperada:
sub-tarea extraída: SubTask(agent='shipping_agent', task='Envía el contrato de la reserva por correo.')
KeyError capturado: KeyError('shipping_agent')
Explicación: extract_subtasks no falla — arma la SubTask sin problema, porque no valida el
nombre del agente contra SPECIALISTS (esa responsabilidad no es suya). La falla ocurre recién
dentro de run_specialist, cuando busca SPECIALISTS["shipping_agent"] y no lo encuentra — el
mismo KeyError inmediato que ya viste en el Módulo 2, lección 02, y en el Módulo 3, lección 02.
Es preferible a que el fan-out siguiera de largo con un especialista inexistente porque, en un
patrón donde varias sub-tareas corren "a la vez" (más aún con concurrencia real en la lección 06),
un error silencioso en una de ellas podría pasar desapercibido mientras las demás sí completan —
mucho más difícil de rastrear que un KeyError inmediato y ruidoso.
Resumen y siguiente paso
SubTask(agent, task)es más simple quePipelineStage— no necesitakind, porque no hay ningún rol de secuencia que distinguir.- El split de una petición compuesta en sub-tareas es una decisión concepto
(
split_into_subtasks,claude-sonnet-5);extract_subtaskses la extracción ejecutada, el mismo patrón deextract_routing_decisiondel Módulo 2, aplicado a una lista. run_fanout_sequentiales el caso base: despacha cada sub-tarea, una después de la otra, en un orden fijo alfabético por nombre de agente — nunca el orden en que el modelo (concepto) las haya devuelto.- El Ejercicio 2 confirmó, con un caso donde los dos órdenes difieren, que el orden de ejecución real ignora por completo el orden de llegada de la decisión concepto.
Siguiente lección: 04 — Agregando resultados de forma determinista. Completamos el caso base con el historial completo de cada sub-tarea, la síntesis final que combina ambos resultados, y el conteo del costo de coordinación.
Recursos adicionales
- Python —
dataclasses— El módulo detrás deSubTask, más simple quePipelineStage(M3) porque no necesita distinguir un rol de secuencia. - Anthropic — Tool use (function calling) overview — La forma de
tool_usequesplit_into_subtasksreutiliza para estructurar una decisión de reparto, el mismo protocolo queroute_to_agenten el Módulo 2. - Python —
sortedconkey— La función detrás del orden fijo alfabético derun_fanout_sequential, la pieza central del determinismo de esta lección. - Anthropic — Building effective agents — El patrón "parallelization (sectioning)": dividir una tarea en sub-tareas independientes antes de despacharlas, exactamente el mecanismo que construye esta lección.