Módulo 3: Pipelines secuenciales

Eligiendo pipeline o supervisor

Descripción

Seis lecciones construyeron el patrón pipeline por completo: la anatomía de una etapa (02), el mecanismo de encadenar el dato (03), el pipeline de Reservo corriendo de punta a punta (04), su costo de coordinación medido contra un supervisor (05), y la guarda que lo protege cuando una etapa no alcanza (06). Esta lección cierra el criterio: ¿cuándo, de verdad, conviene un pipeline sobre un supervisor? Y hay una pregunta más incómoda, que esta lección no evita: el propio pipeline de este módulo, ¿de verdad necesitaba las tres etapas en orden fijo?

La respuesta, confirmada ejecutando el código —no supuesta— es que no. Vas a probar, con build_stage_task tal como quedó en las lecciones 03 a 06, que dos de las tres etapas —quote y validate_policy— no leen absolutamente nada que la otra produzca. Solo confirm depende genuinamente de lo que dejó validate_policy. Ese hallazgo no invalida este módulo —el pipeline sigue siendo una forma válida y más simple de correr las tres etapas—, pero es exactamente la pregunta que abre la puerta al Módulo 4: si dos etapas no dependen entre sí, ¿por qué esperar a que una termine para empezar la otra?

Conexión con el módulo

Esta lección reusa, sin cambios, PipelineStage, build_stage_task y PIPELINE_STAGES de las lecciones 02 a 06. No agrega ningún mecanismo de ejecución nuevo —el fan-out real, con ThreadPoolExecutor, es del Módulo 4—; lo que agrega es el análisis que confirma, con el código ya escrito, cuáles dependencias son reales y cuáles son solo una decisión de diseño. El mini-proyecto de la lección 08 cierra el módulo aplicando el pipeline completo, con su guarda, sobre escenarios nuevos.


El criterio completo: pipeline vs. supervisor

CriterioPipeline (este módulo)Supervisor (Módulo 2)
El orden de los pasosSiempre el mismo, fijo desde el diseño del sistemaPuede cambiar según qué pida cada petición
Quién decide "¿cuál sigue?"Nadie — ya está escrito en PIPELINE_STAGESUn router (reglas o el modelo), en cada petición
Costo de ruteoCero llamadas, sin importar cuántas etapas tenga (lección 05)Una llamada por decisión — o una por etapa, si decidiera en cada una
Un paso que no aplica a esta peticiónEl pipeline lo corre igual — no hay forma de "saltarlo" sin cambiar el códigoEl router simplemente no lo agenda
Cuando un paso "falla" sin excepciónNecesita una guarda explícita (lección 06) — nada avisa gratisUn router honesto (Módulo 2, lección 07) puede devolver None y no delegar nada
Ejemplo en Reservocotizar → validar política → confirmar, SIEMPRE en ese orden"cotiza y reserva" vs. "¿cuál es la política?" — la petición decide el camino

Ninguna fila de esta tabla dice "el pipeline es mejor" en abstracto. Dice, con precisión, qué gana y qué pierde cada patrón — el mismo espíritu de la lección 05, que midió el ahorro sin declarar un ganador universal.


La pregunta incómoda: ¿de verdad las tres etapas dependen entre sí?

PIPELINE_STAGES las puso en un orden fijo, pero un orden fijo en el código no prueba una dependencia real de datos — solo prueba que alguien decidió ponerlas en ese orden. La forma de confirmar una dependencia real es simple: ¿la función que arma la tarea de una etapa lee algo que dejó otra? Si la respuesta es no, esa etapa correría exactamente igual sin importar cuándo se ejecute la otra.

from dataclasses import dataclass


@dataclass
class PipelineStage:
    kind: str
    name: str
    label: str


def build_stage_task(stage, payload):
    if stage.kind == "quote":
        return f"Cotiza {payload['room']} {payload['tier']} {payload['hours']}h."
    if stage.kind == "validate_policy":
        return (f"¿Cuál es la política de cancelación para una reserva de "
                 f"{payload['room']} de {payload['hours']}h, antes de confirmarla?")
    if stage.kind == "confirm":
        if payload.get("cleared_to_book"):
            return (f"Reserva {payload['room']} {payload['tier']} {payload['hours']}h "
                     f"para {payload['member']} -- la política de cancelación ya se validó.")
        return (f"Reserva {payload['room']} {payload['tier']} {payload['hours']}h "
                 f"para {payload['member']}.")
    raise ValueError(f"no sé armar la tarea de la etapa {stage.kind!r}")


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"),
]

INITIAL_PAYLOAD = {"room": "Focus", "tier": "pro", "hours": 3, "member": "Ana"}

print("--- ¿validate_policy necesita el price_cents que dejó quote? ---")
task_policy_before_quote = build_stage_task(PIPELINE_STAGES[1], INITIAL_PAYLOAD)
print("tarea de validate_policy, con el payload INICIAL (sin price_cents):")
print(" ", repr(task_policy_before_quote))

payload_after_quote = dict(INITIAL_PAYLOAD)
payload_after_quote["price_cents"] = 6000
task_policy_after_quote = build_stage_task(PIPELINE_STAGES[1], payload_after_quote)
print("tarea de validate_policy, con price_cents YA en el payload:")
print(" ", repr(task_policy_after_quote))
print("¿son idénticas?", task_policy_before_quote == task_policy_after_quote)

print()
print("--- ¿confirm necesita el price_cents que dejó quote? ---")
payload_cleared_no_price = {"room": "Focus", "tier": "pro", "hours": 3, "member": "Ana", "cleared_to_book": True}
task_confirm_no_price = build_stage_task(PIPELINE_STAGES[2], payload_cleared_no_price)
print("tarea de confirm, SIN price_cents en el payload:")
print(" ", repr(task_confirm_no_price))

print()
print("--- ¿confirm necesita lo que dejó validate_policy (cleared_to_book)? ---")
payload_no_policy_yet = {"room": "Focus", "tier": "pro", "hours": 3, "member": "Ana"}
task_confirm_before_policy = build_stage_task(PIPELINE_STAGES[2], payload_no_policy_yet)
print("tarea de confirm, ANTES de que validate_policy corriera:")
print(" ", repr(task_confirm_before_policy))
payload_after_policy = dict(payload_no_policy_yet)
payload_after_policy["cleared_to_book"] = True
task_confirm_after_policy = build_stage_task(PIPELINE_STAGES[2], payload_after_policy)
print("tarea de confirm, DESPUÉS de que validate_policy corriera:")
print(" ", repr(task_confirm_after_policy))
print("¿cambia el texto?", task_confirm_before_policy != task_confirm_after_policy)

print()
print("--- mapa de dependencias, confirmado con las cuatro llamadas de arriba ---")
print("quote            depende de: nada (solo el payload inicial)")
print("validate_policy  depende de: nada (solo el payload inicial) -- NO de price_cents")
print("confirm          depende de: validate_policy (cleared_to_book) -- el texto SÍ cambia")

Qué esperar:

--- ¿validate_policy necesita el price_cents que dejó quote? ---
tarea de validate_policy, con el payload INICIAL (sin price_cents):
  '¿Cuál es la política de cancelación para una reserva de Focus de 3h, antes de confirmarla?'
tarea de validate_policy, con price_cents YA en el payload:
  '¿Cuál es la política de cancelación para una reserva de Focus de 3h, antes de confirmarla?'
¿son idénticas? True

--- ¿confirm necesita el price_cents que dejó quote? ---
tarea de confirm, SIN price_cents en el payload:
  'Reserva Focus pro 3h para Ana -- la política de cancelación ya se validó.'

--- ¿confirm necesita lo que dejó validate_policy (cleared_to_book)? ---
tarea de confirm, ANTES de que validate_policy corriera:
  'Reserva Focus pro 3h para Ana.'
tarea de confirm, DESPUÉS de que validate_policy corriera:
  'Reserva Focus pro 3h para Ana -- la política de cancelación ya se validó.'
¿cambia el texto? True

--- mapa de dependencias, confirmado con las cuatro llamadas de arriba ---
quote            depende de: nada (solo el payload inicial)
validate_policy  depende de: nada (solo el payload inicial) -- NO de price_cents
confirm          depende de: validate_policy (cleared_to_book) -- el texto SÍ cambia

Ahí está el hallazgo, confirmado con código, no con intuición: task_policy_before_quote y task_policy_after_quote son idénticas — a build_stage_task para validate_policy le da exactamente igual si price_cents existe o no en el payload, porque nunca lo lee. En cambio, task_confirm_before_policy y task_confirm_after_policy difieren — la mención a "la política de cancelación ya se validó" solo aparece cuando cleared_to_book llegó antes. confirm depende genuinamente de validate_policy. quote y validate_policy no dependen entre sí.


Por qué siguen en orden fijo, entonces

Si quote y validate_policy no dependen entre sí, ¿por qué PIPELINE_STAGES las puso una después de la otra en vez de juntas? Dos razones, ninguna de ellas "porque hacía falta":

  1. Simplicidad de diseño. Un pipeline con tres pasos, uno detrás del otro, es más fácil de leer, trazar y depurar que un sistema que corre dos cosas a la vez y espera a que ambas terminen antes de la tercera. Cuando el ahorro de tiempo no importa —Reservo procesando una reserva a la vez, sin presión de latencia—, el orden fijo es la opción más simple que resuelve la tarea, el mismo principio que ya guio varias decisiones de esta guía.

  2. El orden fijo no es gratis, pero tampoco es caro. La lección 05 ya midió que el pipeline ahorra, no cuesta, frente a un supervisor repetido. Correr quote y validate_policy en secuencia en vez de en paralelo no le agrega ninguna llamada extra al modelo — solo le agrega tiempo de reloj, que esta guía nunca contó como parte del costo de coordinación.

Cuando ese tiempo de reloj importa —muchas reservas por procesar, o una interfaz esperando una respuesta rápida—, la independencia real entre quote y validate_policy deja de ser un detalle académico: es la justificación concreta para el fan-out del Módulo 4, que corre exactamente estas dos etapas a la vez y agrega sus resultados antes de la tercera.


Errores comunes

  1. Concluir que "no dependen entre sí" significa "el orden en PIPELINE_STAGES está mal". No — el pipeline de este módulo sigue siendo correcto tal como está. La independencia entre quote y validate_policy es una oportunidad de optimización futura (Módulo 4), no un defecto de este módulo.

  2. Confundir "no hay dependencia de datos" con "no hay dependencia de negocio". confirm no lee price_cents en build_stage_task, pero eso no significa que Reservo pueda confirmar una reserva sin haberla cotizado antes — la respuesta compuesta de la lección 04 sí usa price_cents para informarle el precio al socio. La independencia que esta lección mide es puntual: qué necesita build_stage_task para armar el texto de esa etapa, no qué necesita el proceso de negocio completo.

  3. Pensar que la tabla de criterio de esta lección reemplaza la del Módulo 2. Esa tabla —cuándo usar reglas, decisión del modelo, o un híbrido— responde una pregunta distinta (cómo decide un router). La tabla de esta lección responde otra (pipeline contra supervisor, en general). Son complementarias, no una versión más nueva de la otra.

  4. Generalizar "dos de tres etapas son independientes" a cualquier pipeline. Es el resultado de este pipeline puntual, confirmado ejecutando su código — no una propiedad universal de todos los pipelines. Un pipeline donde la etapa 2 sí necesitara price_cents de la etapa 1 tendría una dependencia real, y forzarlo a fan-out produciría resultados incorrectos.

  5. Pensar que la independencia de quote y validate_policy implica que ya construiste fan-out. No — esta lección solo mide la independencia con build_stage_task, ejecutado sobre payloads armados a mano. Ningún ThreadPoolExecutor corrió estas dos etapas a la vez todavía; eso es, exactamente, lo que construye el Módulo 4.


Ejercicios

Ejercicio 1: Confirma que quote tampoco depende de validate_policy (Fácil)

El ejemplo trabajado probó que validate_policy no depende de quote. Falta la otra dirección: confirma que build_stage_task para quote produce el mismo texto con o sin cleared_to_book en el payload.

Ver solución
task_quote_plain = build_stage_task(PIPELINE_STAGES[0], INITIAL_PAYLOAD)
payload_with_cleared = dict(INITIAL_PAYLOAD)
payload_with_cleared["cleared_to_book"] = True
task_quote_with_cleared = build_stage_task(PIPELINE_STAGES[0], payload_with_cleared)
print("tarea de quote sin cleared_to_book:", repr(task_quote_plain))
print("tarea de quote CON cleared_to_book:", repr(task_quote_with_cleared))
print("¿son idénticas?", task_quote_plain == task_quote_with_cleared)

Salida esperada:

tarea de quote sin cleared_to_book: 'Cotiza Focus pro 3h.'
tarea de quote CON cleared_to_book: 'Cotiza Focus pro 3h.'
¿son idénticas? True

Explicación: confirma la independencia en las dos direcciones — quote no lee nada de validate_policy, tal como validate_policy no lee nada de quote. Las dos etapas son, en los hechos, mutuamente independientes.

Ejercicio 2: Confirma que quote tampoco depende de confirm (Medio)

Completa la matriz de dependencias: confirma que build_stage_task para quote tampoco cambia si el payload ya trae booking_id y confirmed —los datos que deja confirm, la ÚLTIMA etapa del pipeline—.

Ver solución
payload_with_booking = dict(INITIAL_PAYLOAD)
payload_with_booking["booking_id"] = 99
payload_with_booking["confirmed"] = True
task_quote_with_booking = build_stage_task(PIPELINE_STAGES[0], payload_with_booking)
print("tarea de quote CON booking_id/confirmed ya en el payload:", repr(task_quote_with_booking))
print("¿son idénticas?", task_quote_plain == task_quote_with_booking)

Salida esperada:

tarea de quote CON booking_id/confirmed ya en el payload: 'Cotiza Focus pro 3h.'
¿son idénticas? True

Explicación: esto era esperable —quote es la PRIMERA etapa; en un pipeline real, nunca vería booking_id antes de correr—, pero confirmarlo con código, no solo con el orden en PIPELINE_STAGES, es la misma disciplina de esta lección: nunca asumir una dependencia, verificarla.

Ejercicio 3: Cuenta las rondas de coordinación con fan-out (Difícil)

La lección 05 contó llamadas al modelo. Esta lección cuenta algo distinto: rondas —cuántos turnos de reloj necesitaría el pipeline si quote y validate_policy, al ser independientes, corrieran juntas en la misma ronda, con confirm esperando a que ambas terminen (exactamente lo que mide el Módulo 4, sin construirlo todavía). Calcula las rondas del pipeline secuencial de este módulo contra las de ese fan-out hipotético, y confirma que el número de llamadas al modelo no cambia entre los dos caminos.

Ver solución
sequential_rounds = 3  # quote, DESPUÉS validate_policy, DESPUÉS confirm -- una ronda cada una
fanout_rounds = 2       # ronda 1: quote y validate_policy JUNTOS -- ronda 2: confirm
print(f"pipeline secuencial (este módulo): {sequential_rounds} rondas")
print(f"fan-out hipotético (quote + validate_policy juntos, M4): {fanout_rounds} rondas")
print("llamadas al modelo en ambos casos: 7 (las MISMAS -- fan-out no cambia CUÁNTAS, solo CUÁNDO)")

Salida esperada:

pipeline secuencial (este módulo): 3 rondas
fan-out hipotético (quote + validate_policy juntos, M4): 2 rondas
llamadas al modelo en ambos casos: 7 (las MISMAS -- fan-out no cambia CUÁNTAS, solo CUÁNDO)

Explicación: esta es la distinción exacta que separa la lección 05 de lo que mide el Módulo 4. La lección 05 comparó pipeline contra supervisor y encontró una diferencia en llamadas al modelo (7 contra 10) — un ahorro real de trabajo de coordinación. Fan-out, en cambio, no reduce cuántas llamadas hacen falta —siguen siendo las mismas 7—, reduce cuántas rondas de reloj hacen falta para completarlas, porque dos de ellas dejan de esperarse la una a la otra. Son dos tipos de ahorro distintos, y confundirlos —pensar que fan-out "ahorra llamadas" como lo hace un pipeline frente a un supervisor— es exactamente el error que el Módulo 4 va a prevenir desde su primera lección.


Resumen y siguiente paso

  • El criterio completo: un pipeline gana cuando el orden es genuinamente fijo y el costo de decidir en cada paso no se justifica (lección 05); pierde la robustez "gratis" de un router honesto, y necesita una guarda explícita para sus propios modos de falla (lección 06).
  • El hallazgo central de esta lección, confirmado ejecutando el código: de las tres etapas de este módulo, solo confirm depende genuinamente de otra (validate_policy, por cleared_to_book). quote y validate_policy no leen nada la una de la otra — son, en los hechos, independientes.
  • Esa independencia no invalida el pipeline de este módulo —sigue siendo la forma más simple de resolver la tarea cuando el tiempo de reloj no importa—, pero es la justificación concreta y medida, no supuesta, para el patrón que viene.
  • Fan-out (Módulo 4) no reduce cuántas llamadas al modelo hacen falta —el Ejercicio 3 lo confirmó: 7 en ambos caminos—; reduce cuántas rondas de reloj hacen falta, porque etapas genuinamente independientes dejan de esperarse entre sí.

Siguiente lección: 08 — Mini-proyecto: el pipeline de Reservo. Aplicas el pipeline completo —con su guarda de la lección 06— sobre tres escenarios nuevos, dos que lo completan de punta a punta y uno que activa la guarda.


Recursos adicionales

  1. Anthropic — Building effective agents — La distinción entre "prompt chaining" (este módulo) y "parallelization" (el patrón que anticipa el Módulo 4), presentada ahí como dos formas de flujo distintas según exista o no dependencia real entre los pasos.
  2. Python — dataclassesPipelineStage, reusado sin cambios desde la lección 02, la base sobre la que se construyó todo el análisis de esta lección.
  3. Anthropic — Multi-agent research system — Un sistema real donde identificar qué sub-tareas son genuinamente independientes —y cuáles no— determinó qué partes del flujo se paralelizaron y cuáles se mantuvieron secuenciales.
  4. Python — Funciones puras y efectos observables — El principio detrás de la prueba de esta lección: si una función produce el mismo resultado sin importar qué claves adicionales tenga su entrada, esa entrada no es una dependencia real.