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.

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ão

API REST tradicional

LLM em produção

Determinismo

Mesma entrada → mesma saída

Mesma entrada → saídas diferentes

Versão

Você controla o deploy

Provedor pode atualizar silenciosamente

Custo

Por request, previsível

Por token, variável em ordens de magnitude

Qualidade

Status code é suficiente

Exige avaliação semântica

Latência

Geralmente estável

Varia com tokens, tools e raciocínio

Falha comum

Erro 500

Resposta 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:

Atributo

O que registra

gen_ai.system

Provedor (openai, anthropic, azure, bedrock)

gen_ai.request.model

Modelo solicitado (gpt-4o, claude-sonnet-4-20250514)

gen_ai.response.model

Modelo efetivamente usado (pode diferir do request)

gen_ai.request.max_tokens

Teto de tokens de saída

gen_ai.request.temperature

Temperatura

gen_ai.usage.input_tokens

Tokens consumidos na entrada

gen_ai.usage.output_tokens

Tokens produzidos na saída

gen_ai.tool.name

Nome da tool chamada

gen_ai.tool.description

Descrição da tool

gen_ai.operation.name

chat

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étrica

O que mede

Threshold típico

Latência p50

Experiência mediana

Alerta se > 2x baseline

Latência p95

Cauda longa

Alerta se > 3x baseline

Latência p99

Pior caso

Alerta se > 5x baseline

Erro rate

Falhas explícitas

Alerta se > 1%

Cache hit rate

Efetividade do cache

Investigar se cair > 10 p.p.

Guardrail block rate

Bloqueios de segurança

Investigar se subir > 5 p.p.

Tool call rate

Tools por request

Investigar se variar > 30%

Tokens in/out por request

Consumo

Budget alert por usuário/tenant

Custo por request

USD por chamada

Budget alert por feature

Métricas de qualidade

Métrica

O que mede

Threshold típico

Eval score rolling (janela 1h)

Qualidade semântica média

Alerta se cair > 8% vs baseline

Judge score distribution

Distribuição de notas

Investigar se moda deslocar

Hallucination rate

Respostas sem fonte

Alerta se > 3%

Refusal rate

Recusas legítimas vs spurious

Investigar se variar > 50%

Tool selection accuracy

Tool certa para tarefa

Eval offline periódico

Task completion rate

Objetivo atingido

Mé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:

Source

1 share