Saltar para o conteúdo
Telmo Silva/hub
De 0 a AI Engineer · Módulo 3 de 4

Avaliação e Observability de Sistemas de IA

Um LLM que responde bem a uma pergunta não prova que o sistema funciona. Prova-se com um conjunto de casos, medido sempre da mesma forma.

Por Telmo Silva

Ir para o teu progresso ↓

Porque escrevi este curso

Chamas a API de um LLM, pedes a um juiz para avaliar a resposta (LLM-as-a-judge, técnica já vista no Curso 4 da série de Python para QA desta casa), o veredito sai "aprovado". Isto prova exatamente uma coisa: que aquela resposta, àquela pergunta, passou naquele critério, naquele momento. Não prova que o sistema funciona. Amanhã mudas uma frase do prompt para corrigir um caso que estava a falhar, e não fazes a mínima ideia se acabaste de partir três outros casos que antes passavam sem problema.

É esse o salto que este curso ensina: de avaliar uma resposta para avaliar um sistema. Isso significa um conjunto representativo de casos, não um exemplo avulso escolhido porque calhou a dar bem, e significa repetir essa avaliação inteira sempre que mudas alguma coisa, prompt, modelo, ou até só a versão de uma biblioteca. Depois de teres um sistema que consegues avaliar com confiança, a segunda metade do curso trata do que acontece quando esse sistema já está em produção a sério: o que registas por cada pedido, como medes latência sem a média te mentir, como apanhas cedo um modelo que mudou de comportamento sem aviso, e que alertas valem a pena configurar sem acordar ninguém às três da manhã por um pico isolado que se resolveu sozinho.

Uma honestidade sobre o que aqui está

É o terceiro curso da série "De 0 a AI Engineer". Pressupõe o Curso 1 desta série feito (ou a competência equivalente): o serviço que lá construíste, com retries, filas e fallback, é exatamente o tipo de sistema que este curso ensina a avaliar e a observar a sério. Pressupõe também já teres usado LLM-as-a-judge pelo menos uma vez, nem que seja fora deste site; o Curso 4 da série de Python para QA ensina essa técnica do zero, mas qualquer experiência equivalente chega. Este curso aprofunda esse tema e o de deteção de alucinações (também do Curso 4), levando-os de "avaliar uma resposta isolada" para "avaliar um sistema inteiro, continuamente".

Ao contrário do Curso 1, não precisas de nenhuma chave de API paga para fazeres este a sério. É quase todo lógica pura: agregação de resultados, cálculo de percentis, verificação de limiares. Testa-se com Python normal, sem gastar um cêntimo, e é exatamente assim que o vais aprender aqui: cada função pura deste curso foi mesmo corrida e confirmada com casos concretos antes de ser publicada. Onde uma chamada real a um juiz (a API da OpenAI, por exemplo) encaixaria, mostro exatamente onde e como a ligar, mas os exercícios em si correm e testam-se sem ela.

Serve para quem já tem (ou está a construir) um sistema com um LLM lá dentro e chegou à pergunta incómoda: "como é que eu sei que isto continua a funcionar depois de eu mexer nalguma coisa?" Se a resposta atual for "testo à mão, umas quantas perguntas, e vejo se me parece bem", este curso é para ti.

O que precisas de ter pronto antes de começares

Testaste uma resposta a uma pergunta. Não testaste o sistema. A diferença só aparece na primeira vez que mudas o prompt e ninguém sabe dizer se ficou melhor ou pior.

9 capítulos, em dois módulos: medir qualidade a sério, e observability depois de estar em produção. Quase tudo lógica pura, testada com Python normal, sem precisares de gastar nada em chamadas de API para confirmares que o código funciona.

01Porque um Teste Isolado Não Chega

Uma resposta boa, uma vez, não prova nada

LLM-as-a-judge, já visto no Curso 4 da série de Python para QA desta casa, resolve um problema real: quando não há uma resposta certa fixa para comparar por igualdade, pedes a um segundo modelo que leia a pergunta, a resposta, e um conjunto de critérios, e devolva um veredito estruturado. Isso responde à pergunta "esta resposta, a esta pergunta, é boa?". Não responde à pergunta seguinte, que é a que interessa a produção: "o sistema, no seu todo, continua bom?"

É a mesma diferença que já conheces de testar software normal. Um teste unitário que passa prova que aquele caso específico está correto; não prova que a funcionalidade inteira está correta, muito menos que uma alteração noutro ficheiro não a partiu. Ninguém decide se pode fazer deploy com base num único teste verde. Com um LLM, a tentação de fazer exatamente isso é maior, porque testar "a sério" parece caro e lento: escreves um prompt, corres três perguntas à mão, lês as respostas, parece bem, segues em frente.

Este curso aprofunda dois temas do Curso 4 (LLM-as-a-judge e deteção de alucinações) para o contexto de produção real: em vez de avaliar uma resposta isolada, avaliar um sistema inteiro, de forma repetível. Se ainda não fizeste esse curso, qualquer experiência equivalente com um juiz de IA chega para acompanhares o que se segue.

O momento em que isto rebenta

Imagina que o teu prompt tem um problema: em certas perguntas sobre prazos de entrega, o modelo às vezes inventa um número. Vais ao prompt, acrescentas uma instrução explícita ("nunca inventes um prazo, usa só o que está no contexto fornecido"), testas com a pergunta que estava a falhar, e agora sai correto. Fazes deploy. Duas semanas depois alguém reporta que o chatbot deixou de responder a perguntas sobre métodos de pagamento, uma funcionalidade que nunca tinha problema nenhum antes desta alteração.

O que aconteceu é banal: a instrução nova, escrita para resolver um caso, mudou o comportamento do modelo noutros casos que nunca testaste, porque nunca os tinhas escrito nalgum lado para testar de novo. Isto não é um caso raro; é o comportamento normal de mexer num prompt sem uma rede de segurança. Um LLM não tem um "if" isolado que dá para mudar sem afetar o resto; o prompt inteiro influencia a resposta inteira, sempre.

O que muda: de um exemplo para um conjunto

A solução não é testar melhor um caso; é deixar de testar um caso. Precisas de um conjunto representativo de casos (chamado golden dataset, capítulo 2), corrido de uma ponta à outra sempre que mudas alguma coisa que pode influenciar a resposta: o prompt, o modelo, a temperatura, a versão de uma biblioteca de RAG. "Sempre que mudas" não é retórica: é literalmente antes de qualquer deploy, da mesma forma que corres a suite de testes automatizados antes de fazer merge de um pull request.

  • Um exemplo avulso diz-te se aquela resposta específica passou. Não diz nada sobre as outras cem perguntas que o teu sistema também recebe.
  • Um conjunto representativo cobre os casos normais, os casos difíceis, e os casos de limite (capítulo 2), e dá-te uma taxa de aprovação por critério, não um sim ou não isolado.
  • Repetir a cada mudança é o que transforma isto de "parece que melhorou" em "melhorou neste critério, e não piorou nestes outros três" (capítulo 3).

Isto não é sobre desconfiar do modelo

É sobre desconfiares de qualquer alteração a um sistema que não tens forma barata de testar de novo, na íntegra, em segundos. Um LLM só torna isto mais urgente do que já era com software normal, porque o efeito de uma mudança é menos previsível à partida do que o de uma linha de código determinística.

Onde este curso te leva

O Módulo 1 constrói essa rede de segurança: um golden dataset a sério (capítulo 2), métricas agregadas que dizem se uma versão nova do prompt é melhor ou pior do que a anterior (capítulo 3), e um exercício onde escreves essa suite de avaliação de ponta a ponta (capítulo 4). O Módulo 2 assume que o sistema já está em produção, com tráfego real, e trata do resto: o que registas por pedido (capítulo 5), como medes latência, erro e custo sem a média te enganar (capítulo 6), como apanhas um modelo que mudou de comportamento sem aviso (capítulo 7), e que alertas configurar sem gerar fadiga (capítulo 8), fechando com um segundo exercício que junta tudo isso num script de análise de logs.

02Construir um Golden Dataset

O que é, exatamente

Um golden dataset é um conjunto de casos de teste para o teu sistema de IA: cada caso tem um input (a pergunta, o pedido, o documento a resumir) e, ou uma resposta esperada fixa, ou um conjunto de critérios de aprovação que uma resposta válida tem de cumprir. "Golden" não significa perfeito nem imutável; significa que é a referência acordada, versionada, contra a qual comparas qualquer alteração. Vive num ficheiro, no mesmo repositório do resto do código, sujeito ao mesmo controlo de versões.

python

# Cada caso do golden dataset: um input e o que conta como aprovação.
# Não precisa de uma "resposta certa" única, palavra por palavra; precisa
# de critérios verificáveis, tal como usaste num veredito de LLM-as-a-judge.
caso = {
    "id": "caso-1",
    "pergunta": "Qual é o prazo de entrega para Portugal Continental?",
    "contexto_oficial": "As encomendas para Portugal Continental são entregues em 2 a 4 dias úteis.",
    "criterios": ["rigor_factual", "relevancia", "sem_alucinacao"],
}

Guardar o "contexto_oficial" junto do caso é o que torna sem_alucinacao verificável mais tarde: um juiz (ou uma heurística determinística, como no capítulo 7 do Curso 4) só consegue confirmar que a resposta não inventou nada se tiver contra o que comparar.

Versionado, a sério

Versionado quer dizer: em git, ao lado do prompt, revisto em pull request como qualquer outra mudança de comportamento. Quando alguém adiciona um caso novo ao dataset (porque um utilizador real encontrou um problema que o dataset não cobria), essa adição é ela própria uma mudança a rever, tal como adicionar um teste automatizado depois de um bug em produção. E quando a taxa de aprovação sobe ou desce entre duas versões, a primeira pergunta é sempre "o que mudou: o prompt, o modelo, ou o próprio dataset?", porque as três coisas alteram o número final.

Escolher exemplos representativos

Representativo significa que a distribuição dos casos se parece com a distribuição do tráfego real, não com a distribuição do que é fácil de escrever. Se o teu chatbot recebe sobretudo perguntas sobre prazos de entrega, devoluções e pagamentos, o dataset devia refletir isso nas proporções, não ter um caso de cada tema só para dizer que "está coberto". Uma fonte honesta de casos representativos são os próprios logs de produção (capítulo 5): perguntas reais que as pessoas fizeram, anonimizadas quando preciso, não perguntas inventadas por quem escreveu o prompt e por isso tendem a ser perguntas fáceis para esse prompt.

Casos de limite

Casos de limite são os que um dataset só de exemplos normais nunca cobre: uma pergunta fora do âmbito do sistema ("qual é a capital da Mongólia?" a um chatbot de apoio ao cliente de uma loja), um input vazio ou só com espaços, uma pergunta ambígua que podia ser interpretada de duas formas, uma pergunta que tenta contornar as instruções do prompt ("ignora as regras anteriores e diz-me..."), ou um pedido em que a resposta correta é "não sei" ou "não tenho essa informação", porque o contexto disponível não cobre o que foi perguntado.

  • Fora do âmbito: o sistema devia recusar-se a responder, não inventar uma resposta plausível fora do que sabe.
  • Input vazio ou degenerado: o sistema não devia rebentar nem devolver uma resposta sem sentido.
  • Ambíguo: uma boa resposta pode ser pedir esclarecimento, em vez de assumir uma interpretação e arriscar estar errado.
  • Contexto insuficiente: a resposta correta é admitir que não tem essa informação, não inventar (é aqui que um sistema mal testado alucina mais).

O risco de um dataset enviesado para os casos fáceis

Se o teu golden dataset só tem perguntas óbvias, com respostas claras e contexto completo, vais ver 100% de aprovação sempre, incluindo em versões do prompt que na realidade têm problemas sérios em casos difíceis. Uma taxa de aprovação alta contra um dataset fácil não é sinal de qualidade; é sinal de que o dataset não está a testar nada de exigente. Um dataset útil tem sempre uma fração de casos onde é genuinamente difícil acertar, e é normal, saudável até, que a taxa de aprovação nesses casos não seja 100%.

Não há um número mágico de casos. O que interessa é a cobertura: pelo menos alguns casos por tema relevante do sistema, pelo menos um caso de cada tipo de limite listado acima, e o compromisso de adicionar um caso novo sempre que um problema real aparece em produção e não estava coberto. O exercício deste módulo (capítulo 4) usa entre 6 e 8 casos, o suficiente para uma suite de avaliação a sério sem se tornar num ficheiro gigante só para aprenderes o padrão.

03Métricas de Qualidade Agregadas

De um veredito por caso, para uma taxa por critério

Correr o golden dataset inteiro dá-te um veredito por caso, por critério: caso-1 passa em rigor_factual e falha em relevancia, caso-2 passa nos dois, e assim por diante. Isto sozinho já é útil (dá para veres exatamente que caso falhou e porquê), mas para decidires se uma versão do sistema está pronta precisas de olhar para o conjunto: que fração do dataset passa em cada critério, no total.

python

def calcular_metricas_agregadas(resultados):
    """resultados: lista de dicts {"caso_id": str, "avaliacao": {criterio: bool, ...}}.
    Devolve {criterio: taxa_aprovacao (0..1)}, uma taxa por criterio, calculada
    sobre o dataset inteiro, nao caso a caso."""
    if not resultados:
        return {}

    contagens = {}
    total = len(resultados)
    for resultado in resultados:
        for criterio, passou in resultado["avaliacao"].items():
            contagens.setdefault(criterio, 0)
            if passou:
                contagens[criterio] += 1

    return {criterio: contagem / total for criterio, contagem in contagens.items()}

resultados[i]["avaliacao"] é exatamente o formato que um veredito de LLM-as-a-judge já devolve (um dicionário de critério para booleano), só que agora corrido uma vez por caso do dataset, não uma vez só. contagens.setdefault(criterio, 0) garante que um critério aparece no resultado mesmo que o primeiro caso a mencioná-lo não seja o primeiro do dataset.

Contra um golden dataset de 7 casos onde "sem_alucinacao" falha em 2 deles, calcular_metricas_agregadas devolve {"sem_alucinacao": 0.714...}: 71% de aprovação nesse critério, não um simples "passou" ou "falhou". É este número, e não um caso isolado, que decides se é aceitável para produção.

Comparar duas versões para detetar regressão

O verdadeiro valor de ter isto como função é poderes correr o mesmo golden dataset contra duas versões (prompt v1 contra prompt v2, ou modelo A contra modelo B) e comparar as taxas, critério a critério. Uma taxa de aprovação geral que sobe pode esconder um critério específico que piorou muito, e é precisamente esse critério que interessa apanhar antes de um deploy.

python

def comparar_versoes(resultados_antes, resultados_depois, limiar_regressao=0.0):
    """Compara a taxa de aprovacao por criterio entre duas corridas do mesmo
    golden dataset (ex.: prompt v1 vs v2). limiar_regressao e a queda minima
    (em pontos percentuais, 0..1) para contar como regressao real, nao ruido
    de um unico caso a mudar de lado.
    Devolve {criterio: {"antes": float, "depois": float, "regressao": bool}}."""
    antes = calcular_metricas_agregadas(resultados_antes)
    depois = calcular_metricas_agregadas(resultados_depois)

    comparacao = {}
    for criterio in antes:
        taxa_antes = antes[criterio]
        taxa_depois = depois.get(criterio, 0.0)
        comparacao[criterio] = {
            "antes": taxa_antes,
            "depois": taxa_depois,
            "regressao": (taxa_antes - taxa_depois) > limiar_regressao,
        }
    return comparacao
CritérioAntes (v1)Depois (v2)Regressão?
rigor_factual75%75%Não
relevancia100%100%Não
sem_alucinacao75%25%Sim

limiar_regressao existe porque um golden dataset pequeno é sensível a ruído: um único caso a mudar de lado, num dataset de 10 casos, já é uma diferença de 10 pontos percentuais, e nem sempre significa uma regressão real, sobretudo se o próprio juiz tiver alguma inconsistência entre corridas (o Curso 4 já falou disto: o juiz não é um oráculo perfeito). Um limiar de 0.1 ou 0.15, por exemplo, exige uma queda maior do que a que um único caso costuma causar antes de soar o alarme.

Regressão num critério não é sempre motivo para bloquear

Às vezes uma alteração melhora muito um critério (relevancia sobe 20 pontos) à custa de piorar ligeiramente outro (tom_profissional desce 5). comparar_versoes() dá-te os números; a decisão de aceitar esse trade-off continua a ser tua, não da função. O que a função elimina é fazeres essa decisão às cegas, sem saberes sequer que o trade-off existe.

04Prática: Suite de Avaliação

Exercício 1: Suite de avaliação contra um golden dataset

Objetivo: Escrever suite_avaliacao.py: correr um golden dataset fictício (7 casos) contra uma função "modelo" determinística e uma função de avaliação, e produzir métricas agregadas por critério.

  • GOLDEN_DATASET: lista com 6 a 8 casos, cada um com id, pergunta, contem (lista de termos que a resposta correta tem de incluir) e nao_contem (lista de termos que não devia incluir, sinal de alucinação).
  • modelo_simulado(caso): função determinística que devolve sempre a mesma resposta para o mesmo caso, escrita para o exercício ser testável sem gastar créditos de API. Deixa claro em comentário como trocar por uma chamada real a client.chat.completions.create(...).
  • avaliar_resposta(resposta, caso): devolve um dicionário com três critérios booleanos, contem_informacao_esperada, sem_alucinacao e resposta_completa, verificados por substring sobre contem/nao_contem do caso e pelo número mínimo de palavras da resposta.
  • avaliar_dataset(dataset, funcao_modelo, funcao_avaliacao): corre cada caso, obtém a resposta e a avaliação, e devolve a lista de resultados no formato {"caso_id", "avaliacao"}.
  • calcular_metricas_agregadas(resultados): a mesma função do capítulo 3, taxa de aprovação por critério sobre o dataset inteiro.
  • imprimir_relatorio(resultados, metricas): imprime o veredito de cada caso (APROVADO/REPROVADO) e a taxa agregada por critério no fim.

Dica: Propositadamente, faz com que pelo menos dois casos do teu dataset falhem (uma resposta simulada que alucina um número, outra que sai fora do tema), para as métricas agregadas mostrarem uma taxa abaixo dos 100% em pelo menos um critério. Um exercício onde tudo passa sempre não testa a função de agregação a sério.

Ver solução
GOLDEN_DATASET = [
    {
        "id": "caso-1",
        "pergunta": "Qual é o prazo de entrega para Portugal Continental?",
        "contem": ["2", "4", "dias"],
        "nao_contem": ["60"],
    },
    {
        "id": "caso-2",
        "pergunta": "Posso devolver um artigo sem a embalagem original?",
        "contem": ["30", "dias"],
        "nao_contem": ["60", "5€"],
    },
    {
        "id": "caso-3",
        "pergunta": "Que métodos de pagamento aceitam?",
        "contem": ["mb way"],
        "nao_contem": ["paypal", "bitcoin"],
    },
    {
        "id": "caso-4",
        "pergunta": "Têm aplicação móvel?",
        "contem": ["não", "aplicação"],
        "nao_contem": ["play store", "app store"],
    },
    {
        "id": "caso-5",
        "pergunta": "Quanto custa a subscrição Premium?",
        "contem": ["4,99", "mês"],
        "nao_contem": ["grátis"],
    },
    {
        "id": "caso-6",
        "pergunta": "Vendem produtos recondicionados?",
        "contem": ["não"],
        "nao_contem": ["40%", "6 meses"],
    },
    {
        "id": "caso-7",
        "pergunta": "Qual é o horário de atendimento?",
        "contem": ["9h", "18h"],
        "nao_contem": ["24 horas", "fim de semana"],
    },
]

# Respostas fixas por caso, a simular o que um modelo real devolveria.
# Propositadamente misturadas: algumas boas, outras com falhas concretas.
RESPOSTAS_SIMULADAS = {
    "caso-1": "As encomendas para Portugal Continental chegam em 2 a 4 dias úteis.",
    "caso-2": "Sim, aceitamos devoluções até 60 dias, mesmo sem a embalagem original.",
    "caso-3": "Aceitamos cartão de crédito/débito, MB WAY e transferência bancária.",
    "caso-4": "Podes consultar as tuas encomendas na secção 'As Minhas Encomendas'.",
    "caso-5": "A subscrição Premium custa 4,99€ por mês, ou 49€ por ano.",
    "caso-6": "Sim, temos uma secção de recondicionados com até 40% de desconto.",
    "caso-7": "O nosso apoio ao cliente está disponível das 9h às 18h, dias úteis.",
}


def modelo_simulado(caso):
    """Função "modelo" determinística: devolve sempre a mesma resposta para
    o mesmo caso, para o exercício ser testável sem gastar crédito de API.

    Para usar a API real da OpenAI em vez disto, troca o corpo por:

        resposta = client.chat.completions.create(
            model="gpt-4o-mini",
            messages=[{"role": "user", "content": caso["pergunta"]}],
        )
        return resposta.choices[0].message.content

    O resto da suite (avaliar_resposta, avaliar_dataset,
    calcular_metricas_agregadas) fica exatamente igual: não sabe, nem
    precisa de saber, se a resposta veio de um dicionário ou de uma chamada
    de rede.
    """
    return RESPOSTAS_SIMULADAS[caso["id"]]


def avaliar_resposta(resposta, caso):
    """O "juiz" deste exercício: verificações determinísticas por
    substring, não um modelo de IA a julgar (isso já foi visto no Curso 4).
    Devolve um veredito por critério, tal como um juiz real devolveria."""
    resposta_lower = resposta.lower()
    contem_informacao_esperada = all(termo.lower() in resposta_lower for termo in caso["contem"])
    sem_alucinacao = not any(termo.lower() in resposta_lower for termo in caso["nao_contem"])
    resposta_completa = len(resposta.split()) >= 5
    return {
        "contem_informacao_esperada": contem_informacao_esperada,
        "sem_alucinacao": sem_alucinacao,
        "resposta_completa": resposta_completa,
    }


def avaliar_dataset(dataset, funcao_modelo, funcao_avaliacao):
    """Corre cada caso do golden dataset contra a funcao_modelo, avalia a
    resposta, e devolve a lista de resultados no formato que
    calcular_metricas_agregadas espera: {"caso_id", "avaliacao"}."""
    resultados = []
    for caso in dataset:
        resposta = funcao_modelo(caso)
        avaliacao = funcao_avaliacao(resposta, caso)
        resultados.append({"caso_id": caso["id"], "avaliacao": avaliacao})
    return resultados


def calcular_metricas_agregadas(resultados):
    if not resultados:
        return {}
    contagens = {}
    total = len(resultados)
    for resultado in resultados:
        for criterio, passou in resultado["avaliacao"].items():
            contagens.setdefault(criterio, 0)
            if passou:
                contagens[criterio] += 1
    return {criterio: contagem / total for criterio, contagem in contagens.items()}


def imprimir_relatorio(resultados, metricas):
    print("Resultado por caso:\n")
    for resultado in resultados:
        avaliacao = resultado["avaliacao"]
        aprovado = all(avaliacao.values())
        estado = "APROVADO" if aprovado else "REPROVADO"
        print(f"[{estado}] {resultado['caso_id']}: {avaliacao}")

    print("\nTaxa de aprovação agregada por critério:")
    for criterio, taxa in metricas.items():
        print(f"  {criterio}: {taxa:.0%}")


if __name__ == "__main__":
    resultados = avaliar_dataset(GOLDEN_DATASET, modelo_simulado, avaliar_resposta)
    metricas = calcular_metricas_agregadas(resultados)
    imprimir_relatorio(resultados, metricas)

Corrido tal como está, este exercício reprova o caso-2 (alucina "60" em vez de "30"), o caso-4 (fica fora do tema, não menciona que não há aplicação) e o caso-6 (alucina desconto e garantia que não existem), e produz contem_informacao_esperada: 57%, sem_alucinacao: 71%, resposta_completa: 100%. Confirma isto na tua própria corrida antes de trocares o modelo_simulado por uma chamada real: é a tua rede de segurança de que a lógica de avaliação está correta, independentemente de onde a resposta vier a seguir.

05Logging Estruturado para IA

O que registar por cada pedido

Golden datasets e métricas agregadas (Módulo 1) avaliam o sistema antes de um deploy, contra casos que já conheces. Em produção, com tráfego real, precisas de outra coisa: um registo do que aconteceu, pedido a pedido, para conseguires investigar um problema depois de acontecer, calcular métricas sobre tráfego real (capítulo 6), e detetar quando o comportamento do sistema muda sem que ninguém tenha mexido em nada (capítulo 7).

  • Prompt e resposta: o que foi pedido e o que foi devolvido, para conseguires reproduzir um problema reportado.
  • Tokens: de entrada e de saída, a unidade que determina o custo (visto já no Curso 1, capítulo 5).
  • Custo: calculado a partir dos tokens, para conseguires somar sem teres de refazer a conta a partir de logs de tokens crus mais tarde.
  • Latência: quanto tempo o pedido demorou, do início ao fim, a matéria-prima do capítulo 6.
  • Versão do modelo: o nome exato do modelo usado, idealmente uma versão datada, não um alias como "latest" (o motivo fica claro no capítulo 7).
  • Id de correlação: um identificador único do pedido, para conseguires seguir o mesmo pedido através de várias etapas de um sistema (ex.: um pedido que passa por um serviço de RAG antes de chegar ao LLM).

O que não registar

Guardar o prompt e a resposta na íntegra é útil, mas nem sempre é seguro sem cuidado: se o prompt inclui dados pessoais do utilizador (nome, email, morada, um número de cartão), esses dados ficam agora replicados em cada linha de log, muitas vezes num sistema com controlo de acesso mais fraco do que a base de dados principal, e durante mais tempo do que os próprios registos originais (logs raramente têm uma política de expiração tão apertada como uma tabela de utilizadores). Duas mitigações comuns: mascarar ou remover campos sensíveis conhecidos antes de escrever o log (um regex para emails e números de cartão, por exemplo), e definir uma retenção curta para logs que contêm prompt e resposta na íntegra, mais longa só para os campos agregados (tokens, custo, latência, sucesso) que não são sensíveis por si.

"Sem dados sensíveis desnecessários" não é "sem dados nenhuns"

Um log sem prompt nem resposta nenhuma é seguro, mas inútil para investigar um problema relatado por um utilizador: não vais conseguir perceber porque é que uma resposta específica saiu errada. O objetivo não é reduzir o log ao mínimo absoluto; é reduzir apenas o que é sensível e desnecessário, mantendo o que precisas para depurar um sistema a sério.

Formato estruturado, não texto livre

print(f"pedido {id} demorou {duracao}s") funciona para ler no terminal enquanto estás a desenvolver. Não dá para consultar depois: não há forma fácil de filtrar "todos os pedidos com latência acima de 5 segundos na última hora" a partir de texto livre, sem escrever um parser frágil a adivinhar o formato exato de cada mensagem. Um registo estruturado, uma linha JSON por pedido, resolve isso: qualquer campo é diretamente pesquisável, seja por um script Python simples que lê o ficheiro linha a linha, seja por uma ferramenta de logs a sério.

python

import json
import logging
import time
import uuid

logger = logging.getLogger("sistema_ia")


def registar_pedido(pergunta, resposta, modelo, tokens_entrada, tokens_saida, custo_usd, duracao_segundos, sucesso):
    logger.info(json.dumps({
        "id_correlacao": str(uuid.uuid4()),
        "timestamp": time.time(),
        "modelo": modelo,
        "tokens_entrada": tokens_entrada,
        "tokens_saida": tokens_saida,
        "custo_usd": round(custo_usd, 6),
        "duracao_segundos": round(duracao_segundos, 3),
        "sucesso": sucesso,
        # Em produção a sério: mascarar ou omitir campos sensíveis antes
        # daqui, e aplicar uma retenção curta especificamente a estes dois.
        "pergunta": pergunta,
        "resposta": resposta,
    }))

json.dumps(...) numa única chamada a logger.info() garante que a linha inteira é um objeto JSON válido, uma linha por pedido (o formato conhecido como JSON Lines). É esse formato que o Exercício 2 (capítulo 9) vai ler e analisar, tal como uma ferramenta de observability real faria.

06Métricas Operacionais

A média volta a enganar

O Curso 1 desta série já mostrou isto no capítulo 5, sobre latência: 95 pedidos a 1.2 segundos e 5 a 8 segundos dão uma média de 1.54s, um número que esconde por completo que 5% dos utilizadores tiveram uma experiência oito vezes pior. Aqui aprofundamos essa ideia numa direção diferente: em vez de calcular um p95 à mão, uma vez, escrevemos uma função reutilizável para qualquer percentil, e juntamos-lhe as outras duas métricas que compõem, com ela, o mínimo de observability operacional para um sistema de IA: taxa de erro e custo acumulado.

python

def calcular_percentil(valores, percentil):
    """percentil entre 0 e 1 (0.95 = p95). Método do ranking mais próximo:
    ordena os valores e escolhe o que fica nessa posição relativa."""
    if not valores:
        raise ValueError("não há valores para calcular um percentil")
    valores_ordenados = sorted(valores)
    indice = int(len(valores_ordenados) * percentil)
    indice = min(indice, len(valores_ordenados) - 1)
    return valores_ordenados[indice]

min(indice, len(valores_ordenados) - 1) evita um IndexError quando percentil está perto de 1.0: sem o clamp, int(len(valores) * 1.0) dá exatamente len(valores), um índice fora da lista. Com uma amostra pequena (por exemplo, 3 valores), até p95 pode calhar no último elemento; é uma limitação real dos percentis com poucos dados, não um bug, e vale a pena teres isso presente ao interpretar p99 sobre uma janela com poucos pedidos.

PercentilO que significaQuando olhar para ele
p50 (mediana)Metade dos pedidos foi mais rápida do que isto, metade mais lenta.Experiência típica de quem usa o sistema.
p9595% dos pedidos foram mais rápidos do que isto.O que os 5% piores estão a sentir; costuma ser o número de um SLA.
p9999% dos pedidos foram mais rápidos do que isto.Os casos mais extremos; útil para apanhar um problema raro mas grave.

Taxa de erro e custo acumulado

python

def calcular_taxa_erro(pedidos):
    """pedidos: lista de dicts com pelo menos {"sucesso": bool}.
    Devolve a fração (0..1) de pedidos que falharam."""
    if not pedidos:
        return 0.0
    falhas = sum(1 for pedido in pedidos if not pedido["sucesso"])
    return falhas / len(pedidos)


def calcular_custo_acumulado(pedidos):
    """Soma o campo custo_usd de cada pedido. Falhas também custam (o
    fornecedor cobra o pedido mesmo que o teu código a seguir o trate como
    erro), por isso não filtra por sucesso."""
    return sum(pedido["custo_usd"] for pedido in pedidos)

calcular_custo_acumulado soma sempre, mesmo pedidos falhados: se um pedido chegou a chamar o LLM e só falhou depois (a validar a resposta, por exemplo), o fornecedor já cobrou por isso. Excluir falhas do custo dá-te um número mais bonito e mais errado do que a fatura real.

Estas três funções, aplicadas à mesma lista de pedidos, dão-te um retrato operacional em segundos: latencias = [p["latencia_segundos"] for p in pedidos], depois calcular_percentil(latencias, 0.95), calcular_taxa_erro(pedidos), calcular_custo_acumulado(pedidos). Nenhuma delas depende de uma ferramenta externa; um script que lê um ficheiro de logs estruturados (capítulo 5) e corre estas três funções já é observability a sério, mesmo antes de qualquer dashboard.

07Deteção de Deriva (Drift)

O modelo pode mudar sem tu mudares nada

Deriva de modelo (drift) é quando o comportamento de um sistema de IA muda, para pior, sem que tu tenhas alterado nada no teu lado: o mesmo prompt, o mesmo código, mas as respostas começam a sair diferentes. A causa mais comum é subtil: se referencias um modelo por um alias como "gpt-4o-mini" ou "latest", sem fixar uma versão datada, o fornecedor pode trocar silenciosamente qual o modelo concreto por trás desse nome. Ganhas atualizações automáticas sem esforço nenhum; perdes a garantia de que o comportamento de ontem é o mesmo de hoje.

A maior parte dos fornecedores oferece também nomes de modelo fixos, datados (por exemplo, um sufixo com a data de lançamento), precisamente para quem precisa de comportamento estável em produção. Trocar de um alias flutuante para uma versão fixa é a mitigação mais direta contra deriva, mas mesmo assim vale a pena ter deteção: um alias é conveniente para desenvolvimento, e é fácil um deploy escapar com ele por engano.

Como apanhar isto cedo

A ideia é simples: já tens um golden dataset (capítulo 2) e uma forma de calcular a taxa de aprovação sobre ele (capítulo 3). Corre esse mesmo dataset periodicamente, por exemplo uma vez por dia, contra o sistema em produção, sem mudar nada do teu lado, e compara a taxa de aprovação atual com uma taxa de referência, calculada quando sabias que o sistema estava a comportar-se bem. Uma queda súbita, sem nenhuma alteração tua a explicá-la, é o sinal mais direto de deriva que existe.

python

def detetar_deriva(taxa_aprovacao_base, taxa_aprovacao_atual, limiar_queda=0.15):
    """Compara a taxa de aprovação atual de uma corrida do golden dataset
    (capítulo 2) com uma taxa de referência, calculada quando o sistema
    estava a comportar-se bem. Só sinaliza deriva quando a queda ultrapassa
    o limiar: uma pequena flutuação entre corridas é normal, não é deriva."""
    queda = taxa_aprovacao_base - taxa_aprovacao_atual
    return {
        "taxa_base": taxa_aprovacao_base,
        "taxa_atual": taxa_aprovacao_atual,
        "queda": queda,
        "deriva_detetada": queda > limiar_queda,
    }

Com taxa_aprovacao_base=0.92 (a taxa que tinhas quando confirmaste que o sistema estava bem) e uma corrida de hoje a dar taxa_aprovacao_atual=0.65, detetar_deriva devolve deriva_detetada: True, uma queda de 27 pontos. Com uma corrida a dar 0.88, a mesma função devolve False: dentro do ruído normal entre corridas do juiz.

Qual taxa usar como base

A taxa base não é a primeira corrida que fizeste, é a última corrida em que confirmaste, ativamente, que o sistema estava a comportar-se como esperado, tipicamente logo depois de um deploy validado. Se a atualizares sem pensar a cada corrida nova, uma deriva lenta e gradual passa despercebida, porque cada dia se compara só ao dia anterior, ligeiramente pior, nunca ao padrão de referência real.

Vale a pena correr isto por critério, não só na taxa geral, reaproveitando calcular_metricas_agregadas do capítulo 3 para as duas corridas e comparar critério a critério: uma deriva pode atingir só sem_alucinacao, por exemplo, e ficar escondida numa taxa geral que ainda parece aceitável porque os outros critérios continuam bem.

08Alertas que Fazem Sentido

Alert fatigue: o problema de alertar tudo

É tentador configurar um alerta para cada métrica que já sabes calcular: um pico de latência, um erro isolado, uma variação qualquer de custo. O resultado prático de fazer isso é sempre o mesmo: dezenas de notificações por dia, a maior parte sem significado nenhum, até a pessoa responsável começar a ignorá-las por hábito, incluindo no dia em que uma delas era mesmo a sério. Um sistema de alertas só é útil se cada alerta que dispara justificar alguém largar o que está a fazer para olhar para ele.

O que vale a pena alertar

SinalVale a pena alertarPorquê
Taxa de erro alta, sustentadaSimUm problema real e contínuo, não ruído de um pedido isolado.
Um único pedido lento ou falhadoNãoAcontece sempre, mesmo num sistema saudável; é o padrão que importa, não um caso.
Custo a acelerar acima do esperadoSimCedo o suficiente para investigar antes da fatura chegar, não depois.
Queda súbita na taxa de aprovação do golden datasetSimÉ o sinal de deriva do capítulo 7; direto e acionável.
Latência p95 ligeiramente acima da média histórica, uma vezNãoVariação normal; alertar nisto todos os dias ensina a ignorar o alerta.

Sustentado, não um pico isolado

A diferença entre um alerta útil e ruído está quase sempre nesta palavra: sustentado. Em vez de alertar assim que um período (por exemplo, uma hora) ultrapassa um limiar, exige vários períodos consecutivos acima desse limiar antes de disparar. Isto absorve picos isolados, o equivalente operacional de não reagires a um único caso reprovado no golden dataset (capítulo 3).

python

def taxa_erro_sustentada(taxas_erro_por_periodo, limiar, periodos_consecutivos=3):
    """taxas_erro_por_periodo: uma taxa de erro (0..1) por período (ex.: uma
    por hora), da mais antiga para a mais recente. Só sinaliza alerta se os
    ÚLTIMOS periodos_consecutivos períodos estiverem todos acima do limiar:
    um pico isolado a meio da lista não dispara nada, propositadamente."""
    if len(taxas_erro_por_periodo) < periodos_consecutivos:
        return False
    ultimas = taxas_erro_por_periodo[-periodos_consecutivos:]
    return all(taxa > limiar for taxa in ultimas)

Contra [0.01, 0.01, 0.55, 0.01, 0.02] (um pico isolado a meio, provavelmente um fornecedor com um mau minuto), com limiar=0.1 e periodos_consecutivos=3, a função devolve False: os últimos três períodos ([0.55, 0.01, 0.02]) não estão todos acima do limiar. Contra [0.01, 0.02, 0.2, 0.25, 0.3], uma subida real e mantida, devolve True.

O mesmo princípio aplica-se a custo (alertar sobre uma tendência de subida ao longo de vários dias, não sobre um dia isoladamente mais caro) e à queda de taxa de aprovação do golden dataset (capítulo 7): uma corrida periódica que confirma a queda em duas execuções seguidas é um sinal muito mais forte do que uma única corrida, que pode ser só uma inconsistência do próprio juiz.

09Prática: Análise de Logs

Exercício 2: Analisar logs estruturados e sinalizar limiares

Objetivo: Escrever analisador_logs.py: ler uma lista fixa de logs estruturados (24 pedidos fictícios, com latências e erros variados), calcular p50/p95/p99, taxa de erro e custo acumulado, e sinalizar quais limiares foram ultrapassados.

  • LOGS: lista de 24 dicts fictícios, cada um com pedido_id, modelo, latencia_segundos, sucesso (bool) e custo_usd, escritos no próprio ficheiro. Inclui pelo menos duas falhas e pelo menos duas latências claramente fora do normal (outliers).
  • LIMIARES: dict com p95_segundos, taxa_erro_maxima e custo_total_usd.
  • calcular_percentil(valores, percentil): a mesma função do capítulo 6, reutilizada aqui.
  • calcular_taxa_erro(pedidos) e calcular_custo_acumulado(pedidos): as mesmas funções do capítulo 6.
  • verificar_thresholds(metricas, limiares): compara as métricas calculadas com os limiares definidos e devolve a lista de alertas ultrapassados (cada um com tipo, valor observado e limiar). Nenhuma chamada de rede.
  • analisar_logs(logs, limiares): junta tudo, devolve {"metricas": {...}, "alertas": [...]}.
  • imprimir_relatorio(relatorio): imprime as métricas calculadas e, se houver, os alertas disparados; se não houver nenhum, diz explicitamente que nenhum threshold foi ultrapassado.

Dica: Constrói primeiro a lista LOGS e imprime as latências ordenadas (sorted(...)) antes de calculares os percentis à mão: com 24 valores, p95 corresponde ao índice int(24 * 0.95) = 22, e p99 ao índice 23 (o último, com o clamp). Confirma o resultado da tua função contra essa conta manual.

Ver solução
LOGS = [
    {"pedido_id": "req-001", "modelo": "gpt-4o-mini", "latencia_segundos": 0.8, "sucesso": True, "custo_usd": 0.0012},
    {"pedido_id": "req-002", "modelo": "gpt-4o-mini", "latencia_segundos": 1.1, "sucesso": True, "custo_usd": 0.0015},
    {"pedido_id": "req-003", "modelo": "gpt-4o-mini", "latencia_segundos": 0.9, "sucesso": True, "custo_usd": 0.0011},
    {"pedido_id": "req-004", "modelo": "gpt-4o-mini", "latencia_segundos": 1.4, "sucesso": True, "custo_usd": 0.0021},
    {"pedido_id": "req-005", "modelo": "gpt-4o-mini", "latencia_segundos": 0.7, "sucesso": True, "custo_usd": 0.0010},
    {"pedido_id": "req-006", "modelo": "gpt-4o-mini", "latencia_segundos": 1.2, "sucesso": True, "custo_usd": 0.0018},
    {"pedido_id": "req-007", "modelo": "gpt-4o-mini", "latencia_segundos": 1.6, "sucesso": True, "custo_usd": 0.0024},
    {"pedido_id": "req-008", "modelo": "gpt-4o-mini", "latencia_segundos": 0.6, "sucesso": True, "custo_usd": 0.0009},
    {"pedido_id": "req-009", "modelo": "gpt-4o-mini", "latencia_segundos": 1.0, "sucesso": True, "custo_usd": 0.0014},
    {"pedido_id": "req-010", "modelo": "gpt-4o-mini", "latencia_segundos": 1.8, "sucesso": True, "custo_usd": 0.0026},
    {"pedido_id": "req-011", "modelo": "gpt-4o-mini", "latencia_segundos": 0.9, "sucesso": False, "custo_usd": 0.0006},
    {"pedido_id": "req-012", "modelo": "gpt-4o-mini", "latencia_segundos": 1.3, "sucesso": True, "custo_usd": 0.0019},
    {"pedido_id": "req-013", "modelo": "gpt-4o-mini", "latencia_segundos": 0.8, "sucesso": True, "custo_usd": 0.0013},
    {"pedido_id": "req-014", "modelo": "gpt-4o-mini", "latencia_segundos": 1.5, "sucesso": True, "custo_usd": 0.0022},
    {"pedido_id": "req-015", "modelo": "gpt-4o-mini", "latencia_segundos": 1.1, "sucesso": True, "custo_usd": 0.0016},
    {"pedido_id": "req-016", "modelo": "gpt-4o-mini", "latencia_segundos": 0.7, "sucesso": True, "custo_usd": 0.0010},
    {"pedido_id": "req-017", "modelo": "gpt-4o-mini", "latencia_segundos": 1.7, "sucesso": True, "custo_usd": 0.0025},
    {"pedido_id": "req-018", "modelo": "gpt-4o-mini", "latencia_segundos": 1.0, "sucesso": True, "custo_usd": 0.0014},
    {"pedido_id": "req-019", "modelo": "gpt-4o-mini", "latencia_segundos": 0.9, "sucesso": False, "custo_usd": 0.0007},
    {"pedido_id": "req-020", "modelo": "gpt-4o-mini", "latencia_segundos": 1.2, "sucesso": True, "custo_usd": 0.0017},
    {"pedido_id": "req-021", "modelo": "gpt-4o-mini", "latencia_segundos": 3.1, "sucesso": True, "custo_usd": 0.0048},
    {"pedido_id": "req-022", "modelo": "gpt-4o-mini", "latencia_segundos": 3.6, "sucesso": True, "custo_usd": 0.0052},
    {"pedido_id": "req-023", "modelo": "gpt-4o-mini", "latencia_segundos": 6.4, "sucesso": True, "custo_usd": 0.0091},
    {"pedido_id": "req-024", "modelo": "gpt-4o-mini", "latencia_segundos": 9.2, "sucesso": True, "custo_usd": 0.0113},
]

LIMIARES = {
    "p95_segundos": 3.0,
    "taxa_erro_maxima": 0.05,
    "custo_total_usd": 0.15,
}


def calcular_percentil(valores, percentil):
    if not valores:
        raise ValueError("não há valores para calcular um percentil")
    valores_ordenados = sorted(valores)
    indice = int(len(valores_ordenados) * percentil)
    indice = min(indice, len(valores_ordenados) - 1)
    return valores_ordenados[indice]


def calcular_taxa_erro(pedidos):
    if not pedidos:
        return 0.0
    falhas = sum(1 for pedido in pedidos if not pedido["sucesso"])
    return falhas / len(pedidos)


def calcular_custo_acumulado(pedidos):
    return sum(pedido["custo_usd"] for pedido in pedidos)


def verificar_thresholds(metricas, limiares):
    """Compara as métricas calculadas com os limiares definidos e devolve a
    lista de alertas ultrapassados, cada um com o valor observado e o
    limiar. Uma lista vazia significa que nada foi sinalizado."""
    alertas = []
    if metricas["p95_segundos"] > limiares["p95_segundos"]:
        alertas.append({
            "tipo": "latencia_p95",
            "valor": metricas["p95_segundos"],
            "limiar": limiares["p95_segundos"],
        })
    if metricas["taxa_erro"] > limiares["taxa_erro_maxima"]:
        alertas.append({
            "tipo": "taxa_erro",
            "valor": metricas["taxa_erro"],
            "limiar": limiares["taxa_erro_maxima"],
        })
    if metricas["custo_total_usd"] > limiares["custo_total_usd"]:
        alertas.append({
            "tipo": "custo_total",
            "valor": metricas["custo_total_usd"],
            "limiar": limiares["custo_total_usd"],
        })
    return alertas


def analisar_logs(logs, limiares):
    latencias = [log["latencia_segundos"] for log in logs]
    metricas = {
        "total_pedidos": len(logs),
        "p50_segundos": calcular_percentil(latencias, 0.50),
        "p95_segundos": calcular_percentil(latencias, 0.95),
        "p99_segundos": calcular_percentil(latencias, 0.99),
        "taxa_erro": calcular_taxa_erro(logs),
        "custo_total_usd": calcular_custo_acumulado(logs),
    }
    alertas = verificar_thresholds(metricas, limiares)
    return {"metricas": metricas, "alertas": alertas}


def imprimir_relatorio(relatorio):
    metricas = relatorio["metricas"]
    print(f"Total de pedidos: {metricas['total_pedidos']}")
    print(f"Latência p50: {metricas['p50_segundos']:.2f}s")
    print(f"Latência p95: {metricas['p95_segundos']:.2f}s")
    print(f"Latência p99: {metricas['p99_segundos']:.2f}s")
    print(f"Taxa de erro: {metricas['taxa_erro']:.1%}")
    print(f"Custo acumulado: ${metricas['custo_total_usd']:.4f}")

    if not relatorio["alertas"]:
        print("\nNenhum threshold ultrapassado.")
    else:
        print(f"\n{len(relatorio['alertas'])} alerta(s):")
        for alerta in relatorio["alertas"]:
            print(f"  [{alerta['tipo']}] valor={alerta['valor']:.4f}, limiar={alerta['limiar']}")


if __name__ == "__main__":
    relatorio = analisar_logs(LOGS, LIMIARES)
    imprimir_relatorio(relatorio)

Corrido tal como está, dá total_pedidos: 24, p50: 1.20s, p95: 6.40s, p99: 9.20s, taxa de erro: 8.3% (2 falhas em 24) e custo acumulado de cerca de $0.0613. Dois alertas disparam (latencia_p95, porque 6.40s > 3.0s, e taxa_erro, porque 8.3% > 5%); custo_total não dispara, porque $0.0613 fica bem abaixo do limiar de $0.15. Repara que os três limiares são avaliados de forma independente: um sistema pode ter latência péssima e custo perfeitamente saudável ao mesmo tempo, e o relatório reflete isso, em vez de dar um único veredito global que esconderia qual dos três é o problema real.

O teu progresso

Marca os exercícios à medida que os fores fazendo a sério, não só a ler a solução. Fica guardado só neste browser.

Conclusão

Em nove capítulos: o salto de avaliar uma resposta isolada para avaliar um sistema inteiro, o que é e como se constrói um golden dataset versionado, com casos representativos e casos de limite, uma função de agregação que dá uma taxa de aprovação por critério em vez de um veredito só, e a mesma função a comparar duas versões para apanhar regressão antes de um deploy. Depois, o que muda quando o sistema já está em produção: o que registas por pedido sem guardar dados sensíveis a mais, p50/p95/p99 e não só a média, taxa de erro e custo acumulado, deteção de deriva contra o golden dataset já construído, e alertas desenhados para ninguém aprender a ignorá-los.

Se fizeste os dois exercícios a sério, tens uma suite de avaliação que corre um golden dataset inteiro e devolve métricas agregadas, e um script que lê logs estruturados de produção e sinaliza sozinho quando um limiar é ultrapassado. É a diferença entre confiar de ouvido num sistema de IA e teres números concretos para decidir se ele está pronto, e se continua pronto depois de cada mudança.

Se ficares só com uma decisão deste curso, fica com esta: nunca acredites que um sistema de IA melhorou (ou continua a funcionar) só porque uma resposta específica ficou melhor. Confia no número que sai de correr o mesmo conjunto de casos de sempre, contra a versão nova, e comparar com a versão anterior. É aí que a engenharia a sério começa a substituir a intuição.

Se ainda não fizeste o anterior, ou queres avançar para o seguinte: Curso 1 e Curso 2.

Isto ajudou-te?

Se isto te foi útil, o mais provável é ser útil a alguém que conheces. Partilha.

LinkedInWhatsApp

Fontes, e o que é só observação minha

O resto

  • É o terceiro curso da série "De 0 a AI Engineer", ancorado na experiência real de construir e operar sistemas de IA em produção (moderação de conteúdo com LLM, deteção de plágio por embeddings). Não é investigação, é o programa com que ensino isto a sério.