Um Modelo de Decisão "System 1" para Atendimento em PT-BR — Parte 1: Contexto, Dados e o Bug que Quase Enterrou o Projeto
- decisum
- making-of
- benchmarks
Decisum é o nome público do modelo que chamamos internamente de "decisor". Esta série de making-of preserva a nomenclatura interna usada durante o desenvolvimento.
Raphael Pizzo — Especialista em Machine Learning Outubro de 2026 · Série técnica, parte 1
Nota de anonimização: este artigo descreve um projeto real, com dados reais de produção. Todos os nomes de empresas, produtos, plataformas e clientes foram omitidos ou generalizados de propósito. Onde você ler "a plataforma de atendimento", "a plataforma de e-commerce" ou "o helpdesk", trata-se de sistemas reais em operação — os nomes não importam para a ciência.
1. O problema: decidir é diferente de conversar
Nos últimos anos, a indústria de ML aplicada a atendimento ao cliente se concentrou em geração: chatbots que conversam, resumem, respondem. Mas quem opera um contact center sabe que o gargalo real não é conversar — é decidir:
- Esse pedido de reembolso é legítimo ou é fraude?
- Esse chamado é um incidente crítico ou uma dúvida de configuração?
- Isso eu resolvo automaticamente ou escalo para um humano?
São decisões binárias ou de múltipla escolha, tomadas centenas de vezes por dia, por analistas humanos, sob pressão. Cada uma carrega risco financeiro (estorno indevido), reputacional (cliente irritado) e operacional (fila travada).
A intuição que guiou este projeto vem da psicologia cognitiva, popularizada por Kahneman: humanos têm dois sistemas de pensamento — o System 1, rápido, intuitivo, barato; e o System 2, lento, deliberado, caro. Um bom time de atendimento funciona assim: o analista júnior resolve 80-90% dos casos "no automático" (System 1) e escala os difíceis para o especialista (System 2).
Por que não construir essa arquitetura de verdade?
2. A lacuna: ninguém faz isso em português
Em 2026 surgiram os primeiros "decision models" dedicados — modelos pequenos treinados especificamente para escolher entre opções, com probabilidades calibradas, em vez de gerar texto livre. As referências da categoria (algumas fechadas, outras abertas) compartilham três ideias:
- Encoder bidirecional pequeno (centenas de milhões de parâmetros, não bilhões) — roda em CPU, latência de dezenas de milissegundos;
- Option-markers: cada opção da decisão é pontuada individualmente pelo modelo, em vez de gerar a resposta token a token;
- Calibração pós-treino: as probabilidades reportadas precisam significar alguma coisa — se o modelo diz 90% de confiança, ele deve acertar ~90% das vezes.
O problema: tudo isso é em inglês, para domínios genéricos. Nenhum focado em PT-BR, nenhum focado nas decisões reais de quem vende e atende no e-commerce brasileiro. Essa é a lacuna que este projeto ocupa.
3. Arquitetura: System 1 + System 2
chamado → DECISOR System 1 (encoder 322M, CPU, ~50-200ms, probs calibradas)
├─ confiança ≥ 0.8 → decisão automática (alvo: 80-90% do volume)
└─ confiança baixa → LLM System 2 (14B, GPU) decide + justifica
└─ saída alimenta a próxima rodada de treino do S1
System 1 — o decisor (foco desta série)
-
Encoder: mmBERT-base — 322M parâmetros, treinado com português nativo, contexto de até 8k tokens. Pequeno o bastante para CPU; grande o bastante para entender "cliente quer estorno porque o pedido atrasou 8 dias".
-
Cabeça de decisão por option-markers: em vez de pedir ao modelo que gere "reembolso", montamos o input assim:
Pergunta: Qual a categoria principal do chamado? <eos> opção: reembolso <mask> <eos> opção: incidente <mask> <eos> opção: integracao <mask> <eos> ... (17 opções) ... <eos> [texto do chamado]Cada
<mask>recebe um logit via uma cabeça linear sobre o hidden state daquela posição; softmax sobre as opções da pergunta. O modelo nunca gera texto — ele pontua alternativas. Isso elimina toda uma classe de erros (alucinação de formato, resposta fora do vocabulário) e torna a saída determinística em estrutura. -
Primitivas tipadas:
choice(escolher 1 de N),score(urgência 0-3) e uma cabeça auxiliaract(probabilidade de executar ação direto). Cada chamado vira várias perguntas: categoria, decisão, urgência. -
Treino em duas fases: supervised (cross-entropy) → RLCD (Reinforcement Learning for Calibrated Decisions): REINFORCE com baseline estilo GRPO, recompensa = strictly proper scoring rules (log score + spherical score). A propriedade matemática das scoring rules proper: a recompensa esperada só é maximizada quando a distribuição reportada é a crença verdadeira do modelo — ou seja, o RL literalmente ensina o modelo a ser honesto sobre a própria incerteza.
-
Calibração final: temperature scaling por número de opções, medido por ECE (Expected Calibration Error). Alvo: ECE abaixo de 0,10.
System 2 — o deliberador (fora do escopo desta parte)
Um LLM de 14B parâmetros com QLoRA, que recebe só os casos de baixa confiança, devolve JSON estruturado com justificativa citando a política aplicável, e gera dados novos para retreinar o System 1. A documentação da plataforma vai para RAG, nunca para o treino — política muda com o tempo; pesos, não.
4. Os dados: 447 mil exemplos, três fontes
4.1 Tickets próprios (a peça que faltava)
A fonte mais valiosa: 5.715 chamados reais de um contact center de produto SaaS, exportados semanalmente ao longo de 4,5 meses (jun–out/2026).
O export veio num formato hostil: "CSV" separado por ; cujo campo de
histórico é um array JSON quebrado pelo próprio sanitizador da
ferramenta — URLs substituídas por [URL] engoliam aspas de fechamento e
vírgulas, corrompendo o JSON de 92% dos registros. Em vez de consertar o
JSON, escrevi um parser tolerante baseado em regex que extrai cada mensagem
(tempo, tipo, autor, corpo) sem depender da validade do documento. Detalhes
que o parser precisou tratar:
- objetos
sentBycontêm umidque se disfarça de início de mensagem; - anexos também têm
id— só mensagens têmchatIdlogo após o id; - mensagens citadas (
"quoted": {...}) replicam o objeto inteiro da mensagem original — sem cuidado, contam dobrado.
Validação: nos 2.307 tickets cujo JSON era parseável (após reparo parcial), a contagem de mensagens do parser divergiu em 0,00%.
Sem campo de resolução explícito no export, os rótulos de decisão são
derivados por regras sobre o comportamento do atendente humano (escalou?
pediu info? executou?) — a decisão que o modelo aprende a imitar é a do
time real. Notas internas dos atendentes viram o campo justificativa.
4.2 Dataset público de e-commerce brasileiro
Um dataset público e bem conhecido de e-commerce BR (100k+ pedidos, reviews, logística) transformado em 397.636 exemplos de decisão via regras sobre resultados reais: atraso de entrega, nota da review, cancelamento. Licença não-comercial — a versão de pesquisa do modelo pode incluí-lo; a comercial, não. Isso está documentado desde o dia 1.
4.3 Tickets de suporte em inglês
34.751 exemplos de um dataset público de tickets multilíngues, usado como regularizador de robustez (o modelo não deve depender de idiossincrasias do nosso português corporativo).
4.4 O vocabulário: 10 → 17 categorias (lição de auditoria)
Primeira versão do vocabulário: 10 categorias de e-commerce (reembolso, troca, prazo de envio...). Ao rodar nos tickets reais, 67% caiu em "outro" — sinal clássico de vocabulário errado, não de dados ruins.
Auditoria manual de 20 casos amostrados por categoria revelou os clusters reais do contact center: incidentes de indisponibilidade (34%), mensageria corporativa/integrações de canal (28%), bugs de produto, configuração de conta, plano/créditos... Decisão de design: adicionar 7 categorias novas em vez de substituir — as de e-commerce continuam valendo para o dataset público e para o domínio-alvo. Resultado: "outro" caiu de 67% para 14%.
Regra que ficou: o vocabulário é fechado, mas não é fixo — ele segue o catálogo real de chamados, auditado a cada iteração.
4.5 Higiene de dados (não negociável)
- PII mascarada antes de qualquer treino: CPF, CNPJ, e-mail, telefone,
cartão → tokens
[CPF],[EMAIL]... (pendência conhecida: nomes próprios no corpo das mensagens ainda passam — NER está no roadmap); - Split temporal, não aleatório: últimos 60 dias = conjunto de teste. Split aleatório vaza padrões temporais (o mesmo cliente, a mesma semana, o mesmo incidente aparecem nos dois lados);
- Gold set intocável: os 5.244 exemplos de teste nunca entram em nenhuma fase de treino;
- Dados brutos com PII nunca saem da máquina de origem — o host de treino recebe só os JSONL processados.
5. Infraestrutura: AMD na veia
Nada de CUDA aqui. O host de treino é uma workstation com 3 GPUs AMD
gfx1100 (2× 24GB + 1× 20GB, 68GB VRAM total), ROCm 10.1, 80GB RAM,
Threadripper 24c/48t, tudo containerizado (Docker Compose, imagem oficial
rocm/pytorch). Atenção via SDPA nativa do PyTorch — flash-attention não
compila para gfx1100 e está banida do requirements.
Três otimizações que saíram da prática, não da teoria:
- Padding dinâmico: o dataset mistura estados de ~200 tokens (dataset público) com conversas de 1k+ tokens. Padding fixo em 1024 jogava ~70% do compute fora. Padding por lote (ao maior da amostra, múltiplo de 16) quase triplicou o throughput: 1,0 → 2,9 steps/s.
- Batch size sob convivência: as GPUs dividem o host com modelos de serving em produção. Batch 32 estourava a VRAM em lotes longos; batch 24 é o ponto seguro (19,3/24GB, com folga para picos).
- num_workers 16: com 48 threads, a tokenização — não a GPU — era o gargalo com os 4 workers padrão.
6. O bug que quase enterrou o projeto 🕵️
Aqui a parte mais valiosa deste artigo. O primeiro treino (v1) completou 5 horas, loss caindo, acurácia de treino em 27% — modesta, mas plausível para 17 classes. A avaliação no gold set, porém, foi catastrófica:
| pergunta | acurácia gold | baseline aleatório |
|---|---|---|
| categoria (17 opções) | 1,8% | 5,9% |
| decisão (5 opções) | 0,0% | 20% |
| urgência (4 opções) | 0,0% | 25% |
Pior que aleatório não é modelo ruim — é bug sistemático. A hipótese
veio da estrutura do input: as opções com [MASK] ficavam no final do
texto, e a truncagem em 1024 tokens as cortava fora nas conversas longas.
Fui medir a proporção de exemplos afetados e o resultado foi pior que o
esperado: 100% dos exemplos perdiam as máscaras — inclusive os curtos.
Isso descartava a truncagem como causa única. A causa raiz estava um nível abaixo, na tokenização:
O código montava os spans como
opção: reembolso [MASK], hardcoded. Mas o mmBERT é um ModernBERT — seu token de máscara real é<mask>, não[MASK]. O string[MASK]tokenizava como texto comum. Nenhum exemplo, nunca, continha um token de máscara de verdade.
O efeito cascata: sem token <mask> no input, as posições de máscara
(mask_pos) vinham zeradas; todas as opções liam o hidden state da posição
0 (o <bos>); todas recebiam o mesmo logit; o argmax era sempre a opção
0. A "acurácia de 27%" do treino era simplesmente a frequência da opção 0
na mistura de dados. O modelo não aprendeu nada — aprendeu a jogar cara ou
coroa numa moeda viciada.
E o detalhe mais insidioso: a calibração reportou ECE de 0,041 para categoria — "excelente"! — porque o ECE media a calibração de um modelo que sempre diz a mesma coisa com confiança uniforme. Métrica boa sobre comportamento degenerado é métrica mentirosa.
A correção (e a vacina)
# antes (bugado)
partes += [f"opção: {op} [MASK]" for op in opcoes]
return " [SEP] ".join(partes)
# depois: tokens especiais REAIS do tokenizer + opções ANTES do estado
partes = [f"Pergunta: {pergunta['instrucao']}"]
partes += [f"opção: {op} {tok.mask_token}" for op in opcoes]
partes.append(estado) # truncagem corta o fim da conversa, nunca as opções
return f" {tok.sep_token} ".join(partes)
E a vacina permanente: um script de checagem (checa_trunc.py) que mede,
para cada conjunto de dados, quantos exemplos perdem máscaras após
tokenização+truncagem. Antes da correção: 100%. Depois: 0%. Esse check
agora faz parte do pipeline — nunca mais um treino de 5 horas sai do chão
sem ele.
Os sinais do v2
A diferença foi imediata e visível no primeiro milhar de steps:
| v1 (bugado) | v2 (corrigido) | |
|---|---|---|
| loss @ step 1000 | 1,70 (estagnada) | 1,40 (caindo) |
| acc @ step 1000 | 27% (frequência da opção 0) | 47,3% (aprendendo de verdade) |
7. Lições até aqui
- Valide tokens especiais programaticamente. Um string hardcoded que
funciona numa família de modelos (
[MASK]no BERT clássico) vira texto inerte em outra (<mask>no ModernBERT). O teste custa 30 segundos; o bug custou 5 horas de GPU. - Acurácia pior-que-aleatório é bug, não é modelo fraco. Nenhum modelo real fica consistentemente abaixo do acaso — quando acontece, procure o erro sistemático.
- Métrica de calibração não valida comportamento. ECE baixo sobre um modelo degenerado é falso positivo. Avalie acurácia antes de calibração.
- Auditoria de rótulos > mais dados. 20 casos lidos à mão mudaram o vocabulário e dobraram o sinal útil do dataset.
- Dados próprios são o produto. Os datasets públicos dão escala; os 5.715 tickets reais dão o domínio.
Na Parte 2
Os resultados completos do decisor v2: acurácia por tipo de pergunta, P/R/F1 das 17 categorias, matriz de confusões (e o que elas dizem sobre os dados vs. o modelo), ECE pós-temperature-scaling, o efeito do RLCD na honestidade das probabilidades, e a simulação de roteamento — quantos por cento do volume real o System 1 consegue decidir sozinho com confiança ≥ 0,8. Depois: System 2, o loop de refinamento e a publicação do modelo.
Raphael Pizzo é Especialista em Machine Learning. Este projeto foi desenvolvido em infraestrutura própria (AMD ROCm), com dados anonimizados e aprovados para uso em pesquisa.