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