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.
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.
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.
Sou Apaixonado pela programação e estou trilhando o caminho de ter cada diz mais conhecimento e trazer toda minha experiência vinda do Design para a programação resultando em layouts incríveis e idéias inovadoras! Conecte-se Comigo!