Arquitetura de Agentes e Sub-agentes com Kafka e Gemini: Padrão Hierárquico Resiliente com Isolamento de Falhas

Ilustração técnica conceitual: Arquitetura de Agentes e Sub-agentes com Kafka e Gemini: Padrão Hierárquico Resiliente com Isolamento de Falhas

1. Contexto e Problema de Arquitetura

Diagrama de arquitetura vertical: fluxo de processamento, tratamento de poison pills e isolamento de falhas de infraestrutura downstream.
Topologia de mensageria resiliente com isolamento defensivo de erros em três camadas.

Sistemas autônomos baseados em LLMs (Large Language Models) que operam em ambiente de produção frequentemente falham quando desenhados como pipelines síncronos e monolíticos. A orquestração de múltiplos agentes exigida por fluxos complexos (onde um Agente Orquestrador Master decompõe um problema e delega tarefas especializadas para N Sub-agentes) impõe desafios críticos de latência, volatilidade de rede e descontinuidade de estado.

Quando Sub-agentes dependem de APIs externas de modelos de linguagem (como a API do Gemini) e de persistência em banco de dados, falhas de conectividade, estouramentos de limite de taxa (rate limits) e payloads malformados (poison pills) podem paralisar o consumo de tarefas ou provocar duplicidade e perda de processamento.

Para resolver o acoplamento síncrono e garantir a resiliência operacional, adota-se um padrão event-driven assíncrono ancorado no Apache Kafka. O Agente Master atua como produtor e consumidor de controle, dividindo o plano de execução em mensagens pub/sub distribuídas em tópicos dedicados. Os Sub-agentes consomem esses tópicos de forma concorrente em Consumer Groups distintos.

Semântica de Entrega e Trade-offs Arquiteturais

  1. Garantia At-Least-Once (Pelo Menos Uma Vez): Adota-se a confirmação condicionada por meio de commit manual de offsets no Kafka após a conclusão do processamento downstream ou redirecionamento defensivo. Isso previne a perda de mensagens em caso de queda do worker, exigindo a implementação de idempotência na camada de aplicação via chaves de tarefa (task_id).
  2. Desacoplamento Temporal vs. Latência de Eventos: O modelo de mensageria adiciona pequeno overhead de I/O em comparação com chamadas de função diretas em memória, mas previne falhas em cascata ao isolar oscilações de infraestrutura do fluxo principal do sistema.
  3. Isolamento em Três Camadas de Exceção: Para conter a contaminação de filas e evitar loops infinitos de reprocessamento, o fluxo de erro dos Sub-agentes é desacoplado rigorosamente em:
  4. Camada 1 (Dados/Schema): Captura de erros de decodificação de bytes e Poison Pills antes da execução de regras de negócio. Como o payload é inválido e nunca será processado com sucesso, a mensagem é enviada para a Dead Letter Queue (DLQ) e o offset original é confirmado (commit) para evitar o travamento da partição.
  5. Camada 2 (Integração Downstream): Tratamento de indisponibilidade de APIs externas (Gemini) com timeouts delimitados e backoff exponencial. Se as retentativas forem exauridas, a aplicação não realiza o commit do offset e encerra o processo (fail-fast). Isso evita a perda silenciosa de dados e garante que a mensagem permaneça na fila para reprocessamento assim que a API downstream for restabelecida.
  6. Camada 3 (Infraestrutura do Broker): Tratamento de falhas de conectividade com o próprio cluster Kafka, preservando a posição do offset não processado e disparando alertas de monitoramento.

2. Ambiente e Setup Reproduzível

Para validar localmente a arquitetura de mensageria sem dependências externas complexas de infraestrutura, utiliza-se a imagem oficial do Apache Kafka em modo KRaft (sem dependência do Zookeeper), parametrizada para nó único local.

Arquivo docker-compose.yml

version: '3.8'

services:
  kafka:
    image: apache/kafka:3.7.0
    container_name: kafka-agente
    ports:
      - "9092:9092"
    environment:
      KAFKA_NODE_ID: 1
      KAFKA_PROCESS_ROLES: 'broker,controller'
      KAFKA_CONTROLLER_QUORUM_VOTERS: '1@localhost:29093'
      KAFKA_LISTENERS: 'PLAINTEXT://0.0.0.0:29092,CONTROLLER://0.0.0.0:29093,PLAINTEXT_HOST://0.0.0.0:9092'
      KAFKA_ADVERTISED_LISTENERS: 'PLAINTEXT://kafka:29092,PLAINTEXT_HOST://localhost:9092'
      KAFKA_LISTENER_SECURITY_PROTOCOL_MAP: 'CONTROLLER:PLAINTEXT,PLAINTEXT:PLAINTEXT,PLAINTEXT_HOST:PLAINTEXT'
      KAFKA_INTER_BROKER_LISTENER_NAME: 'PLAINTEXT'
      KAFKA_CONTROLLER_LISTENER_NAMES: 'CONTROLLER'
      KAFKA_LOG_DIRS: '/tmp/kraft-combined-logs'
      KAFKA_CLUSTER_ID: 'MkU3OEVBNTcwNTJENDM2Qk'
      KAFKA_OFFSETS_TOPIC_REPLICATION_FACTOR: 1
      KAFKA_TRANSACTION_STATE_LOG_REPLICATION_FACTOR: 1
      KAFKA_TRANSACTION_STATE_LOG_MIN_ISR: 1
      KAFKA_GROUP_INITIAL_REBALANCE_DELAY_MS: 0
    healthcheck:
      test: ["CMD-SHELL", "/opt/kafka/bin/kafka-topics.sh --bootstrap-server localhost:9092 --list"]
      interval: 5s
      timeout: 10s
      retries: 5

Instalação de Dependências Python

Execute o comando abaixo para instalar as bibliotecas necessárias com tipagem e suporte ao Kafka e Pydantic:

pip install confluent-kafka==2.3.0 pydantic==2.6.4 requests==2.31.0

3. Implementação Completa com Tratamento Defensivo e Resiliente

A implementação é dividida em dois arquivos Python completos e auditáveis:
1. schemas.py: Contratos de dados com validações estritas via Pydantic (strict=True).
2. subagent_worker.py: Worker do Sub-agente especialista com isolamento defensivo nas três camadas, manipulação segura de payload bruto e confirmação condicionada de offsets.

Arquivo schemas.py

from typing import Dict, Any
from pydantic import BaseModel, Field, ConfigDict

class TaskPayload(BaseModel):
    model_config = ConfigDict(strict=True)

    task_id: str = Field(..., description="UUID unico da tarefa para garantia de idempotencia")
    agent_name: str = Field(..., description="Nome do sub-agente destino")
    instruction: str = Field(..., description="Instrucao detalhada para o processamento")
    context_data: Dict[str, Any] = Field(default_factory=dict, description="Metadados complementares")

Arquivo subagent_worker.py

import sys
import json
import time
import logging
import requests
from typing import Optional
from confluent_kafka import Consumer, Producer, KafkaError, KafkaException, Message
from pydantic import ValidationError
from schemas import TaskPayload

# Configuracao de Logging
logging.basicConfig(
    level=logging.INFO,
    format="%(asctime)s [%(levelname)s] %(name)s: %(message)s",
    handlers=[logging.StreamHandler(sys.stdout)]
)
logger = logging.getLogger("SubAgentWorker")

# Configuracoes de Infraestrutura
KAFKA_BOOTSTRAP_SERVERS = "localhost:9092"
INPUT_TOPIC = "agent.tasks.analyzer"
DLQ_TOPIC = "agent.tasks.analyzer.dlq"
CONSUMER_GROUP = "subagent-analyzer-group"

# Configurações da API do Gemini (Mock ou Real)
GEMINI_API_URL = "https://generativelanguage.googleapis.com/v1beta/models/gemini-pro:generateContent"
GEMINI_API_KEY = "MOCK_API_KEY_FOR_TESTING"

class DownstreamAPIError(Exception):
    """Excecao lancada quando a API externa (Gemini) falha de forma irrecuperavel."""
    pass

class SubAgentWorker:
    def __init__(self) -> None:
        # Inicializacao do Consumidor Kafka
        consumer_conf = {
            "bootstrap.servers": KAFKA_BOOTSTRAP_SERVERS,
            "group.id": CONSUMER_GROUP,
            "auto.offset.reset": "earliest",
            "enable.auto.commit": False,  # Controle manual e condicionado de offsets
        }
        self.consumer = Consumer(consumer_conf)

        # Inicializacao do Produtor para DLQ
        producer_conf = {
            "bootstrap.servers": KAFKA_BOOTSTRAP_SERVERS,
            "acks": "all",  # Garantia de persistencia no broker antes do ack
            "retries": 5,
            "max.in.flight.requests.per.connection": 1,
        }
        self.producer = Producer(producer_conf)

    def start(self) -> None:
        """Inicia o loop de consumo do Kafka."""
        try:
            self.consumer.subscribe([INPUT_TOPIC])
            logger.info(f"Sub-agente iniciado. Consumindo topico: '{INPUT_TOPIC}'")

            while True:
                msg: Optional[Message] = self.consumer.poll(timeout=1.0)
                if msg is None:
                    continue

                if msg.error():
                    if msg.error().code() == KafkaError._PARTITION_EOF:
                        continue
                    else:
                        logger.error(f"Erro de infraestrutura do Kafka: {msg.error()}")
                        # Camada 3: Erro critico de infraestrutura do Broker. Nao commita e encerra.
                        raise KafkaException(msg.error())

                self._process_message_lifecycle(msg)

        except KeyboardInterrupt:
            logger.info("Encerrando sub-agente graciosamente...")
        finally:
            self.consumer.close()

    def _process_message_lifecycle(self, msg: Message) -> None:
        """Gerencia o ciclo de vida da mensagem com isolamento defensivo de erros."""
        raw_payload: Optional[bytes] = msg.value()

        # Camada 1: Isolamento de Decode e Schema (Poison Pills)
        if raw_payload is None:
            logger.warning("Mensagem com payload nulo recebida. Enviando para DLQ.")
            self._route_to_dlq(msg, "Payload nulo")
            self._commit_offset_safely(msg)
            return

        try:
            # Decodificacao segura de bytes para string e parse JSON
            decoded_str = raw_payload.decode("utf-8")
            parsed_json = json.loads(decoded_str)

            # Validacao estrita com Pydantic
            payload = TaskPayload(**parsed_json)
            logger.info(f"Payload validado com sucesso. Task ID: {payload.task_id}")

        except (UnicodeDecodeError, json.JSONDecodeError, ValidationError) as err:
            logger.error(f"Camada 1 - Poison Pill detectada: {str(err)}")
            # Como o payload esta corrompido, a retentativa nao resolvera. Envia para DLQ e commita.
            self._route_to_dlq(msg, str(err))
            self._commit_offset_safely(msg)
            return

        # Camada 2: Execucao Downstream (Chamada ao Gemini com Retentativas)
        try:
            self._execute_downstream_task_with_retry(payload)
            # Sucesso absoluto no processamento downstream. Commita o offset.
            self._commit_offset_safely(msg)
            logger.info(f"Task {payload.task_id} processada e offset confirmado.")

        except DownstreamAPIError as err:
            # Erro de infraestrutura downstream exaurido.
            # VIOLACAO DE SRE EVITADA: Nao enviamos para a DLQ e NAO commitamos o offset.
            # O worker deve falhar para evitar perda silenciosa de dados.
            logger.critical(
                f"Camada 2 - Falha critica downstream exaurida para a Task {payload.task_id}. "
                f"Encerrando aplicacao sem realizar commit do offset. Erro: {str(err)}"
            )
            sys.exit(1)

    def _execute_downstream_task_with_retry(self, payload: TaskPayload, max_retries: int = 3) -> None:
        """Executa a chamada para a API do Gemini aplicando backoff exponencial."""
        headers = {"Content-Type": "application/json"}
        params = {"key": GEMINI_API_KEY}

        # Payload estruturado para a API do Gemini
        data = {
            "contents": [{
                "parts": [{
                    "text": f"Instrucao: {payload.instruction}. Contexto: {json.dumps(payload.context_data)}"
                }]
            }]
        }

        backoff_sec = 2.0
        for attempt in range(1, max_retries + 1):
            try:
                logger.info(f"Tentativa {attempt} de {max_retries} - Chamando API Gemini...")

                # Timeout delimitado para evitar travamento de thread
                response = requests.post(
                    GEMINI_API_URL,
                    headers=headers,
                    params=params,
                    json=data,
                    timeout=10.0
                )

                if response.status_code == 200:
                    result = response.json()
                    logger.info(f"Resposta recebida do Gemini para Task {payload.task_id}")
                    # Processamento de sucesso simulado
                    return

                elif response.status_code in [429, 500, 502, 503, 504]:
                    logger.warning(
                        f"Erro transitorio HTTP {response.status_code} recebido da API Gemini. "
                        f"Aplicando backoff de {backoff_sec}s."
                    )
                else:
                    # Erros HTTP 4xx (exceto 429) indicam falhas de integracao nao transitorias
                    raise DownstreamAPIError(f"Erro nao transitorio HTTP {response.status_code}: {response.text}")

            except requests.exceptions.RequestException as req_err:
                logger.warning(f"Falha de conexao de rede na tentativa {attempt}: {str(req_err)}")

            if attempt < max_retries:
                time.sleep(backoff_sec)
                backoff_sec *= 2.0  # Backoff exponencial

        raise DownstreamAPIError(f"Retentativas exauridas para a API Gemini apos {max_retries} tentativas.")

    def _route_to_dlq(self, msg: Message, error_reason: str) -> None:
        """Envia o payload original integro para o topico de Dead Letter Queue (DLQ)."""
        try:
            dlq_headers = {
                "error_reason": error_reason.encode("utf-8"),
                "original_topic": msg.topic().encode("utf-8"),
                "original_partition": str(msg.partition()).encode("utf-8"),
                "original_offset": str(msg.offset()).encode("utf-8")
            }

            # Publicacao sincrona para garantir a persistencia na DLQ antes de prosseguir
            self.producer.produce(
                topic=DLQ_TOPIC,
                value=msg.value(),
                key=msg.key(),
                headers=list(dlq_headers.items())
            )
            self.producer.flush()
            logger.info(f"Mensagem enviada com sucesso para a DLQ '{DLQ_TOPIC}'.")
        except Exception as e:
            logger.critical(f"Falha catastrofica ao tentar publicar na DLQ: {str(e)}")
            # Se nao for possivel gravar na DLQ, nao podemos commitar o offset original
            raise e

    def _commit_offset_safely(self, msg: Message) -> None:
        """Realiza o commit manual do offset de forma sincrona."""
        try:
            self.consumer.commit(message=msg, asynchronous=False)
        except KafkaException as e:
            logger.error(f"Falha ao realizar commit do offset: {str(e)}")
            # Camada 3: Se o commit falhar, a mensagem sera reprocessada.
            raise e

if __name__ == "__main__":
    worker = SubAgentWorker()
    worker.start()

4. Roteiro CLI e Validação de Resiliência

Siga rigorosamente os passos abaixo para subir o ambiente, criar os tópicos necessários, simular cenários de falha e validar o comportamento do sistema.

Passo 1: Inicializar a Infraestrutura Local

Suba o container do Apache Kafka utilizando o Docker Compose:

docker-compose up -d

Verifique se o broker está saudável e pronto para receber conexões:

docker ps --filter "name=kafka-agente"

Passo 2: Criação dos Tópicos no Kafka

Crie o tópico principal de tarefas (agent.tasks.analyzer) e o tópico de mensagens mortas (agent.tasks.analyzer.dlq):

# Criar topico principal
docker exec -it kafka-agente kafka-topics.sh \
  --bootstrap-server localhost:9092 \
  --create \
  --topic agent.tasks.analyzer \
  --partitions 3 \
  --replication-factor 1

# Criar topico de DLQ
docker exec -it kafka-agente kafka-topics.sh \
  --bootstrap-server localhost:9092 \
  --create \
  --topic agent.tasks.analyzer.dlq \
  --partitions 1 \
  --replication-factor 1

Valide a criação bem-sucedida de ambos os tópicos:

docker exec -it kafka-agente kafka-topics.sh --bootstrap-server localhost:9092 --list

Passo 3: Executar o Sub-agente Worker

Abra um terminal dedicado e inicie o script do worker:

python subagent_worker.py

5. Laboratório de Testes e Simulação de Falhas

Cenário A: Processamento com Sucesso (Caminho Feliz)

Abra um novo terminal e envie uma mensagem válida para o tópico principal utilizando o produtor nativo do Kafka:

docker exec -it kafka-agente kafka-console-producer.sh \
  --bootstrap-server localhost:9092 \
  --topic agent.tasks.analyzer

Cole o seguinte payload JSON válido no terminal do console-producer e pressione Enter:

{"task_id": "8f4b2c8a-1a2b-3c4d-5e6f-7a8b9c0d1e2f", "agent_name": "analyzer", "instruction": "Analise o sentimento desta avaliacao", "context_data": {"review": "Excelente produto, superou as expectativas!"}}

Comportamento esperado no terminal do Worker:
* O worker decodifica o JSON com sucesso.
* O Pydantic valida o schema estruturado.
* É feita a chamada simulada para a API do Gemini.
* O offset é commitado manualmente.


Cenário B: Injeção de Poison Pill (Camada 1 – Erro de Schema)

No terminal do kafka-console-producer.sh, envie um payload malformado (violando a tipagem estrita do Pydantic ou contendo JSON inválido):

{"task_id": "invalid-uuid-format", "agent_name": 12345, "instruction": null}

Comportamento esperado no terminal do Worker:
* O worker intercepta a falha de validação na Camada 1.
* A mensagem corrompida é enviada para o tópico agent.tasks.analyzer.dlq.
* O offset da mensagem original é commitado, liberando a partição para as próximas mensagens.

Inspeção do Tópico de DLQ:
Execute o comando abaixo para ler as mensagens enviadas para a DLQ e validar se o payload malformado foi isolado com sucesso:

docker exec -it kafka-agente kafka-console-consumer.sh \
  --bootstrap-server localhost:9092 \
  --topic agent.tasks.analyzer.dlq \
  --from-beginning \
  --property print.headers=true

Cenário C: Falha Crítica Downstream (Camada 2 – Sem Perda de Dados)

Para simular uma falha de rede ou indisponibilidade total da API downstream (Gemini), podemos alterar o endereço da API no arquivo subagent_worker.py para um IP de destino inexistente ou simular o bloqueio de tráfego de saída.

  1. No arquivo subagent_worker.py, altere temporariamente a variável GEMINI_API_URL para um endpoint inválido:
    “`python
    GEMINI_API_URL = “https://192.0.2.1/v1beta/models/gemini-pro” # IP reservado para testes que gera timeout
2. Reinicie o worker:
   ```bash
   python subagent_worker.py
   ```
3. Envie uma mensagem válida pelo console-producer:
   ```json
   {"task_id": "9e8d7c6b-5a4b-3c2d-1e0f-9a8b7c6d5e4f", "agent_name": "analyzer", "instruction": "Processamento sob indisponibilidade", "context_data": {}}
   ```

**Comportamento esperado no terminal do Worker:**
* O worker tentará realizar a chamada HTTP.
* Ocorrerá timeout de conexão.
* O worker executará as 3 retentativas com backoff exponencial.
* Ao exaurir as tentativas, o worker lançará `DownstreamAPIError` e **encerrará a execução (sys.exit(1)) sem realizar o commit do offset**.

**Verificação de Offsets Não Commitados (Garantia de Não Perda):**
Execute o comando abaixo para inspecionar o status do grupo de consumidores:

```bash
docker exec -it kafka-agente kafka-consumer-groups.sh \
  --bootstrap-server localhost:9092 \
  --describe \
  --group subagent-analyzer-group

Resultado esperado na tabela de offsets:
Você observará que o valor de CURRENT-OFFSET para a partição correspondente estará posicionado exatamente atrás do LOG-END-OFFSET (gerando um LAG de 1). Isso prova que a mensagem não foi perdida nem descartada na DLQ; ela permanece segura no broker aguardando o restabelecimento do worker e da API downstream.

Rolar para cima