raphael cunhaSOFTWARE, PEOPLE & IDEAS

Um guard de IA não é só um prompt: contexto, Jev e avaliação em produção

Ilustração editorial de duas mensagens com marcadores de sino em posições diferentes, passando por uma estação de triagem para uma bandeja com sino de atendimento e outra silenciosa

Um guard de IA parece uma classificação binária: responde ou silencia. Em produção, ele é uma decisão de produto com custo de erro nos dois lados.

Um falso positivo interrompe uma conversa com uma resposta inútil, vaza uma ação para o canal errado ou contorna uma política. Um falso negativo deixa uma pessoa sem a ajuda que pediu. Quando o guard é a porta de entrada de um agente, a decisão também define se todo o restante do sistema será executado.

Este post nasceu de uma regressão real. Uma pessoa mencionava o bot e escrevia uma ordem curta para que ele aprendesse com uma conversa. O guard escolhia silence. O caso não exigia um modelo mais eloquente: exigia descobrir quais dados chegavam ao modelo e quais tinham desaparecido antes da decisão.

Depois da correção, a avaliação passou de dez para treze casos e cobriu os exemplos que haviam falhado. A mudança foi implantada. O que proponho nas seções seguintes é uma evolução arquitetural: como eu trataria esse mesmo tipo de guard hoje, combinando regras determinísticas, Jev como modelo de decisão e um modelo de raciocínio recente para a minoria de casos que realmente precisa dele.

O incidente: normalização apagou intenção

A entrada da triagem não recebia a mensagem original. Antes de chegar ao prompt, a menção ao bot era removida como parte da normalização.

Isso parece uma limpeza inofensiva até comparar duas frases:

Mensagem originalPapel da mençãoDecisão esperada
@Assistente, registre o que decidimos nesta conversa.chamada direta no inícioresponder ou iniciar o fluxo solicitado
A @Assistente registrou o que decidimos ontem.referência no meiosilenciar

Depois de remover a menção, ambas perdiam um dos sinais que tornam a interpretação possível. A palavra no texto pode ajudar, mas ela não substitui o fato de que a pessoa chamou o bot e a posição em que isso ocorreu.

O problema, portanto, não era apenas de instrução. Era de contrato de dados. O classificador recebia uma representação incapaz de distinguir duas intenções que o produto precisava tratar de formas diferentes.

Ilustração conceitual: o texto continua legível depois do recorte, mas o sinal de chamada desaparece. A normalização precisa preservar essa evidência no contrato de entrada.
type TriageInput = {
  messageId: string
  channelId: string
  threadId?: string
  authorId: string
  rawText: string
  normalizedText: string
  mentions: Array<{
    subject: 'bot' | 'user' | 'group'
    start: number
    end: number
    position: 'start' | 'middle' | 'end'
  }>
  botMentioned: boolean
  botMentionPosition?: 'start' | 'middle' | 'end'
  channelPolicyVersion: string
}

Não é necessário expor essa estrutura inteira a todos os modelos. Mas ela precisa existir, ser versionada e sobreviver à normalização. rawText permite auditoria; normalizedText reduz ruído; mentions preserva a semântica que a limpeza textual não consegue carregar sozinha.

Na correção, a triagem passou a receber a menção ao bot e sua posição. O prompt foi atualizado para dizer explicitamente que imperativos como “aprenda”, “registre”, “resuma” e “abra um bug” podem ser pedidos, mesmo sem interrogação. Também removi uma reação automática do caminho de silêncio: se a decisão é não responder, reagir visivelmente já é uma forma de resposta.

A arquitetura que eu usaria agora

Eu não colocaria um único LLM para decidir tudo. A estrutura abaixo separa regras que pertencem ao produto de inferências que dependem de linguagem.

evento do canal
       │
       ▼
parser de menções + normalização semântica
       │
       ├── regras determinísticas de política ──────────────► decisão final
       │       (canal proibido, origem inválida, allowlist)
       ▼
Jev: decisão estruturada e probabilidades
       │
       ├── confiança alta + sem conflito ───────────────────► decisão final
       │
       └── incerteza, conflito ou ação sensível
                         │
                         ▼
              modelo de raciocínio + saída estruturada
                         │
                         ▼
                 decisão, auditoria e amostragem humana

Essa cascata não é sobre usar muitos modelos porque isso parece sofisticado. Ela atribui uma tarefa a cada mecanismo:

CamadaPergunta que respondeResultado
Regras determinísticashá uma condição objetiva de bloquear, permitir ou escalar?decisão explicável e estável
Jevdada a política e o estado, qual classe é mais provável?escolha, distribuição de probabilidades e confiança
Modelo de raciocínioa política entra em conflito com o contexto conversacional?decisão estruturada para os poucos casos ambíguos
Revisão humanaa rubrica e os limiares ainda representam o uso real?rótulos de referência e correções de política

A principal regra é simples: a política não pode existir apenas no prompt. Se um canal nunca permite uma determinada ação, isso deve ser checado antes da chamada de modelo. Se uma operação exige confirmação humana, a resposta do classificador não pode removê-la. O modelo interpreta linguagem; o produto aplica controles.

Onde Jev encaixa bem

Jev é um modelo de decisão, não um chat. Ele recebe um estado e um conjunto de perguntas tipadas, devolvendo escolhas, scores ou probabilidades binárias. Isso combina bem com a triagem porque a saída desejada já é uma decisão estruturada, não uma explicação em prosa. A documentação também permite enviar até vinte perguntas sobre o mesmo estado em uma chamada, o que evita duplicar contexto entre classificadores independentes. A API do Jev documenta esse contrato de state, questions e distribuições de probabilidade.

Para este guard, eu formularia perguntas pequenas e ortogonais. Em vez de pedir “decida se responde”, separaria os sinais que depois podem ser auditados:

const decision = await jev.decide({
  model: 'jev-1.13', // versão fixada para avaliações reproduzíveis
  state: {
    text: input.normalizedText,
    botMentioned: input.botMentioned,
    botMentionPosition: input.botMentionPosition,
    inThread: Boolean(input.threadId),
    channelPolicy: policy.publicSummary,
  },
  questions: {
    addressee: {
      type: 'choice',
      instructions: 'Quem é o destinatário da mensagem?',
      criteria: {
        bot: 'A pessoa está falando diretamente com o bot.',
        human: 'A pessoa está falando com outra pessoa.',
        unknown: 'Não há evidência suficiente para decidir.',
      },
    },
    intent: {
      type: 'choice',
      instructions: 'Qual é a intenção que controla a triagem?',
      criteria: {
        request: 'Pede uma ação, informação, registro, resumo ou aprendizado.',
        reference: 'Apenas menciona ou descreve o bot.',
        conversational: 'É conversa entre pessoas sem pedido ao bot.',
      },
    },
    policyConflict: {
      type: 'noul',
      instructions: 'Há sinal de que o pedido viola a política deste canal?',
    },
  },
})

Os rótulos e os critérios fazem parte da implementação. Eles precisam ser curtos, mutuamente exclusivos e versionados junto da política. Um choice com opções sobrepostas produz uma aparência de precisão sem uma decisão realmente bem definida.

Também fixaria o modelo na produção e na suíte de avaliação. jev-latest é útil para experimentar melhorias, mas muda com o tempo; jev-1.13 representa uma build fixa. A recomendação é registrar model_version em toda decisão, inclusive quando o identificador usado já é fixo. Isso permite explicar por que a distribuição mudou e reproduzir uma regressão. A distinção entre versão fixada e alias móvel está documentada pelo Jev.

A probabilidade não é autorização automática

Uma resposta como request: 0.94 é evidência, não uma permissão. Eu transformaria probabilidades em uma decisão operacional somente depois de aplicar três verificações:

const confidence = decision.answers.intent.confidence
const margin =
  decision.answers.intent.probabilities.request -
  Math.max(
    decision.answers.intent.probabilities.reference,
    decision.answers.intent.probabilities.conversational,
  )

const canAutoRespond =
  decision.answers.addressee.choice === 'bot' &&
  decision.answers.intent.choice === 'request' &&
  decision.answers.policyConflict.noul < 0.05 &&
  confidence >= thresholds.intentConfidence &&
  margin >= thresholds.intentMargin

O limiar não deve nascer de intuição. Ele vem de um conjunto de validação rotulado por humanos. Para cada faixa de confiança, eu mediria precisão, recall, taxa de escalonamento e o custo do erro. Se o guard silencia um pedido legítimo, talvez o limiar de silêncio deva ser mais conservador que o limiar de resposta. Essas decisões são de produto, não métricas universais.

Quando um modelo de raciocínio vale a latência

Jev é apropriado para decisões bem delimitadas. Ele não produz uma investigação em linguagem natural nem usa ferramentas. Eu não o usaria como desculpa para reduzir uma situação complexa a um rótulo prematuro.

Casos que eu escalaria incluem:

  • a mensagem contém uma menção, mas o destinatário e a intenção entram em conflito;
  • a política depende do que foi dito anteriormente na thread;
  • a mensagem pede uma ação consequente, como publicar, alterar dados ou abrir um ticket externo;
  • a distribuição é pouco concentrada ou a margem entre classes é pequena;
  • o texto pertence a um cluster novo que ainda não tem exemplos rotulados suficientes.

Para essa segunda camada, eu usaria um modelo de raciocínio recente com entrada estruturada e saída em JSON Schema. Os modelos de raciocínio atuais da OpenAI trabalham bem em fluxos com múltiplas etapas e uso de ferramentas; a documentação recomenda a Responses API para preservar o raciocínio entre chamadas de ferramenta. Veja o guia atual de modelos de raciocínio.

O objetivo desse modelo não é escrever uma defesa longa da sua decisão. É avaliar o caso contra a mesma rubrica, devolver motivos enumerados e declarar a evidência que usou.

const escalationSchema = z.object({
  action: z.enum(['respond', 'silence', 'require_confirmation', 'human_review']),
  intent: z.enum(['request', 'reference', 'conversation', 'unknown']),
  policy: z.enum(['allowed', 'blocked', 'needs_review']),
  evidence: z.array(
    z.enum(['bot_mentioned_at_start', 'imperative_verb', 'thread_context', 'channel_rule']),
  ),
  confidence: z.number().min(0).max(1),
})

O serviço deve validar essa estrutura e decidir o efeito. Se o modelo devolver respond, por exemplo, o executor ainda verifica permissões, idempotência e confirmação. Uma saída estruturada melhora a observabilidade e reduz ambiguidade de integração; ela não substitui autorização.

Em uma stack OpenAI, eu começaria com um modelo de custo menor nos casos de escalonamento rotineiros e reservaria um modelo de raciocínio de fronteira para disputas de política ou análises de thread mais longas. O modelo exato deve ser uma configuração versionada, não uma string espalhada pelo código. A página de modelos da OpenAI mantém os modelos disponíveis e orienta essa escolha entre capacidade, custo e latência. Consulte o catálogo atual.

A avaliação precisa testar a cascata inteira

No incidente original, aumentar o dataset de dez para treze casos foi o passo correto: os dois exemplos reais passaram a ter uma proteção contra regressão, e um contraexemplo proibido pela política preservou a fronteira.

Mas uma arquitetura de cascata precisa de uma suíte mais rica que “o prompt acertou?”. Eu dividiria a avaliação em quatro conjuntos.

ConjuntoO que contémO que mede
Regrascanais, permissões e operações com resposta objetivanenhuma regra foi delegada ao modelo por acidente
Pares contrafactuaismesma frase com menção no início, meio ou fimsensibilidade à evidência que realmente muda a decisão
Casos douradosincidentes reais anonimizados e revisadosregressões de comportamento observadas em produção
Casos de fronteiraironia, thread longa, política em conflito e ação consequentetaxa de escalonamento e segurança sob ambiguidade

Os pares contrafactuais são particularmente importantes aqui. Se trocar a posição da menção deveria mudar a resposta, o teste precisa mudar apenas isso. Caso contrário, um acerto não prova que o modelo está usando o sinal certo; ele pode estar se apoiando em uma palavra acidentalmente correlacionada.

Também manteria uma matriz de versões:

dataset v12 × policy v7 × jev-1.13 × escalation-model v3
dataset v12 × policy v7 × jev-candidate × escalation-model v3
dataset v12 × policy v7 × jev-1.13 × escalation-model candidate

Cada alteração de política, modelo, limiar ou serialização do contexto executa a matriz. O relatório deve registrar decisões, probabilidades, caminho percorrido, latência e custo. Um score agregado é útil para detectar mudança; os casos que inverteram de decisão são o que explica a mudança.

Calibração: a parte que costuma faltar

Confiança só é útil se estiver razoavelmente calibrada para o domínio. Se o modelo responde com 0,90 em cem casos, aproximadamente noventa deles deveriam estar certos para aquela população. A métrica não exige perfeição, mas exige medição.

Eu faria amostragem estratificada por faixa de confiança e caminho da cascata. Decisões com 0,50–0,65 merecem revisão por serem candidatas naturais a escalonamento. Decisões acima de 0,95 também devem ser auditadas em uma pequena amostra, porque excesso de confiança pode esconder um erro sistemático. A cada revisão humana, o sistema ganha um rótulo para recalibrar limiares e enriquecer os casos dourados.

faixa de confiança     0.50────0.65────0.80────0.95────1.00
amostragem humana      alta     alta     média    baixa
efeito esperado        escalar escalar  automático automático + auditoria

Os limiares precisam ser específicos ao risco. Pode ser aceitável silenciar automaticamente uma referência com alta confiança. Pode não ser aceitável executar uma ação externa sem confirmação, mesmo quando a intenção é quase certa.

Ilustração conceitual: revisar amostras de decisões em produção alimenta os casos de avaliação e ajuda a recalibrar os limiares.

Observabilidade é parte do contrato

Para depurar uma decisão, eu preciso reconstruir o caminho sem registrar mais conteúdo do que o necessário. Um evento de triagem deveria conter, no mínimo:

type TriageDecisionEvent = {
  messageId: string
  traceId: string
  inputContractVersion: string
  policyVersion: string
  rulesApplied: string[]
  route: 'rule' | 'jev' | 'reasoning_model' | 'human_review'
  model?: { id: string; version: string }
  answers?: Record<string, unknown>
  thresholds?: { intentConfidence: number; intentMargin: number }
  finalAction: 'respond' | 'silence' | 'require_confirmation' | 'human_review'
  latencyMs: number
}

O texto da mensagem deve seguir a política de retenção e acesso do produto. Em muitos casos, guardar um identificador, uma versão de entrada e a decisão já é suficiente para detectar um padrão; a inspeção do texto completo pode ficar restrita a uma ferramenta protegida e a um período curto de retenção.

A observabilidade também fecha o ciclo de avaliação. Uma alteração de inputContractVersion que reduz a taxa de resposta pode ser uma regressão do parser, não uma piora do modelo. Sem registrar a versão da transformação, a equipe tende a culpar o prompt pelo componente errado.

O resultado prático

A correção original foi pequena: preservar a menção, informar sua posição, esclarecer o prompt e adicionar casos de avaliação. Isso resolveu a falha observada porque corrigiu a representação que chegava ao guard.

A lição mais durável é maior. Um guard confiável é um sistema de decisão: tem contrato de entrada, regras que não podem ser negociadas, modelos com responsabilidades delimitadas, limiares calibrados, avaliações versionadas e sinais de produção que voltam para o dataset.

Jev pode reduzir custo e latência quando a pergunta é realmente uma escolha bem definida. Um modelo de raciocínio recente é valioso quando há contexto, conflito e consequências. Nenhum deles compensa contexto perdido ou política escondida em prosa. O ganho vem de saber exatamente qual decisão cada camada pode tomar — e de medir, em produção, quando ela erra.