raphael cunhaSOFTWARE, PEOPLE & IDEAS

Como medir a qualidade de um agente de IA em produção

Raphael ilustrado mede uma sequência de conversas e separa casos que precisam de correção

Os exemplos deste post usam nomes fictícios. O assistente se chama Atlas. O painel onde a métrica vive se chama Mirante. O ritual diário de triagem se chama Ronda. Os números são ilustrativos. O que interessa aqui é o desenho, não os dados.

Colocar um agente em produção é a parte fácil. A pergunta difícil vem no dia seguinte. Ele está bom?

Não estou falando de "o modelo é bom" nem de "o benchmark deu bem". Estou falando de saber se o agente respondeu certo as perguntas que os seus usuários realmente fizeram, ontem, no seu produto.

Passei os últimos meses construindo essa resposta. Escrevo aqui o desenho que sobreviveu e os erros que me custaram semanas.

Por que joinha não resolve

A primeira reação de todo time é colocar 👍/👎 em cada mensagem. Nós fizemos isso também. Serve, mas não como métrica.

O primeiro problema é cobertura. Quase ninguém clica. O usuário que recebeu a resposta certa vai embora satisfeito e em silêncio. Você fica com uma amostra enviesada para os extremos.

O segundo problema é ambiguidade. Um 👎 pode significar que a resposta está errada, que o dado não existe, que o usuário não tem permissão para ver aquilo, que ele queria outra coisa ou que a fonte está desatualizada. São cinco problemas com cinco donos diferentes. Um bit não distingue isso.

O terceiro problema é que o número não é acionável. Saber que 12% das respostas levaram 👎 não diz o que precisa ser consertado na segunda-feira.

Joinha é ótimo como fonte de dataset, porque gera exemplos rotulados por humanos de graça. Como termômetro, é ruim.

Interface ilustrativa com dados fictícios. Score, rubrica e fila de triagem oferecem diferentes leituras da qualidade.

A unidade é o turno, e quem avalia é um juiz

O desenho que funcionou foi outro. A cada turno da conversa, ou seja, uma pergunta do usuário mais a resposta final do assistente, um segundo LLM classifica aquele turno em exatamente um rótulo de uma rubrica fechada. Esse segundo modelo é o juiz.

O juiz recebe cinco coisas. A pergunta do usuário. A resposta final do assistente. Quais tools foram chamadas e como cada uma terminou, com sucesso ou com erro e o status HTTP. O histórico recente da conversa, apenas como contexto de leitura. E a data atual do sistema.

Esse último item parece bobo, e não é. Sem a data, o juiz marcava datas recentes como alucinação de data futura, porque o modelo assume que ainda estamos no ano do treino dele. Injetar a data eliminou uma classe inteira de falso positivo.

A rubrica tem oito rótulos, e só dois são dívida sua

Essa é a decisão mais importante do desenho inteiro.

RótuloSignificadoÉ falha?
OKRespondeu bem: correta, embasada, no escoponão
PROMPT_GAPO dado e a tool existiam, e o agente errou mesmo assimsim
TOOL_GAPO dado existe no produto, mas falta tool, ou a tool quebrou por erro técnicosim
USER_AMBIGUOUSPergunta incompleta, e pedir esclarecimento foi o certonão
USER_ABANDONEDIntenção clara, bem conduzida, e o usuário sumiunão
OUT_OF_SCOPEPedido de suporte, fora do que o agente se propõe a fazernão
DATA_GAPO dado genuinamente não existe, e recusar foi o certonão
PERMISSION_GAPA tool devolveu 403, o dado existe, o perfil não tem acesso, e o agente explicou o bloqueio e entregou o que davanão

A ideia central é simples. Só PROMPT_GAP e TOOL_GAP são falhas que você consegue corrigir. Todos os outros rótulos fora do OK descrevem comportamentos corretos.

Isso não é generosidade com a métrica. É proteção contra incentivo perverso. Se você penaliza o agente por recusar quando o dado não existe, a métrica ensina o agente a alucinar. Uma rubrica binária de acertou ou errou otimiza ativamente contra a honestidade.

Daí saem dois números.

AQR bruto        = OK / total
AQR endereçável  = OK / (OK + PROMPT_GAP + TOOL_GAP)

O segundo é o que a engenharia persegue. O primeiro mostra quanto do não-OK está fora do seu alcance, e isso também é informação de produto. Muito PERMISSION_GAP é conversa de empacotamento comercial. Muito DATA_GAP é conversa de roadmap.

Existem ainda dois rótulos internos. SKIP, para turno sem pergunta do usuário, e JUDGE_ERROR, para quando o próprio juiz falhou. Nenhum dos dois é veredito. Eles são ausência de veredito, e precisam ficar fora de todos os denominadores. Já vi métrica despencar num dia porque um provedor caiu e centenas de JUDGE_ERROR entraram na conta como zero.

Raphael ilustrado separa conversas em bandejas de acertos, falhas corrigíveis e limites válidos
Ilustração da rubrica: só PROMPT_GAP e TOOL_GAP são dívida de engenharia. SKIP e JUDGE_ERROR ficam fora dos dois denominadores.
Ver o diagrama técnico
Diagrama ilustrativo. Compare o AQR bruto ao endereçável e observe quais rótulos entram em cada denominador.

O dia em que a resposta honesta perdeu

Esse foi o erro que mais me ensinou.

O juiz recebe o histórico da conversa. E, por padrão, ele trata histórico como verdade. Aconteceu o seguinte.

No primeiro turno, o usuário pediu uma distribuição diária de eventos. O agente não tinha essa visão. Em vez de dizer isso, ele pegou o total, distribuiu entre os dias com um código que simulava a curva e devolveu um gráfico bonito. O juiz deu OK.

No turno seguinte, o usuário insistiu. Dessa vez o agente admitiu que não tinha o recorte diário. O juiz deu zero, alegando regressão em relação ao turno anterior.

A métrica premiou a fabricação e puniu a honestidade. Pior que isso, o sinal ia direto para quem ajusta o prompt.

A correção foi escrever uma regra de evidência explícita na rubrica. O histórico serve para interpretar o turno, e nunca como baseline de correção. Divergir do turno anterior não rebaixa nada, porque o errado pode ser o anterior. Dado fabricado é PROMPT_GAP, e é a falha mais grave da rubrica. Recusa honesta de um recorte indisponível não é fabricação. E trocar o recorte pedido sem avisar, quando o usuário pediu por dia e recebeu por tipo, também é PROMPT_GAP.

Vale registrar os sinais típicos de fabricação, porque eles se repetem. Um gráfico num recorte que nenhuma tool devolveu. Um código que distribui um total entre buckets. Valores que somam exatamente um agregado conhecido.

A regra geral que eu levaria para qualquer juiz é essa. Evidência de tool vale mais que prosa do assistente, e prosa do assistente vale mais que histórico. Um juiz que lê só texto acredita em qualquer coisa bem escrita, que é justamente o que um LLM produz de melhor.

O turno é a unidade errada para metade das conversas

Eu descobri isso do jeito difícil, num fluxo trivial.

turno 1  usuário: "cria um grupo com esses ativos"
         assistente: "confirma?"      (julgado antes de o turno 2 existir)
turno 2  usuário: "sim"
         assistente: cria o grupo     (OK)

O primeiro turno levava TOOL_GAP, com a justificativa de que não existia tool para criar grupo, numa sessão que terminou em sucesso. Todo fluxo de confirmar e depois agir era penalizado só por existir.

A solução foi um segundo scorer, agora no nível de sessão. Mesma rubrica, mesma gramática de saída, outra unidade. Ele julga o desfecho da intenção, e não o passo isolado.

O detalhe de implementação que vale copiar é o gatilho. Não existe evento de conversa terminada, porque threads voltam à vida uma semana depois. Em vez de inventar timeout de inatividade com timer por thread, o scorer é rolling. Ele roda anexado ao agente, como o outro, e re-julga a thread inteira a cada turno novo.

Isso traz duas consequências boas. A primeira é que você não precisa detectar o fim da conversa, porque quando o usuário para, o veredito do último turno já é o veredito final da sessão. A segunda é que cada linha de score continua carregando o identificador de trace daquele turno, então todo filtro por canal que já existia segue funcionando, sem migração e sem trace forjado.

O custo é quadrático por thread, já que o turno N re-julga N turnos. Num volume de dezenas de conversas por dia, isso é troco. Em milhões de conversas, não seria, e aí o desenho pediria debounce de verdade.

Na hora de agregar, a unidade de sessão depende do canal. Na web, a thread é a conversa. No WhatsApp, a thread é o número de telefone e nunca reinicia, então lá é preciso cortar por episódio, usando uma janela de silêncio. Agregar WhatsApp por thread te dá uma conversa eterna por cliente e uma métrica sem sentido.

Raphael ilustrado abre uma conversa em papel sanfonado, da espera por confirmação ao desfecho concluído
Ilustração do score de sessão: pedir confirmação é uma etapa do fluxo. O veredito considera o desfecho da intenção e é recalculado a cada turno.
Ver o diagrama técnico
Diagrama ilustrativo. A confirmação intermediária faz parte de um fluxo que termina com a intenção atendida.

Três armadilhas que custaram semanas

O juiz vendo o mesmo turno duas vezes

O scorer roda depois de o turno ser persistido. Então o histórico que ele recebe já contém a pergunta e a resposta do turno corrente. Montar o prompt como histórico mais pergunta mais resposta mostrava o mesmo turno duplicado. O juiz lia o eco como o usuário se repetindo e devolvia USER_AMBIGUOUS falso, com a justificativa de que o usuário apenas repetiu o comando original.

Remover o turno corrente tem duas restrições que não são óbvias. A primeira é casar por id, e nunca por texto, porque repetição genuína, como o usuário mandando "sim" duas vezes, gera mensagem nova com id novo, e as duas ocorrências precisam sobreviver. A segunda é que a remoção não pode ser posicional, porque a sobreposição depende do timing da persistência, e descartar as duas últimas mensagens comeria histórico real nas execuções que não duplicaram.

O que o usuário viu não está no texto

O agente tem uma tool que renderiza um card de múltipla escolha na tela, e o contrato dela proíbe repetir as opções em prosa. O resultado é que o texto da resposta fica curto de propósito. O juiz, que só lia texto, batia PROMPT_GAP naqueles turnos, alegando que o agente não apresentou as opções para o usuário escolher. Só que as opções foram apresentadas.

A correção tem duas metades, e a divisão entre elas importa.

A primeira metade é determinística. Reconstruir o card a partir dos argumentos da invocação da tool e injetar isso no prompt do juiz, com as opções, a recomendada e o status de renderização. Card que não chegou a ser exibido, porque o canal não suporta, entra marcado como não exibido. Nesse caso o usuário realmente ficou sem nada na tela.

A segunda metade é de rubrica. Card exibido significa dados apresentados. Encerrar o turno logo depois do card é o comportamento correto. Sessão parada esperando a escolha é USER_ABANDONED se a intenção estava clara, e USER_AMBIGUOUS se o pedido é que era vago. Nunca PROMPT_GAP só porque a ação final ainda não rodou.

A lição maior é que tudo que o usuário vê e o juiz não lê vira falso positivo. Se a sua interface renderiza qualquer coisa além do texto, seja card, tabela, gráfico ou botão, o juiz precisa receber uma reconstrução determinística disso.

Permissão não é falha, mas mau manejo é

Um 403 tem dois desfechos bem diferentes. Se o agente explicou o bloqueio e entregou o parcial possível, aquilo é PERMISSION_GAP, que é assunto de empacotamento comercial e não dívida de engenharia, então fica fora do denominador endereçável. Se ele recusou mais do que precisava, virou PROMPT_GAP.

Ou seja, o 403 é classificado pelo manejo, e não pelo status code. Foi só depois dessa separação que o TOOL_GAP voltou a significar uma coisa só, que é tool ausente ou quebrada por erro técnico. E aí virou um número que a engenharia consegue perseguir.

Mexer no juiz é mexer na régua

Em algum momento você vai querer dar mais contexto ao juiz. A tentação é grande e o risco é invisível, porque mexer no juiz muda o passado.

O processo que adotei foi rodar a versão nova como scorer sombra, em paralelo com a de produção, nos mesmos turnos, por alguns dias. Depois comparar veredito a veredito, e ir ler as divergências uma a uma.

Fiz isso com uma mudança que parecia obviamente boa, que era passar ao juiz os nomes dos campos que cada tool devolveu. O sombra deu uma taxa melhor, alguns pontos acima. Quase todo o ganho vinha de um único padrão, que era tool respondendo lista vazia com sucesso e virando DATA_GAP falso.

Mas as divergências mostraram o custo. Numa conversa, o usuário pediu a localização de um ativo. O agente chamou a tool certa e transcreveu a coordenada corretamente. Mesmo assim o sombra deu PROMPT_GAP, alegando que nenhuma ferramenta de rastreamento foi disparada, enquanto listava aquela mesma tool no próprio campo de tools da resposta. A causa é que a extração de campos tem profundidade 1 de propósito, então a coordenada aninhada nunca aparecia na lista. Havia até uma regra explícita no prompt dizendo que campo não listado não é campo inexistente. O juiz de sessão ignorou essa regra. O juiz por turno, com o mesmo modelo e os mesmos campos, citou a regra e acertou.

A mudança comprava um DATA_GAP falso e vendia um PROMPT_GAP falso. Ela não foi adotada. E o ganho real, aquele único padrão, foi resolvido sem ela.

Tirei duas lições disso. A primeira é que métrica agregada melhor não prova mudança melhor, então vá ler as divergências. A segunda é mais incômoda. A mesma instrução, no mesmo modelo, é obedecida num prompt e ignorada em outro. Regra em prompt não é contrato, é probabilidade. O que precisa ser garantido, garanta de forma determinística, fora do LLM.

Métrica que não vira fila é decoração

Um painel bonito não conserta nada. O que falta é o ritual, que aqui eu chamo de Ronda.

Todo dia um sorteio pega as conversas do dia anterior que caíram em PROMPT_GAP ou TOOL_GAP, só essas duas, porque só elas são dívida nossa, e distribui em round-robin entre as pessoas do time. Cada item vira uma tarefa de resolver o problema, com dono, status e prazo.

Três detalhes fizeram a diferença entre um ritual vivo e uma planilha morta.

O primeiro é que a tarefa nasce onde o trabalho já mora. Cada item é espelhado como issue no rastreador do time, atribuída à mesma pessoa, com label própria e um prompt de investigação já escrito no corpo. Link da conversa, rótulo, justificativa do juiz e o que verificar. Quem pega a tarefa não precisa reconstruir o contexto.

O segundo é que o espelho é bidirecional e se auto-cura. Mudou o status no painel, a issue move. Mudou na issue, o sync escreve de volta. E se alguém apagou a issue, o sync recria em vez de quebrar. Sincronismo que depende de disciplina humana não sobrevive à segunda semana.

O terceiro é que o sorteio é idempotente por dia. Se rodar duas vezes, não duplica, e conversa já sorteada nunca entra de novo. Parece detalhe, mas é o que permite deixar o scheduler agressivo sem medo.

O digest diário no chat do time é curto de propósito. Quem pegou o quê, e o link. Digest longo ninguém lê.

Interface ilustrativa com dados fictícios. Cada falha tem responsável, status e vínculo com o rastreador de issues.

O que eu levaria para qualquer agente

Decida a rubrica antes do número. O que conta como falha sua precisa estar definido antes de você calcular qualquer média, porque rubrica binária ensina o agente a mentir.

Trabalhe com dois denominadores. O bruto mostra a realidade do usuário, e o endereçável mostra o seu backlog. Os dois dizem coisas diferentes, e as duas importam.

Ausência de veredito não é zero. Falha do juiz e turno sem pergunta ficam fora da conta.

Dê ao juiz evidência de execução, e não só prosa. Quais tools foram chamadas, como elas terminaram e o que a interface renderizou. Um juiz que só lê texto premia a lábia.

Julgue a intenção, e não apenas o passo. Sem score de sessão, todo fluxo de confirmação vira falha.

Trate o juiz como código. Versione, rode sombra, compare divergência a divergência e aceite que às vezes a resposta certa é não adotar a mudança.

Termine em fila com dono. Métrica que não vira tarefa atribuída é decoração cara.

O trabalho mais valioso que fiz nesse período não foi melhorar o agente. Foi construir o instrumento que diz se ele melhorou. Sem isso, todo ajuste de prompt vira fé. E time de engenharia operando na fé anda rápido em círculos.