Existe uma distância grande entre uma demonstração de IA que impressiona e um sistema que entra em produção num banco e sobrevive à primeira auditoria. Essa distância é quase toda engenharia — e quase nada modelo.
Este documento descreve o padrão que aplicamos em todo projeto multiagente. Não é teoria: é o que restou depois de várias entregas em ambiente onde a decisão precisa ser explicada a alguém que não aceita “o modelo entendeu assim”.
Publicamos na íntegra, incluindo as pegadinhas que custaram caro para descobrir.
O princípio que organiza tudo
O LLM é usado apenas para geração de linguagem natural. Toda decisão de negócio — autenticação, autorização, cálculo, roteamento por regra — é determinística e executada por código puro.
Modelo de linguagem é excelente para interpretar pedido ambíguo e redigir resposta clara. É péssimo como registro de decisão: não é reprodutível, não é testável linha a linha e não tem histórico de mudança.
Quando o auditor pergunta por que a proposta 84.213 foi negada em março, a resposta precisa ser um commit e um registro — não uma hipótese sobre o comportamento do modelo naquele dia.
Na prática, a fronteira fica assim:
# business/credito.py
# Função pura. Mesma entrada, mesma decisão. Sempre.
# Coberta por teste unitário. Rastreável no histórico do Git.
def avaliar_elegibilidade(proposta: Proposta) -> Decisao:
if proposta.idade < IDADE_MINIMA:
return Decisao.negar(motivo="IDADE_MINIMA", politica="POL-CRED-014")
if proposta.score < faixa_minima(proposta.produto):
return Decisao.negar(motivo="SCORE_ABAIXO_DA_FAIXA", politica="POL-CRED-021")
return Decisao.aprovar(
limite=calcular_limite(proposta),
politica="POL-CRED-030",
)
O agente recebe a Decisao pronta, com motivo e número da política aplicada, e
escreve a mensagem ao cliente. Ele não sabe aprovar. Ele não sabe negar.
Tudo o que vem a seguir existe para sustentar essa fronteira sob pressão de prazo, de mudança de escopo e de rotatividade de time.
Estrutura
A separação de pastas não é estética — cada fronteira existe para impedir uma classe específica de erro.
<projeto>/
├── <gateway> FastAPI, webhook, CLI — plugável, fora do núcleo
├── agents/ um arquivo por agente, todos herdando de AgenteBase
├── core/ infraestrutura agnóstica de domínio
│ ├── state.py TypedDict do estado + helpers
│ ├── graph_builder.py
│ ├── router.py
│ ├── registry.py
│ ├── llm.py
│ ├── observability.py
│ └── prompt_loader.py
├── business/ regras determinísticas — ZERO LLM
├── guardrails/ entrada, saída, fluxo
├── tools/ ferramentas expostas aos agentes
├── prompts/ <agente>_v<N>.yaml
├── evals/ datasets e execução de avaliação
├── docs/adr/ decisões arquiteturais registradas
└── tests/ unit e integration
A regra que mais paga: business/ não importa nada de core/ nem de
agents/. Se uma regra de negócio precisa de contexto de conversa para
decidir, ela não é regra de negócio — é interpretação, e pertence ao agente.
Essa restrição é chata no começo e salva o projeto no sexto mês.
O estado
Um TypedDict único atravessa o grafo. O que importa mais que os campos é a
coluna da direita: quem tem permissão de escrever cada um.
| Campo | Tipo | Quem escreve |
|---|---|---|
messages |
list |
agentes, via helper |
session_id |
str |
gateway |
turno |
int |
core |
autenticado |
bool |
business/auth.py apenas — nunca o LLM |
agente_atual |
str |
core |
proximo_agente |
str | None |
agente que faz o handoff |
handoff_context |
dict | agente de origem |
encerrado |
bool |
agentes |
autenticado é o exemplo mais claro do princípio. Se o LLM pudesse escrever
nesse campo, bastaria uma mensagem bem construída do usuário para se declarar
autenticado. Ele não pode. Só business/auth.py escreve ali, e essa função é
uma das mais testadas do repositório.
Nunca use campos com prefixo _. Checkpointers que validam o TypedDict
descartam esses campos silenciosamente, e você perde estado sem erro. Para dados
parciais, guarde em messages.
Registro dinâmico de agentes
Nenhum componente do núcleo conhece um agente pelo nome. O grafo é construído a partir de um registro:
class AgenteBase(ABC):
nome: str
descricao: str
requer_autenticacao: bool
@abstractmethod
def run(self, state: State) -> State: ...
Adicionar um agente novo é herdar de AgenteBase e chamar registrar(). Nada
mais muda — nem o roteador, nem o construtor do grafo, nem os guardrails.
Isso não é elegância. É o que permite adicionar um especialista em produção sem tocar em caminho de código já auditado.
AgenteBase também entrega o que todo agente precisa e ninguém deveria
reimplementar: montagem de contexto com janela deslizante (as duas primeiras
mensagens, para preservar o enquadramento inicial, e as seis últimas), chamada ao
LLM com callbacks de observabilidade já injetados, e consumo do
handoff_context.
Guardrails como envelope obrigatório
Todo agente roda dentro do mesmo invólucro:
FlowGuard → InputGuard → Agente → OutputGuard
O construtor do grafo aplica esse wrapper a cada nó registrado. Nenhum agente escapa — não porque as pessoas são disciplinadas, mas porque não existe caminho no código que registre um agente sem ele.
FlowGuard lê requer_autenticacao do registro e redireciona para a triagem
quando necessário. É a autorização, e ela acontece antes de qualquer token ser
gasto.
InputGuard, em ordem, porque a ordem importa para custo e para segurança:
- Sessão encerrada bloqueia, exceto no agente de entrada
- Rate limiting
- Tamanho máximo
- Mensagem vazia ou curta demais
- Tentativa de prompt injection
- Escopo de domínio
O passo 6 usa um classificador leve para casos ambíguos. Respostas de até cinco palavras passam direto — sem isso o sistema rejeita “sim”, “pode ser” e “o segundo”, que são metade das respostas reais de um usuário.
OutputGuard remove traceback e detalhe técnico vazado, redige PII de terceiros e detecta número de cartão, senha e token antes que a mensagem saia.
Roteamento e handoff invisível
O roteador tem três passos, nessa ordem:
state.encerrado→ENDstate.proximo_agentepreenchido → vai para lá- Caso contrário →
END
O terceiro passo é onde os projetos quebram. Retornar agente_atual no
caso padrão parece inofensivo e produz loop infinito: o agente termina, o
roteador o devolve para si mesmo, ele termina de novo.
O handoff é silencioso: o agente de origem apenas preenche proximo_agente sem
adicionar mensagem. O grafo executa o próximo nó antes de devolver o controle ao
usuário. Do lado de fora, a transição entre especialistas é invisível — o
usuário vê uma conversa, não um organograma.
Prompts versionados
Prompts vivem em YAML, com versão explícita no nome do arquivo:
triagem_v3.yaml. O fluxo de mudança é sempre o mesmo:
- Criar
<agente>_v<N+1>.yaml - Rodar a suíte de avaliação
- Atualizar
PROMPT_VERSION - Commit separado, só disso
O ganho aparece no incidente. Comportamento estranho em produção às três da manhã se resolve apontando para a versão anterior — sem deploy de código, sem rollback de aplicação, com rastro de quem mudou o quê e quando.
Avaliação com gate de regressão
Cada agente tem um conjunto de avaliação construído com casos reais do domínio do cliente. Mudou prompt, modelo ou regra, a suíte roda.
Queda além do limiar acordado bloqueia a promoção. É o equivalente a teste de regressão para comportamento não determinístico — e é a única forma honesta de responder “melhorou ou piorou?” depois de mexer no prompt.
Separamos em três níveis:
- Por agente — o agente isolado responde corretamente ao seu escopo
- Comportamental — o sistema recusa o que deve recusar, não vaza, não sai de escopo
- Ponta a ponta — jornadas completas, com LLM real
Os dois primeiros rodam no CI. O terceiro fica atrás de um marcador do pytest e roda sob demanda, porque custa dinheiro a cada execução.
Observabilidade em três camadas
Três camadas, com degradação graciosa entre elas:
- Log estruturado em JSON — sempre ativo, sem dependência externa
- Buffer em memória — alimenta o painel operacional
- Tracing externo — opcional, e falha em silêncio
A terceira camada nunca pode derrubar a primeira. Se o provedor de tracing cai, o sistema perde visibilidade externa e continua atendendo. Em ambiente regulado indisponibilidade também é incidente reportável — vale mais operar sem gráfico do que não operar.
O que instrumentamos por sessão: custo, latência por etapa, versão de prompt em vigor, eventos de guardrail e cada handoff.
Estado persistido
Um checkpointer persiste o estado a cada turno. SQLite serve para desenvolvimento; Redis ou Postgres para produção. Sempre com fallback para memória — o sistema perde durabilidade, não disponibilidade.
É isso que transforma “acho que foi assim” em trilha de auditoria de verdade: meses depois, dá para reconstruir a sessão passo a passo, com o estado exato em cada turno.
Uma limitação prática: não dá para listar as sessões ativas consultando o
checkpointer do LangGraph diretamente. Resolvemos com um índice de sessões
plugável, alimentado por upsert do gateway a cada turno.
Camada de modelo trocável
O provedor fica atrás de uma fábrica com cache, configurada por variável de ambiente. Duas instâncias, com propósitos diferentes:
- Principal —
temperature=0.2, para redação - Classificador —
temperature=0.0,max_tokens=10, para decisão binária
Trocar de fornecedor, ou migrar para um modelo aberto na infraestrutura do cliente, não toca na lógica de negócio. Preço e qualidade de modelo mudam a cada trimestre; a arquitetura não deveria.
Decisões registradas
Toda escolha estrutural vira um ADR curto no repositório: contexto, alternativas consideradas, decisão, consequência. Numeração estável, uma decisão por arquivo.
Seis meses depois, quando alguém perguntar por que o roteador não faz retry, a resposta está escrita — e vem com o raciocínio, não só com a conclusão.
Pegadinhas que custaram caro
Esta é a seção que eu gostaria de ter lido antes.
proximo_agente não limpo após o handoff. O agente de destino termina sem
definir um novo handoff, o campo antigo ainda está preenchido, e o grafo entra em
loop. O wrapper deve zerar proximo_agente no início de cada execução; o
agente só reescreve se quiser um novo handoff.
Roteador devolvendo agente_atual no caso padrão. Loop infinito. Já
mencionado acima, e mencionado de novo porque é o erro mais caro da lista.
Campos com _ no estado. Descartados em silêncio pelo checkpointer. Sem
erro, sem log, sem pista.
Handoff prematuro. O agente transfere antes de o usuário ver a etapa atual, e a mensagem se perde. Uma flag local impede a transição imediata.
handoff_context consumido mais de uma vez. O agente de destino repete a
saudação a cada turno. Consuma o contexto na montagem do system prompt e limpe.
Regex em Python não casa acento. \bmedic não pega “médico”. Normalize com
unicodedata.NFKD antes de comparar. Vale um helper único no núcleo, porque esse
erro reaparece em todo projeto.
monkeypatch.setattr("modulo.funcao", ...) não pega from modulo import funcao já executado. Aplique o patch no módulo de destino do import, não no de
origem. Teste que passa sem testar nada é pior que teste que falha.
AsyncIOScheduler precisa de event loop ativo. Teste apenas a construção,
sem iniciar, ou use um teste assíncrono com desligamento no teardown.
O que não faz parte
Deliberadamente fora do padrão, porque muda de projeto para projeto: a interface de usuário, o domínio, o provedor de LLM concreto, as APIs externas e a infraestrutura de tracing.
O núcleo não deve saber se está atendendo um cliente de banco ou de operadora de saúde. No dia em que souber, ele parou de ser núcleo.
Por onde começar
Se você está montando isso do zero, a ordem que economiza retrabalho:
state.pye o roteador, com o caso padrão corretoAgenteBasee o registro- O wrapper de guardrails — antes do segundo agente, não depois
- Um agente real, ponta a ponta
- O primeiro conjunto de avaliação, com casos reais
- Checkpointer persistente
- Observabilidade
Os itens 3 e 5 são os que todo mundo adia. São também os dois que decidem se o sistema chega a produção.
