System Design para Aplicações com IA: os princípios que realmente importam

Guia técnico e aplicável: como desenhar uma aplicação com LLMs que não quebra quando o provider muda, não torra orçamento, entrega saída validada e é testável. Nove princípios com mecanismo, trade-off, exemplo e validação.

Fonte: https://youtu.be/2Ls_uc0fo-Y

Neste artigo 13 seções

Você vai precisar deste guia antes de escrever outro import openai

As IAs estão em todo lugar e dentro dos seus sistemas. Se você construiu um app que chama um LLM, já caiu (ou vai cair) nestes buracos: a API de um provider mudou e o código quebrou; um usuário descobriu um jeito de torrar seu crédito; ou o modelo respondeu uma coisa na sexta e outra na segunda, sem que nada mudasse no input.

Este é um guia. Nove princípios separam quem só pluga um SDK de quem desenha um sistema que aguenta produção. Cada princípio tem a mesma estrutura: o problema, o mecanismo, o trade-off, o como fazer, o exemplo e a validação. Ao final, um checklist para o deploy, e o critério de "quando" cada coisa é obrigatória (MVP vs. produção).

Princípio 1. Abstraia o provedor, ou o provedor vira dono do seu código

O problema: você espalhou import openai (ou anthropic) pela codebase. Funciona no primeiro dia; no dia em que precisar trocar de provider, você reescreve quase tudo. Um founder fez exatamente isso, o sistema inteiro preso a um SDK, e o código virou refém de um único fornecedor.

O mecanismo: ports and adapters. Uma interface (port) declara as operações que você usa (chat, complete, callTool). Um adaptador por provedor traduz essa interface para a API nativa de cada um. A aplicação recebe o adaptador por injeção de dependência e chama apenas o contrato.

O trade-off: a abstração deve capturar só o que seu sistema realmente usa. Abstrair demais, por precaução, esconde capacidades específicas (structured output nativo, tool use e outra) e adiciona manutenção. Abstrair de menos deixa o provider vazar no core. Desenhe o port o mais fino que o seu uso atual exige, e amplie quando houver evidência de necessidade.

Cuidado com nomenclatura: functionCall é termo da OpenAI; Claude usa tool use, Gemini usa function declarations. O contrato deve descrever a intenção (callTool(name, args)), e cada adaptador traduz.

Como fazer:

  • Crie o port com as operações de fato usadas.
  • Implemente um adaptador por provedor.
  • Injete o adaptador; a codebase chama o contrato, nunca o SDK.
  • Trocar de provider = trocar a dependência injetada.

Validação: teste capaz de trocar um adaptador fake (sem rede) por um real e ver o core passar inalterado. Um smoke test com dois providers provando que o mesmo fluxo roda = o port está no ponto certo.

Princípio 2. Colapse a resposta no determinístico e valide o formato

O problema: LLMs não são determinísticas. O mesmo input pode gerar outputs diferentes. Seu sistema precisa tomar decisão sobre respostas imprevisíveis.

O mecanismo: a decisão do modelo sai "solto" (texto livre) e você a colapsa num formato estruturado (JSON Schema), com tipos e enums. Isso restringe a forma da resposta.

O trade-off: structured output garante a sintaxe, não a semântica. O modelo pode devolver um JSON válido com um valor incoerente. Por isso a validação de schema não é opcional, é parte do mecanismo.

Como fazer:

  • Declare o JSON Schema esperado (campos, tipos, enums). Ex.: "o pedido do cliente é atendível? sim|não".
  • Valide o schema na codebase após receber (uma lib de JSON Schema).
  • Trate a falha: se a validação falhar, refaça com guidance mais rígida ou sinalize para revisão. Nunca assuma "veio estruturado, logo está certo".
  • Não confie na "verdade universal" do modelo: confie no formato após validação.

Exemplo: um fluxo de classificação devolve {classificacao: "spam"|"nao_spam"}. O modelo erra às vezes, a validação pega valor fora do enum e despacha para revisão ou retry, em vez de classificar errado silenciosamente.

Validação: um teste de regressão que alimenta outputs válidos e inválidos e confirma que o sistema só avança com schema validado.

Princípio 3. Separe memória de contexto de tarefa

O problema: contexto é caro e polui. Mandar história inteira para cada chamada encarece e confunde a resposta.

O mecanismo: dois conceitos distintos. Memória é o histórico persistido (banco), consultada seletivamente. Contexto de tarefa é só o que é relevante para responder aquele prompt específico. A construção de cada prompt escolhe o que pertencente à tarefa.

O trade-off: em chat iterativo, às vezes reconstituir a conversa inteira é necessário para grounding; em análises pontuais, passar só o trecho relevante é melhor (custo, menos ruído). A decisão é por tipo de fluxo.

Como fazer:

  • Memória vai para um banco (Mongo ou qualquer store), com busca por relevância.
  • Tarefa = apenas os trechos que respondem àquele prompt.
  • Desenhe essa separação explícita no pipeline de construção de contexto.

Validação: comparação A/B do mesmo prompt com memória "toda" vs. "relevante", medindo custo, latência e qualidade (ver Princípio 9).

Princípio 4. Proteja o custo com token budget

O problema: o LLM é um centro de custo. Um usuário descontrolado (ou um loop mal escrito) torra seu crédito. Um clique num botão pode disparar uma chamada cara; 99% não abusarão, mas o limite existe para os 1% que sim.

O mecanismo: token budget por usuário, como um rate limit, verificado antes da chamada (store de baixa latência, ex.: Redis).

O trade-off: limite duro (bloqueia) vs. limite suave (degrada/avisa). Para onboarding/upsell, suave; para controle de custo real, duro. E escolha de modelo: request simples → modelo barato; request complexo → modelo caro, roteamento por custo.

Como fazer:

  • Token budget por usuário (contagem de tokens, não de requests, prompts variam em tamanho).
  • Lógica de leaky bucket, rolling window ou fixed window.
  • Roteie por complexidade para custo.

Validação: teste que simula usuário excessivo e confirma o bloqueio/degradação no limite; monitorar % de usuários que batem no teto.

Princípio 5. Faça cache com invalidação

O problema: chamadas de LLM são lentas e caras. Repetir trabalho já feito é jogar dinheiro e tempo fora.

O mecanismo: cache por chave determinística, hash canônico do prompt + versão do modelo + parâmetros. Só responda de cache para chaves exatamente iguais (não "parecidas").

O trade-off (cache vs. recomputação): cache vence quando há alta repetição e o custo de invalidar é baixo. Recomputar vence quando os dados mudam rápido ou a resposta é pessoal/contextual. Cache semântico (prompts "parecidos") é pesquisa ativa, não recomende como prática inicial sem avaliação de risco, pois pode servir conteúdo errado e aparentemente válido.

Como fazer:

  • Chave determinística (hash de prompt + modelo + params).
  • Defina TTL e versão do prompt/modelo na chave.
  • No RAG, cacheie o resultado do retrieval (documentos reusáveis), não só a "palavra-chave".
  • Invalidação explícita: TTL + bump de versão. Cache desatualizado é pior que não ter cache.

Validação: teste de invalidação, muda a pergunta cuja resposta dependia de um dado; confirma que o cache não devolve valor velho.

Princípio 6. Mova o trabalho pesado para background

O problema: agregar histórico, categorizar dados ou gerar um perfil demora e custa. Se responde na thread de request, o sistema engasga.

O mecanismo: fila de background jobs. O request síncrono devolve rápido; o fluxo pesado roda em segundo plano e publica o resultado.

O trade-off (síncrono vs. assíncrono): chat/consulta é síncrono por natureza. Análise que agrega estado (histórico, perfil) é assíncrona: evita gargalo de latência e carrega melhor sob concorrência. É o mesmo princípio de upload de vídeo de sempre, LLM não muda a regra.

Como fazer:

  • Identifique fluxos que agregam estado do usuário → fila.
  • A resposta síncrona devolve rápido; o resultado chega quando fica pronto (poll/webhook/stream).

Validação: teste de carga que mostra tempo p95 síncrono estável mesmo com N jobs pesados enfileirados.

Princípio 7. Modele fluxos como máquinas de estado (e deixe LLMs conversarem)

O problema: LLMs usadas em request/response puro desperdiçam a chance de orquestrar tarefas de várias etapas.

O mecanismo: especifique estados formalmente; cada estado pode chamar uma LLM (ou outra parte do sistema). É uma máquina de estado mesmo que hoje esteja como um monte de if/else. Deixe LLMs conversarem: uma gera (ex.: plano de treino), outra é judge (avalia com o histórico e devolve feedback para regerar).

O trade-off (workflow determinístico vs. agente autônomo): máquina de estado (passos enumeráveis) é determinística e previsível, use quando o caminho é conhecido (formulário, categorização, e-mail). Agente autônomo (agente) é quando o caminho não é enumerável (pesquisa, planos abertos), pague guardrails/judge pelo grau de autonomia. Comece determinístico; só evolua para autônomo quando a tarefa exigir.

Como fazer:

  • Liste os estados e transições.
  • Cada estado chama a LLM certa e valida a saída (Princípio 2).
  • Use judge para loops de melhoria, com critério de parada (N iterações máx).
  • Sempre idempotente (Princípio 8).

Exemplo: recebeu e-mail → armazena → categoriza (LLM) → gera resposta (LLM) → aguarda aprovação → envia. GPT gera o plano, Claude julga e devolve feedback.

Validação: teste de fluxo completo com entradas planejadas confirmando cada transição; teste de loop máx iterações.

Princípio 8. Garanta idempotência

O problema: fluxos caros não podem rodar duas vezes para o mesmo input.

O mecanismo: idempotência, se o input é o mesmo, o resultado é o mesmo (ou reutilizado). Ocorre em retries (rede, timeout) e no cliente (usuário bobo clicando duas vezes).

O trade-off: idempotência tem custo (guarda de estado/chave). Em fluxos baratos, um retry simples basta; em fluxos caros (multi-LLM, background), a chave de idempotência é obrigatória. O custo da idempotência escala com o custo do fluxo.

Como fazer:

  • Chave determinística do input.
  • Cache de resultado ou flag de estado evita reexecução.
  • No background, um job não roda duas vezes com a mesma chave.

Validação: teste de retry, envia o mesmo pedido duas vezes e confirma execução única.

Princípio 9. Observe: custo, velocidade, qualidade e groundedness

O problema: você não sabe se o modelo está bom, rápido ou barato até medir. E "bom" não se resume à resposta isolada.

O mecanismo: painel que cruza, por provider/modelo e por input: custo, velocidade, qualidade (um judge, outra LLM, nota a resposta) e groundedness/fidelidade ao contexto. Capacidade ≠ confiabilidade do sistema completo (dunning futura).

O trade-off: eval off-line (gold set + regressão) vs. online (produção). Judge é útil, mas precisa de calibração (comparar com gold set) e não deve ser a única métrica, porque mede resposta isolada, não o fluxo (task success, tool execution success, groundedness).

Como fazer:

  • Meça por provider/modelo e por input: custo, latência, qualidade (judge calibrado), groundedness, task success, tool success.
  • Monte painel cruzando custo × velocidade × nota; versionamento de modelo/prompt no eixo.
  • Roteamento/troca de modelo ao vivo é PROJEÇÃO (possibilidade avançada de produção), não prática de MVP: exige judge calibrado + regressão + versionamento + rollback. Em MVP, priorize painel passivo.
  • Métricas que fazem sentido: só as que você consegue coletar e agir, task success rate, tool execution success rate, groundedness, custo e latência por tarefa concluída, taxa de human review.

Validação: um gold set de N casos; judge comparado a ele para medir calibração; teste de regressão antes de qualquer troca de modelo.

MVP vs. produção: o que é obrigatório onde

Para não inflar complexidade, separe:

Obrigatório no MVP

  • Provider atrás de port (P1), evita refém.
  • Structured output + validação de schema (P2).
  • Token budget por usuário com limite duro (P4).
  • Error handling razoável: timeout, um retry, idempotência básica.
  • Observabilidade mínima: logar custo, latência e falha (P9).

Obrigatório em produção

  • Cache com invalidação (P5) e background/async (P6).
  • Retries com backoff + idempotência documentada (P8), rate limiting/backpressure real.
  • Versionamento de modelo e prompt.
  • Eval contínuo: offline + online + regressão + painel de drift (P9).
  • Segurança, isolamento e PII.

Regra: adicione cada mecanismo quando houver uma métrica observável que prove sua necessidade, não por antecipação. Complexidade paga com evidência, não com fé.

Trade-offs que sua aplicação vai exigir

  • Workflow determinístico vs. agente autônomo: veja P7. Determinístico quando o caminho é conhecido; autônomo quando não é enumerável, pague judge/guardrails pelo grau de autonomia.
  • RAG vs. contexto longo: RAG quando os dados são muitos, mutáveis e você precisa groundedness verificável/atualidade; contexto longo quando o corpus é pequeno/estável e a fidelidade importa mais que o custo.
  • API gerenciada vs. self-hosted: gerenciada quando time pequeno e custo variável é aceitável; self-hosted quando há requisito de privacidade/custo fixo em escala. Cuidado: self-hosted não reduz LLM engineering, só troca o provider.
  • Single-agent vs. multi-agent: comece single. Multi-agent só com papéis genuínos; custa latência, tokens e falhas de orquestração.
  • Síncrono vs. assíncrono: veja P6.
  • Cache vs. recomputação: veja P5.
  • Modelos especialistas vs. generalistas: roteie por tarefa (barato p/ simples, top p/ complexo), só se o tráfego for realmente misto.

Checklist antes do deploy

  • Provider atrás de port/adapter (nenhum SDK no core)?
  • Saída validada por schema (structured output com validação)?
  • Falha de validação tratada (retry/sinalização)?
  • Memória separada do contexto de tarefa?
  • Token budget por usuário (limite duro se necessário)?
  • Cache com invalidação definida e chave determinística?
  • Jobs pesados em background?
  • Fluxos como máquina de estado?
  • Idempotência garantida para retry?
  • Painel de custo × velocidade × qualidade (judge calibrado) + groundedness?
  • Definido o que é MVP vs. produção (e regra: complexidade paga com métrica)?
  • Timeouts, retries com backoff e rate limiting no lugar de produção?

Nove princípios, mas o teste não é decorar o checklist: é observar. Escolha um fluxo real, valide o structured output (P2), meça custo/latência/falha (P9) e verse se roda sem intervenção. Quando um mecanismo some dos logs de falha, você sabe que está cumprindo o papel. São passos verificáveis assim que separam a aplicação "com IA" da aplicação "desenhada para IA".