La forma en que integras un LLM en tu aplicación depende fundamentalmente de quién tiene el control: ¿tu código decide el flujo y el LLM solo responde, o el LLM decide qué hacer a continuación?

Esta distinción te lleva a dos patrones arquitectónicos muy diferentes.

Patrón 1: LLM como Infraestructura

Cuando la interacción es sencilla —envías un prompt, recibes una respuesta— el LLM es infraestructura. Es un servicio externo más, como un servicio de traducción o de geocoding.

Desde la perspectiva de arquitectura hexagonal:

  • El dominio define un puerto (interfaz)
  • El LLM es un adapter que implementa ese puerto
  • Es intercambiable: OpenAI, Claude, Ollama, un mock para tests
# Puerto en el dominio
class TextAnalyzer(ABC):
    def analyze_sentiment(self, text: str) -> Sentiment:
        ...

# Adapter en infraestructura
class OpenAISentimentAnalyzer(TextAnalyzer):
    def __init__(self, client: OpenAI):
        self.client = client
    
    def analyze_sentiment(self, text: str) -> Sentiment:
        response = self.client.chat.completions.create(
            model="gpt-4",
            messages=[{"role": "user", "content": f"Analyze sentiment: {text}"}]
        )
        return self._parse_response(response)

El dominio no sabe que hay un LLM detrás. Solo sabe que existe algo que analiza sentimiento. Mañana podrías cambiarlo por un modelo de ML clásico o por reglas hardcodeadas y el dominio no se enteraría.

Características de este patrón:

  • Tu código tiene el control del flujo
  • El LLM no tiene estado ni memoria entre llamadas
  • No hay autonomía: prompt entra, respuesta sale
  • Es síncrono y predecible en el comportamiento

Este patrón cubre también casos como RAG (Retrieval Augmented Generation). Aunque enriquezcas el prompt con contexto de tu base de datos, la estructura es la misma: tu código orquesta, el LLM responde.

Patrón 2: Agente como Actor

Pero hay un salto cualitativo cuando el agente tiene comportamiento rico: puede consultar el estado del sistema, ejecutar comandos, tomar decisiones sobre qué hacer a continuación.

En ese momento, el agente deja de ser infraestructura y se convierte en un actor de tu sistema. Debe tratarse como tratarías a cualquier otro consumidor externo: un usuario, un partner B2B, otro microservicio.

La diferencia fundamental es la dirección de la dependencia:

Patrón 1Patrón 2
Tu sistema → LLMAgente → Tu sistema
Tu código iniciaEl agente inicia
Sin autonomíaCon autonomía
ImplementaciónActor

Variante A: Agente acotado a un servicio

Si el agente tiene un propósito específico dentro de un bounded context, puede vivir dentro del servicio y comunicarse via command/query bus interno. También podría interactuar con servicios de aplicación directamente, aunque estaría más acoplado y probablemente requeriría manejar transacciones.

class OnboardingAgent:
    def __init__(
        self,
        llm: LLMClient,
        command_bus: CommandBus,
        query_bus: QueryBus,
    ):
        self.llm = llm
        self.tools = [
            Tool(
                name="check_customer_exists",
                fn=lambda id: query_bus.ask(GetCustomerQuery(id))
            ),
            Tool(
                name="validate_tax_id", 
                fn=lambda tax_id: query_bus.ask(ValidateTaxIdQuery(tax_id))
            ),
            Tool(
                name="create_customer",
                fn=lambda data: command_bus.execute(CreateCustomerCommand(**data))
            ),
        ]
    
    def run(self, request: OnboardingRequest) -> OnboardingResult:
        return self.llm.run_agent(
            goal=f"Onboard customer: {request.company_name}",
            tools=self.tools,
            context=request.to_dict()
        )

El agente tiene autonomía —decide qué tools usar y en qué orden— pero respeta los boundaries. Usa el mismo command bus y query bus que usaría cualquier otro componente. No tiene acceso privilegiado a repositorios o servicios internos.

Ventajas:

  • Menor latencia (no hay HTTP de por medio)
  • Evitamos implementar APIs específicas para un agente interno
  • Más fácil de testear (puedes mockear el bus)

Variante B: Agente de propósito amplio

Cuando el agente necesita interactuar con múltiples servicios, debería comunicarse a través de interfaces públicas: APIs REST, MCP (Model Context Protocol), o similar.

Aquí el agente es un actor externo. Sus tools son wrappers sobre las APIs públicas:

# Las tools del agente llaman a APIs públicas
tools = [
    MCPTool(
        name="get_order_status",
        endpoint="https://orders.internal/api/orders/{id}",
        method="GET"
    ),
    MCPTool(
        name="cancel_order",
        endpoint="https://orders.internal/api/orders/{id}/cancel",
        method="POST"
    ),
    MCPTool(
        name="process_refund",
        endpoint="https://payments.internal/api/refunds",
        method="POST"
    ),
]

El agente no sabe nada de la implementación interna de Orders o Payments. Solo conoce sus contratos públicos.

Ventajas:

  • Desacoplamiento total
  • El agente puede evolucionar independientemente
  • Mismas políticas de seguridad/autorización que cualquier otro cliente
  • Fácil de reemplazar o escalar

Implicaciones arquitectónicas

Acoplamiento

Si le das al agente acceso directo a servicios de dominio o repositorios, estás acoplando tu implementación interna al comportamiento del agente. Cada refactor interno puede romper las tools.

Si el agente usa interfaces públicas (ya sean APIs HTTP o command/query buses), puedes evolucionar los internos sin afectarle.

Testing

Con el Patrón 1, testeas el adapter como cualquier otro adapter: mockeando el cliente HTTP.

Con el Patrón 2, tienes dos niveles de testing:

  1. Tests del agente: mockeas las tools y verificas que el agente toma las decisiones correctas
  2. Tests de integración: verificas que las tools funcionan contra las APIs reales
def test_agent_creates_customer_when_validation_passes():
    # Arrange
    mock_tools = {
        "check_customer_exists": lambda id: None,  # No existe
        "validate_tax_id": lambda tax_id: {"valid": True},
        "create_customer": Mock()
    }
    agent = OnboardingAgent(llm=mock_llm, tools=mock_tools)
    
    # Act
    agent.run(OnboardingRequest(company_name="Acme", tax_id="B12345678"))
    
    # Assert
    mock_tools["create_customer"].assert_called_once()

Cuándo usar cada patrón

Usa LLM como infraestructura cuando:

  • Necesitas una capacidad específica: resumir, clasificar, extraer, generar
  • El flujo está predefinido en tu código

Usa Agente como actor cuando:

  • El agente decide qué acciones tomar
  • Necesita consultar estado y ejecutar comandos
  • El flujo no es predecible de antemano

Y dentro del Patrón 2:

  • Variante acotada si el agente opera dentro de un único bounded context
  • Variante amplia si necesita orquestar múltiples servicios

Conclusión

La próxima vez que integres IA en tu aplicación, pregúntate: ¿quién tiene el control del flujo?

Si tu código orquesta y el LLM solo responde, es infraestructura. Trátalo como un adapter.

Si el agente decide qué hacer, es un actor. Trátalo como tratarías a cualquier otro consumidor de tu sistema: a través de interfaces públicas y contratos bien definidos.

Un agente no es una implementación, es un actor que interactúa con nuestro sistema.