Módulo 3: Pipelines secuenciales
La anatomía de una etapa
Descripción
El Módulo 2 identificaba a cada especialista por su nombre: SPECIALISTS["booking_agent"],
SPECIALISTS["policy_agent"] — un diccionario, porque el supervisor solo necesitaba saber "a quién"
delegar cada petición. Este módulo necesita algo distinto, porque un mismo especialista puede
aparecer más de una vez en la misma secuencia: booking_agent cotiza en la etapa 1 del pipeline de
Reservo y confirma en la etapa 3, con policy_agent en el medio. Si identificaras cada etapa solo
por el nombre del especialista, no habría forma de distinguir "la etapa donde booking_agent
cotiza" de "la etapa donde booking_agent confirma" — las dos comparten el mismo nombre.
Esta lección construye PipelineStage, la pieza que resuelve esa ambigüedad: cada etapa se
identifica por su rol (kind) —qué resuelve, no solo quién la resuelve—, además del especialista
que la corre (name) y una etiqueta legible (label). Con las tres etapas del pipeline de Reservo
declaradas en PIPELINE_STAGES, vas a ejecutar la primera —cotizar— y armar, a mano, el payload
mínimo que la siguiente etapa necesitaría. Ese "a mano" es intencional: expone exactamente el trabajo
repetitivo que la lección 03 generaliza.
Conexión con el módulo
Esta lección es la base formal sobre la que se apoya el resto del módulo: PipelineStage y
PIPELINE_STAGES se reusan sin cambios en las lecciones 03 a 08. La lección 03 automatiza el paso
manual que cierra esta lección —armar el payload— con build_stage_task y extract_payload; la
lección 04 corre las tres etapas completas con ese mecanismo ya generalizado.
Analogía: la ficha de cada estación de la línea de ensamblaje
Retoma la receta de la lección 01, pero llevada a una fábrica: una línea de ensamblaje tiene
estaciones fijas —cortar, soldar, pintar—, y cada estación tiene una ficha que dice qué hace, con qué
máquina, y qué recibe de la estación anterior. Dos estaciones distintas pueden usar la misma
máquina —la misma soldadora podría usarse en la estación 2 para unir una pieza y, más adelante en la
línea, en la estación 5 para un ajuste final—, pero siguen siendo estaciones distintas, con fichas
distintas, porque hacen trabajos distintos en momentos distintos de la línea. PipelineStage es esa
ficha: identifica el trabajo (kind), no solo la máquina (name) que lo hace.
Ejemplo trabajado: PipelineStage y el primer paso ejecutado
from dataclasses import dataclass
import concurrent.futures
import reservo_tools as rt
def dispatch_parallel(tool_use_blocks, tools):
"""El de agent-fundamentals M4/M5, sin cambios."""
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):
"""El de agent-fundamentals M4/M5, sin cambios."""
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 = {
"no-show-policy": (
"Si un miembro no se presenta a una reserva confirmada y no cancela "
"con al menos 2 horas de anticipación, Reservo cobra el 50% del "
"precio cotizado como cargo por no-presentación."
),
"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):
"""STUB de policy_agent, idéntico al del Módulo 2."""
q = query.lower()
if "cancela" in q:
return f"[cancellation-policy] {POLICY_DOCS['cancellation-policy']}"
if "no" in q and ("present" in q or "show" in q):
return f"[no-show-policy] {POLICY_DOCS['no-show-policy']}"
return "No se encontró una política relevante para esa pregunta."
# El registro de especialistas del Módulo 2, sin cambios.
SPECIALISTS = {
"booking_agent": {
"tools": {
"list_rooms": rt.list_rooms, "get_quote": rt.get_quote,
"book_room": rt.book_room, "cancel_booking": rt.cancel_booking,
},
"expertise": "cotizar, reservar y cancelar salas",
},
"policy_agent": {
"tools": {"search_docs": search_docs},
"expertise": "responder preguntas de política (cancelación, no-presentación)",
},
}
def run_specialist(name, task, model_script):
"""El de la lección 02 del Módulo 2, sin cambios."""
tools = SPECIALISTS[name]["tools"]
return run_agent_parallel(task, model_script, tools)
@dataclass
class PipelineStage:
"""Una etapa fija del pipeline: QUÉ resuelve (kind), a QUÉ especialista
se despacha (name), y una etiqueta corta para trazar (label). `kind`
identifica el ROL de la etapa -- no el agente -- porque el MISMO
especialista puede aparecer en más de una etapa."""
kind: str
name: str
label: str
PIPELINE_STAGES = [
PipelineStage(kind="quote", name="booking_agent", label="cotizar"),
PipelineStage(kind="validate_policy", name="policy_agent",
label="validar la política de cancelación"),
PipelineStage(kind="confirm", name="booking_agent", label="confirmar la reserva"),
]
print("--- el pipeline de Reservo: orden fijo, sin ninguna decisión de ruteo ---")
for stage in PIPELINE_STAGES:
print(f"etapa {stage.kind:16} -> {stage.name:15} ({stage.label})")
Qué esperar:
--- el pipeline de Reservo: orden fijo, sin ninguna decisión de ruteo ---
etapa quote -> booking_agent (cotizar)
etapa validate_policy -> policy_agent (validar la política de cancelación)
etapa confirm -> booking_agent (confirmar la reserva)
Compara esta lista con SPECIALISTS del Módulo 2: ahí, SPECIALISTS["booking_agent"] devolvía
una entrada, sin importar cuántas veces el supervisor decidiera delegarle una tarea. Acá,
booking_agent aparece en la lista dos veces, una vez como kind="quote" y otra como
kind="confirm" — dos etapas distintas, mismo especialista. Esta es la diferencia estructural que
justifica PipelineStage en vez de reusar SPECIALISTS tal cual: un pipeline necesita distinguir
posiciones en una secuencia, no solo identidades de agentes.
Corriendo la primera etapa, sola
print()
print("--- corremos SOLO la etapa 1, a mano, para ver qué produce ---")
task = "Cotiza Focus pro 3h."
model_script_quote = [
{"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."}]},
]
stage_1 = PIPELINE_STAGES[0]
final, history = run_specialist(stage_1.name, task, model_script_quote)
print("resultado de la etapa 1:", final["content"][0]["text"])
# A mano, todavía sin ninguna función general: armamos el payload que la
# etapa 2 va a necesitar. Esto es exactamente lo que la lección 03
# automatiza -- acá se hace a mano a propósito, para ver el problema antes
# de resolverlo.
payload = {"room": "Focus", "tier": "pro", "hours": 3, "price_cents": 6000}
print("payload armado a mano:", payload)
Qué esperar:
--- corremos SOLO la etapa 1, a mano, para ver qué produce ---
resultado de la etapa 1: Focus pro 3h cuesta 6000 centavos.
payload armado a mano: {'room': 'Focus', 'tier': 'pro', 'hours': 3, 'price_cents': 6000}
run_specialist(stage_1.name, task, model_script_quote) es exactamente el mismo run_specialist
del Módulo 2 —sin ninguna modificación—, corriendo sobre booking_agent con el mismo TOOLS de
siempre. Lo único nuevo es que la etapa se identifica ahora con un PipelineStage, no con un string
suelto. El payload que sigue es, por ahora, texto armado a mano: sé que price_cents es 6000
porque leí el tool_result del historial y lo copié — un proceso manual, propenso a error, que no
escala a un pipeline de tres etapas reales.
Por qué kind y no solo name
Vale la pena detenerse en la decisión de diseño: ¿por qué no alcanzaba con SPECIALISTS del Módulo
2, indexado solo por nombre? Tres razones concretas:
- Un especialista puede repetirse en el pipeline.
booking_agentcotiza y confirma —dos trabajos distintos, mismo agente—. Sinkind, no habría forma de decirle a la etapa 3 "confirma" en vez de "cotiza otra vez". - Cada etapa arma su tarea de forma distinta. La etapa 1 necesita construir "Cotiza X Y Zh."; la
etapa 3 necesita construir "Reserva X Y Zh para W." — funciones distintas, aunque ambas despachen
a
booking_agent. La lección 03 usakindexactamente para elegir cuál construir. - Cada etapa extrae un dato distinto del resultado. La etapa 1 necesita
price_cents; la etapa 3 necesitabooking_id. De nuevo,kindes la clave que decide qué extraer — noname, que solo dice quién corrió.
Errores comunes
-
Confundir
PIPELINE_STAGESconSPECIALISTS. Son estructuras distintas para preguntas distintas:SPECIALISTSresponde "¿qué tools tiene este agente?";PIPELINE_STAGESresponde "¿en qué orden corren las etapas, y qué rol cumple cada una?". Un pipeline sigue necesitandoSPECIALISTSpor debajo —run_specialistlo usa sin cambios— pero le agrega la secuencia y el rol encima. -
Pensar que
kindtiene que coincidir conname. No hay ninguna regla que los relacione —de hecho, en este módulo,kind="quote"ykind="confirm"comparten el mismoname="booking_agent", y son etapas completamente distintas. -
Armar el payload a mano en un pipeline real. El ejemplo de esta lección lo hace a propósito, para exponer el problema — pero copiar valores del
tool_resulta mano no escala ni es confiable una vez que el pipeline tiene varias etapas. La lección 03 resuelve esto con una función que ancla el payload en el resultado real, sin copiar nada a mano. -
Olvidar que
PIPELINE_STAGESes una LISTA, no un diccionario. El orden de la lista es el orden de ejecución — a diferencia deSPECIALISTS, donde el orden de las claves nunca importó porque el supervisor accedía por nombre, no por posición. -
Ejecutar este ejemplo en un proceso que ya tenía reservas. Si corriste otro ejemplo de la guía en el mismo intérprete,
reservo_tools.BOOKINGSno está vacío — aunque esta lección en particular no llama abook_room, las lecciones siguientes del módulo sí, y asumen un proceso nuevo.
Ejercicios
Ejercicio 1: Identifica el rol correcto para una etapa nueva (Fácil)
Reservo agrega, al final del proceso de reserva, un cuarto paso: booking_agent cancela
automáticamente cualquier reserva anterior del mismo socio para el mismo horario, antes de crear la
nueva. Escribe el PipelineStage para esa etapa, decidiendo un kind que no colisione con
"quote" ni "confirm".
Ver solución
cancel_conflicting_stage = PipelineStage(
kind="cancel_conflicting",
name="booking_agent",
label="cancelar reservas previas en conflicto",
)
print(cancel_conflicting_stage)
Salida esperada:
PipelineStage(kind='cancel_conflicting', name='booking_agent', label='cancelar reservas previas en conflicto')
Explicación: el kind nuevo, "cancel_conflicting", no colisiona con "quote" ni "confirm" —
aunque el name sea, otra vez, "booking_agent" —la tercera vez que ese especialista aparecería en
el pipeline, con un tercer rol distinto—. Esto confirma el punto central de la lección: lo que
distingue una etapa de otra es el rol (kind), nunca el agente que la corre.
Ejercicio 2: Corre la etapa 2 sola, con el mismo patrón manual (Medio)
Repite el patrón de esta lección —correr una etapa sola y armar el payload a mano— pero con la
etapa 2 (policy_agent, validar la política de cancelación). Usa la tarea "¿Cuál es la política de cancelación para una reserva de Focus de 3h, antes de confirmarla?".
Ver solución
task_2 = "¿Cuál es la política de cancelación para una reserva de Focus de 3h, antes de confirmarla?"
model_script_policy = [
{"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. No hay ningún impedimento para confirmar."
)}]},
]
stage_2 = PIPELINE_STAGES[1]
final_2, history_2 = run_specialist(stage_2.name, task_2, model_script_policy)
print("resultado de la etapa 2:", final_2["content"][0]["text"])
payload_2 = dict(payload)
payload_2["cleared_to_book"] = True
print("payload armado a mano:", payload_2)
Salida esperada:
resultado de la etapa 2: Puedes cancelar sin cargo hasta 2 horas antes del horario reservado. No hay ningún impedimento para confirmar.
payload armado a mano: {'room': 'Focus', 'tier': 'pro', 'hours': 3, 'price_cents': 6000, 'cleared_to_book': True}
Explicación: el payload_2 conserva todo lo que ya tenía payload de la etapa 1 —room,
tier, hours, price_cents— y le agrega cleared_to_book, el dato nuevo que produce la etapa 2.
Este crecimiento acumulativo del payload —nunca perder lo que ya se tenía, solo sumar— es exactamente
el comportamiento que extract_payload va a formalizar en la lección 03.
Ejercicio 3: ¿Qué pasa si PIPELINE_STAGES tiene un name inválido? (Difícil)
Agrega una cuarta entrada a una copia de PIPELINE_STAGES con name="shipping_agent" —un
especialista que no existe en SPECIALISTS— e intenta correrla con run_specialist. ¿Qué excepción
se produce, y por qué es preferible a que el pipeline siga corriendo con un especialista inventado?
Ver solución
bad_stage = PipelineStage(kind="notify", name="shipping_agent", label="notificar al socio")
try:
run_specialist(bad_stage.name, "notifica al socio", [])
except KeyError as e:
print(f"KeyError capturado: {e!r}")
Salida real:
KeyError capturado: KeyError('shipping_agent')
Explicación: el mismo KeyError que ya viste en el Módulo 2, lección 02 — run_specialist
busca SPECIALISTS["shipping_agent"] y no lo encuentra, así que falla antes de intentar despachar
nada. Es preferible a un pipeline que "siguiera de largo" con un especialista inexistente porque, en
un patrón donde ninguna etapa se vuelve a revisar después, un error silencioso en la etapa 4 podría
pasar completamente desapercibido hasta que alguien note que el socio nunca recibió su notificación —
un fallo mucho más caro de rastrear que un KeyError inmediato.
Resumen y siguiente paso
- Un pipeline identifica cada etapa por su rol (
kind), no solo por el especialista que la resuelve (name) — porque un mismo especialista puede repetirse en distintas posiciones de la secuencia. PipelineStageagregakind,nameylabel;PIPELINE_STAGESes una lista, no un diccionario — el orden de la lista es literalmente el orden de ejecución.- Corrimos la etapa 1 (cotizar) sola, con
run_specialistsin ningún cambio del Módulo 2, y armamos el payload que la etapa 2 necesitaría a mano — un proceso que no escala y que la lección 03 automatiza. booking_agentaparece dos veces enPIPELINE_STAGES—cotizar y confirmar—, conpolicy_agenten el medio: la prueba concreta de por qué el rol, no el nombre, es lo que distingue una etapa.
Siguiente lección: 03 — Encadenando el dato entre etapas. Generalizamos el payload armado a
mano de esta lección con build_stage_task y extract_payload, ejecutados sobre un pipeline
pequeño de dos etapas.
Recursos adicionales
- Python —
dataclasses— El módulo detrás dePipelineStage, la misma herramienta que ya usaste paraRoutingDecisionen el Módulo 2. - Anthropic — Building effective agents — El patrón "prompt chaining", la base conceptual de una secuencia de etapas con orden fijo.
- Anthropic — Tool use (function calling) overview — El protocolo que
run_specialistsigue respetando, sin cambios, dentro de cada etapa. - Python — Listas — La estructura detrás de
PIPELINE_STAGES, donde el orden de los elementos es, por primera vez en esta guía, parte del significado de la estructura.