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ápidostext-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
- OpenAI Documentation: Chat Completions API — Documentação oficial da API de chat da OpenAI, incluindo parâmetros de temperatura, seed e como controlar o determinismo
- Sentence-Transformers Documentation — Biblioteca para gerar embeddings de frases e calcular similaridade semântica entre textos
- pytest: Parametrize Test Functions — Guia oficial do pytest sobre como criar testes parametrizados para múltiplos prompts e configurações
- LangSmith: LLM Testing and Evaluation — Plataforma da LangChain para monitoramento, teste e avaliação de aplicações com LLMs
- ChromaDB Documentation — Banco de dados vetorial open-source para armazenar embeddings e realizar buscas por similaridade
- Hugging Face: Evaluation of LLMs — Biblioteca da Hugging Face para avaliação sistemática de modelos de linguagem, incluindo métricas de similaridade
- DeepLearning.AI: Building Systems with ChatGPT — Curso prático sobre construção de sistemas com LLMs, incluindo estratégias de teste e avaliação