leonardo 4a9861a8bb 1 bulan lalu
..
data 4a9861a8bb 1 bulan lalu
README.md 4a9861a8bb 1 bulan lalu
publicar_no_ollama.sh 816e235920 3 bulan lalu
treinar_lora_mlx.sh 4a9861a8bb 1 bulan lalu

README.md

Treinamento do agente de atendimento (estágio 2)

Fine-tuning (SFT com LoRA) do Llama-3.1-8B com os atendimentos reais do ifbot que foram bem avaliados pelo estágio 1 (LLM-as-judge): nota do atendente ≥ 8, problema resolvido e cliente não-insatisfeito. O juiz funciona como filtro de qualidade do dataset — só "assim se atende" entra no treino.

Fluxo completo

avaliações (estágio 1, MySQL)
      │  node backend/scripts/exportarDatasetTreinamento.js
      ▼
training/data/{train,valid}.jsonl        ← formato chat {"messages":[...]}
      │  ./treinar_lora_mlx.sh
      ▼
training/adapters/                       ← LoRA treinado (MLX, roda no M4 16GB)
      │  ./publicar_no_ollama.sh
      ▼
ollama: star-atendente                   ← modelo fundido + quantizado q4_K_M

Passo a passo

  1. Acumular avaliações — o job do backend avalia sozinho; para acelerar: cd backend && node scripts/avaliarTudo.js --lote 25 (~20s por atendimento no llama3.1).

  2. Exportar o dataset (da pasta backend/):

    node scripts/exportarDatasetTreinamento.js            # score>=8, resolvido=sim
    node scripts/exportarDatasetTreinamento.js --score-min 7 --incluir-parcial   # filtro mais frouxo
    

    Gera training/data/train.jsonl, valid.jsonl e manifest.json (rastreia protocolos usados — protocolosTrain/protocolosValid, por atendimento — e filtros). Telefones são mascarados; mensagens do bot/sistema ficam de fora; cada amostra começa no cliente e termina na resposta do atendente.

Modo de amostragem (--modo) — importa porque o mlx-lm com --mask-prompt só calcula loss no último turno de cada amostra:

  • por-turno (default, ciclo 5+): cada atendimento gera várias amostras, cada uma terminando num turno diferente do atendente (prefixo da conversa até ali). Sorteia até --alvos-por-atendimento (2) turnos com >= --min-palavras-alvo (8) palavras, mais acks/fechamentos curtos limitados a --frac-triviais (0,15) do total, pra o modelo ainda saber fechar conversa. --max-alvo-repetido (30) limita quantas vezes um mesmo texto de atendente ("Vou transferir seu atendimento…") pode ser alvo. O split treino/validação é por atendimento, então não há vazamento.
  • ultimo-turno (ciclos 1-4): 1 amostra por atendimento terminando no último turno. Nos ciclos 1-4 isso fazia o alvo ser quase sempre o fechamento ("Disponha!", mediana de 7 palavras, 43,6% com <=5) e o modelo aprendia a fechar conversa em vez de resolver. Mantido só pra reproduzir os datasets antigos.

Com por-turno o dataset tem ~2x mais amostras que o ultimo-turno: prefira 1 época (./treinar_lora_mlx.sh 1) pra manter o tempo de treino próximo ao dos ciclos anteriores.

  1. Treinar (nesta máquina, Apple Silicon):

    ./treinar_lora_mlx.sh        # 3 épocas
    ./treinar_lora_mlx.sh 2      # 2 épocas
    

    Usa mlx-lm com o modelo 4-bit (mlx-community/Meta-Llama-3.1-8B-Instruct-4bit), batch 1, seq 2048, gradient checkpointing — cabe nos 16 GB do M4, mas feche apps pesados e espere algumas horas com centenas de amostras. Acompanhe o Val loss: se começar a subir enquanto o train loss cai, reduza as épocas (overfitting).

O script roda automaticamente, antes do treino:

  • filtrar_por_tokens.py — descarta amostras cujo tamanho real em tokens (tokenizer do próprio modelo) passa de 1600. exportarDatasetTreinamento.js só filtra por caracteres (--max-chars), que não é proxy confiável de tokens em pt-BR; uma amostra de 14-18 mil caracteres pode passar de 4000 tokens — bem acima do --max-seq-length de 2048 — e isso já causou OOM de GPU (e reinício da máquina) durante a validação em 04/08.
  • o treino roda via run_lora_seguro.py, que capa o memory_limit/wired_limit do MLX em 9 GiB antes de carregar o modelo. Por padrão o MLX permite até 1.5x o working set recomendado da GPU (~19 GiB neste M4), acima da RAM física (16 GiB) — sem teto explícito, um pico de memória derruba o sistema inteiro em vez de estourar uma exceção Python capturável.

Alternativa com GPU NVIDIA (mais rápido, datasets maiores): os mesmos JSONL funcionam no unsloth ou axolotl — treinar lá, trazer o adapter PEFT e converter com convert_lora_to_gguf.py do llama.cpp.

  1. Testar o adapter antes de publicar:

    source .venv/bin/activate
    mlx_lm.generate --model mlx-community/Meta-Llama-3.1-8B-Instruct-4bit \
     --adapter-path adapters --max-tokens 200 \
     --prompt "Boa tarde, minha internet caiu"
    
  2. Publicar no Ollama: ./publicar_no_ollama.sh — funde o LoRA, cria o modelo star-atendente quantizado e remove o intermediário fp16 (~16 GB temporários em disco).

  3. Validar e ativar: compare respostas com o llama3.1 puro em perguntas típicas de suporte. O backend já usa star-atendente por default (config.atendenteSugestao.model, na feature de sugestão de resposta ao atendente) — só é preciso setar OLLAMA_ATENDENTE_MODEL=star-atendente no backend/.env se o modelo for publicado com outro nome. Não confundir com OLLAMA_CHAT_MODEL, que é o modelo do chat RAG geral.

Avaliação (eval/) — rode antes de publicar qualquer modelo ou mudar o prompt da sugestão

Harness com 30 cenários fictícios (eval/cenarios.json), sem dado de cliente:

# modelo MLX (base, ciclos, checkpoints) — da pasta training/, com a .venv
HF_HUB_OFFLINE=1 .venv/bin/python eval/gerar_respostas.py --variantes base ciclo5 ciclo5@1400 --temp 0.3 --n-amostras 5 --saida eval/out/x.json
# caminho de produção (Ollama + atendenteSugestaoService) — da pasta backend/
node training/eval/gerar_ollama.mjs --n 3 --saida training/eval/out/y.json \
  --variante atual=prompt:atual,rag:nenhum,guard:1 --variante antiga=prompt:producao_v0.txt,rag:avaliacao,guard:0
# julgar (juiz llama3.1 local + regex de src/services/atendenteSugestaoGuardrails.js)
node training/eval/juiz.mjs training/eval/out/y.json --saida training/eval/out/y.julgado.json
node training/eval/juiz.mjs --calibrar      # concordância do juiz com eval/vereditos_manuais.json

Cuidados de leitura: o juiz erra a fronteira adequada/parcial (69% de concordância exata com o manual, 88% em 3 níveis útil/fraca/afirma_falso) e tem falsos positivos de afirma_falso (recusas corretas); confira manualmente os casos marcados. Com n≈90 por variante, diferenças de poucos pontos são ruído. ciclo5@N é o arquivo numerado N da 2ª tentativa: iteração global = N + 1400. Resultados de 25/09/2026: o ciclo 5 ficou em 33% de respostas úteis contra 79% da base; nenhum checkpoint chegou perto da base.

Sugestão de resposta em produção (desde 25/09/2026): base llama3.1, não o LoRA. Prompt com regras anti-invenção, guardrail pós-geração e RAG desligado (ATENDENTE_SUGESTAO_RAG_TIPO=nenhum; o RAG de avaliação elevava as afirmações inventadas de ~3% para ~10%). Chaves: ATENDENTE_SUGESTAO_GUARDRAIL, ATENDENTE_SUGESTAO_RAG_TIPO (nenhum|avaliacao|conversa), ATENDENTE_SUGESTAO_RAG_ENABLED.

Quando re-treinar

O dataset cresce conforme o sync importa protocolos novos e o juiz os avalia. Re-exportar + re-treinar vale a pena a cada leva significativa de amostras novas (ex.: +30%). O manifest.json diz o que entrou em cada rodada.

Próximo estágio (futuro)

Com volume maior, os scores viabilizam DPO: pares "atendimento nota alta" vs "nota baixa" para o mesmo tipo de problema, treinando por preferência em vez de imitação. Exige mais dados e curadoria — só faz sentido depois do SFT mostrar limite.