Loops, harness e grafos

Matheus Cardoso

Você já usou um agente: Claude Code, Cursor, Copilot em modo agent. Ele lê o seu pedido, faz uma coisa, olha o resultado e decide a próxima. Às vezes parece mágica. Às vezes ele tenta a mesma correção errada quatro vezes seguidas. Os dois comportamentos saem da mesma estrutura, e essa estrutura dá pra entender inteira sem uma linha de matemática.


O loop: uma coisa por vez

Todo agente é um while. Ele monta um contexto (o seu pedido, o que já aconteceu, a lista de ferramentas disponíveis), manda pro modelo, recebe de volta uma ação, executa essa ação, guarda o resultado no histórico e volta pro topo. Quando o modelo responde “terminei” em vez de pedir outra ação, o loop para.

const history = [userRequest]

while (true) {
  const answer = await model.generate({ history, tools })

  if (answer.done) return answer.text

  const result = await runTool(answer.tool, answer.input)
  history.push(answer, result)
}

É isso. Todo agente de código que você já usou é uma variação de vinte linhas disso, e é justamente por ser tão pouco que ele funciona tão bem em tanta coisa.

Agora repare em duas propriedades desse código, porque tudo neste texto sai delas. A primeira: em cada volta existe exatamente uma coisa pra fazer. Não duas. O modelo devolve uma ação, você executa, volta pro topo. A segunda: quem escolhe essa ação é o modelo. Não existe nenhuma função no seu código dizendo “agora é a vez do passo 3”. A decisão vem de dentro de uma inferência que você não consegue inspecionar nem repetir igual.

Dessas duas propriedades saem os três problemas que você já viu na prática, mesmo sem ter nome pra eles:

A ordem só existe na conversa
Você pediu “edita o arquivo, depois roda os testes”. Nada no sistema impede rodar os testes primeiro. A dependência é uma frase no contexto, não uma regra no executor. Com contexto curto ele acerta; com contexto longo, esquece.
Ninguém definiu quantas tentativas
O teste falhou. Tenta de novo? Tenta outra abordagem? Refaz o plano? O modelo decide na hora, e não existe um número máximo escrito em lugar nenhum. É daí que vem o agente que insiste na mesma correção errada até você matar o processo.
O plano é sobrescrito
Ele planejou A, no meio do caminho mudou pra B, e agora o plano A é uma mensagem antiga enterrada no histórico. Semanas depois, quando você quer saber qual plano gerou aquele commit estranho, não tem como responder.

Harness: tudo que não é o modelo

Harness, em inglês, é o cinto de segurança de quem faz algo arriscado. O nome cai bem. O modelo é o motor. O harness é o resto do carro: o loop, quais ferramentas você entrega pra ele, o que entra no contexto, quantas tentativas ele tem, quando desistir.

A distinção importa por um motivo prático. Você não controla o modelo: ele é um serviço de terceiro que você chama por HTTP. O harness é 100% seu código. Quando um agente seu se comporta mal, a chance de o problema estar no harness é muito maior que a de estar no modelo. E quatro linhas de harness resolvem a maior parte da seção anterior:

const MAX_STEPS = 40
const MAX_ATTEMPTS = 3
const HISTORY_TURNS = 30

const history = [userRequest]

for (let step = 0; step < MAX_STEPS; step++) {
  const answer = await model.generate({
    history: history.slice(-HISTORY_TURNS),
    tools,
    maxTokens: 4096,
  })

  if (answer.done) return answer.text

  const result = await withAttempts(
    () => runTool(answer.tool, answer.input),
    MAX_ATTEMPTS,
  )

  history.push(answer, result)
}

throw new StepLimitReached(MAX_STEPS)

Nada aí é sofisticado, e é esse o ponto. MAX_STEPS transforma “loop infinito” em “loop que termina”. Um teto de tentativas por passo transforma “insiste pra sempre” em “insiste três vezes”. O slice(-HISTORY_TURNS) transforma “a conversa cresce até estourar a janela de contexto” em “a conversa tem tamanho máximo”. E maxTokens transforma “a fatura é uma surpresa no fim do mês” em “a fatura tem teto”.

Se você levar uma única coisa deste texto, leve esta: quase todo agente amador não tem essas quatro linhas, e quase todo agente de produção tem. Antes de trocar de arquitetura, aperte o harness.


O grafo: escrever o plano antes de começar

O loop decide o próximo passo no meio do caminho. A alternativa é decidir todos os passos antes de começar, escrever essa decisão num formato que o código consegue ler, e depois só executar o que está escrito.

Esse formato é um grafo. Se a palavra assusta, troca por uma que você já usa toda semana: é um pipeline de CI. No GitHub Actions você escreve jobs e coloca needs: build em um deles. Pronto, é um grafo: caixas, e setas dizendo “esta só começa depois daquela”. Um Makefile é a mesma ideia. As dependências do seu package.json também.

O nome técnico é DAG, e vale traduzir os três pedaços porque cada um carrega uma garantia. Grafo: caixas ligadas por setas. Dirigido: as setas têm ponta, então A → B é diferente de B → A. Acíclico: nenhuma seta volta pra trás. Esse último não é detalhe de vocabulário: é a garantia estrutural de que a execução termina. Sem ciclo, não existe caminho que se repita pra sempre.

Na prática, o plano é um array:

const plan = [
  { id: "search_auth",  needs: [] },
  { id: "search_utils", needs: [] },
  { id: "read_auth",    needs: ["search_auth"] },
  { id: "read_utils",   needs: ["search_utils"] },
  { id: "analyze",      needs: ["read_auth", "read_utils"] },
  { id: "fix_a",        needs: ["analyze"] },
  { id: "fix_b",        needs: ["analyze"] },
  { id: "update_docs",  needs: ["analyze"] },
  { id: "run_tests",    needs: ["fix_a", "fix_b"], waitFor: "any" },
  { id: "report",       needs: ["run_tests", "update_docs"] },
]

Repare no que mudou. needs não é uma frase pedindo bom comportamento: é dado. O executor lê needs e simplesmente não despacha analyze antes de read_auth e read_utils terminarem. Não tem como “esquecer”: a dependência deixou de ser memória do modelo e virou uma condição num if.

E aparece um ganho que o loop não consegue ter de jeito nenhum. A cada rodada, o executor pega todos os passos cujas dependências já terminaram, não apenas um. Se dois passos não têm seta entre eles, eles rodam juntos:

function readySteps(plan, settled) {
  return plan.filter((step) => {
    if (settled.has(step.id)) return false

    return step.waitFor === "any"
      ? step.needs.some((id) => settled.has(id))
      : step.needs.every((id) => settled.has(id))
  })
}

while (settled.size < plan.length) {
  const batch = readySteps(plan, settled)
  if (batch.length === 0) throw new NothingReady()

  await Promise.all(batch.map(run))
}

São quatro linhas de filter e um Promise.all. A mesma tarefa que o loop faz em onze voltas em série sai em seis rodadas:

  1. search_authsearch_utils
  2. read_authread_utils
  3. analyze
  4. fix_afix_bupdate_docs
  5. run_tests
  6. report
Seis rodadas em vez de onze voltas. As duas buscas não têm dependência entre si, então rodam juntas. E isso não é o modelo tendo um bom dia, é o que o array diz.

Esperar todos, ou esperar um

Quando um passo depende de dois outros, “depende” pode significar duas coisas bem diferentes. Confundir as duas é um bug caro, e o loop não tem como expressar a segunda.

Esperar todos. report precisa dos testes e da documentação. Só começa quando os dois terminarem. É o caso comum, e é o padrão.

Esperar um. fix_a e fix_b são duas correções alternativas pro mesmo bug. run_tests precisa de uma delas. Se fix_b funcionar, fix_a deixou de importar, e a coisa certa é marcá-lo como dispensado, não ficar tentando de novo até gastar o orçamento de retentativas em um caminho que ninguém mais vai usar.

No loop, a segunda situação não tem como ser escrita. O modelo tenta A, falha, decide tentar B, e a desistência de A é uma frase no histórico. No grafo é um campo: waitFor: "any". É a diferença entre combinar uma coisa e esperar que alguém lembre dela.


Quando falha: uma escada de três degraus

O sintoma mais irritante de agente é o que gira em falso: falhou, replanejou, falhou, replanejou, e quarenta mil tokens depois está exatamente onde começou. Isso acontece porque “o que fazer quando falha” foi delegado ao modelo, que tem um viés forte para tentar outra coisa em vez de tentar de novo.

A correção é tirar essa decisão dele e transformar em uma escada com três degraus fixos:

  1. Tenta de novoMesmo passo, mesma configuração. Serve pro que é transitório: rede caiu, rate limit, timeout. Barato.
  2. Ajusta o passoMesmo passo, configuração diferente: outro prompt, outro modelo, outra ferramenta. A estrutura do plano continua intacta.
  3. Refaz o planoGera um plano novo do zero. Caro, lento, e é o único degrau que consegue consertar um plano que estava errado desde o começo.

E a regra que faz a escada funcionar: não pode pular degrau. O degrau 3 só depois de esgotar 1 e 2. Isso é meia dúzia de linhas: um contador por passo, que só sobe de um em um:

const LADDER = ["retry", "patch", "replan"]

function nextAction(stepId) {
  const rung = attempts.get(stepId) ?? 0

  if (rung >= LADDER.length) throw new GaveUp(stepId)

  attempts.set(stepId, rung + 1)
  return LADDER[rung]
}

Não é elegante e não precisa ser. O ponto é que agora existe um lugar no código onde está escrito quantas vezes o agente pode tentar antes de escalar, e esse lugar não é um prompt.


O plano não muda no meio

Um plano que pode ser editado durante a execução parece flexibilidade e é, na prática, um problema de depuração. Se o agente mudou o plano na metade e algo deu errado depois, você não sabe se a culpa foi do plano original, da mudança, ou da combinação dos dois, porque nenhum dos dois existe mais em forma inteira.

A convenção que resolve isso você já usa todo dia: commit. O plano tem uma versão. Durante a execução, ninguém edita. Se precisa mudar, gera a versão 2 e registra que a 1 foi abandonada e por quê. Cada linha do log de execução diz qual versão governava naquele momento.

O custo é real: você perde a capacidade de ajustar o plano com o que acabou de descobrir sem pagar o preço de um replanejamento inteiro. O ganho é conseguir responder “qual plano produziu isso?” semanas depois. Em tarefa exploratória, o custo é maior que o ganho. Em tarefa que mexe em produção, é o contrário.


Onde o grafo é pior

Grafo não é o upgrade do loop. É outra escolha, com outras contas. Quatro situações em que ele perde, e vale conhecer as quatro antes de reescrever nada:

O plano depende de quem escreve o plano

O paralelismo só existe se alguém desenhou as setas certas. Se o planejador escrever uma linha reta (1 → 2 → 3 → 4), o grafo executa uma coisa por vez, igualzinho ao loop, só com muito mais código no caminho. E quem normalmente escreve o plano é um LLM, que erra. Todo o ganho de velocidade estava na estrutura, e a estrutura não é garantida.

Erro em paralelo custa mais

No loop, se o modelo erra na volta 4, muitas vezes ele percebe na volta 5 e corrige: desperdiçou um passo. No grafo, se o plano tem uma seta faltando, três ramos rodam ao mesmo tempo em cima da premissa errada. Você paralelizou o desperdício.

Tarefa exploratória não cabe num desenho

“Investiga o outage e conserta o que estiver quebrado.” Você não consegue listar os passos antes, porque o passo 3 depende do que o passo 2 encontrar. Grafo estático não expressa isso. Loop expressa naturalmente: é literalmente o que ele faz.

O código é uma ordem de magnitude maior

Um loop honesto com harness apertado cabe em algumas centenas de linhas. Um executor de grafo de verdade precisa validar o plano (tem ciclo? tem passo que nunca roda?), agendar em paralelo respeitando rate limit, persistir estado para auditoria, implementar a escada de recuperação e validar a saída de cada passo. São alguns milhares de linhas, e cada uma delas é sua para manter.


Como escolher

Ordem prática, do mais barato para o mais caro:

  1. Loop com harness apertadoA resposta certa na maioria dos casos. Teto de passos, teto de tentativas por passo, janela no histórico, teto de tokens. Uma tarde de trabalho, e resolve a maior parte dos sintomas que fazem as pessoas quererem trocar de arquitetura.
  2. Loop com plano no promptO modelo escreve os passos antes e você mantém isso visível no contexto. Ajuda ele a não se perder em tarefa longa, mas continua uma coisa por vez: plano no prompt é sugestão, não regra, e não paraleliza nada.
  3. Grafo de verdadeQuando as três coisas forem verdade ao mesmo tempo: você conhece as dependências antes de começar, existe paralelismo real para ganhar, e alguém vai precisar auditar o que aconteceu depois. Se só duas forem verdade, provavelmente não vale o custo.

O ponto do meio é a armadilha mais comum. “Meu agente planeja antes de agir” soa como grafo e não é: se a execução continua pedindo uma ação por vez para o modelo, você melhorou a qualidade das decisões e não mudou nada na estrutura. Paralelismo e ordem garantida só aparecem quando o plano sai do prompt e vira dado que o executor lê.


O resumo

Loop é uma coisa por vez, escolhida pelo modelo. Grafo é várias coisas por vez, escolhidas pela estrutura. Harness é o seu código em volta dos dois, e é onde vive a maior parte da qualidade de um agente, independente de qual dos dois você escolher.

Se o seu agente está gastando demais ou girando em falso, o problema é harness, e a correção é de hoje. Se ele está lento porque faz em série coisas que não dependem uma da outra, aí sim vale olhar grafo.

Qualquer dúvida, me chama no X.