Testando aplicações com LLMs: estratégias para um output não determinístico

1. O Desafio do Não Determinismo em LLMs

1.1. O que é não determinismo e por que LLMs geram respostas diferentes para o mesmo prompt

LLMs (Large Language Models) são intrinsecamente não determinísticos por design. Diferentemente de funções matemáticas puras, onde a mesma entrada sempre produz a mesma saída, um LLM pode gerar respostas diferentes para o mesmo prompt mesmo sem alteração aparente nos parâmetros. Isso ocorre porque o processo de geração de texto envolve amostragem probabilística a partir de uma distribuição de tokens.

1.2. Principais fontes de variação

As principais fontes de variação incluem:

  • Temperatura: Controla a "criatividade" do modelo. Valores mais altos (>0.8) aumentam a aleatoriedade
  • Top-k e Top-p: Limitam o conjunto de tokens candidatos, mas ainda introduzem variação
  • Seed aleatória: A semente inicial do gerador de números aleatórios
  • Hardware e implementação: Diferentes GPUs, bibliotecas ou versões de CUDA podem produzir resultados ligeiramente diferentes

1.3. Impacto do não determinismo em testes de software

O não determinismo causa dois problemas principais:

  • Falsos positivos: Testes que falham intermitentemente porque o LLM gerou uma resposta diferente, mas ainda correta
  • Falsos negativos: Testes que passam quando deveriam falhar, porque o LLM "acertou por sorte"
# Exemplo de teste que falha devido a não determinismo
def testar_saudacao():
    prompt = "Diga 'Olá mundo' em português"
    resposta = llm.generate(prompt, temperature=0.7)

    # Pode falhar se o modelo gerar "Oi mundo" ou "Olá, mundo!"
    assert resposta == "Olá mundo"  # Teste frágil!

2. Estratégias de Seed Fixa e Controle de Parâmetros

2.1. Uso de seed e temperature=0

A abordagem mais direta é fixar a seed e usar temperature=0:

import openai

def gerar_deterministico(prompt, seed=42):
    response = openai.ChatCompletion.create(
        model="gpt-4",
        messages=[{"role": "user", "content": prompt}],
        temperature=0,
        seed=seed  # Disponível em modelos mais recentes
    )
    return response.choices[0].message.content

2.2. Limitações do determinismo

Infelizmente, mesmo com seed fixa, o determinismo não é garantido:

  • Implementações diferentes do mesmo modelo podem usar algoritmos de amostragem distintos
  • Paralelismo em GPUs introduz não determinismo de hardware
  • Atualizações de API podem alterar o comportamento interno

2.3. Boas práticas

Documente todos os parâmetros de inferência como parte do caso de teste:

def testar_resumo():
    parametros = {
        "model": "gpt-4",
        "temperature": 0,
        "seed": 12345,
        "top_p": 1.0,
        "max_tokens": 200
    }
    resposta = llm.generate("Resuma: ...", **parametros)
    # ... asserções

3. Testes Baseados em Similaridade Semântica

3.1. Uso de embeddings

Em vez de comparar strings exatas, compare o significado semântico:

from sentence_transformers import SentenceTransformer
from sklearn.metrics.pairwise import cosine_similarity

modelo_embedding = SentenceTransformer('all-MiniLM-L6-v2')

def similaridade_semantica(texto1, texto2):
    emb1 = modelo_embedding.encode(texto1)
    emb2 = modelo_embedding.encode(texto2)
    return cosine_similarity([emb1], [emb2])[0][0]

3.2. Oráculos de teste com thresholds

def testar_resposta_correta():
    prompt = "Qual a capital do Brasil?"
    resposta_esperada = "A capital do Brasil é Brasília"

    resposta_gerada = llm.generate(prompt)
    similaridade = similaridade_semantica(resposta_esperada, resposta_gerada)

    assert similaridade > 0.85, f"Similaridade baixa: {similaridade}"

3.3. Ferramentas práticas

  • sentence-transformers: Embeddings locais rápidos
  • text-embedding-ada-002: Embeddings da OpenAI (via API)
  • chromadb: Banco vetorial para armazenar e comparar embeddings

4. Testes com Asserções Estruturais e de Formato

4.1. Validação de estrutura JSON

import json

def testar_saida_json():
    prompt = "Retorne um JSON com nome e idade de uma pessoa"
    resposta = llm.generate(prompt)

    try:
        dados = json.loads(resposta)
        assert "nome" in dados
        assert "idade" in dados
        assert isinstance(dados["idade"], (int, float))
    except json.JSONDecodeError:
        assert False, "Resposta não é JSON válido"

4.2. Asserções sobre palavras-chave

def testar_palavras_chave():
    prompt = "Explique o que é machine learning"
    resposta = llm.generate(prompt)

    palavras_obrigatorias = ["dados", "algoritmo", "aprendizado"]
    for palavra in palavras_obrigatorias:
        assert palavra.lower() in resposta.lower(), f"Palavra '{palavra}' não encontrada"

4.3. Uso de expressões regulares

import re

def testar_formato_data():
    prompt = "Qual a data de hoje?"
    resposta = llm.generate(prompt)

    # Extrair data no formato DD/MM/AAAA
    padrao = r'\d{2}/\d{2}/\d{4}'
    datas_encontradas = re.findall(padrao, resposta)

    assert len(datas_encontradas) > 0, "Nenhuma data encontrada"

5. Testes de Robustez com Múltiplas Execuções (Monte Carlo)

5.1. Estratégia de múltiplas execuções

def testar_consistencia(n_execucoes=10):
    prompt = "O que é 2 + 2?"
    respostas = []

    for _ in range(n_execucoes):
        respostas.append(llm.generate(prompt))

    return respostas

5.2. Métricas estatísticas

import numpy as np
from collections import Counter

def analisar_distribuicao(respostas):
    # Encontrar a resposta mais comum (moda)
    contagem = Counter(respostas)
    moda = contagem.most_common(1)[0][0]
    proporcao_moda = contagem.most_common(1)[0][1] / len(respostas)

    return {
        "moda": moda,
        "proporcao_moda": proporcao_moda,
        "variacao": len(contagem)  # Número de respostas únicas
    }

5.3. Thresholds de aceitação

def testar_consistencia_aceitavel():
    respostas = testar_consistencia(20)
    analise = analisar_distribuicao(respostas)

    # Pelo menos 80% das respostas devem ser iguais
    assert analise["proporcao_moda"] >= 0.8, "Respostas muito inconsistentes"

6. Uso de LLMs como Juízes (LLM-as-Judge)

6.1. Avaliação automática de qualidade

def avaliar_com_juiz(resposta_gerada, criterios):
    prompt_juiz = f"""
    Avalie a seguinte resposta com base nestes critérios:
    {criterios}

    Resposta: {resposta_gerada}

    Atribua uma nota de 0 a 10 para cada critério.
    Retorne apenas um JSON com as notas.
    """

    avaliacao = llm_juiz.generate(prompt_juiz, temperature=0)
    return json.loads(avaliacao)

6.2. Rubrics de avaliação

RUBRIC = """
Critérios de avaliação:
1. Factualidade: A resposta está correta factualmente? (0-10)
2. Completude: A resposta cobre todos os aspectos da pergunta? (0-10)
3. Clareza: A resposta é clara e bem estruturada? (0-10)
"""

6.3. Cuidados com LLM-as-Judge

  • Viés do juiz: O LLM avaliador pode ter seus próprios vieses
  • Custo computacional: Cada avaliação consome tokens extras
  • Baseline humano: Sempre tenha um conjunto de validação humana para calibrar

7. Integração com Frameworks de Teste

7.1. Testes parametrizados com pytest

import pytest

@pytest.mark.parametrize("prompt, seed", [
    ("Qual a capital do Brasil?", 42),
    ("Qual a capital da França?", 43),
    ("Qual a capital do Japão?", 44),
])
def testar_capitais(prompt, seed):
    resposta = llm.generate(prompt, seed=seed, temperature=0)
    assert len(resposta) > 10

7.2. Mock de LLM em testes unitários

from unittest.mock import Mock

def testar_sem_llm():
    mock_llm = Mock()
    mock_llm.generate.return_value = "Resposta mockada"

    resultado = processar_com_llm(mock_llm, "prompt")
    assert resultado == "Resposta mockada"

7.3. Logging e rastreabilidade

import logging

logging.basicConfig(level=logging.INFO)
logger = logging.getLogger(__name__)

def testar_com_logging():
    prompt = "Teste"
    seed = 12345

    logger.info(f"Prompt: {prompt}, Seed: {seed}")
    resposta = llm.generate(prompt, seed=seed)
    logger.info(f"Resposta: {resposta}")

    # Armazenar para debugging posterior
    with open("test_log.txt", "a") as f:
        f.write(f"{prompt}||{seed}||{resposta}\n")

8. Estratégias de CI/CD e Monitoramento Contínuo

8.1. Pipeline de testes em staging

# .github/workflows/test-llm.yml
name: Testes LLM
on: [pull_request]
jobs:
  test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v3
      - name: Rodar testes com LLM
        run: |
          pytest tests/llm/ --llm-api-key=${{ secrets.LLM_API_KEY }}

8.2. Monitoramento de drift

def monitorar_drift(versao_antiga, versao_nova, amostras=100):
    similaridades = []

    for prompt in gerar_prompts_teste(amostras):
        resp_antiga = versao_antiga.generate(prompt, temperature=0)
        resp_nova = versao_nova.generate(prompt, temperature=0)

        sim = similaridade_semantica(resp_antiga, resp_nova)
        similaridades.append(sim)

    return np.mean(similaridades)

8.3. Alertas automáticos

def verificar_qualidade():
    similaridade_media = monitorar_drift(modelo_atual, modelo_novo)

    if similaridade_media < 0.7:
        enviar_alerta("Drift detectado! Similaridade média: {similaridade_media}")

Referências