Agent Mentor Learn
De loops a grafos: engenharia de orquestração para sistemas de agentes · Lição 6 de 6

Lição 6: Mão na massa: elevando seu harness a um pequeno grafo

Objetivos de aprendizado:

  • Soldar roteamento, fan-out, fusão, loop de revisão e relatório das cinco primeiras lições em um único orchestrate.mjs: o plano vive no código, cada nó ainda roda o loop de stop_reason do Curso 7 (Fundamentos do Harness de Agentes: Laços e Controle), e os resultados intermediários permanecem em variáveis do script
  • Colocar o loop de revisão realmente para girar, e observar as duas maneiras pelas quais ele pode parar — um chamado corrigido conforme o relatório do gate e pronto, outro devolvendo relatórios idênticos duas rodadas seguidas, julgado sem progresso adicional e marcado needs_human
  • Persistir o rastro de execução do grafo inteiro em run-state.json e run.jsonl, e depois conciliá-lo com a tabela-resumo da execução real: qual nó gastou quanto tempo, quantas chamadas de modelo, quantos tokens, quantas rodadas de gate

Pré-requisitos: Lições 1–5 concluídas, capaz de rodar o loop de harness do Curso 7 (Fundamentos do Harness de Agentes: Laços e Controle) | Anterior: << Lição 5

Primeiro, veja rodando

As cinco primeiras lições separaram as peças: quem guarda o plano (Lição 1), encadeamento e roteamento (Lição 2), seccionamento e votação mais um pool de concorrência limitado (Lição 3), orquestrador-workers e os quatro elementos dos prompts de delegação (Lição 4), o loop de revisão e como compor esses padrões no que a Lição 5 chama de “grafo” (Lição 5). Esta lição solda tudo em um arquivo.

A tarefa é deliberadamente banal: inbox/ contém seis chamados de suporte a clientes, e o trabalho é escrever para cada um uma resposta que possa ser enviada como está. Primeiro, como fica quando termina:

text
\$ node orchestrate.mjsinbox/ recebeu 6 chamados: T-1001, T-1002, T-1003, T-1004, T-1005, T-1006[route] T-1001=billing  T-1002=bug  T-1003=other  T-1004=billing  T-1005=bug  T-1006=other[fanout] teto de concorrência 2, 6 rascunhos produzidos[merge] gravou 6 arquivos em out/, repassando adiante só referências e resumos de uma linha[review] reescritas do gate: 2 rodadas no total
=== Resumo da execução do grafo ===Nó       Tempo   Chamadas    Tokens   Rodadas     Statusroute     62ms    1           720      -           okfanout    244ms   8           8903     -           okmerge     2ms     0           0        -           okreview    129ms   2           3033     2           ok
=== Detalhamento por chamado ===Chamado  Categoria Tratador          Rodadas     Motivo parada   StatusT-1001   billing   worker:billing    0           gate_pass       passT-1002   bug       worker:bug        0           gate_pass       passT-1003   other     template          0           gate_pass       passT-1004   billing   worker:billing    1           no_progress     needs_humanT-1005   bug       worker:bug        1           gate_pass       passT-1006   other     template          0           gate_pass       pass
Diretório de saída out/: 6 respostas; precisam de repasse humano: 1 chamado  - T-1004 (no_progress): Chamado T-1004: altera…Rastro: run-state.json / run.jsonl (run_id=run-mtem9rmb)\$ echo \$?1

Toda saída de terminal desta lição vem de execuções reais deste script, copiadas linha a linha — nenhuma linha é um exemplo digitado à mão. Duas coisas mudam a cada execução: os tempos em milissegundos e o run_id (é um timestamp em base 36). Todo o resto — resultados de classificação, contagens de chamadas, números de tokens, contagens de rodadas de gate, qual chamado é needs_human — é constante fixada. O motivo é explicado adiante, na seção “Montagem da verificação”.

Vale encarar primeiro aquele 1 final. Isso não é um erro — é um veredito: seis chamados, um não conseguiu terminar automaticamente, então o código de saída não é 0. Cada execução deste grafo produz uma conclusão que a CI ou um cron job consegue parsear, não apenas uma pilha de logs.

Como é o grafo: o plano é aquela dúzia de linhas em main()

Comece pelo esqueleto do script. Usar “grafo” e “nó” é o vocabulário que a Lição 5 introduziu — este é o nosso próprio sistema visual, não um conceito oficial, e ele se apoia em exatamente uma âncora primária: o próprio script do fluxo de trabalho guarda o loop, as ramificações e os resultados intermediários1. O trecho abaixo é a implementação literal dessa afirmação:

A Lição 5 desenhou um grafo composto primeiro; este grafo é uma variante dele, com três diferenças: a Lição 5 dividia por dificuldade em “simples / complexo”, aqui dividimos por assunto em billing / bug / other; o fan-out da Lição 5 era “um chamado complexo despachado a três workers e depois fundido”, aqui é seccionamento — “seis chamados, cada um atribuído a um tratador”; a aresta de retorno da Lição 5 voltava a um nó [rascunho] separado, aqui ela volta ao worker original. Por que essas mudanças, está tudo reunido na seção “Tabela de conciliação” no fim.

routed, drafts, items — essas três declarações const são todo o estado do grafo. São variáveis JavaScript comuns, não objetos de estado tipados, e não há estratégia de fusão — os resultados intermediários permanecem em variáveis do script1, e os nós passam dados por valores de retorno de função. Nenhum modelo vê o quadro completo: o modelo de roteamento vê apenas os textos dos seis chamados, o worker de billing vê apenas o chamado que lhe foi atribuído, o gate de revisão vê apenas um arquivo de resposta.

É assim que a distinção arquitetural entre fluxo de trabalho e agente se parece no código: LLMs e ferramentas são orquestrados através de caminhos de código predefinidos2, não o modelo dirigindo autonomamente os próprios processos2.

Cinco nós, cada um cuidando de um trecho:

O que fazQuem faz
routeUma chamada barata divide seis chamados em três categoriasUm loop de modelo
fanoutDespacha por categoria a workers especializados, com concorrência limitadaDois tipos de loop de modelo + um template de código puro
mergeA saída vai para o disco, adiante só referências e resumos de uma linhaCódigo puro
reviewO gate determinístico filtra primeiro, e as falhas entram em verificar-corrigir-reverificarCódigo puro + loops de modelo sob demanda
reportImprime as tabelas-resumo, determina o código de saídaCódigo puro

Apenas dois dos cinco nós de fato chamam modelos. Nem todo nó precisa ser um modelo — esta é a regra mais barata e mais facilmente ignorada da lição: merge e report são funções puras, a categoria other em fanout usa um template de string, e o primeiro filtro de review são algumas linhas de includes. Onde código determinístico consegue dar a mesma resposta, não há motivo para pagar o custo e a latência de uma chamada de modelo.

Dentro dos nós: ainda o loop do Curso 7

Fixe primeiro a camada mais interna e o grafo passa a fazer sentido. Cada nó de modelo roda internamente o loop de stop_reason do Curso 7 (Fundamentos do Harness de Agentes: Laços e Controle), sem alteração:

Os quatro passos no corpo do loop — empilhar assistant, executar ferramentas, empilhar tool_result, reatribuir response — são palavra por palavra idênticos aos da Lição 6 do Curso 7, até os comentários foram copiados. A Válvula 1 (máximo de turnos) está na posição original: no início do corpo do loop, antes do turns++. Deixar uma contagem máxima de iterações como condição de parada de loops é prática padrão para manter o controle2.

Comparado ao Curso 7, duas mudanças, ambas fora do corpo do loop: client e system deixaram de ser constantes de módulo e viraram parâmetros (três papéis precisam de stubs diferentes e prompts de sistema diferentes, então precisam ser passados); a contagem de tokens e chamadas saiu de dentro do corpo do loop para uma camada envolvente fora do cliente, e o interior do loop não mudou:

Essa mudança tem um custo e ele precisa ser declarado: a Válvula 2 do Curso 7 (orçamento de tokens) originalmente dependia do valor acumulado dentro do corpo do loop; esse acumulador não está mais no loop, então a Válvula 2 também não fez a mudança. Neste grafo, a fila de respostas do stub de cada nó tem comprimento fixo, e esgotar a fila lança erro diretamente, sem possibilidade de descontrole; mas, quando você trocar os stubs por um cliente real, recoloque a Válvula 2 — ou faça metered lançar quando estourar o orçamento, ou mova a contagem de volta para o corpo do loop e restaure a forma original do Curso 7. A Válvula 3 (detecção de giro em falso) e a Válvula 4 (aprovação humana) igualmente não se mudaram; o motivo está listado adiante, na seção “Tabela de conciliação”.

A metade das ferramentas também é cópia: a resposta de um turno contém múltiplos blocos tool_use, devolvem-se outros tantos blocos tool_result, uma ferramenta lança e isso é embrulhado em is_error: true e devolvido ao modelo, em vez de derrubar o processo inteiro.

Nó um: Roteamento — uma chamada barata e depois apertar a saída

O roteamento classifica uma entrada e a direciona para tarefas de acompanhamento especializadas2. É a chamada de modelo mais barata do grafo: uma requisição classifica os seis, sem ferramentas, sem escrever resposta.

A chave são as dez linhas do meio, não a chamada de modelo. O modelo devolve texto livre, cada ramo a jusante depende desse valor, então ele precisa ser apertado em um de três rótulos legais antes de entrar a jusante: linhas que não batem com o formato são descartadas; categorias fora da lista branca caem para other; chamados sem nem uma linha correspondente são capturados por parsed.get(t.id) ?? "other".

Fiz o stub devolver deliberadamente “reclamação” para o último chamado — que não está na lista branca. O log da execução real mostra esse aperto:

text
{"ts":"2026-08-29T16:48:31.297Z","run_id":"run-mtem76rl","node":"route","event":"clamped","ticket":"T-1006","raw":"reclamação","category":"other"}

O modelo deu um rótulo que ele mesmo inventou, o código o grampeou de volta para other e deixou registro dizendo o que foi grampeado. Ramos a jusante só reconhecem valores que o código examinou — esta é a diferença prática entre um nó de roteamento e “deixar o modelo decidir diretamente para onde saltar em seguida”, e é por isso que roteamento pode ser testado por unidade.

Nó dois: Fan-out — três workers e um pool de concorrência limitado

O fan-out segue o seccionamento: quebrar a tarefa em subtarefas mutuamente independentes e rodá-las em paralelo2. Aqui “independentes” é natural — os seis chamados têm zero dependências entre si, e a ordem não importa.

Três categorias, três tratadores, apenas dois são modelos:

O pool de concorrência é o pool da Lição 3 (chamado pool lá, runPool aqui): as tarefas ficam atrás de um cursor, criam-se limit consumidores para pegá-las, e termina quando se esgotam. O teto funciona de verdade, não é decorativo. Ajuste-o para 1 e rode de novo: o tempo da linha fanout se alonga perceptivelmente (contagem de chamadas e tokens idênticos, milissegundos oscilam como sempre):

text
\$ POOL_SIZE=1 node orchestrate.mjs...=== Resumo da execução do grafo ===Nó       Tempo   Chamadas    Tokens   Rodadas     Statusroute     61ms    1           720      -           okfanout    490ms   8           8903     -           okmerge     3ms     0           0        -           okreview    128ms   2           3033     2           ok

490ms contra 244ms, contagem de chamadas e tokens idênticos. Concorrência compra tempo de relógio, não menos trabalho — isso continua verdadeiro depois de trocar por uma API real, exceto que aí você também precisa considerar os limites de taxa do provedor, o que torna o teto ainda mais essencial.

Prompts de delegação: os quatro elementos presentes

Os prompts dos três papéis de modelo seguem os quatro elementos da Lição 4: objetivo, formato de saída, orientação de ferramentas, limites da tarefa. Subagentes precisam de um objetivo, um formato de saída, orientação sobre ferramentas e fontes e limites claros da tarefa; sem descrição adequada, os workers duplicam trabalho, deixam lacunas ou não encontram o que deveriam3. O worker de billing:

Quatro linhas, cada uma fazendo seu serviço: o objetivo determina o que ele escreve; o formato de saída dá ao gate a jusante algo para verificar (a exigência de “começar com o id do chamado” mapeia diretamente para a primeira regra do gate); a orientação de ferramentas prega “de onde vêm os valores” em lookup_order, bloqueando o caminho de inventar números a partir da descrição do chamado; os limites da tarefa tanto bloqueiam ações fora de escopo quanto proíbem preventivamente palavras de enrolação.

A versão do worker de bug troca o conteúdo por consultar a base de problemas conhecidos, citar números de problema e proibir números inventados; a “orientação de ferramentas” do roteador diz “este passo não lhe dá ferramentas, julgue apenas pelo texto do chamado”, casando com o array de ferramentas vazio passado no código. As diferenças entre esses três prompts são, elas próprias, o retorno do roteamento: depois da classificação, cada um escreve o seu, sem precisar enfiar as exigências de três tipos de trabalho em um único prompt — isso é precisamente a separação de responsabilidades e os prompts mais especializados que o roteamento permite2.

Nó três: Fusão — repassar referências, não cargas

merge é código puro, zero chamadas de modelo. Faz duas coisas: gravar cada rascunho em out/ e depois coletar um manifesto leve para a jusante — {id, category, handler, file, oneLine}, um caminho de arquivo mais um resumo de uma linha, não seis respostas completas. (Simultaneamente também cria um registro para cada chamado em run-state.json, com os campos mostrados na Seção 9 do código completo.)

Isso traz para um script de processo único o conselho de engenharia de sistemas multiagente: fazer agentes especializados armazenarem saídas em sistemas externos e repassarem apenas referências leves de volta ao coordenador3. Naquela retrospectiva, esse conselho resolvia o inchaço de contexto de “tudo retransmitido via agente líder”; aqui ele resolve a versão em pequena escala da mesma coisa — o nó de revisão precisa de “qual arquivo deve ser verificado”, não dos seis textos completos empilhados numa variável passada adiante.

Por isso a primeira ação do nó de revisão é reler o conteúdo do arquivo:

Esse passo parece redundante — está tudo no mesmo processo mesmo, é só passar a string direto. Mas ele compra duas coisas: o arquivo em out/ vira a única fonte de verdade daquele chamado, e quem quer que o edite é o que a revisão verifica; e, no momento em que essa aresta precisar cruzar processos ou máquinas, só essa linha de readFileSync muda, e o contrato entre nós não se mexe.

Um teste rápido

A esta altura, três dos cinco nós do grafo estão completos: o roteamento é apertado por código, a fusão é código puro, e o gate que vem a seguir também será código puro. A pergunta mais frequente neste ponto pode ser colocada diretamente.

Nó quatro: Loop de revisão — o gate filtra primeiro, as falhas voltam para a fornalha

O nó de revisão faz verificar-corrigir-reverificar: rodar um verificador, corrigir o que falhou, repetir até passar ou parar de progredir1. É o único lugar deste grafo onde “a saída de um modelo é devolvida para reescrita”.

O primeiro filtro é determinístico, algumas linhas de includes e pronto:

Duas regras, ambas do tipo que o Curso 10 (Verificação e Garantia de Qualidade: Não Deixe o “Parece Certo” Passar) disse “se pode ser determinado deterministicamente, não pergunte a um avaliador”: a resposta precisa conter o id do chamado (os sistemas de suporte se indexam por ele) e não pode conter enrolação como “aguarde um momento”, “agradecemos a paciência” ou “resolveremos em breve”, sem conteúdo informativo. Nenhuma das duas exige compreensão semântica, inclusão de string basta, o resultado é o mesmo toda vez, e convenientemente produz uma string de relatório que pode ser realimentada direto ao worker.

O avaliador LLM aqui poderia fazer “o tom da resposta é apropriado”, “os fatos excedem o que as ferramentas devolveram” — coisas realmente não julgáveis via includes. Mas ele precisa vir depois do gate: o gate é grátis e determinístico, deixe-o filtrar primeiro os problemas evidentes, e o que sobra vale gastar uma chamada para consultar um avaliador. Este grafo instalou apenas a camada de gate, porque os critérios de aceitação desta leva de chamados por acaso são expressáveis como regras; quando os critérios de aceitação incluírem palavras como “adequação de tom”, acrescente a camada de avaliador conforme a alocação de julgamento em camadas do Curso 10.

O loop em si é assim:

Três instruções break correspondem a três maneiras de parar, casando com o que a Lição 5 declarou: passou (a condição do while fica naturalmente falsa), nenhum progresso adicional, atingiu o máximo de rodadas. O terceiro if é um remendo — as respostas da categoria other são geradas por template de código puro, não há worker para devolver, e, se o próprio template estiver quebrado, a única opção é o repasse direto. Esta execução não caiu nele (o template é constante e obrigatoriamente passa no gate); ele é mantido porque, se alguém corromper a string do template, prefiro ver um registro no_rewriter a um loop girando.

O que é realimentado ao worker na reciclagem é direto: texto completo da versão anterior + relatório do gate + uma frase “corrija apenas os problemas nomeados no relatório e reescreva a resposta completa” (montada em callWorker).

As duas maneiras de parar aconteceram de fato nesta execução

Plantei dois roteiros nos stubs, fazendo cada saída do loop ser executada uma vez.

T-1005: Corrigido corretamente, pronto. A primeira versão do worker de bug esqueceu o id do chamado (a primeira regra falha), o gate devolve missing_ticket_id, o worker acrescenta a linha de abertura conforme o relatório, e a segunda versão passa:

text
{"ts":"2026-08-29T16:48:48.316Z","run_id":"run-mtem7jnm","node":"review","event":"gate","ticket":"T-1005","round":0,"pass":false,"report":"missing_ticket_id"}{"ts":"2026-08-29T16:48:48.377Z","run_id":"run-mtem7jnm","node":"review","event":"worker_done","ticket":"T-1005","round":2,"calls":1,"tokens":1638}{"ts":"2026-08-29T16:48:48.377Z","run_id":"run-mtem7jnm","node":"review","event":"gate","ticket":"T-1005","round":1,"pass":true,"report":""}

T-1004: Revisado, mas não corrigido, e o loop parou sozinho. A primeira versão do worker de billing escreveu “aguarde um momento”, e o gate devolve filler_word:aguarde um momento; o worker reescreveu uma versão, com a frase inteiramente diferente, mais longa, com uma explicação a mais, mas aquela expressão permanece. O relatório da segunda rodada é idêntico ao da primeira:

text
{"ts":"2026-08-29T16:48:48.253Z","run_id":"run-mtem7jnm","node":"review","event":"gate","ticket":"T-1004","round":0,"pass":false,"report":"filler_word:aguarde um momento"}{"ts":"2026-08-29T16:48:48.315Z","run_id":"run-mtem7jnm","node":"review","event":"worker_done","ticket":"T-1004","round":2,"calls":1,"tokens":1395}{"ts":"2026-08-29T16:48:48.315Z","run_id":"run-mtem7jnm","node":"review","event":"gate","ticket":"T-1004","round":1,"pass":false,"report":"filler_word:aguarde um momento"}

Neste momento gate.report === lastReport se sustenta, o loop julga que não há progresso adicional, para e marca este chamado como needs_human. Ele ainda tinha mais duas rodadas de orçamento (MAX_REVIEW_ROUNDS é 3), mas gastá-las seria desperdício — realimentando o mesmo relatório, o mais provável é voltar a mesma resposta. O valor da saída por “nenhum progresso adicional” está aqui: ela corta as perdas mais cedo do que o máximo de rodadas, e dá uma conclusão informativa — não “tentei três vezes e ainda falha”, mas “ele não entende este feedback”, que é precisamente o sinal para escalar a um humano.

A diferença entre as duas saídas nos dados é imediatamente visível:

Os gate_rounds dos dois chamados são 1, e a contagem de rodadas sozinha não distingue sucesso de fracasso; a linha divisória é o comprimento de gate_reports — ele registra cada relatório reprovado, incluindo o último, que causou a parada. T-1005 deixa apenas uma entrada (a segunda versão passou, sem segundo relatório), T-1004 deixa duas com conteúdo idêntico, e o campo stop escreve a conclusão diretamente como no_progress.

Nó cinco: Relatório e rastro

O nó final também é código puro: imprime state.nodes e o detalhamento por chamado como duas tabelas, conta os needs_human e determina o código de saída. Todos passaram é 0, um precisa de humano é 1.

O rastro se divide em dois arquivos, cada um com sua finalidade. run.jsonl é o log estruturado do Curso 11 (Observabilidade e Depuração: Enxergando Cada Passo do Seu Agente), um evento JSON por linha, cada um carregando ts e run_id, grepável depois — esta execução totalizou 39 linhas, e os trechos das seções anteriores foram todos extraídos dela literalmente.

run-state.json registra o rastro de execução (distinto de “o estado do grafo = aquelas poucas variáveis de script”), escrito no estilo do Curso 9 (Gestão de Estado e Persistência: Fazendo Tarefas Longas Sobreviverem a Interrupções): grava .tmp primeiro, depois troca atomicamente com rename; morto em qualquer momento, no disco está ou o estado completo anterior ou o novo estado completo, nunca meio JSON:

O momento da escrita é “persistir após cada passo pequeno”: após cada nó concluir, persiste uma vez; dentro do nó de revisão, após o julgamento de cada chamado, persiste de novo. O motivo que a Lição 5 citou — rastrear incrementalmente o resultado de cada agente é precisamente a premissa para recuperar uma execução dentro da mesma sessão1; um fluxo de trabalho que distribui o trabalho entre muitos agentes pequenos preserva mais progresso do que um agente longo1. Este grafo não é um runtime multiagente, mas a mesma afirmação vale aqui: seis chamados são seis unidades independentes de progresso, e morrer no meio da revisão não deveria fazer desaparecer junto o que já foi persistido (a fase de fan-out ainda não alcançou isso — veja o item 3 da Tabela de conciliação).

Para ver o efeito prático dessa afirmação, use STOP_AFTER=merge para parar o processo depois do fan-out e antes da revisão:

text
\$ STOP_AFTER=merge node orchestrate.mjsinbox/ recebeu 6 chamados: T-1001, T-1002, T-1003, T-1004, T-1005, T-1006[route] T-1001=billing  T-1002=bug  T-1003=other  T-1004=billing  T-1005=bug  T-1006=other[fanout] teto de concorrência 2, 6 rascunhos produzidos[merge] gravou 6 arquivos em out/, repassando adiante só referências e resumos de uma linha[stop] STOP_AFTER=merge: parando antes da revisão, sem veredito nesta execução\$ echo \$?2

run-state.json neste momento (trecho):

As contas de três nós estão registradas, a categoria, o tratador e os caminhos dos arquivos de saída dos seis chamados estão registrados, e os seis arquivos de rascunho já estão persistidos em out/. Perdeu-se apenas o trecho de revisão: todos os chamados param em status: "drafted", stop: null. Esse estado é suficiente para sustentar uma retomada — ler os rascunhos de volta de out/ e começar direto pelo nó de revisão. Note que o one_line de T-1005 por acaso expõe o defeito do rascunho: a abertura não tem o id do chamado. A revisão ainda não rodou, então esse defeito ainda não foi capturado.

(STOP_AFTER só reconhece merge como valor único; é a versão simplificada do ponto de queda controlada do Curso 9: código de saída 0 todos passaram, 1 há chamados para humano, 2 parou cedo sem veredito, 3 o próprio script quebrou — quatro códigos sem sobreposição, e a CI distingue num relance “rodou mas alguns precisam de repasse” de “quebrou”.)

orchestrate.mjs completo

Abaixo está o texto completo, um bloco contínuo; copie e cole em orchestrate.mjs num diretório vazio e depois node orchestrate.mjs. Zero dependências, sem necessidade de npm i, sem necessidade de package.json (o sufixo .mjs já declara que é um módulo ES) e sem necessidade de chave de API — o cliente de modelo é um stub. A primeira execução cria inbox/, kb/, out/ e grava aqueles seis chamados.

Seiscentas e setenta e nove linhas no total, das quais cerca de cento e noventa são dados alimentados aos stubs (a tabela SCRIPTS, os textos originais dos seis chamados, a base de problemas conhecidos, o cliente stub); a lógica de orquestração propriamente dita — cinco nós, pool de concorrência, gate e ponto de entrada — são cerca de duzentas e cinquenta linhas, mais umas quarenta para observabilidade e rastro de estado. Essa escala é deliberada: um loop mais alguns padrões é genuinamente algo implementável em poucas linhas de código2.

Montagem da verificação

Toda saída de terminal desta lição veio de execuções reais deste script, não de “rodar várias vezes e escolher a mais bonita”, mas de fixar de antemão duas fontes de não determinismo.

Troque o modelo por um stub que reproduz uma fila fixa. SCRIPTS é uma tabela, a chave é “id do chamado + qual versão”, o valor é uma sequência de respostas pré-escrita; cada chamada de messages.create cospe a próxima em ordem, e continuar chamando com a fila esgotada lança erro direto. Assim, “qual chamado chama qual ferramenta em qual rodada, quando o modelo termina” são todos constantes. O stub também deixou uma asserção: create precisa carregar model e max_tokens, e faltando um lança — o cliente real exige esses dois parâmetros, o stub não cobre por você, então você não descobre a lacuna no dia em que trocar pelo cliente real. Esse método é usado desde a prática do Curso 8 até aqui, de modo que o objeto verificado é a sua lógica de controle, não o desempenho do modelo naquele dia (modelos reais são não determinísticos, e a mesma entrada ainda pode dar respostas diferentes4).

O stub também acrescenta um atraso fixo de 60ms, substituindo a ida e volta de rede real. Sem ele, todo nó daria 0ms e o efeito do pool de concorrência não apareceria na tabela-resumo — a comparação com POOL_SIZE=1 acima (490ms contra 244ms) depende disso.

Dois roteiros de loop plantados nos stubs. O loop de revisão precisa girar de verdade, o que exige algo genuinamente reprovando no gate. Então:

  • T-1005#1 (primeira versão do worker de bug) omite deliberadamente o id do chamado, disparando missing_ticket_id; T-1005#2 acrescenta a linha de abertura e a segunda versão passa — isso demonstra a saída de conclusão normal do “verificar-corrigir-reverificar”.
  • T-1004#1 e T-1004#2 (as duas versões do worker de billing) carregam ambas “aguarde um momento”. As frases das duas versões são inteiramente diferentes, os comprimentos diferem, mas o gate procura se aquela expressão está presente, então as strings de relatório das duas rodadas são idênticas, disparando “nenhum progresso adicional” — isso demonstra a saída de corte de perdas.

A escrita dos dois roteiros tem ofício: não fazer a segunda versão repetir literalmente a primeira (assim até um humano veria que é um loop morto), mas fazê-la “revisada, porém não corrigida corretamente”. Esse é o modo de falha mais comum em loops reais, e precisamente o que o critério “duas rodadas seguidas com relatório idêntico” captura.

Parada antecipada controlada. STOP_AFTER=merge para o processo depois do fan-out e antes da revisão, com código de saída 2. É a versão simplificada do CRASH_AFTER do Curso 9: tornar “interromper em qual passo” um parâmetro precisamente especificável, em vez de depender da sorte para acertá-lo. O run-state.json em estado drafted acima veio dessa execução.

Tabela de conciliação: este grafo deve dívidas às lições anteriores, quitadas linha a linha

Num curso que chega à sua prática final, o erro mais fácil é derrubar em silêncio regras estabelecidas antes. Então conciliamos linha a linha aqui, com as discrepâncias escritas explicitamente.

1. O corpo do loop bate com o Curso 7. Os quatro passos no corpo do loop — empilhar assistant, executar ferramentas, empilhar tool_result, reatribuir response — são palavra por palavra idênticos aos da Lição 6 do Curso 7, e até os comentários não mudaram. A Válvula 1 também está na posição original. Diferenças declaradas: a assinatura de runAgent ganhou dois parâmetros, client e system (três papéis precisam de stubs diferentes e prompts de sistema diferentes), e a chamada de create ganhou um campo system; a medição de tokens saiu do corpo do loop para a camada envolvente metered, de modo que a Válvula 2 do Curso 7 (orçamento de tokens) não veio junto, e a Válvula 3 (detecção de giro em falso) e a Válvula 4 (aprovação humana) também não se mudaram — as ferramentas deste grafo são apenas leitura de arquivo e consulta de pedido, ambas operações somente leitura, sem ações de alto impacto que exijam aprovação; e a fila do stub é finita, sem como girar indefinidamente. Antes de conectar a API real, essas três válvulas precisam ser reinstaladas.

2. Prompts de delegação com os quatro elementos completos (Lição 4). Os três prompts — roteador, worker de billing, worker de bug — escreveram cada um as quatro seções completas de objetivo, formato de saída, orientação de ferramentas e limites da tarefa, uma linha cada, e podem ser comparados linha a linha3.

3. O pool de concorrência tem teto, e a fusão repassa referências e não cargas (Lição 3). O limit de runPool é teto rígido, e a diferença de tempo entre POOL_SIZE=1 e POOL_SIZE=2 já foi verificada. De merge em diante repassa-se adiante {id, category, handler, file, oneLine}, o texto completo fica em out/, e o nó de revisão o lê de volta do arquivo por conta própria3. Diferença declarada: o pool da Lição 3 era “a mesma leva de subtarefas rodando em paralelo”, aqui o pool abrange três tipos de tratador — dois workers de modelo mais um template de código puro, cuja entrada no pool custa quase nenhum tempo. A semântica do pool não mudou (a contagem de tarefas em voo não excede o teto), apenas as tarefas em si são heterogêneas. Também uma coisa que a Lição 3 estabeleceu e aqui foi omitida por brevidade do script: a Lição 3 exigia try/catch separado por pista, para que a falha de uma pista não derrubasse o lote inteiro, e runPool não tem esse envoltório — o custo é que, na fase de fan-out, se qualquer pista lançar, a leva inteira de rascunhos não será persistida. Antes de conectar a API real isso precisa ser acrescentado, pois timeout de pista única em rede real é normal.

4. Gate antes do avaliador, e condições de parada do loop batem com a Lição 5. O primeiro filtro é código determinístico, não modelo; esta lição não instalou a camada de avaliador LLM, porque os critérios de aceitação desta leva de chamados por acaso são expressáveis como regras, e instalá-la seria dinheiro desperdiçado — o julgamento em camadas do Curso 10 segue esta ordem: o que é julgável deterministicamente primeiro, e o que sobra consulta o avaliador. As condições de parada do loop são três: passou, nenhum progresso adicional, atingiu o máximo de rodadas1 2, e os conceitos correspondem um a um aos da Lição 5. Mas nomes de campo e de valores mudaram: a Lição 5 aterrissava no campo reason, com valores passed/no-progress/max-rounds; aqui aterrissa no campo stop, com valores gate_pass/no_progress/max_rounds (o critério trocou de avaliador para gate, e o hífen também virou sublinhado conforme a convenção snake_case desta lição); além disso, o rounds da Lição 5 conta vezes de geração, com o rascunho contando como rodada 1, enquanto o gate_rounds desta lição conta vezes de reescrita, com o rascunho sendo rodada 0 — então, para o mesmo chamado, os pontos de partida da contagem de rodadas das duas lições diferem em um. Diferença declarada: o código tem uma quarta saída, no_rewriter (template de código puro não tem worker para devolver). Isso não é um padrão que a Lição 5 omitiu, é a situação específica deste grafo — o loop da Lição 5 pressupunha “o produtor é um modelo”, e aqui uma categoria de produtores é template. Esta execução não caiu nesse ramo.

5. A expressão “grafo” bate com a declaração da Lição 5. “Grafo” e “nó” no texto inteiro são metáfora de engenharia própria desta lição, a Lição 5 já declarou isso explicitamente ao introduzir o sistema visual, e não é conceito oficial de nenhum material primário; a âncora primária em que pode se apoiar é apenas aquela: o próprio script do fluxo de trabalho guarda o loop, as ramificações e os resultados intermediários1. Esta lição não acrescentou terminologia nova — “máquina de estados” e “objeto de estado passado entre nós” não foram usados; a “aresta” definida pela Lição 5 (de quem a saída alimenta quem) apareceu apenas uma vez, ao explicar o fluxo de dados merge → review, e não é vocabulário novo. routed / drafts / items são apenas três variáveis locais comuns.

6. A escrita atômica de run-state.json bate com o Curso 9. Grava .tmp primeiro, depois troca com renameSync, sem faltar um passo. O momento da escrita também segue o calibre daquele curso: persistir uma vez após cada passo pequeno concluir, não uma vez após a execução inteira concluir.

7. O calibre de observabilidade tem a mesma forma do Curso 11, mas granularidade mais grossa. Um evento JSON por linha, cada um carregando ts e run_id, grepável depois. Quatro diferenças: (a) o logger do Curso 11 registra resumo de conteúdo (forma, comprimento, primeiros caracteres), e esta lição registra apenas id, categoria, nome de arquivo, string de relatório e contagens, sem registrar o texto completo da resposta — o texto completo já está em out/; (b) o campo de associação que o Curso 11 chama de trace_id aqui se chama run_id; (c) o núcleo daquele curso é usar span_id/parent_id para encadear uma árvore de rastro, e este grafo, embora tenha aninhamento de três camadas nó→worker→ferramenta, não implementou o vínculo pai-filho, então não há árvore de rastro; (d) initLog() limpa run.jsonl a cada execução, mantendo apenas a mais recente, e para fazer a comparação entre execuções do Curso 11 (v-good contra v-bug) é preciso mudar para anexar em arquivos separados por run_id. Para conectar este grafo a um sistema de rastro real, os campos de span do Curso 11 precisam ser acrescentados seguindo aquele padrão.

8. O padrão orquestrador-workers, esta lição intencionalmente não implementou (Lição 4). No orquestrador-workers da Lição 4, a chave é “quantos despachar, o que cada um faz” ser decidido pelo modelo observando a entrada na hora; este grafo não é assim — como os seis chamados se classificam e para qual worker cada categoria vai foram travados em CATEGORIES e em três prompts constantes antes de escrever a primeira linha de código. Esta é precisamente a aplicação direta do “se pode predefinir, não torne dinâmico” da Lição 4: a forma deste trabalho é conhecida, e a autoridade de decisão não deveria voltar ao modelo. Então, estritamente falando, foram soldados neste arquivo quatro padrões (encadeamento, roteamento, paralelização-seccionamento, loop de revisão), a votação complementa o quinto no exercício de Nível 2, e o orquestrador-workers é o que a natureza desta leva de tarefas barra.

Limites

Este grafo administra algo pequeno: um processo, uma leva de chamados, roda e sai. Vale a pena construí-lo porque os cinco passos “chegam chamados → classificar → tratar por categoria → verificar → reportar” foram travados antes de escrever a primeira linha de código. Se a tarefa virar “descubra o que este cliente de fato enfrentou nos últimos seis meses, e quantos passos são necessários você mesmo julga”, então este grafo é a arquitetura errada — esse tipo de problema em aberto, em que você não consegue prever os passos de antemão nem fixar um caminho no código, pertence intrinsecamente a um loop autônomo2.

Vários limites, declarados explicitamente:

O fan-out é síncrono, e vai doer em escala. O pool em fanoutNode precisa esperar o lote inteiro concluir antes de entrar em merge. Esse é precisamente o gargalo que aquele sistema real em produção reconheceu: a execução síncrona simplifica a coordenação, mas cria gargalos no fluxo de informação — um subagente demorando demais e o sistema inteiro fica travado esperando3. Seis chamados, cada um com no máximo duas chamadas, e esse gargalo não dói nada; seiscentos chamados, cada um com dez chamadas, e ele vira “o mais lento determina o tempo de relógio do lote inteiro”. Se mudar para assíncrono, é preciso calcular o custo: o assíncrono deixa os agentes trabalharem concorrentemente e criarem novos sob demanda, mas acrescenta dificuldade em coordenação de resultados, consistência de estado e propagação de erros entre subagentes3 — esses três não existem na versão síncrona, porque a ordem é determinada pelo código.

As duas regras do loop de revisão são rasas e frágeis. includes("aguarde um momento") vai marcar erradamente frases como “não é preciso aguarde um momento algum, já resolvemos” como enrolação. Esse é o velho problema advertido pelo Curso 10: validadores determinísticos rígidos demais julgam o correto como incorreto. Para produção real, essas duas regras precisam ser calibradas contra uma pequena leva de respostas reais, ou rebaixadas para “sinalizar para rerrevisão do avaliador” em vez de devolver diretamente para reescrita.

Ao trocar para a API real, troque apenas o stub, a estrutura não se mexe. makeStubClient(queue) vira new Anthropic(), apaga-se a tabela SCRIPTS inteira, e o resto não muda uma linha — runAgent sempre foi escrito para a forma stop_reason / tool_use / tool_result da API real, e model e max_tokens sempre foram carregados. Depois da troca, três coisas vão mudar: o resultado da classificação vai oscilar (os mesmos chamados, duas execuções podem cair em categorias diferentes), as rodadas de gate vão oscilar, a contagem de tokens vai oscilar; uma execução custa dinheiro e tempo; e as três válvulas não mudadas do Curso 7 precisam ser reinstaladas.

Cada camada de complexidade acrescentada precisa passar pelo portão do “melhora mensurável”. Cada padrão deste grafo pode ser removido individualmente: sem roteamento, um prompt genérico também responde chamados; sem fan-out, rodar os seis em série também termina; sem loop de revisão, conferência manual por amostragem também é um método. Se as métricas caem após a remoção, e quanto caem, só testando para saber. Só quando a complexidade genuinamente melhora os resultados é que vale a pena acrescentá-la2.

💻 Exercícios

Recapitulação

  • Quatro padrões soldados em um arquivo (a votação complementa o quinto no exercício, e o orquestrador-workers está intencionalmente ausente porque o despacho pode ser predefinido), e a afirmação “plano no código” ganha forma concreta: a dúzia de linhas de main() é todo o fluxo de controle, e as três variáveis comuns routed / drafts / items são todo o estado. LLMs e ferramentas são orquestrados através de caminhos de código predefinidos2, e o próprio script guarda o loop, as ramificações e os resultados intermediários, enquanto o contexto do modelo guarda apenas o que ele precisa para este passo1
  • Nem todo nó precisa ser um modelo: dos cinco nós, dois chamam modelos, enquanto merge, report e o primeiro filtro do gate são todos código puro, e a categoria other vai por template de string. Onde código determinístico consegue dar a mesma resposta, não há motivo para pagar o dinheiro e a latência de uma chamada
  • O valor do roteamento não está naquela chamada, mas nas dez linhas de código de aperto depois dela: o texto livre do modelo é prensado em um de três rótulos legais, e os ramos a jusante só reconhecem valores que o código examinou; prompts especializados são o dividendo que a classificação comprou2
  • A concorrência do fan-out precisa ter teto, e a fusão precisa repassar referências e não cargas — a saída vai para o disco, e adiante seguem apenas referências leves3, com o nó de revisão lendo de volta do arquivo por conta própria. O fan-out síncrono não dói nesta escala, mas em escala grande vira gargalo3, e mudar para assíncrono exige pagar três custos: coordenação de resultados, consistência de estado, propagação de erros entre subagentes3
  • O loop de revisão é verificar-corrigir-reverificar, até passar ou não haver progresso adicional1, mais uma rede de segurança de máximo de rodadas2. O gate determinístico vem antes do avaliador; o critério “duas rodadas seguidas com relatório idêntico” corta as perdas mais cedo do que o máximo de rodadas, e a conclusão que dá é mais informativa: não “tentei três vezes e falha”, mas “ele não entende este feedback”
  • O rastro incremental traz recuperabilidade: persistir uma vez após cada nó concluir é precisamente a premissa para uma execução ser continuável dentro da mesma sessão1 (continuar entre processos ou entre máquinas é uma elevação de um degrau própria desta lição, depois de persistir o estado em disco); combine com a troca atômica de gravar .tmp e depois rename e, morto em qualquer momento, o disco tem um estado completo legível de volta
  • Este grafo administra um processo, uma leva de chamados, trabalho de passos travados. Problemas em aberto com passos imprevisíveis devem voltar a loops autônomos2; cada camada de complexidade acrescentada precisa passar pelo portão do “melhora mensurável”2

Doze lições completas aqui.

Olhando para trás, o que você tem agora veio peça por peça: no Curso 1 (Claude Code Skills: Construa Seus Próprios Fluxos de Trabalho com IA) você escreveu seu primeiro prompt e aprendeu a enunciar requisitos com clareza; depois vieram chamada de ferramentas, fluxos de trabalho, skills, colaboração multiagente, até o Curso 7 — aquele curso fez você escrever um loop por conta própria, while (response.stop_reason === "tool_use"), e a partir daquele dia agentes deixaram de ser uma caixa-preta para você e viraram um pedaço de código que você consegue ler. O Curso 8 (Engenharia de Contexto: Gastar Atenção Finita Onde Ela Conta) ensinou você a administrar o contexto dele, para o loop não girar até a janela estourar. O Curso 9 ensinou você a fazê-lo sobreviver a interrupções, retomando de onde parou quando morto. O Curso 10 ensinou você a verificar a saída dele, separando “parece pronto” de “pronto”. O Curso 11 ensinou você a enxergar o processo dele, para que, quando as coisas quebram, haja logs e rastros para consultar. Este curso ensinou você a compor múltiplos loops em um grafo que guarda o próprio plano.

Essas seis coisas são seis facetas de uma só: em código que você escreveu, você está controlando uma coisa não determinística. O loop é a sua escrita, o contexto é a sua administração, os checkpoints são os seus salvamentos, os critérios de aceitação são a sua definição, os logs são o seu print, o plano é o seu arranjo. O modelo é muito forte, mas ele trabalha dentro deste código de controle que você construiu.

O passo final aterrissa em ação concreta: troque o makeStubClient(queue) de orchestrate.mjs por new Anthropic(), apague a tabela SCRIPTS, reinstale as três válvulas não mudadas do Curso 7, e então despeje em inbox/ a leva real de tarefas empilhadas do seu trabalho — chamados reais, logs reais, pendências reais — e rode pela primeira vez. Provavelmente algumas vão cair em needs_human; é exatamente essa a cara que este grafo deve ter.

Footnotes

  1. Orchestrate subagents at scale with dynamic workflows — Claude Code official documentation — https://code.claude.com/docs/en/workflows 2 3 4 5 6 7 8 9 10

  2. Building Effective AI Agents — Anthropic Engineering — https://www.anthropic.com/engineering/building-effective-agents 2 3 4 5 6 7 8 9 10 11 12 13 14 15

  3. How we built our multi-agent research system — Anthropic Engineering — https://www.anthropic.com/engineering/multi-agent-research-system 2 3 4 5 6 7 8 9

  4. Writing effective tools for agents — with agents — Anthropic Engineering — https://www.anthropic.com/engineering/writing-tools-for-agents

Exercícios

01

Abaixo está a tabela-resumo de uma execução completa deste grafo, mais os registros de dois chamados em run-state.json (resultados de execução real; os milissegundos e o run_id mudam a cada vez):

Nível 1: Ler o diagrama — o que de fato aconteceu no loop
text
=== Resumo da execução do grafo ===Nó       Tempo   Chamadas    Tokens   Rodadas     Statusroute     62ms    1           720      -           okfanout    244ms   8           8903     -           okmerge     2ms     0           0        -           okreview    129ms   2           3033     2           ok
=== Detalhamento por chamado ===Chamado  Categoria Tratador          Rodadas     Motivo parada   StatusT-1001   billing   worker:billing    0           gate_pass       passT-1002   bug       worker:bug        0           gate_pass       passT-1003   other     template          0           gate_pass       passT-1004   billing   worker:billing    1           no_progress     needs_humanT-1005   bug       worker:bug        1           gate_pass       passT-1006   other     template          0           gate_pass       pass

Sem escrever código, responda três perguntas: (1) Quais dos seis chamados entraram no loop de revisão, cada um girou quantas rodadas, e de qual campo você leu isso? (2) Os gate_rounds de T-1004 e T-1005 são ambos 1; por que um dá pass e o outro needs_human? A evidência está em qual campo, e como se lê? (3) Suponha que o processo seja morto logo depois de o fan-out concluir e antes de a revisão começar: o que run-state.json consegue preservar, o que se perde? Depois de reiniciar, de qual passo dá para retomar?

Critérios de conclusão · marcado localmente
02

A categoria other tem um chamado de tom difícil de calibrar — T-1006: “Uso há três meses, relatei problemas várias vezes e nunca tive retorno. Esse produto ainda tem alguém dando manutenção?” Um template fixo respondendo a isso é, muito provavelmente, inadequado: frio demais parece descaso, caloroso demais arrisca prometer demais.

Nível 2: Acrescentar um nó de votação ao grafo

Acrescente um nó de votação a este grafo: mesmo chamado, mesma tarefa, rodada uma vez a partir de dois ângulos[^S1], e depois use código puro para comparar as duas versões e levar a superior para a fusão. As regras de comparação são apenas duas, e nenhuma pode perguntar ao modelo: primeiro use as regras determinísticas do gate para eliminar (tem palavra proibida ou está sem o id do chamado, sai direto), e entre as sobreviventes escolha a mais curta (respostas a clientes não devem se estender).

Requisitos: os prompts dos dois ângulos precisam ter todos os quatro elementos; as duas chamadas precisam passar honestamente por runAgent (ou seja, passar pelo loop completo), com os stubs dando a cada uma sua fila de respostas; o processo de seleção precisa deixar rastro no terminal e em run.jsonl, para as pessoas saberem por que aquela versão foi escolhida. Depois de escrever, rode de verdade uma vez e cole a saída. Responda também uma pergunta: por que usar comparação em código puro aqui, em vez de chamar um modelo para julgar qual versão é melhor?

Critérios de conclusão · marcado localmente