Como Integrar LLMs em Aplicações Existentes: Guia Prático

Como Integrar LLMs em Aplicações Existentes: Guia Prático





Integrando LLMs em Aplicações Existentes | yurideveloper.com

Integração em produção • arquitetura, segurança e observabilidade

Integrando LLMs em Aplicações Existentes

Quando você adiciona uma camada de geração de linguagem a um sistema que já funciona, o objetivo não é “adicionar um recurso”:
é desenhar contratos, fluxos e garantias para que o comportamento seja consistente, auditável e previsível.

Interface estável
Validação de entradas/saídas
Fallback e degradação
Observabilidade
Controle de custos

1) Crie um “boundary” claro: onde o texto entra e onde a resposta sai

Antes de qualquer chamada externa, eu defino um limite de responsabilidade no código: uma função/serviço que
traduz requisitos de negócio em uma requisição bem formada e valida o retorno antes de tocar na UI ou em rotinas críticas.

Esse boundary reduz acoplamento com provedores e evita que detalhes de formato virem dependência do restante da aplicação.
Eu costumo modelar assim:

  • Entrada tipada: sempre que possível, em vez de passar “string solta”, passo objetos com campos (ex.: contexto, ação, idioma, restrições).
  • Saída validada: retorno vem como estrutura (ex.: campos nomeados) — mesmo que a resposta venha em texto.
  • Protocolos internos: logs, IDs de correlação, timeouts e estratégia de retry ficam nesse mesmo boundary.

Um bom sinal: eu consigo testar o restante do sistema com um “stub” do boundary sem mexer em controllers, persistência ou front.

2) Padronize prompts e contratos: consistência vence improviso

Em aplicações existentes, o problema mais comum não é “a resposta ser ruim”, e sim a resposta variar
de formato, escala e detalhes — quebrando o que o sistema espera.

Para evitar isso, eu trato o pedido como um contrato:

  • Contexto mínimo: inclua apenas o que muda a decisão; o resto vira padrão.
    Isso reduz custo e melhora repetibilidade.
  • Instruções de formato: defina estrutura e restrições (por exemplo: “retorne somente JSON válido”, “use campos X/Y”).
  • Separação de responsabilidades: combine “intenção” (o que decidir) com “política” (como decidir),
    em blocos estáveis.
  • Normalização: antes de enviar, eu normalizo strings (trim, encoding), limites e idioma.

Na prática: se eu não consigo validar o retorno de forma determinística, eu não ligo isso a fluxos críticos (pagamento, cadastro, alterações irreversíveis).

3) Validação, parsing e fallback: o sistema precisa continuar vivo

Integração robusta é menos sobre “chamar” e mais sobre “o que fazer quando algo falha”.
Eu desenho 3 camadas: validação, degradação e recuperação.

3.1 Validação no retorno

  • Formato: valide se a resposta atende ao schema esperado (ex.: JSON parseável, campos obrigatórios presentes).
  • Conteúdo: aplique regras (ex.: limites de tamanho, enumerações, padrões regex para IDs e datas).
  • Semântica mínima: quando fizer sentido, valide consistência interna (ex.: “ação” compatível com “resultado”).

3.2 Degradação planejada

  • Para erros transitórios: retry com backoff e timeout por requisição.
  • Para erros de validação: rerun somente quando for “regras de formatação” (e.g., JSON incompleto).
  • Para falhas persistentes: fallback para uma estratégia segura (ex.: resposta padrão, rota humana, ou consulta a dados existentes).

3.3 Monitoramento do comportamento

  • Taxa de sucesso de parsing/validação.
  • Latência por etapa (tempo de rede + tempo de parsing).
  • Distribuição de tamanho de resposta e custo estimado.

4) Observabilidade e custos: instrumente desde o primeiro commit

Se eu não consigo responder “o que aconteceu” depois de uma falha, a integração vira um risco operacional.
Por isso eu adiciono logs estruturados e métricas no boundary.

O que eu sempre registro:

  • request_id (correlação entre backend, logs e UI)
  • endpoint e versão do “contrato” (prompts e schema)
  • latência (por segmento)
  • status (sucesso, falha transitória, falha de validação, fallback)
  • tamanho da entrada/saída (para ajustar limites)

Para custos, eu defino guardrails: teto de tokens/bytes por contexto, truncamento determinístico e “modo curto” para fluxos não críticos.

Dica prática: trate “schema e prompt versionados” como dependência real do sistema. Quando isso muda, eu registro a mudança e monitorei impacto antes de liberar amplamente.

Exemplo: boundary com validação de JSON e fallback

Abaixo um exemplo (Node/TypeScript) de boundary que: aplica timeout, faz parsing seguro, valida campos e escolhe fallback quando necessário.

Exemplo
boundary.ts

timeouts • parsing • validação • fallback

import { setTimeout as delay } from "node:timers/promises";

type RequestPayload = {
  userId: string;
  intent: "resumo" | "classificar";
  text: string;
  language: "pt-BR" | "en-US";
};

type ResponsePayload = {
  action: "mostrar_resumo" | "sugerir_categoria";
  result: string;
  confidence: number; // 0..1
};

type BoundaryResult =
  | { ok: true; data: ResponsePayload }
  | { ok: false; reason: "VALIDATION" | "TIMEOUT" | "PROVIDER"; data: ResponsePayload };

function isValidResponsePayload(x: any): x is ResponsePayload {
  return (
    x &&
    (x.action === "mostrar_resumo" || x.action === "sugerir_categoria") &&
    typeof x.result === "string" &&
    typeof x.confidence === "number" &&
    x.confidence >= 0 && x.confidence <= 1
  );
}

function safeFallback(req: RequestPayload): ResponsePayload {
  if (req.intent === "resumo") {
    return {
      action: "mostrar_resumo",
      result: "Não foi possível gerar o resultado no momento. Tente novamente em instantes.",
      confidence: 0
    };
  }
  return {
    action: "sugerir_categoria",
    result: "Não foi possível classificar agora. Vamos seguir com uma categoria padrão.",
    confidence: 0
  };
}

async function callProviderRaw(payload: RequestPayload, signal: AbortSignal): Promise<string> {
  // Aqui você chama o seu provedor/endpoint real.
  // Retorne a resposta como string (texto) para aplicar parsing/validação no boundary.
  // Exemplo:
  // const res = await fetch(url, { method: "POST", body: JSON.stringify(payload), signal });
  // return await res.text();
  await delay(50, null); // placeholder
  if (payload.text.length < 3) return "{ "action": "mostrar_resumo" }"; // inválido proposital
  return JSON.stringify({
    action: payload.intent === "resumo" ? "mostrar_resumo" : "sugerir_categoria",
    result: "Resultado gerado com sucesso (exemplo).",
    confidence: 0.82
  });
}

export async function generateWithBoundary(
  req: RequestPayload
): Promise<BoundaryResult> {
  const timeoutMs = 2500;
  const controller = new AbortController();
  const t = setTimeout(() => controller.abort(), timeoutMs);

  try {
    // 1) Normalização / guardrails antes do envio
    const normalized: RequestPayload = {
      ...req,
      text: req.text.trim().slice(0, 4000)
    };

    // 2) Chamada externa
    const raw = await callProviderRaw(normalized, controller.signal);

    // 3) Parsing seguro
    let parsed: any;
    try {
      parsed = JSON.parse(raw);
    } catch {
      const data = safeFallback(req);
      return { ok: false, reason: "VALIDATION", data };
    }

    // 4) Validação de schema mínimo
    if (!isValidResponsePayload(parsed)) {
      const data = safeFallback(req);
      return { ok: false, reason: "VALIDATION", data };
    }

    return { ok: true, data: parsed };
  } catch (err: any) {
    const isTimeout = err?.name === "AbortError";
    const data = safeFallback(req);
    return { ok: false, reason: isTimeout ? "TIMEOUT" : "PROVIDER", data };
  } finally {
    clearTimeout(t);
  }
}

Repare como o resto do sistema recebe sempre um ResponsePayload consistente.
Isso permite que a UI e as rotas de negócio tratem sucesso/erro sem depender do formato “solto” da resposta.

Quer mais exemplos práticos?

Se você gostou da abordagem de boundary, validação e fallback, leia também outros posts do yurideveloper.com
para aplicar boas práticas em integração, arquitetura e qualidade no dia a dia.

Ver mais posts