Agentes multi-tenant: firewall de capacidade em código, aprovação humana no canal
Um agente de IA pode operar o dia a dia de um negócio: responde no canal do dono, agenda, emite documento, propõe ação. Isso é um problema de engenharia antes de ser um problema de prompt. E quando o mesmo serviço é desenhado para atender vários negócios, os dois problemas clássicos se multiplicam. O primeiro é isolar um tenant do outro, ou seja, um cliente do outro. O segundo é limitar o que o modelo pode fazer de verdade, não por pedido educado no system prompt.
Esta nota descreve a arquitetura de um serviço assim que roda em produção na casa. Uma ressalva de escala, para o texto não sugerir o que não é: hoje há um tenant de verdade — o meu próprio negócio — e um piloto de cliente provisionado que ainda não abriu o canal. O multi-tenant aqui é desenho, não carteira de clientes. A descrição vem em termos genéricos, porque os padrões importam mais que o produto. São quatro camadas. Isolamento por schema, firewall de capacidade em código (que limita o que o agente consegue fazer) e aprovação humana com semântica estrita. Mais as guardas de resiliência que fazem o conjunto sobreviver a um provedor lento. Duas dessas camadas foram invertidas depois de quase-incidentes, e as inversões são a parte mais instrutiva.
Camada 1: um schema por tenant, e o teste é o DROP
Cada cliente vive num schema próprio do Postgres (c_<slug>), uma gaveta separada dentro do mesmo banco, criada na conexão, com search_path apontado para ela. A consequência que paga o padrão: nenhuma query no código tem WHERE cliente = ?. Não existe a classe de bug “esqueci o filtro de tenant”, porque o isolamento não é uma cláusula que cada query precisa lembrar. É o chão em que a conexão pisa.
Dois bônus operacionais que vêm de graça e viram argumento de compliance:
pg_dump -n c_xexporta um cliente inteiro, e nada além dele.DROP SCHEMA c_x CASCADEé a saída LGPD: apagar um cliente é uma operação de banco, não uma caça a linhas espalhadas.
O checkpointer do grafo de agentes, que guarda o estado de cada conversa, usa o mesmo schema. Assim, threads de conversa de clientes diferentes não podem colidir nem por acidente de id.
E o isolamento não para no banco. O filesystem virtual que o agente enxerga termina em deny /**: permite ler/escrever a área do próprio cliente, ler a área compartilhada, e nega todo o resto explicitamente. A frase da documentação interna resume a postura: o deny não é higiene, é LGPD.
Um detalhe de concorrência: o grafo de cada cliente é construído sob lock por cliente, não global. Com lock global, montar o grafo de um tenant bloqueia a montagem de todos os outros. E num processo frio, em que nenhum grafo existe ainda, os webhooks (avisos que chegam de fora) entram em fila com paralelismo efetivo 1. Lock por tenant é o mesmo código com a contenção no lugar certo.
Camada 2: o firewall de capacidade — a ferramenta sai antes de o grafo existir
Aqui está a regra central do desenho, e a diferença entre firewall e etiqueta. Capacidade se controla removendo a ferramenta do registro, não pedindo para o modelo não usá-la.
O serviço tem um catálogo fechado de ferramentas (~57 nomes). Cada área de cada cliente declara quais usa. Um nome fora do catálogo explode no load da configuração, não no meio de uma conversa. Módulos podem ser desligados por cliente no manifest, o arquivo de configuração do cliente. E quando um módulo está desligado, as ferramentas dele são filtradas do registro antes de o grafo ser montado. A frase interna que vale emoldurar: o modelo não recebe uma instrução para não usar; ele não sabe que existe. É código, não persuasão.
O andar mais alto do firewall é uma proibição absoluta: nenhuma ferramenta move dinheiro. O que a torna interessante é como ela é cobrada. Não por convenção, mas por um teste que roda um regex sobre os nomes de todas as funções registradas:
(^|_)(pagar|pagamento|pix|ted|saque|boleto|remessa|transferir|estornar|...)
O raciocínio do teste é de UX de segurança, não de estilo. Ele é este: o nome da ferramenta é o que o dono lê no pedido de aprovação — e a ferramenta de pagar sempre se chama pagar. Se alguém um dia tentar registrar uma, o CI reprova antes de qualquer conversa acontecer. Ler extrato bancário (read-only) é permitido. Gerar arquivo de remessa, não: a linha passa exatamente entre observar dinheiro e movê-lo.
A primeira inversão: a lista protegia a coisa errada
O serviço interrompe um conjunto fixo de ferramentas, ou seja, pausa o grafo e pede aprovação humana. A régua atual é limpa. Interrompe o que sai da empresa, isto é, chega a um terceiro: mensagem, e-mail, publicação, emissão de documento. Interrompe também o que é caro de desfazer, como reescrever dado do negócio em massa. Não interrompe o que é interno e reversível numa frase. E há um teste que prova que a lista de interrupção é exatamente a união desses dois conjuntos. A régua não é comentário, é asserção.
Só que a primeira versão da lista era outra, e a lição está no diff. Ela interrompia coisas como “propor nova skill” e “guardar conhecimento”, a memória do agente. E não interrompia a importação que reescrevia a carteira de dados do cliente. Protegia a introspecção do sistema e deixava o ativo do cliente aberto.
A régua abstrata (“o que é arriscado?”) tinha produzido uma lista que refletia o que era interessante para quem construiu, não o que era caro para quem usa. A correção inverteu a lista. E criou o teste que amarra lista à régua, para a próxima ferramenta nova ser classificada pela regra e não pelo instinto.
Um refinamento evita fadiga de aprovação: a interrupção pode ser por argumento. A mesma ferramenta de importação não interrompe com confirmar=False, porque a prévia não grava nada. Interrompe com confirmar=True. Aprovação humana é um recurso escasso. Gastá-la em prévia é treinar o humano a aprovar sem ler.
Camada 3: aprovação humana onde silêncio nunca é sim
A aprovação acontece no canal onde o dono já vive (mensagem, via plataforma de atendimento). O grafo pausa no checkpoint, a pendência é gravada, e o dono recebe um pedido em português de gente. Um dicionário mapeia cada ferramenta para uma frase legível e para quais argumentos o dono vê.
Ids de conversa, nomes de função, caminhos de container nunca aparecem. Isso também é teste: nenhum pedido de aprovação pode conter arg=valor ou nome de função Python. A versão antiga mostrava exatamente isso, truncado. Nas palavras do registro interno, treinava o dono a responder “sim” sem ler. Legibilidade do pedido de aprovação é requisito de segurança, não de polimento.
A semântica da resposta é deliberadamente estrita, e cada regra fecha um buraco específico:
- Só um conjunto fechado de palavras (e emojis) aprova. E só se todas as palavras da resposta forem afirmativas.
- Qualquer outro texto rejeita, com o próprio texto como motivo.
- Resposta vazia rejeita. Ausência de resposta não decide nada: a pendência fica de pé. O agente é incapaz de se auto-aprovar por silêncio, por construção.
- Aprovador é papel, não canal. Quem não tem papel de dono e responde a uma pendência não a consome. Não aprova nem recusa; o sistema registra e informa quem autoriza. E ação externa pedida por quem não é dono nem vira pendência.
A segunda inversão: lista vazia negava… nada
O filtro de quem pode falar com o agente pela linha do dono tinha um default traiçoeiro. Lista de telefones vazia desligava o filtro, e era o estado de três dos quatro manifests na época. O quase-incidente virou regra invertida. Hoje, lista vazia nega tudo, e desligar o filtro exige o chamador declarar explicitamente que não quer filtragem.
É o mesmo princípio do painel que responde 503 sem configuração de auth, em vez de abrir. Fechado por padrão não é slogan. É o valor do default quando alguém esquece de configurar.
Camada 4: as guardas que o provedor de LLM exige
Duas guardas de resiliência que qualquer serviço de agentes em produção acaba aprendendo, aqui com os números que as motivaram:
- Timeout explícito e curto no provedor: 90 segundos, uma retentativa. O default do SDK era 600 s com duas retentativas. Uma chamada travada seguraria um slot do pool de threads por até 30 minutos. E com o pool cheio o serviço inteiro para de aceitar webhook de qualquer cliente, mudo, sem exceção e sem alerta. A régua para os 90 s não foi gosto. O p95 medido da mensagem inteira, o tempo que 95% delas não ultrapassam, era ~22 s. Então uma única chamada passar de 90 s é travamento, não lentidão. E a leitura do valor da env var cai no padrão com aviso se alguém configurar errado. Config inválida não pode reabilitar o timeout de 10 minutos por acidente.
- Teto de turnos como corte, não como erro. O framework de agentes dava um limite de recursão efetivamente infinito aos subagentes. O serviço impõe um teto de chamadas por delegação via middleware, o código que roda entre as chamadas. E estourar o teto não é exceção: a área devolve um marcador de limite, com métrica e evento. Um agente em loop é um custo linear no tempo. A diferença entre “erro 500” e “corte limpo com telemetria” é quem descobre primeiro: você ou a fatura.
O padrão, empilhado
- Isolamento que não depende de memória de quem escreve query: schema por tenant, checkpointer dentro, filesystem com
deny /**no fim. O teste de aceitação é poder responder “como apago um cliente?” com um comando. - Capacidade é o registro de ferramentas, não o prompt. Catálogo fechado que explode no load; módulo desligado = ferramenta inexistente; proibições absolutas cobradas por teste sobre os nomes.
- Interrupção pela régua “sai da empresa ou é caro de desfazer”. A lista fica amarrada à régua por asserção, e a granularidade por argumento evita queimar a atenção do aprovador.
- Semântica de aprovação estrita: conjunto fechado aprova, resto rejeita com motivo, vazio rejeita, silêncio não decide, papel decide. E o pedido é legível por humano, com teste cobrando a legibilidade.
- Defaults fechados: a lista vazia nega; o modo dev não existe em produção por construção.
- Timeouts e tetos dimensionados por medição (p95 → 90 s; corte com telemetria em vez de loop com fatura).
Nada aqui torna o agente mais inteligente. Tudo aqui limita o estrago do dia em que ele for burro. É essa assimetria que separa um sistema de agentes que dá para operar dormindo de um demo com webhook.
Agentes de IA que aguentam produção
Escrevo aqui sobre os agentes que eu mesmo opero: memória em Postgres, ferramentas registradas em código e limites que o prompt não pode contornar. O método e os números medidos vão junto com cada post.
Ver os posts sobre agentes →