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:
- 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.
- Context propagation: o
traceparentviaja 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. - 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:
- Seu eval passa em staging com
claude-sonnet-4-20250514. - Você promove para produção.
- Três semanas depois, o provedor atualiza a snapshot internamente (sem alterar o nome do modelo).
- Algumas classes de respostas começam a falhar.
- Erro rate está estável (respostas são 200), latência está estável, cache hit está estável.
- Usuários reclamam. Sua equipe procura bugs no código. Não encontra.
- 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:
