Módulo 1: Por qué multi-agente (y cuándo no)

Cuándo multi-agente SÍ ayuda

Descripción

La lección 05 midió el peor caso: una tarea que un solo agente ya resuelve bien, resuelta también por un sistema de dos agentes — y el sistema multi-agente perdió, con números reales, sin ganar nada a cambio. Sería un error leer esa lección como "nunca uses multi-agente". Esta lección construye el caso contrario, con el mismo rigor: una tarea que justifica repartir el trabajo, y por qué el mismo costo de coordinación que perdió en la lección 05 esta vez vale la pena pagarlo.

Vas a ejecutar tres señales concretas, cada una con su propia medición: separabilidad genuina (dos sub-preguntas que no dependen la una de la otra, y por lo tanto pueden resolverse en paralelo, no en fila), expertise distinto (tools cuyo input_schema nunca puede colisionar, porque viven en dominios de datos completamente distintos), y aislamiento de contexto (nombrado, no construido — la razón por la que dos dominios muy distintos conviene que no compartan la misma ventana). El caso: booking_agent y un policy_agent nuevo, con su tool search_docs — el stub mínimo que representa, sin reconstruirlo, el índice real de production-rag-and-document-ingestion-guide.

Conexión con el módulo

Esta lección cierra el círculo abierto por la 04: ahí, repartir CANONICAL de SPRAWL no seguía ninguna frontera de dominio real, y el ahorro de bytes no alcanzaba a justificar, por sí solo, el costo de coordinación de la lección 03. Acá, el reparto entre booking_agent y policy_agent sí sigue una frontera de dominio genuina, y vas a ver cómo eso cambia la cuenta completa. La lección 07 toma este mismo par de agentes como el caso de entrada al patrón de fan-out del Módulo 4.


Analogía: el especialista de logística y el abogado de contratos

Vuelve al equipo de personas de la lección 01. Organizar un evento necesita, entre otras cosas, reservar el salón y revisar la cláusula de cancelación del contrato con el proveedor. Ninguna de las dos tareas depende del resultado de la otra —puedes reservar el salón mientras alguien más revisa el contrato, al mismo tiempo, sin esperarse—. Y ninguna de las dos personas necesita saber lo que la otra sabe: quien reserva el salón no necesita entender cláusulas legales, y el abogado de contratos no necesita saber cuántas sillas caben en el salón. Cuando las dos condiciones se cumplen a la vez —el trabajo se separa limpio, y cada parte pide un conocimiento que la otra no tiene—, contratar a dos especialistas no agrega una reunión innecesaria: evita que una sola persona tenga que dominar dos oficios distintos, y deja que las dos partes avancen a la vez.


Señal 1: separabilidad — la tarea se reparte en sub-preguntas independientes

La pregunta clave para esta señal: ¿el resultado de una sub-tarea depende del resultado de la otra? En la tarea de la lección 05 ("cotiza y resérvala"), la respuesta era sí — book_room necesitaba el precio de get_quote antes de poder ejecutarse, así que las dos tool calls tenían que ir en secuencia. En la tarea de esta lección, la respuesta es no.

import reservo_tools as rt

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 mínimo -- 2 entradas fijas por coincidencia de palabra clave.
    Sustituto EXPLÍCITO de la tool real de
    production-rag-and-document-ingestion-guide (índice BM25 sobre 57
    chunks/13 docs); aquí solo alcanza para que la orquestación de este
    módulo se ejecute de punta a punta."""
    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."


print(search_docs("¿cuál es la política de cancelación?"))
print(search_docs("¿qué pasa si no me presento a mi reserva?"))
print(search_docs("¿aceptan tarjeta corporativa?"))

Qué esperar:

[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.
[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.
No se encontró una política relevante para esa pregunta.

Con search_docs funcionando, ahora despachamos la tarea compuesta "Cotiza Focus pro 3h y dime la política de cancelación" — get_quote y search_docs en el mismo turno, en paralelo, igual que el fan-out con hilos de agent-fundamentals M5 L05.

def dispatch_parallel(tool_use_blocks, tools):
    import concurrent.futures
    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)
    ]


TOOLS_FANOUT = {"get_quote": rt.get_quote, "search_docs": search_docs}

turn = [
    {"type": "tool_use", "id": "toolu_01", "name": "get_quote",
     "input": {"room": "Focus", "tier": "pro", "hours": 3}},
    {"type": "tool_use", "id": "toolu_02", "name": "search_docs",
     "input": {"query": "política de cancelación"}},
]
results = dispatch_parallel(turn, TOOLS_FANOUT)
for r in results:
    print(r)

Qué esperar:

{'type': 'tool_result', 'tool_use_id': 'toolu_01', 'content': "{'price_cents': 6000}"}
{'type': 'tool_result', 'tool_use_id': 'toolu_02', 'content': '[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.'}

Compara con la lección 05: ahí, get_quote y book_room no podían despacharse juntos en un mismo turno —book_room necesitaba el price_cents que get_quote todavía no había devuelto—. Acá, get_quote y search_docs sí, porque ninguno de los dos necesita nada del otro. Esta es la separabilidad genuina: no es que "hay dos preguntas", es que ninguna depende del resultado de la otra. El Módulo 4 de esta guía construye el patrón de fan-out completo sobre exactamente esta propiedad.


Señal 2: expertise distinto — tools que nunca pueden colisionar

La lección 04 midió el costo de tools que colisionan en input_schema — el problema de fondo de SPRAWL. Con booking_agent y policy_agent, el riesgo de colisión desaparece por diseño, no por cuidado adicional: los input_schema de los dos dominios son estructuralmente distintos.

import json
import itertools

BOOKING_TOOLS = [
    {"name": "get_quote", "description": "Cotiza el precio de una sala.",
     "input_schema": {"type": "object", "properties": {
         "room": {"type": "string"}, "tier": {"type": "string"},
         "hours": {"type": "integer"}}, "required": ["room", "tier", "hours"]}},
    {"name": "book_room", "description": "Reserva una sala.",
     "input_schema": {"type": "object", "properties": {
         "room": {"type": "string"}, "tier": {"type": "string"},
         "hours": {"type": "integer"}, "member": {"type": "string"}},
         "required": ["room", "tier", "hours", "member"]}},
]

SEARCH_DOCS_TOOL = {
    "name": "search_docs",
    "description": "Busca en la base de políticas de Reservo un fragmento relevante.",
    "input_schema": {
        "type": "object", "properties": {"query": {"type": "string"}},
        "required": ["query"],
    },
}


def schema_collisions(tools):
    pairs = itertools.combinations(tools, 2)
    return sum(1 for a, b in pairs if a["input_schema"] == b["input_schema"])


merged = BOOKING_TOOLS + [SEARCH_DOCS_TOOL]
print("colisiones si se declaran juntas en un agente:", schema_collisions(merged))
print("input_schema de get_quote:", set(BOOKING_TOOLS[0]["input_schema"]["properties"]))
print("input_schema de search_docs:", set(SEARCH_DOCS_TOOL["input_schema"]["properties"]))

Qué esperar:

colisiones si se declaran juntas en un agente: 0
input_schema de get_quote: {'room', 'tier', 'hours'}
input_schema de search_docs: {'query'}

Cero colisiones, incluso fusionadas en un solo agente. Esta es una diferencia importante con la lección 04: ahí, el argumento para repartir era reducir colisiones que ya existían. Acá, nunca hubo ni va a haber colisión posible —get_quote pide números estructurados (room/tier/hours); search_docs pide una sola cadena de texto libre (query)—. El argumento para repartir booking_agent de policy_agent no es "menos confusión de schema" — ya viste que es cero de cualquier forma—. Es la separabilidad de la Señal 1, y la Señal 3, que sigue.


Señal 3: aislamiento de contexto (nombrada, no construida aquí)

La tercera señal no se mide con bytes ni con conteo de colisiones — se nombra. policy_agent podría, en una versión de producción real, tener acceso al contenido completo de los documentos de política de Reservo —no solo las 2-3 entradas de este stub, sino el corpus real, los 57 chunks de production-rag-and-document-ingestion-guide—. Si esa cantidad de texto viviera en el mismo contexto que booking_agent, cada llamada de booking_agent —incluso una tan simple como "cotiza Focus pro 3h"— cargaría con documentos de política que nunca va a usar. Aislar cada especialista en su propio agente, con su propia ventana de contexto, evita ese "ruido" — cada agente solo ve lo que su expertise necesita.

Presupuestar y comprimir el contenido dentro de esa ventana —qué entra exactamente en el system/messages de policy_agent, cómo se resume un corpus grande, cuánto cuesta en tokens— es terreno de context-engineering-guide, no de esta lección: aquí la ventana aislada ya existe por construcción (cada agente es un proceso lógico separado, con su propio historial), pero cómo se llena esa ventana con cuidado es una guía hermana completa.


La cuenta completa: costo medido, beneficio real

Con las tres señales sobre la mesa, la cuenta de esta tarea se ve distinta a la de la lección 05. El costo de coordinación sigue siendo el mismo tipo de costo —más llamadas, más hops— pero ahora se compara contra beneficios reales, no contra cero:

                              Sistema A (1 agente, 5 tools)   Sistema B (2 agentes)
llamadas al modelo                        ~3                        ~5-6
bytes declarados por request              1986                 1621 (booking) / 365 (policy)
sub-tareas en paralelo                    no aplica            SÍ -- get_quote y search_docs
riesgo de colisión de schema              0 (ya sano)           0 (estructuralmente imposible)
contexto de policy_agent en booking_agent SIEMPRE presente      NUNCA presente

La fila que cambia la decisión frente a la lección 05 es la de paralelo: en la tarea de la lección 05, no había nada que paralelizar —book_room dependía de get_quote—, así que el costo de coordinación no compraba ninguna reducción de latencia. Acá, las dos sub-tareas SÍ corren en paralelo (lo ejecutaste arriba, con dispatch_parallel), así que parte del costo de coordinación se paga a cambio de no esperar en fila — un beneficio que la lección 05 nunca tuvo la oportunidad de ofrecer.


Errores comunes

  1. Pensar que "menos colisiones de schema" es la razón para separar booking_agent de policy_agent. Ya viste que las colisiones son cero de cualquier forma, fusionadas o no. La razón real es la separabilidad (Señal 1) y el aislamiento de contexto (Señal 3) — no la Señal 2, que en este caso concreto no aporta nada nuevo por sí sola.

  2. Confundir "el stub de search_docs funciona" con "el policy_agent está completo". Este stub tiene 2-3 entradas fijas por palabra clave — es un sustituto explícito y mínimo del índice BM25 real de production-rag-and-document-ingestion-guide, no una reimplementación. Sirve para que la orquestación se ejecute de punta a punta; no sirve para responder preguntas de política que no calcen con esas palabras clave.

  3. Pensar que toda tarea con dos sub-preguntas es automáticamente separable. La tarea de la lección 05 ("cotiza Y reserva") también tenía dos partes, pero NO eran independientes — la señal correcta no es "cuántas partes tiene la tarea", es "¿el resultado de una depende del resultado de la otra?".

  4. Construir el aislamiento de contexto en vez de solo nombrarlo. Esta lección deliberadamente no presupuesta ni comprime nada dentro de la ventana de policy_agent — eso es context-engineering-guide. Confundir "cada agente tiene su propia ventana" (aquí, gratis, por construcción) con "gestionar el contenido de esa ventana con cuidado" (allá, una guía entera) es perder la frontera.

  5. Olvidar que las tres señales no siempre aparecen juntas. El escenario de esta lección tiene las tres a la vez —caso fuerte—. Un escenario real puede tener solo una o dos; el Módulo 1 no exige las tres para justificar multi-agente, pero cuantas más señales aparezcan, más fuerte es el caso (retomado con una función de decisión en la lección 08).


Ejercicios

Ejercicio 1: Identifica las señales en un escenario nuevo (Fácil)

Reservo agrega un feedback_agent (el del Ejercicio 2 de la lección 02) que recopila la satisfacción de un socio después de su reserva, con una tool record_feedback(booking_id, stars, comment). Para una tarea compuesta "reserva Boardroom pro 2h para Sofía y, cuando termine, pregúntale su feedback": (a) ¿la Señal 1 (separabilidad) se cumple entre book_room y record_feedback? (b) ¿la Señal 2 (expertise distinto) se cumple? Justifica comparando los input_schema de las dos tools.

Ver solución

(a) No se cumple — al menos no en el sentido de "independientes". record_feedback necesita el booking_id que solo existe después de que book_room se ejecutó — hay una dependencia de datos real entre las dos, igual que book_room dependía del precio de get_quote en la lección 05. No pueden despacharse en el mismo turno en paralelo; tienen que ir en secuencia (una señal de que este caso, más que fan-out, podría ser un candidato para el patrón pipeline del Módulo 3 — la salida de una alimenta directamente a la siguiente, en orden fijo).

(b) Sí se cumple. book_room pide {room, tier, hours, member}; record_feedback pide {booking_id, stars, comment} — ningún campo se repite entre los dos, y las propiedades no colisionan. Que la Señal 2 se cumpla no alcanza sola, sin embargo: como muestra este ejercicio, sin la Señal 1 (separabilidad/paralelismo) la razón para repartir en dos agentes es más débil que la del booking_agent/policy_agent de esta lección.

Ejercicio 2: Ejecuta el fan-out con tres tools distintas (Medio)

Extiende el dispatch_parallel de esta lección para que la tarea compuesta sea "Cotiza Focus pro 3h, dime la política de no-presentación, y lista todas las salas disponibles" — tres tool_use en el mismo turno: get_quote, search_docs y list_rooms. Ejecútalo y confirma que las tres respuestas llegan, sin importar el orden real en que los hilos terminaron.

Ver solución
import reservo_tools as rt

TOOLS_THREE_WAY = {
    "get_quote": rt.get_quote,
    "search_docs": search_docs,
    "list_rooms": rt.list_rooms,
}

turn = [
    {"type": "tool_use", "id": "toolu_01", "name": "get_quote",
     "input": {"room": "Focus", "tier": "pro", "hours": 3}},
    {"type": "tool_use", "id": "toolu_02", "name": "search_docs",
     "input": {"query": "¿qué pasa si no me presento?"}},
    {"type": "tool_use", "id": "toolu_03", "name": "list_rooms", "input": {}},
]
results = dispatch_parallel(turn, TOOLS_THREE_WAY)
# Ordenamos por tool_use_id antes de imprimir -- el orden de FINALIZACIÓN de
# los hilos no es determinista, pero el orden de los resultados sí lo es,
# porque dispatch_parallel arma la lista final en el orden de los blocks de
# entrada (zip(tool_use_blocks, results)), no en el orden en que terminan.
for r in results:
    print(r["tool_use_id"], "->", r["content"][:60])

Salida esperada:

toolu_01 -> {'price_cents': 6000}
toolu_02 -> [no-show-policy] Si un miembro no se presenta a una reserva 
toolu_03 -> [{'room': 'Focus', 'rate_cents': 2500}, {'room': 'Studio', '

Explicación: dispatch_parallel construye la lista final con zip(tool_use_blocks, results) — es decir, empareja cada resultado con el bloque que lo pidió por posición en la lista de entrada, no por el momento en que el hilo terminó. Por eso, aunque los tres hilos puedan terminar en cualquier orden real (search_docs y list_rooms son casi instantáneos; get_quote también, en este caso, porque ninguno tiene latencia simulada), la salida siempre sigue el orden toolu_01, toolu_02, toolu_03 — el mismo requisito de determinismo de salida que exige la Regla dura de esta guía.

Ejercicio 3: Diseña un cuarto escenario con las tres señales completas (Difícil)

Diseña, en prosa y con el input_schema de al menos una tool nueva, un escenario de Reservo distinto al de esta lección que cumpla las TRES señales a la vez (separabilidad, expertise distinto, necesidad de aislar contexto). No hace falta que lo ejecutes con el runner completo — eso es el Módulo 4—; alcanza con: (a) nombrar los dos (o más) agentes involucrados y su tool característica, (b) explicar por qué las dos sub-tareas son independientes entre sí, y (c) explicar qué contenido, si viviera en el mismo agente, generaría "ruido" de contexto.

Ver solución

Escenario: un socio corporativo pregunta, en un solo mensaje: "¿Cuál es la política de facturación mensual de mi empresa, y cuántas salas tenemos disponibles para reservar hoy?"

(a) Los agentes: billing_agent, con una tool nueva get_invoice_policy(company_id) -> str (input_schema: {"type": "object", "properties": {"company_id": {"type": "string"}}, "required": ["company_id"]}) que consulta un documento de facturación corporativa —un dominio completamente distinto al de reservas—; y booking_agent, con list_rooms(), sin cambios.

(b) Separabilidad: la política de facturación de una empresa no depende de qué salas están disponibles hoy, y viceversa — ninguna de las dos respuestas necesita el resultado de la otra. Se pueden despachar en el mismo turno, en paralelo, exactamente como get_quote y search_docs en el ejemplo trabajado de esta lección.

(c) El ruido de contexto: un documento de facturación corporativa real —términos de pago, ciclos de facturación, condiciones por volumen de reservas— puede ser largo y específico de cada empresa cliente. Si ese documento viviera en el mismo contexto que booking_agent, cada pregunta de reserva —incluso una tan simple como "¿hay salas libres?"— cargaría con términos de facturación que nunca necesita para responder. Peor: si Reservo atiende a varias empresas, el contexto de booking_agent tendría que decidir CUÁL política de facturación es relevante para cada socio que pregunta, un problema que no tiene nada que ver con su expertise real (reservar salas). Aislar billing_agent en su propio agente evita ese cruce por completo — el patrón exacto que context-engineering-guide desarrolla a fondo, nombrado aquí sin construirse.


Resumen y siguiente paso

  • Multi-agente se justifica cuando aparecen, con evidencia concreta, alguna de estas tres señales: separabilidad genuina (las sub-tareas no dependen entre sí — pueden correr en paralelo), expertise distinto (tools de dominios de datos que nunca colisionan), y necesidad de aislar contexto (un dominio que, mezclado, contaminaría el de otro).
  • Ejecutamos el caso Reservo que las tiene las tres: booking_agent + policy_agent con search_docs (el stub mínimo, sustituto explícito de production-rag-and-document-ingestion-guide), resolviendo "cotiza Focus pro 3h y dime la política de cancelación" con get_quote y search_docs en el mismo turno, en paralelo — algo que la tarea secuencial de la lección 05 no podía ofrecer.
  • La colisión de input_schema (Señal 2, medida en la lección 04) resultó ser cero incluso fusionadas en un agente — el argumento real para separar booking_agent de policy_agent no es la limpieza de schema, es la separabilidad y el aislamiento.
  • El aislamiento de contexto se nombró, no se construyó: presupuestar y comprimir lo que entra en la ventana de cada especialista es terreno de context-engineering-guide.

Siguiente lección: 07 — Preview de los patrones. Con el criterio completo del módulo ya construido —costo medido, señales que sí lo justifican— vemos un mapa de los cinco patrones que los Módulos 2 a 6 construyen: supervisor, pipeline, fan-out, handoff y blackboard.


Recursos adicionales

  1. Anthropic — Multi-agent research system — El caso real de Anthropic donde separar agentes por dominio (y correrlos en paralelo) sí compensó el costo de coordinación, la misma lógica que esta lección aplica a Reservo.
  2. Anthropic — Building effective agents — "Workflows" con pasos paralelos vs. secuenciales — la base conceptual de la Señal 1 (separabilidad) de esta lección.
  3. Anthropic — Tool use (function calling) overview — La forma de varios tool_use en un mismo turno, la pieza del protocolo que hace posible el fan-out ejecutado en esta lección.
  4. Python — concurrent.futures — El módulo detrás de dispatch_parallel, reusado de agent-fundamentals M5 L05 sin cambios.