Saltar a contenido

Agentes de IA y servidores MCP

Dos patrones donde el modelo de actores de lapinbeam suele encajar especialmente bien: despachar tool calls de MCP a procesos worker especializados, y coordinar varios actores "expertos" respaldados por LLMs que necesitan hablarse entre sí con latencia sub-milisegundo. Ambos se apoyan en las mismas dos cosas ya cubiertas en el resto de esta documentación — payloads tipados (los modelos Pydantic hacen el roundtrip como ellos mismos, no como dicts) y ask()/current_message().reply() para request/response — aplicadas a una forma concreta de problema en vez de un ejemplo genérico.

Ilustrativo, no un tutorial completo de MCP

El cableado específico de MCP de abajo (MCPServer, @mcp.tool()) muestra el punto de integración, no un servidor MCP completo — consulta el SDK de Python de MCP para eso. Se probó contra mcp==2.0.0 (pip install mcp, la última versión en el momento de escribir esto): registrar la tool, listarla, y llamarla a través del propio call_tool de MCPServer llegan correctamente al actor de lapinbeam de abajo. Con un mcp<2.0 fijado, esta clase se llamaba FastMCP, importable desde mcp.server.fastmcp — el SDK la renombró en la 2.0. Todo lo específico de lapinbeam (Node, Supervisor, actor, ask, current_message) es la API real y actual en cualquier caso.

Despachar tool calls de MCP a workers especializados

Un servidor MCP es el proceso que habla de verdad con el cliente LLM (Claude Desktop, Claude Code, ...); una tool que expone no tiene por qué ejecutarse en el mismo proceso. Si una tool necesita un sandbox aislado, un modelo residente en GPU, o un índice grande en memoria, eso es un nodo separado al que el servidor MCP despacha — y como la llamada está en el camino crítico de una sesión interactiva, un salto por un broker es exactamente la latencia que no quieres pagar.

sequenceDiagram
    participant Client as Cliente LLM (p.ej. Claude)
    participant MCP as Proceso servidor MCP
    participant Sandbox as Actor "sandbox" de lapinbeam

    Client->>MCP: tool call run_code(code, language)
    MCP->>Sandbox: ask(RunCodeInput) — tipado, sub-ms en una LAN
    Note right of Sandbox: ejecuta el código de forma aislada
    Sandbox-->>MCP: reply(RunCodeResult)
    MCP-->>Client: resultado de la tool (JSON, según el protocolo MCP)

El nodo worker — un actor normal de lapinbeam, sin nada específico de MCP:

from pydantic import BaseModel
from lapinbeam import Node, Supervisor, actor, on, current_message


class RunCodeInput(BaseModel):
    code: str
    language: str


class RunCodeResult(BaseModel):
    stdout: str
    stderr: str
    exit_code: int


@actor(name="sandbox")
class Sandbox:
    @on(RunCodeInput)
    async def run(self, msg: RunCodeInput):
        stdout, stderr, exit_code = await execute_in_sandbox(msg.code, msg.language)
        await current_message().reply(
            RunCodeResult(stdout=stdout, stderr=stderr, exit_code=exit_code)
        )


async def main():
    async with Node("sandbox@10.0.0.5:9101") as node:
        Supervisor(node=node).spawn(Sandbox)
        await node.wait_until_stopped()

El handler de la tool en el servidor MCP es una llamada ask() fina — el trabajo real, y el aislamiento de proceso/recursos que necesite, vive enteramente en el lado del worker:

from mcp.server.mcpserver import MCPServer
from lapinbeam import Node, RemoteRef

mcp = MCPServer("sandbox-gateway")
node = Node("gateway@10.0.0.1:9100")
SANDBOX_PEER = "sandbox@10.0.0.5:9101"


@mcp.tool()
async def run_code(code: str, language: str) -> dict:
    """Ejecuta `code` en un sandbox aislado; devuelve stdout/stderr/exit_code."""
    remote: RemoteRef = node.get_remote_actor(SANDBOX_PEER, "sandbox")
    result: RunCodeResult = await remote.ask(RunCodeInput(code=code, language=language), timeout=10.0)
    return result.model_dump()


async def startup():
    await node.start()
    await node.connect_peer(SANDBOX_PEER)

Nada de esto es específico de un sandbox de código — la misma forma cubre un índice de búsqueda vectorial, un modelo de embeddings local, o cualquier tool cuyo trabajo real prefieras mantener fuera del propio proceso del servidor MCP (distinto dominio de fallo, distinta máquina, necesidades de escalado distintas).

Coordinar varios actores expertos (mixture-of-experts)

Cuando más de un especialista podría razonablemente responder — un modelo enfocado a investigación, uno enfocado a código, el reparto que tenga sentido para tu caso — reparte una pregunta entre todos a la vez (concurrentemente) y elige (o combina) los resultados. ask(), tanto en ActorRef como en RemoteRef, hace que sea el mismo código tanto si los expertos viven en el mismo proceso como repartidos por un clúster:

sequenceDiagram
    participant Coordinator as Coordinador
    participant Research as actor research_expert
    participant Code as actor code_expert

    Coordinator->>Research: ask(Question)
    Coordinator->>Code: ask(Question)
    Note over Coordinator: repartido concurrentemente (asyncio.gather)
    Research-->>Coordinator: reply(Opinion, confidence=0.8)
    Code-->>Coordinator: reply(Opinion, confidence=0.9)
    Note over Coordinator: elige la opinión con más confianza
import asyncio
from pydantic import BaseModel
from lapinbeam import ActorRef, Node, Supervisor, actor, current_message


class Question(BaseModel):
    text: str


class Opinion(BaseModel):
    expert: str
    answer: str
    confidence: float


@actor(name="research_expert")
class ResearchExpert:
    async def receive(self, msg: Question):
        answer = await call_research_model(msg.text)
        await current_message().reply(Opinion(expert="research", answer=answer, confidence=0.8))


@actor(name="code_expert")
class CodeExpert:
    async def receive(self, msg: Question):
        answer = await call_code_model(msg.text)
        await current_message().reply(Opinion(expert="code", answer=answer, confidence=0.9))


async def main():
    async with Node("experts@127.0.0.1:0") as node:
        sup = Supervisor(node=node)
        research_ref: ActorRef = sup.spawn(ResearchExpert)
        code_ref: ActorRef = sup.spawn(CodeExpert)

        question = Question(text="¿Por qué mi función recursiva revienta la pila?")
        opinions: list[Opinion] = await asyncio.gather(
            research_ref.ask(question, timeout=5.0),
            code_ref.ask(question, timeout=5.0),
        )
        best: Opinion = max(opinions, key=lambda o: o.confidence)
        print(f"Va con {best.expert}: {best.answer}")

Para repartir los expertos entre máquinas (cada uno con su propia GPU, digamos), cambia research_ref/code_ref por node.get_remote_actor(peer_id, "research_expert") — el código del coordinador no cambia, ya que ask() es idéntico en ActorRef y en RemoteRef.

Este ejemplo reparte una pregunta entre dos tipos de experto distintos. Si en cambio repartes la misma pregunta (o un lote de preguntas) entre N instancias intercambiables de un mismo experto — varios workers de modelo idénticos compartiendo una cola, responde el que esté libre — Supervisor.spawn_pool() encaja mejor que dos llamadas a spawn() más un gather() a mano: pool.map(preguntas) envía cada pregunta y recoge cada respuesta, en orden, en una sola llamada. Ver Concurrencia para la API completa del pool (colas acotadas, particionado por clave, y executor="process" para lo que sea CPU-bound en vez de I/O-bound como estas llamadas await a un modelo).

¿Por qué no llamarlos por HTTP, o ponerlos detrás de un broker?

Puedes hacerlo — esta no es la única forma válida. El argumento a favor de lapinbeam aquí es específicamente: son llamadas en el camino interactivo de una sesión LLM (hay un usuario esperando), los payloads ya son objetos Python tipados que prefieres no serializar a mano, y perder una llamada en vuelo porque un proceso worker murió a mitad de la petición es un modo de fallo aceptable (reintentar la tool call) en vez de algo que necesite una cola duradera. Si algo de eso no se cumple — el trabajo debe sobrevivir a un fallo, o el pool necesita más workers de los que un solo proceso/máquina puede sostener — mira lapinbeam frente a Celery + RabbitMQ; nada impide usar ambos en el mismo sistema para las partes que de verdad lo necesiten.

Vale la pena releer también, antes de meter cualquiera de estos dos patrones en algo real, que estos ejemplos tienen las mismas Limitaciones que el resto de lapinbeam: sin persistencia de mensajes, mailboxes de actor sin límite a menos que fijes mailbox_capacity, y payloads acotados a 16 MiB.