O que é Avaliador e Otimizador?
Avaliador e Otimizador (Evaluator-Optimizer) é um padrão de projeto de IA para apps com LLM e agentes, do grupo Composição. Um modelo gera, outro avalia com critério claro, e o ciclo repete até passar.
Evaluator-Optimizer
Avaliador e Otimizador (Evaluator-Optimizer) é um padrão de projeto de IA para apps com LLM e agentes, do grupo Composição. Um modelo gera, outro avalia com critério claro, e o ciclo repete até passar.
Um modelo gera, outro avalia contra critérios claros, e o laço se repete até passar ou até um limite. Serve para tarefas em que existe um critério de qualidade bem definido e uma primeira versão raramente basta.
Um editor que devolve o texto ao autor com a lista do que falta, até o texto estar pronto para ir para a gráfica.
Gustavo cuida de um gerador de testes unitários. O modelo recebe uma função e escreve testes. Uma parte dos testes não compila, outra parte compila mas não testa nada de útil, e alguns testam o comportamento errado.
Pedir “escreva testes bons” no prompt não mudou nada. Acrescentar uma lista de regras ajudou um pouco. O que mais ajudou foi rodar os testes e mostrar o erro ao modelo: com a mensagem do compilador na frente, ele corrigia quase sempre na segunda tentativa.
O mesmo vale para outras tarefas: uma consulta SQL que não retorna o esperado, um texto que fura o limite de caracteres, um post que não segue o guia da marca. A primeira versão é razoável; a segunda, com uma crítica específica, é boa.
A pergunta que este padrão responde: como transformar “tente de novo” num laço controlado, com critério claro e fim garantido?
Separe duas funções:
O laço termina quando a avaliação passa, quando o número máximo de rodadas acaba, ou quando a nota para de melhorar. O avaliador precisa ser mais confiável que o gerador no critério que julga; senão, o laço só gira.
O post “Building effective agents” da Anthropic (dezembro de 2024) descreve este padrão com um gerador e um avaliador em ciclo, e avisa: ele vale quando há critérios claros. Sem isso, o laço vira o smell Autocrítica Inútil.
flowchart TD
IN(["1. Tarefa e critérios"]):::io --> G(["2. Gerador"]):::llm
G --> E{"3. Avaliador"}
E -->|"passou"| OUT(["5. Saída aprovada"]):::io
E -->|"falhou, com problemas"| B{"4. Ainda há rodadas?"}
B -->|sim| G
B -->|não| BEST(["6. Melhor versão com o relatório"]):::io
Gustavo pede testes para apply_discount(price, pct). A primeira versão do gerador importa um módulo que não existe. O avaliador em código é o pytest:
ImportError: cannot import name 'moneyfmt' from 'utils'
Essa mensagem volta para o gerador. A segunda versão compila, os testes passam, e todos os asserts são assert True. O pytest está verde. O critério “compila e passa” é frouxo.
Aí entra o segundo critério, ainda em código: mutação. Trocar pct por 0 no código original deveria quebrar pelo menos um teste. Nenhum quebra. O avaliador devolve “mata 0% dos mutantes”. A terceira versão testa o desconto real.
Repare a ordem. Compilação primeiro (barata, infalível no critério dela). Testes depois. Mutação por último. O modelo com rubrica, se existir, só vê o que já passou nisso. Pedir ao mesmo modelo “os testes estão bons?” na primeira versão teria aprovado o assert True com um parágrafo elogioso.
schema Evaluation { passed: bool, score: float, problems: list[string] }
function generate_with_feedback(task, criteria, max_rounds=3):
best, history = null, []
for round in range(max_rounds):
draft = llm(GENERATOR_PROMPT, task, history) # 2
# 3. checagens em código antes do modelo
code_problems = run_checks(draft) # compila, testes, schema
if code_problems:
ev = Evaluation(passed=false, score=0, problems=code_problems)
else:
ev = llm(EVALUATOR_PROMPT, criteria, draft) -> Evaluation
trace.span("round", round, ev)
if best is null or ev.score > best.score:
best = { draft, score: ev.score, problems: ev.problems }
if ev.passed:
return draft # 5
if stagnated(history, ev):
break # 4
history.append({ draft, problems: ev.problems })
return best # 6
A rodada 2 tira o ImportError. A rodada 3, tentando matar mutantes, quebra a compilação de novo. Se você devolve a última, o usuário recebe código que nem importa. A melhor versão era a 2, com o relatório do que ainda falha.
O teto também precisa de um motivo de parada visível: passou, estagnou (mesmo erro duas vezes), ou esgotou rodadas. Sem isso, o trace não diz por que a tarefa saiu.
Na segunda rodada, o gerador não precisa do rascunho 1 e do rascunho 2 e da rubrica inteira de novo. Precisa do rascunho atual e da lista curta de problemas. Histórico longo empurra o modelo para repetir defesas (“eu já tinha testado isso”) em vez de corrigir.
Se o avaliador é código (pytest, compilador, schema), a concordância é trivial: ele não “opina”. Se é modelo, monte um dataset em que uma pessoa já marcou passou/falhou e meça quanto o juiz acerta. Um avaliador que discorda da pessoa em 30% dos casos vai treinar o gerador na direção errada.
O híbrido é o padrão que menos se arrepende. Código primeiro: se não compilou, o modelo-juiz nem roda. Isso corta custo e impede que um parágrafo eloquente aprove um teste que não importa.
Várias candidatas por rodada fazem sentido quando a primeira versão varia muito (dois tons de post, duas formas de SQL) e o avaliador em código consegue ranquear barato. Gerar 5 e julgar com rubrica cara 5 vezes costuma perder para 2 rodadas híbridas.
O avaliador só em código não cobre tom de marca. Aí o híbrido ganha de novo: pytest (ou o limite de caracteres) primeiro, rubrica depois, nos que passaram. Inverter a ordem é o desperdício clássico: o juiz de estilo aprova um teste que nem importa, e só então o compilador fala não.
Guarde também por que o laço parou. “Passou”, “estagnou” e “teto” são três desfechos diferentes no painel. Sem o motivo, um pico de custo parece “o gerador piorou”, quando na verdade o avaliador ficou implicante.
| Aspecto | O que acontece | Como aparece no trace |
|---|---|---|
| Chamadas | Até 2 por rodada (gerador e avaliador), vezes o número de rodadas | spans round por tarefa |
| Custo | Proporcional às rodadas; a maioria das tarefas deve passar em 1 ou 2 | média de rodadas por tarefa |
| Latência | Soma das rodadas | duração total muito acima de uma chamada |
| Falha: avaliador frouxo | Aprova saídas ruins | casos aprovados que falham no eval |
| Falha: avaliador implicante | Sempre acha algo e nunca aprova | tarefas que sempre batem no teto |
| Falha: oscilação | O gerador corrige um problema e cria outro | nota sobe e desce entre rodadas |
Falso positivo, aqui, é o avaliador reprovar um texto que a pessoa aceitaria. Falso negativo é aprovar o que a pessoa reprovaria. Em conteúdo, falso negativo é o perigo: o laço entrega um post que fura a marca e ninguém vê. Em testes, falso positivo custa rodadas; falso negativo entrega assert True.
Meça as duas taxas num conjunto pequeno rotulado à mão antes de ligar o laço em produção. Se o juiz com modelo erra 1 em 4, ele não está mais confiável que o gerador.
{ "function": "fixtures/apply_discount.py", "criteria": ["compila", "todos os testes passam", "mata pelo menos 80% dos mutantes"], "max_rounds": 3 }
O laço reenvia a saída anterior ao gerador. Se a tarefa inclui texto de fora (um PDF, um e-mail), esse texto pode tentar instruir o gerador na segunda rodada com mais força. Trate o rascunho como dado, não como ordem, e não deixe o gerador chamar ferramentas de efeito no meio do laço. Efeito (merge, e-mail, pagamento) fica depois da aprovação, com Validar Antes de Executar.
Um avaliador frouxo que aprova SQL gerado a partir de pergunta de usuário é o mesmo risco de sempre: saída do modelo indo para um motor. O avaliador precisa checar a consulta no banco de teste, não só “parecer SQL”.
| Prós | Contras |
|---|---|
| Qualidade sobe com critério explícito | Custo e latência multiplicados pelas rodadas |
| Problemas específicos guiam a correção | O avaliador também precisa ser avaliado |
| Checagens em código dão garantias reais | Pode oscilar sem convergir |
| A melhor versão sempre fica disponível | Critério vago transforma o laço em desperdício |
As abas geram, avaliam com código e repetem com os erros até passar ou até o teto. Python e TypeScript escrevem as rodadas num laço. LangGraph separa gerador e avaliador em nós: voltar ou parar é aresta, não pedido do modelo.
Python. Use quando o critério principal é verificável por código, como compilar e rodar testes.
import subprocess
from pathlib import Path
import anthropic
client = anthropic.Anthropic()
MAX_ROUNDS = 3
def evaluate(test_code: str, workdir: Path) -> tuple[bool, str]: # 3. avaliador em código
path = workdir / "test_generated.py"
path.write_text(test_code, encoding="utf-8")
out = subprocess.run(["pytest", str(path), "-q", "-x"], capture_output=True, text=True, timeout=120)
return out.returncode == 0, (out.stdout + out.stderr)[-4000:]
def generate_tests(source: str, workdir: Path) -> dict:
feedback, best = "", None
for round_ in range(MAX_ROUNDS):
prompt = f"Escreva testes pytest para esta função.\n\n{source}"
if feedback:
prompt += f"\n\nSua versão anterior falhou. Saída do pytest:\n{feedback}\nCorrija."
reply = client.messages.create( # 2. gerador
model="claude-sonnet-5-5", max_tokens=8000,
system="Responda só com código Python, sem markdown.",
messages=[{"role": "user", "content": prompt}],
)
code = reply.content[-1].text
passed, output = evaluate(code, workdir)
best = {"code": code, "passed": passed, "report": output, "rounds": round_ + 1}
if passed:
return best # 5
if feedback and output == feedback:
break # 4. estagnou: mesmo erro duas vezes
feedback = output
return best # 6. melhor versão com o relatório do que falhaO que você ganhou: o avaliador é o próprio pytest, que não erra sobre se o teste passa. O que você perdeu: “passar” não garante testes úteis; acrescente teste de mutação como segundo critério.
TypeScript. Use quando o critério precisa de uma rubrica além das checagens em código.
import Anthropic from "@anthropic-ai/sdk";
import { zodOutputFormat } from "@anthropic-ai/sdk/helpers/zod";
import { z } from "zod";
const client = new Anthropic();
const Evaluation = z.object({
passed: z.boolean(),
score: z.number().min(0).max(10),
problems: z.array(z.string()),
});
const text = (m: Anthropic.Message) => m.content.find((b) => b.type === "text")?.text ?? "";
export async function writePost(brief: string, rubric: string, maxRounds = 3) {
let best = { draft: "", score: -1, problems: [] as string[] };
let feedback: string[] = [];
for (let round = 0; round < maxRounds; round++) {
const draft = text(await client.messages.create({ // 2
model: "claude-sonnet-5-5", max_tokens: 4000,
messages: [{ role: "user", content: `Escreva o post.\n${brief}\n${feedback.length ? "Corrija:\n- " + feedback.join("\n- ") : ""}` }],
}));
const codeProblems = draft.length > 2200 ? ["Passou de 2.200 caracteres."] : []; // 3. código primeiro
const { parsed_output: ev } = codeProblems.length
? { parsed_output: { passed: false, score: 0, problems: codeProblems } }
: await client.messages.parse({
model: "claude-opus-5-5", max_tokens: 2000,
system: `Avalie o texto contra esta rubrica e liste problemas específicos.\n${rubric}`,
messages: [{ role: "user", content: draft }],
output_config: { format: zodOutputFormat(Evaluation) },
});
if (!ev) break;
if (ev.score > best.score) best = { draft, score: ev.score, problems: ev.problems };
if (ev.passed) return { ...best, passed: true }; // 5
feedback = ev.problems;
}
return { ...best, passed: false }; // 6
}O que você ganhou: checagens baratas em código antes da rubrica, e a melhor versão guardada. O que você perdeu: o avaliador com modelo pode errar; meça a concordância dele com uma pessoa.
Python + LangGraph. Use quando gerador e avaliador são nós, e voltar ou parar é uma aresta.
from typing import Literal
from langchain.chat_models import init_chat_model
from langgraph.graph import END, START, StateGraph
from typing_extensions import TypedDict
class State(TypedDict):
task: str
code: str
errors: str
best: str
rounds: int
writer = init_chat_model("anthropic:claude-opus-5-5", temperature=0.2)
def generate(state: State) -> dict:
# 2. gerador, com a avaliação anterior a partir da segunda rodada
reply = writer.invoke([("human", f"{state['task']}\n\nErros:\n{state['errors']}\n\nVersão:\n{state['code']}")])
return {"code": reply.text, "rounds": state["rounds"] + 1}
def evaluate(state: State) -> dict:
ok, log = pytest_run(state["code"]) # 3. código primeiro
return {"errors": "" if ok else log, "best": state["code"] if ok or not state["best"] else state["best"]}
def route(state: State) -> Literal["generate", "done"]:
# 4. teto e estagnação no código
if not state["errors"] or state["rounds"] >= 4:
return "done"
return "generate"
graph = StateGraph(State)
graph.add_node("generate", generate)
graph.add_node("evaluate", evaluate)
graph.add_edge(START, "generate")
graph.add_edge("generate", "evaluate")
graph.add_conditional_edges("evaluate", route, {"generate": "generate", "done": END})
app = graph.compile()pytest_run é sua. As outras formas de escrever o loop do gerador, se um dia ele virar agente, estão em Loop ReAct.
O que você ganhou: o modelo não escolhe se houve rodada seguinte. O que você perdeu: a rubrica com segundo modelo, se você precisar dela, é outro nó depois do pytest.
| Stack | Quem decide a próxima rodada |
|---|---|
| Python, TypeScript | O for |
| Python + LangGraph | A aresta depois do pytest |
Avaliador e Otimizador (Evaluator-Optimizer) é um padrão de projeto de IA para apps com LLM e agentes, do grupo Composição. Um modelo gera, outro avalia com critério claro, e o ciclo repete até passar.
Nos cenários reais Pipeline de criação de conteúdo, Gerador de testes e Perguntas em linguagem natural para SQL.