1

Observabilidade e Evals em Produção: O Fechamento do LLMOps

English summary (PT-BR article): Generative observability is not just logs. It is the full prompt, model version, tools invoked, latency, cost and eval verdict in a single span. This closing article of the LLMOps in Production series covers OpenTelemetry GenAI semantic conventions, online shadow evals, model drift detection and automatic rollback.

Observabilidade e Evals em Produção: O Fechamento do LLMOps

Este é o quinto e último artigo da série LLMOps em Produção. No artigo anterior sobre guardrails runtime, vimos como bloquear, filtrar e moldar respostas antes que cheguem ao usuário. Mas todo guardrails depende de uma decisão anterior: qual versão de prompt, qual versão de modelo, qual rota, qual cache. Essas decisões só podem ser auditadas quando o sistema é observável.

A tese deste fechamento é simples:

Sem prompt completo, versão de modelo e veredito do eval no mesmo span, você não tem observabilidade generativa. Você tem um log de servidor tradicional fingindo que entende LLM.

Sistemas tradicionais têm latência, status code e payload. Sistemas generativos têm tudo isso mais prompt, versão de modelo, tools chamadas, tokens de entrada e saída, custo por token, temperatura, system prompt, roteamento, fallback, cache e — o mais importante — a qualidade semântica da resposta. Sem ela, erro 200 não significa que deu certo; significa apenas que retornou.

Principais aprendizados

  • Observabilidade generativa exige atributos específicos: prompt completo, versão do modelo, tools usadas, tokens, custo e veredito do eval no mesmo span — não em logs separados.
  • OpenTelemetry com GenAI semantic conventions (gen_ai.*) é o padrão que evita vendor lock-in de telemetria.
  • Online evals (shadow) rodando golden set periódico contra produção são a única forma de detectar drift de modelo silencioso.
  • Latência p95/p99, erro rate, cache hit rate e guardrail block rate são métricas operacionais; eval score rolling é métrica de qualidade.
  • Rollback automático baseado em degradação de métricas transforma incidente em rotina, não em página aos 3 da manhã.

Por que observabilidade tradicional falha para LLM

Um endpoint REST tradicional tem semântica binária: 200 ou 500, com payload determinístico para a mesma entrada. Você pode reproduzir o bug localmente com o mesmo request body. Latência importa, mas o significado da resposta não varia entre chamadas.

LLMs quebram todas essas premissas.

Primeiro, a mesma entrada produz saídas diferentes devido à temperatura, sampling e versão implícita do modelo. O provedor pode atualizar gpt-4-turbo ou claude-sonnet-4 silenciosamente para uma snapshot nova, e o seu sistema começa a falhar sem nenhuma mudança no seu código. Esse fenômeno é conhecido como model drift e é a principal causa de regressões silenciosas em produção.

Segundo, o significado da resposta importa mais que o status code. Uma resposta HTTP 200 com texto gramaticalmente correto mas factualmente errado, ofensivo ou fora do escopo é um defeito que nenhum log de servidor tradicional detecta.

Terceiro, o custo varia por request. Uma chamada com 500 tokens de entrada e 50 de saída custa X; uma chamada com 8 mil tokens de contexto e 2 mil de saída custa 10 vezes mais. Sem atribuir tokens e custo a cada span, você não consegue identificar os 1% de usuários ou queries que consomem 50% do orçamento.

DimensãoAPI REST tradicionalLLM em produção
DeterminismoMesma entrada → mesma saídaMesma entrada → saídas diferentes
VersãoVocê controla o deployProvedor pode atualizar silenciosamente
CustoPor request, previsívelPor token, variável em ordens de magnitude
QualidadeStatus code é suficienteExige avaliação semântica
LatênciaGeralmente estávelVaria com tokens, tools e raciocínio
Falha comumErro 500Resposta 200 errada

Por isso, a camada de observabilidade precisa capturar não apenas "o que aconteceu", mas "por que o modelo decidiu isso". A diferença entre debugar um bug clássico e debugar uma regressão semântica é a diferença entre ler um stack trace e reconstruir a cadeia completa de inferência.

OpenTelemetry e GenAI semantic conventions

OpenTelemetry (OTel) é o padrão aberto de telemetria. A vantagem sobre soluções proprietárias (LangSmith, Helicone, Braintrust) é que você não fica refém de um vendor de observabilidade: trocar de backend (Honeycomb, Datadog, Grafana Tempo, Jaeger) é uma configuração de exporter, não uma reescrita de instrumentação.

Para LLMs existe um conjunto específico de atributos chamado GenAI semantic conventions, prefixados com gen_ai.*. Eles padronizam como descrever uma chamada de modelo:

AtributoO que registra
gen_ai.systemProvedor (openai, anthropic, azure, bedrock)
gen_ai.request.modelModelo solicitado (gpt-4o, claude-sonnet-4-20250514)
gen_ai.response.modelModelo efetivamente usado (pode diferir do request)
gen_ai.request.max_tokensTeto de tokens de saída
gen_ai.request.temperatureTemperatura
gen_ai.usage.input_tokensTokens consumidos na entrada
gen_ai.usage.output_tokensTokens produzidos na saída
gen_ai.tool.nameNome da tool chamada
gen_ai.tool.descriptionDescrição da tool
gen_ai.operation.namechat

A distinção entre gen_ai.request.model e gen_ai.response.model é crítica. Você pode pedir claude-sonnet-4 e receber uma snapshot atualizada do provedor. Sem essa separação, drift de modelo fica invisível.

Por que não usar só logs estruturados

Logs em JSON resolvem metade do problema. Mas OTel traz três vantagens estruturais:

  1. Tracing distribuído nativo: uma requisição que passa por roteador, cache, modelo, tool e guardrails gera um trace com spans hierárquicos. Você vê a latência de cada etapa, não apenas a soma.
  2. Context propagation: o traceparent viaja entre serviços. Um request que toca seu backend Node.js, um serviço Python de tools e outro de guardrails mantém a correlação.
  3. Métricas derivadas: a partir dos mesmos spans, exporters calculam p50/p95/p99, throughput e erro rate sem você instrumentar cada cálculo.

O custo é de instrumentação inicial. O ganho é não reinventar telemetria quando o sistema cresce.

O diagrama completo: do request ao rollback

flowchart TD
    Req["Request do usuário"] --> Router["Roteamento"]
    Router --> Cache{"Cache hit?"}
    Cache -- "sim" --> Fast["Responder do cache"]
    Cache -- "não" --> Model["Chamada de modelo"]
    Model --> Guard["Guardrails de saída"]
    Guard --> Tools["Tools executadas"]
    Tools --> Resp["Resposta final"]

    Req -.-> OTel["Spans OTel com gen_ai.*"]
    Model -.-> OTel
    Tools -.-> OTel
    Guard -.-> OTel
    Resp -.-> OTel

    OTel --> Metrics["Métricas: p50/p95/p99, tokens, custo, erro"]
    OTel --> Logs["Traces completos"]
    Metrics --> Dashboard["Dashboard operacional"]
    Metrics --> Drift{"Drift detect?"}
    Drift -- "sim" --> Alert["Alerta"]
    Alert --> Rollback{"Auto rollback possível?"}
    Rollback -- "sim" --> Revert["Reverter versão de prompt/modelo"]
    Rollback -- "não" --> Human["Escalar para humano"]
    Revert --> Dashboard

Cada linha pontilhada representa um span emitido. O trace completo reconstrói a requisição inteira, do cache hit ao veredito do eval. Sem isso, debugging de regressão é advinhação.

Tracing: span por chamada, span por tool, span por guardrail

A regra de ouro é: toda decisão operacionalmente relevante vira um span. Não um log solto. Um span.

Uma requisição típica gera entre 3 e 12 spans:

flowchart LR
    Root["Span raiz: request_id"] --> Router["Span: roteamento escolhido"]
    Root --> Cache["Span: cache lookup"]
    Root --> Model["Span: chamada de modelo"]
    Model --> Tool1["Span: tool search"]
    Model --> Tool2["Span: tool fetch"]
    Root --> Guard["Span: guardrail output"]
    Root --> Eval["Span: eval online (amostra)"]

O span raiz carrega o traceId e atributos comuns: user_id, session_id, feature_flag, prompt_version. Cada span filho adiciona seus atributos específicos. O eval span aparece apenas em uma fração das requisições (sample) — rodar LLM-as-judge em 100% das respostas custa caro demais.

Código: hook OTel com GenAI attributes

import { trace, context, SpanStatusCode } from '@opentelemetry/api';
import { registerInstrumentations } from '@opentelemetry/instrumentation';
import { OpenAIInstrumentation } from '@opentelemetry/instrumentation-openai';
import { AnthropicInstrumentation } from '@opentelemetry/instrumentation-anthropic';

// Registra instrumentação automática para provedores comuns.
registerInstrumentations({
  instrumentations: [
    new OpenAIInstrumentation(),
    new AnthropicInstrumentation(),
  ],
});

const tracer = trace.getTracer('llmops.app');

type LLMSpanAttributes = {
  promptVersion: string;
  modelRequested: string;
  modelResponded: string;
  temperature: number;
  maxTokens: number;
  toolsAvailable: string[];
  toolsUsed: string[];
  route: string;
  cacheHit: boolean;
  guardrailVerdict: 'pass' | 'block' | 'transform';
};

export async function withLLMSpan<T>(
  attributes: LLMSpanAttributes,
  fn: () => Promise<T>,
): Promise<T> {
  return tracer.startActiveSpan(
    'llm.request',
    { attributes: flattenAttributes(attributes) },
    async (span) => {
      try {
        const result = await fn();

        span.setAttribute('gen_ai.request.model', attributes.modelRequested);
        span.setAttribute('gen_ai.response.model', attributes.modelResponded);
        span.setAttribute('llmops.prompt_version', attributes.promptVersion);
        span.setAttribute('llmops.route', attributes.route);
        span.setAttribute('llmops.cache_hit', attributes.cacheHit);
        span.setAttribute('llmops.guardrail_verdict', attributes.guardrailVerdict);
        span.setAttribute('llmops.tools_used_count', attributes.toolsUsed.length);

        span.setStatus({ code: SpanStatusCode.OK });
        return result;
      } catch (error) {
        span.recordException(error as Error);
        span.setStatus({ code: SpanStatusCode.ERROR });
        throw error;
      } finally {
        span.end();
      }
    },
  );
}

function flattenAttributes(attrs: LLMSpanAttributes): Record<string, string | number | boolean | string[]> {
  return {
    'gen_ai.system': 'anthropic',
    'gen_ai.request.model': attrs.modelRequested,
    'gen_ai.request.temperature': attrs.temperature,
    'gen_ai.request.max_tokens': attrs.maxTokens,
    'llmops.tools_available': attrs.toolsAvailable,
    'llmops.tools_used': attrs.toolsUsed,
  };
}

O ponto-chave é capturar prompt_version. Sem isso, regressão após um git revert de prompt fica indistinguível de regressão de modelo. Cada deploy de prompt deve bumpar uma versão semântica (por exemplo, summarizer.v3.4.1) e registrá-la no span.

Exemplo de span capturado

Quando você abre o trace em Jaeger ou Honeycomb, vê algo parecido com:

llm.request                       1.84s
├─ gen_ai.system: anthropic
├─ gen_ai.request.model: claude-sonnet-4-20250514
├─ gen_ai.response.model: claude-sonnet-4-20250514-r2
├─ llmops.prompt_version: summarizer.v3.4.1
├─ llmops.route: long_context_fallback
├─ gen_ai.usage.input_tokens: 4200
├─ gen_ai.usage.output_tokens: 380
├─ llmops.cost_usd: 0.0234
├─ llmops.guardrail_verdict: pass
└─ llmops.eval.verdict: pass (judge_score: 0.82)

Esse trace responde em segundos: qual versão de prompt, qual snapshot do modelo, quanto custou, qual rota, se passou guardrail, qual foi o veredito do judge. Sem isso, três dias de reunião para descobrir.

Métricas que importam

Métricas operacionais são diferentes de métricas de qualidade. As duas famílias precisam coexistir no mesmo dashboard.

Métricas operacionais

MétricaO que medeThreshold típico
Latência p50Experiência medianaAlerta se > 2x baseline
Latência p95Cauda longaAlerta se > 3x baseline
Latência p99Pior casoAlerta se > 5x baseline
Erro rateFalhas explícitasAlerta se > 1%
Cache hit rateEfetividade do cacheInvestigar se cair > 10 p.p.
Guardrail block rateBloqueios de segurançaInvestigar se subir > 5 p.p.
Tool call rateTools por requestInvestigar se variar > 30%
Tokens in/out por requestConsumoBudget alert por usuário/tenant
Custo por requestUSD por chamadaBudget alert por feature

Métricas de qualidade

MétricaO que medeThreshold típico
Eval score rolling (janela 1h)Qualidade semântica médiaAlerta se cair > 8% vs baseline
Judge score distributionDistribuição de notasInvestigar se moda deslocar
Hallucination rateRespostas sem fonteAlerta se > 3%
Refusal rateRecusas legítimas vs spuriousInvestigar se variar > 50%
Tool selection accuracyTool certa para tarefaEval offline periódico
Task completion rateObjetivo atingidoMétrica de produto

Código: registrando métricas

import { metrics } from '@opentelemetry/api';

const meter = metrics.getMeter('llmops.metrics');

const latencyHistogram = meter.createHistogram('llmops.latency', {
  description: 'Latência total de requisições LLM em ms',
  unit: 'ms',
});

const tokenCounter = meter.createUpDownCounter('llmops.tokens', {
  description: 'Tokens consumidos por requisição',
});

const costHistogram = meter.createHistogram('llmops.cost_usd', {
  description: 'Custo em USD por requisição',
});

const evalGauge = meter.createObservableGauge('llmops.eval_score_rolling', {
  description: 'Score rolling do eval online (janela 1h)',
});

type LLMRequestRecord = {
  latencyMs: number;
  inputTokens: number;
  outputTokens: number;
  costUsd: number;
  cacheHit: boolean;
  guardrailBlocked: boolean;
  promptVersion: string;
};

const evalState = new Map<string, number[]>();

export function recordLLMMetric(record: LLMRequestRecord): void {
  latencyHistogram.record(record.latencyMs, {
    'llmops.cache_hit': record.cacheHit.toString(),
    'llmops.prompt_version': record.promptVersion,
  });

  tokenCounter.add(record.inputTokens + record.outputTokens, {
    'gen_ai.usage.direction': 'total',
  });

  costHistogram.record(record.costUsd);

  if (record.guardrailBlocked) {
    meter
      .createUpDownCounter('llmops.guardrail_blocks')
      .add(1, { 'llmops.prompt_version': record.promptVersion });
  }
}

export function recordEvalScore(promptVersion: string, score: number): void {
  const window = evalState.get(promptVersion) ?? [];
  window.push(score);
  // Mantém apenas os últimos 200 scores por versão de prompt.
  if (window.length > 200) window.shift();
  evalState.set(promptVersion, window);
}

evalGauge.addCallback((result) => {
  for (const [promptVersion, scores] of evalState.entries()) {
    if (scores.length === 0) continue;
    const avg = scores.reduce((a, b) => a + b, 0) / scores.length;
    result.observe(avg, { 'llmops.prompt_version': promptVersion });
  }
});

Note que cada métrica carrega prompt_version como atributo. Isso permite segmentar: "p95 para summarizer.v3.4.0 vs summarizer.v3.4.1". Sem esse atributo, comparar versões é impossível e rollback manual vira achismo.

Drift de modelo: o assassino silencioso

O drift de modelo é o problema mais subestimado em LLMOps. Funciona assim:

  1. Seu eval passa em staging com claude-sonnet-4-20250514.
  2. Você promove para produção.
  3. Três semanas depois, o provedor atualiza a snapshot internamente (sem alterar o nome do modelo).
  4. Algumas classes de respostas começam a falhar.
  5. Erro rate está estável (respostas são 200), latência está estável, cache hit está estável.
  6. Usuários reclamam. Sua equipe procura bugs no código. Não encontra.
  7. Uma semana depois, alguém sugere voltar o prompt. Não funciona. O problema não estava no prompt.

Esse cenário é comum o suficiente para ter nome: silent model drift. A única defesa é rodar evals em produção continuamente.

Como detectar drift

type DriftBaseline = {
  promptVersion: string;
  modelName: string;
  baselineScore: number;
  threshold: number; // queda aceitável, por exemplo 0.08
  windowSize: number; // número mínimo de amostras para considerar válido
};

type DriftVerdict =
  | { kind: 'stable'; score: number }
  | { kind: 'drift'; score: number; dropFrom: number; delta: number }
  | { kind: 'insufficient_samples'; samples: number };

export function checkDrift(
  baseline: DriftBaseline,
  recentScores: number[],
): DriftVerdict {
  if (recentScores.length < baseline.windowSize) {
    return { kind: 'insufficient_samples', samples: recentScores.length };
  }

  const currentScore =
    recentScores.reduce((a, b) => a + b, 0) / recentScores.length;
  const delta = baseline.baselineScore - currentScore;

  if (delta > baseline.threshold) {
    return {
      kind: 'drift',
      score: currentScore,
      dropFrom: baseline.baselineScore,
      delta,
    };
  }

  return { kind: 'stable', score: currentScore };
}

A função checkDrift deve rodar a cada janela de tempo (por exemplo, a cada hora) e alimentar um alerta. O threshold não é universal: em um sumarizador, 5% de queda pode ser grave; em um classificador binário, 2% pode ser gravíssimo. Defina o baseline com base no eval de staging antes do deploy.

Online evals e shadow evals

Evals offline (CI/staging) protegem contra regressões conhecidas. Evals online (produção) protegem contra regressões desconhecidas. As duas camadas são complementares, não redundantes.

Online evals com amostragem

Não é viável rodar LLM-as-judge em 100% das respostas em produção. O padrão é amostrar 1% a 5% das requisições e julgar essas. Para um sistema com 100 mil requests/dia, isso significa 1 mil a 5 mil julgamentos — factível com um modelo menor como gpt-4o-mini ou claude-haiku.

type EvalCase = {
  id: string;
  prompt: string;
  expectedBehavior: string;
  category: string;
};

type ShadowEvalResult = {
  caseId: string;
  productionResponse: string;
  judgeVerdict: 'pass' | 'fail' | 'partial';
  judgeScore: number;
  judgeReason: string;
};

export async function runShadowEval(
  cases: EvalCase[],
  productionEndpoint: (prompt: string) => Promise<string>,
  judge: (response: string, expectedBehavior: string) => Promise<ShadowEvalResult>,
): Promise<ShadowEvalResult[]> {
  const results: ShadowEvalResult[] = [];

  for (const testCase of cases) {
    const response = await productionEndpoint(testCase.prompt);
    const verdict = await judge(response, testCase.expectedBehavior);
    results.push({ ...verdict, caseId: testCase.id });
  }

  return results;
}

export async function llmJudge(
  response: string,
  expectedBehavior: string,
): Promise<ShadowEvalResult> {
  // Em produção, isso chama um modelo menor e barato.
  const judgePrompt = `Avalie se a resposta atende ao comportamento esperado.

Comportamento esperado: ${expectedBehavior}
Resposta recebida: ${response}

Responda em JSON:
{
  "verdict": "pass" | "partial" | "fail",
  "score": 0.0 a 1.0,
  "reason": "explicação curta"
}`;

  // chamada ao modelo judge omitida — use structured outputs
  return {
    caseId: '',
    productionResponse: response,
    judgeVerdict: 'pass',
    judgeScore: 0.85,
    judgeReason: 'cobriu todos os pontos do comportamento esperado',
  };
}

Golden set: o ativo mais valioso

O golden set é um conjunto curado de casos representativos (entre 50 e 500, dependendo do domínio) que cobre:

Carregando publicação patrocinada...