>_ josetilabs.com
← blog

14 out 2026 · inteligencia artificial, infraestrutura, testes · 6 min

Como saber se o modelo piorou depois da troca

Trocar a versao do modelo e facil, comparar as duas e chato. A resposta curta: monte um conjunto pequeno de perguntas com resposta conferivel, rode o mesmo conjunto na versao antiga e na nova varias vezes, e compare a faixa, nao o numero solitario.

O mesmo modelo, na mesma versao, da placares diferentes a cada rodada. Nao e mudanca de comportamento: e o conjunto respondendo a hora do dia, a carga da maquina e o humor estocastico da amostra.

O conjunto: 20 perguntas que da para conferir

Cada pergunta tem uma resposta que se verifica sem opinionar: aritmetica, sequencia, unidades, data, hexadecimal, XOR, SQL, regex, shell, status HTTP. Nada de “escreva um poema sobre o mar”, que nao tem resposta certa e faz quem avalia julgar estilo em vez de acerto.

O script vem com as 20 embutidas e a regra de conferencia em cada linha. Duas delas:

python
("p01", "aritmetica",
 "Calcule 47 * 83 - 19. Responda so com o numero.",
 "numero=3882"),
("p11", "regex",
 "Escreva apenas a expressao regular que casa um IP v4 como 192.168.0.1, "
 "sem outros caracteres.",
 "regex-ip"),

A regra regex-ip merece explicacao, porque e a armadilha mais chata de um conjunto desse. Julgar a regex pela forma do texto reprova resposta certa: tanto \d quanto [0-9] casam um IP valido. O jeito certo e compilar a expressao que o modelo devolveu e testar o comportamento:

python
def regex_para_ip(expressao: str) -> bool:
    padrao = expressao.strip().strip("`")
    try:
        rx = re.compile(padrao)
    except re.error:
        return False
    return bool(rx.fullmatch("192.168.0.1")) and not rx.match("192.168.0.1x") \
        and not rx.match("999.1.1.1")

Isso evitou reprovar os dois modelos numa pergunta que eles responderam certo.

O script

Salve como avaliacao.py. Nao instala nada, so usa a biblioteca padrao. O BASE_URL e argumento, porque a pergunta nao e sobre modelo nenhum.

python
#!/usr/bin/env python3
"""avaliacao.py: 20 perguntas de resposta conferivel, rodadas repetidas e comparacao.

A ideia e simples: um placar so nao diz nada. O mesmo modelo, na mesma versao,
erra em perguntas diferentes a cada rodada. Entao o script roda o conjunto N vezes
por modelo, guarda cada rodada em um arquivo, e a comparacao mostra a faixa,
nao so o ultimo numero.

    export CHAVE_API="sua-chave"
    python3 avaliacao.py rodar gpt-4o-mini --rodadas 3
    python3 avaliacao.py rodar llama-3.1-8b --rodadas 3
    python3 avaliacao.py comparar

O BASE_URL e o MODELO sao argumentos, porque a pergunta nao e sobre modelo nenhum:
e sobre ter um metodo antes de trocar de versao.
"""
from __future__ import annotations

import argparse
import glob
import json
import os
import pathlib
import re
import statistics
import sys
import time
import unicodedata
import urllib.error
import urllib.request

BASE_URL = os.environ.get("BASE_URL", "https://api.openai.com/v1")
# Orcamento generoso de proposito: com 900 tokens o raciocinio do modelo consome
# tudo e a resposta volta vazia com finish_reason "length". Isso acontecia em
# 3 das 20 perguntas na medicao deste texto.
MAX_TOKENS = 4000

# --------------------------------------------------------------- o conjunto
# Cada linha traz a resposta conferivel. "contem" ignora acento e caixa.
PERGUNTAS = [
    ("p01", "aritmetica",
     "Calcule 47 * 83 - 19. Responda so com o numero.",
     "numero=3882"),
    ("p02", "ordenacao",
     "Ordene estes numeros do menor para o maior e responda so com eles, separados por "
     "virgula: 91, 17, 4, 63, 28, 55",
     "numero=4, 17, 28, 55, 63, 91"),
    ("p03", "sequencia",
     "Complete a sequencia e responda so com o proximo numero: 2, 6, 18, 54, 162, ...",
     "numero=486"),
    ("p04", "unidades",
     "Um processo consome 2,5 GB de RAM, e 1 GB vale 1000 MB. Quantos MB sao isso? "
     "Responda so com o numero.",
     "numero=2500"),
    ("p05", "data",
     "Quantos dias tem o mes de fevereiro de 2028, ano bissexto? Responda so com o numero.",
     "numero=29"),
    ("p06", "premissa",
     "Se todo retangulo tem quatro lados, e esta figura e um retangulo, quantos lados "
     "ela tem? Responda so com o numero.",
     "numero=4"),
    ("p07", "prosao",
     "Quantos segundos tem 15 minutos? Responda so com o numero.",
     "numero=900"),
    ("p08", "percentual",
     "De 1200 requisicoes, 47 por cento falharam. Quantas requisicoes falharam? "
     "Responda so com o numero.",
     "numero=564"),
    ("p09", "texto",
     "Qual destes e um comando que existe no coreutils do Linux: 'troca-tudo' ou 'cp'? "
     "Responda so com o comando.",
     "contem=cp"),
    ("p10", "sql",
     "Escreva apenas o comando SQL que lista as 10 contas com maior gasto da tabela "
     "conta(id, nome, gasto). Sem explicacao.",
     "contem=order by"),
    ("p11", "regex",
     "Escreva apenas a expressao regular que casa um IP v4 como 192.168.0.1, "
     "sem outros caracteres.",
     "regex-ip"),
    ("p12", "shell",
     "Escreva apenas o comando bash que mostra as 5 maiores linhas de tamanho em bytes "
     "de todos os .log do diretorio atual.",
     "contem=sort"),
    ("p13", "bits",
     "Quantos bytes tem 1 GiB, considerando 1 GiB = 2 elevado a 30? Responda so com "
     "o numero.",
     "numero=1073741824"),
    ("p14", "geometria",
     "Um retangulo tem area 240 cm2 e largura 15 cm. Quanto mede o comprimento, em cm? "
     "Responda so com o numero.",
     "numero=16"),
    ("p15", "bits",
     "Em hexadecimal, quanto e 255 mais 1? Responda so com o valor em hexadecimal, "
     "precedido de 0x.",
     "numero=0x100"),
    ("p16", "bits",
     "Quanto e 1234 XOR 5678, em decimal? Responda so com o numero.",
     "numero=4860"),
    ("p17", "shell",
     "Escreva apenas o comando shell que conta quantas linhas do arquivo access.log "
     "contem a palavra ' 500 '. Sem explicacao.",
     "contem=grep"),
    ("p18", "http",
     "Qual codigo de status HTTP significa 'Sem Autorizacao'? Responda so com o numero.",
     "numero=401"),
    ("p19", "logica",
     "Se chove, o cha fica molhado. Hoje o cha esta seco. Choveu hoje? "
     "Responda apenas 'sim' ou 'nao'.",
     "contem=nao"),
    ("p20", "inversao",
     "Inverta a ordem destes quatro termos e responda so com eles, separados por virgula: "
     "chave publica, hash, chave privada, assinatura",
     "contem=assinatura, chave privada, hash, chave publica"),
]


def normaliza(texto: str) -> str:
    """Minuscula, sem acento, espacos colapsados e sem espaco em volta de virgula.

    O espaco depois da virgula nao pode decidir ponto: o mesmo modelo responde
    '4,17,28' numa rodada e '4, 17, 28' na outra, e a resposta e a mesma.
    """
    n = unicodedata.normalize("NFD", texto.lower())
    n = "".join(c for c in n if unicodedata.category(c) != "Mn")
    n = re.sub(r"\s+", " ", n)
    n = re.sub(r"\s*,\s*", ",", n)
    return n.strip()


def regex_para_ip(expressao: str) -> bool:
    """A resposta para a p11 e certa se casar 192.168.0.1, recusar 999.1.1.1 e recusar
    192.168.0.1x. Julgar pela forma do texto reprova resposta certa: tanto \\d quanto
    [0-9] casam IP, e na medicao deste texto os dois modelos responderam certo."""
    padrao = expressao.strip().strip("`")
    try:
        rx = re.compile(padrao)
    except re.error:
        return False
    return bool(rx.fullmatch("192.168.0.1")) and not rx.match("192.168.0.1x") \
        and not rx.match("999.1.1.1")


def conferivel(regra: str, texto: str) -> bool:
    if regra == "regex-ip":
        return regex_para_ip(texto)
    return normaliza(regra.split("=", 1)[1]) in normaliza(texto)


def uma_rodada(modelo: str, base: str, chave: str, cabecalhos: dict[str, str],
               max_tokens: int) -> dict:
    acertos, disciplina, falhas, sem_resposta = 0, {}, [], []
    for pid, _categoria, pergunta, regra in PERGUNTAS:
        t0 = time.monotonic()
        try:
            cabec = {"Authorization": f"Bearer {chave}",
                     "Content-Type": "application/json", **cabecalhos}
            corpo = json.dumps({
                "model": modelo,
                "messages": [{"role": "user", "content": pergunta}],
                "temperature": 0,
                "max_tokens": max_tokens,
            }).encode()
            pedido = urllib.request.Request(
                f"{base.rstrip('/')}/chat/completions", data=corpo, headers=cabec)
            with urllib.request.urlopen(pedido, timeout=180) as r:
                d = json.load(r)
        except urllib.error.HTTPError as e:
            falhas.append(f"{pid}: HTTP {e.code} "
                          f"{e.read()[:80].decode(errors='replace').strip()}")
            disciplina[pid] = "erro"
            continue
        except Exception as e:  # noqa: BLE001
            falhas.append(f"{pid}: {type(e).__name__} {e}")
            disciplina[pid] = "erro"
            continue
        escolha = (d.get("choices") or [{}])[0]
        texto = ((escolha.get("message") or {}).get("content") or "").strip()
        if conferivel(regra, texto):
            acertos += 1
            disciplina[pid] = "ok"
        elif escolha.get("finish_reason") == "length" and not texto:
            # o modelo gastou o orcamento inteiro pensando e nao sobrou resposta
            disciplina[pid] = "sem-resposta"
            sem_resposta.append(pid)
        else:
            disciplina[pid] = "errou"
        del t0
    return {"acertos": acertos, "de": len(PERGUNTAS), "disciplina": disciplina,
            "falhas_api": falhas, "sem_resposta": sem_resposta}


def cmd_rodar(args) -> int:
    chave = args.chave or os.environ.get("CHAVE_API", "")
    if not chave:
        print("avaliacao.py: falta a chave. Faca assim:")
        print("  export CHAVE_API=\"sua-chave\"")
        print("  python3 avaliacao.py rodar MODELO_ANTIGO --rodadas 3")
        print("  python3 avaliacao.py rodar MODELO_NOVO   --rodadas 3")
        print("  python3 avaliacao.py comparar")
        return 1
    cabecalhos = {}
    for bruto in args.cabecalho:
        if ":" not in bruto:
            print(f"cabecalho invalido, use 'Nome: valor': {bruto}")
            return 1
        nome, valor = bruto.split(":", 1)
        cabecalhos[nome.strip()] = valor.strip()
    rodadas = []
    for n in range(1, args.rodadas + 1):
        print(f"  rodada {n}/{args.rodadas} de {args.modelo} ...", flush=True)
        r = uma_rodada(args.modelo, args.base, chave, cabecalhos, args.max_tokens)
        rodadas.append(r)
        extra = ""
        if r["falhas_api"] or r["sem_resposta"]:
            extra = (f", {len(r['falhas_api'])} falha(s) de API"
                     + (f", {len(r['sem_resposta'])} sem resposta" if r["sem_resposta"] else ""))
        print(f"    {r['acertos']}/{len(PERGUNTAS)}{extra}", flush=True)
        time.sleep(args.pausa)
    arquivo = pathlib.Path(f"avaliacao-{args.modelo.replace('/', '_')}.json")
    arquivo.write_text(json.dumps(
        {"modelo": args.modelo, "base": args.base, "rodadas": rodadas},
        ensure_ascii=False, indent=1), encoding="utf-8")
    notas = [r["acertos"] for r in rodadas]
    erros = sum(len(r["falhas_api"]) for r in rodadas)
    print(f"\n{args.modelo}: {notas}")
    print(f"  media {statistics.mean(notas):.1f}  "
          f"min {min(notas)}  max {max(notas)}  desvio {statistics.pstdev(notas):.1f}")
    print(f"  falhas de API somando as rodadas: {erros}")
    if erros:
        print("  PLACAR INVALIDO: com chamada falhando, o numero mede a sua rede, nao o modelo")
        for falha in rodadas[0]["falhas_api"][:1]:
            print(f"  primeiro erro: {falha}")
        print("  se o provedor pedir cabecalho extra, use --cabecalho \"Nome: valor\"")
    print(f"  gravado em {arquivo}")
    return 0


def cmd_comparar(args) -> int:
    arquivos = sorted(glob.glob("avaliacao-*.json"))
    if not arquivos:
        print("nenhum arquivo avaliacao-*.json ainda. Rode uma vez em cada modelo:")
        print("  CHAVE_API=\"sua-chave\" python3 avaliacao.py rodar MODELO_ANTIGO --rodadas 3")
        print("  CHAVE_API=\"sua-chave\" python3 avaliacao.py rodar MODELO_NOVO   --rodadas 3")
        print("  python3 avaliacao.py comparar")
        return 0
    print(f"{'modelo':28} {'rodadas':>18} {'media':>6} {'min':>4} {'max':>4} "
          f"{'desvio':>7} {'erros':>6}")
    resumo = []
    for arq in arquivos:
        d = json.loads(pathlib.Path(arq).read_text(encoding="utf-8"))
        notas = [r["acertos"] for r in d["rodadas"]]
        erros = sum(len(r.get("falhas_api", [])) for r in d["rodadas"])
        media = statistics.mean(notas)
        desvio = statistics.pstdev(notas) if len(notas) > 1 else 0.0
        resumo.append((d["modelo"], media, min(notas), max(notas), desvio, notas, erros))
        print(f"{d['modelo'][:28]:28} {str(notas):>18} {media:6.1f} {min(notas):4} "
              f"{max(notas):4} {desvio:7.1f} {erros:6}")
    print()
    for modelo, media, _mn, _mx, _desvio, notas, erros in resumo:
        print(f"  {modelo}: media {media:.1f}, cada rodada foi {notas}"
              + ("  INVALIDO" if erros else ""))
    if len(resumo) == 2:
        a, b = resumo
        print()
        if a[6] or b[6]:
            print("  uma das rodadas teve chamada falhando: a diferenca mede a rede, "
                  "nao os modelos")
        elif abs(a[1] - b[1]) <= max(a[4], b[4]):
            print(f"  diferenca de {abs(a[1] - b[1]):.1f} pontos, folga de ruido "
                  f"{max(a[4], b[4]):.1f}: INDISTINGUIVEL com estas rodadas")
        else:
            print(f"  diferenca de {abs(a[1] - b[1]):.1f} pontos acima da folga de "
                  f"{max(a[4], b[4]):.1f}: diferenca real")
        print("  para fechar: mais rodadas nos dois, e so se a diferenca continuar acima "
              "da folga vale concluir")
    return 0


def main() -> int:
    ap = argparse.ArgumentParser(description="20 perguntas conferiveis para comparar modelos")
    ap.add_argument("--chave", default="", help="ou use CHAVE_API no ambiente")
    ap.add_argument("--base", default=BASE_URL, help=f"padrao {BASE_URL}")
    ap.add_argument("--cabecalho", action="append", default=[],
                    help="'Nome: valor', repetivel")
    ap.add_argument("--max-tokens", type=int, default=MAX_TOKENS)
    sub = ap.add_subparsers(dest="cmd")
    r = sub.add_parser("rodar", help="roda o conjunto contra um modelo")
    r.add_argument("modelo")
    r.add_argument("--rodadas", type=int, default=3)
    r.add_argument("--pausa", type=float, default=1.0)
    r.set_defaults(func=cmd_rodar)
    c = sub.add_parser("comparar", help="compara os arquivos ja gravados")
    c.set_defaults(func=cmd_comparar)
    args = ap.parse_args()
    if not getattr(args, "func", None):
        # sem subcomando o script ainda tem de responder algo util, em vez de sair calado
        ap.print_help()
        print()
        print("comeco:")
        print("  export CHAVE_API=\"sua-chave\"")
        print("  python3 avaliacao.py rodar MODELO_ANTIGO --rodadas 3")
        print("  python3 avaliacao.py rodar MODELO_NOVO   --rodadas 3")
        print("  python3 avaliacao.py comparar")
        return 0
    return args.func(args)


if __name__ == "__main__":
    sys.exit(main())```

Como rodar, nos dois modelos e depois comparando:

```bash
export CHAVE_API="sua-chave"
python3 avaliacao.py rodar MODELO_ANTIGO --rodadas 3
python3 avaliacao.py rodar MODELO_NOVO   --rodadas 3
python3 avaliacao.py comparar

O que a medicao deu

Rodei as 20 perguntas em dois modelos, 3 rodadas cada, 120 chamadas, nenhuma falhou. Com o teto padrao de 4000 tokens, os dois fizeram 20 de 20 nas tres rodadas. O conjunto chegou ao topo e nao separou ninguem, e esse e o resultado honesto: 20 perguntas assim nao servem para escolher entre esses dois modelos.

Grafico de barras com dois modelos em dois tetos de token: com teto de 4000 tokens os dois fazem 20 de 20, e com teto de 900 tokens os dois caem para media de 18,3 com respostas vazias
Mesmos dois modelos nas mesmas 20 perguntas: o que muda de verdade e o teto de token, nao a versao.

O parametro que mais mudou o placar foi outro. Com teto de 900 tokens, a media dos dois caiu para 18,3 e apareceram 8 respostas vazias, todas com finish_reason igual a length: o modelo gastou o orcamento pensando e nao sobrou texto, como descreve a documentacao do endpoint. Compare versoes com teto curto e parte da diferenca que voce atribui ao modelo e do seu parametro.

Como verificar

O comando e a saida esperada com os dois modelos rodados:

bash
python3 avaliacao.py comparar
modelo                                  rodadas  media  min  max  desvio  erros
cat-jam-free                       [20, 20, 20]   20.0   20   20     0.0      0
space-bunny                       [20, 20, 20]   20.0   20   20     0.0      0

  cat-jam-free: media 20.0, cada rodada foi [20, 20, 20]
  space-bunny: media 20.0, cada rodada foi [20, 20, 20]

  diferenca de 0.0 pontos, folga de ruido 0.0: INDISTINGUIVEL com estas rodadas

A coluna erros e a primeira que voce olha: ela soma as chamadas que falharam, e um placar com chamada falhando mede a sua rede, nao o modelo. Na primeira versao do meu script isso nao aparecia, e o resultado foi 0 de 20 sem explicacao, porque toda chamada tinha falhado.

Para ver a faixa de ruido de um modelo so, mude o numero de rodadas:

bash
python3 avaliacao.py rodar MODELO --rodadas 5

Se der errado

Todas as chamadas falham com HTTP 400 e a mensagem MissingSessionID. Esse gateway exige cabecalho de sessao alem da chave: passe com --cabecalho "x-opencode-session: minha-sessao". Sem ele o script marca tudo como erro e avisa que o placar e invalido.

Duas rodadas dao numeros bem diferentes. E o normal, e a razao de existirem varias rodadas. Suba para 5 e compare a media com a faixa.

O modelo acerta e o script marca errado. A regra esta estreita demais, e isso aconteceu comigo em duas perguntas antes de acertar o conjunto: uma regra que exigia \d reprovou uma regex certa com [0-9], e uma premissa falsa de aranha media discordancia, nao qualidade. Antes de culpar o modelo, teste o conferidor com uma resposta que voce sabe estar certa.

Comparacao das formas de comparar

forma o que mede custo quando usar
um placar so o ultimo sorteio 1 rodada so como palpite
media de 3 rodadas media e desvio 3 rodadas comparacao de rotina
20 perguntas conferiveis acerto objetivo 20 chamadas padrao minimo
conjunto do seu dominio o que importa escrito por voce tarefa especifica
LLM como juiz estilo e qualidade caro e subjetivo criterio escrito antes

Um conjunto do seu dominio vale mais que qualquer generico. As 20 perguntas aqui mostram o metodo; para o seu servico, escreva 20 perguntas dele e rode o mesmo script.

FAQ

Preciso de 20 perguntas? Menos que 10 nao separa modelo: o desvio de uma rodada ja come a diferenca que voce quer medir. Acima de 30 o custo pesa, e vale escolher as perguntas mais proximas do seu uso.

Como sei que a diferenca que achei e real? So depois de repetir e ela continuar acima do desvio. Com 3 rodadas, 1 ponto contra um desvio de 0,8 nao fecha nada. O script avisa isso em texto para ninguem pular essa parte.

Mudo o conjunto e o modelo junto, preciso refazer? Sim, e o conjunto antigo perde valor. A comparacao so vale com as perguntas fixas entre as duas versoes.

Isso funciona com qualquer provedor? Com qualquer endpoint compativel com chat completion: o BASE_URL e argumento por isso. Alguns gateways exigem cabecalho extra, e o script tem --cabecalho.

E as perguntas com resposta aberta? Nao entram aqui. Resposta aberta exige criterio escrito antes, e em geral isso vira julgamento humano, mais lento e mais caro que ler tres rodadas de placar. O caminho, se quiser medir assim, e um conjunto do seu dominio com resposta esperada.

Fontes

Quem ja mediu custo por consumidor e quer somar isso ao metodo, veja o que da para medir na propria maquina. E quem ainda compara versao por sentinela, trocar a senha do servidor por chave SSH mostra o outro lado: trocar sem medir e o jeito mais rapido de descobrir que voce regrediu.

Leia também

// Por e-mail

Os textos publicados aqui também vão por e-mail. O envio ainda não começou: quem deixar o endereço agora entra na primeira lista.

Guardo só o seu e-mail, a data e o registro desta autorização. Sem cookies, sem rastreador e nada compartilhado com terceiros. Detalhes na política de privacidade.