Agentes e Workflows Multi-Step
Um agente que decide os seus próprios passos só é seguro se o teu código decidir os limites dele.
Por Telmo Silva
Ir para o teu progresso ↓Porque escrevi este curso
Nos três cursos anteriores desta série, o modelo respondia a um pedido de cada vez: recebias um pedido, chamavas o modelo uma vez, devolvias uma resposta. Mesmo com retries, cache e fallback, era sempre uma decisão só, tomada pelo teu código antes de chamar o modelo. Este curso muda essa premissa: e se for o próprio modelo a decidir, a meio da tarefa, que precisa de consultar uma base de dados primeiro, ou de chamar duas ferramentas em sequência antes de responder? É isso que separa um agente de uma chamada única, e é uma mudança que muda tudo o resto: já não sabes de antemão quantas chamadas um pedido vai custar, nem quanto tempo vai demorar, nem sequer se o agente vai mesmo terminar.
"Agente" tornou-se uma palavra vendida a mais e definida a menos. Este curso define-a de forma concreta e depois foca-se no que quase nenhum artigo sobre agentes trata a sério: como dar ao modelo poder real para agir (ferramentas de verdade, não um truque de prompt) sem perderes o controlo sobre o que ele faz, quanto gasta, e o que acontece quando corre mal. Um agente sem limites não é autonomia, é negligência com uma interface simpática.
Uma honestidade sobre o que aqui está
É o quarto e último curso da série "De 0 a AI Engineer". Pressupõe os três anteriores feitos, ou o equivalente em experiência: já saberes operar um serviço de IA em produção com retries, controlo de custo e latência sob controlo (o Curso 1 desta série ensina isso desde o início), e já teres alguma noção de avaliação e observability de sistemas de IA (o Curso 3). O capítulo 8 liga-se diretamente ao Curso 1, e o capítulo 9 ao Curso 3, por nome, sem repetir o que já lá ficou explicado.
Os dois exercícios práticos deste curso correm por completo sem nenhuma chave de API: a decisão de que ferramenta chamar é simulada por uma função Python determinística (com base em palavras-chave do pedido), com uma nota clara, no capítulo 4, de como isto seria substituído por uma chamada real com tools=[...]. Só o capítulo 2 usa mesmo a API da OpenAI, para mostrares o formato real de uma resposta com tool_calls; se tiveres uma chave OPENAI_API_KEY tua, o capítulo 2 corre a sério, mas não é obrigatório para o resto do curso.
O que precisas de ter pronto antes de começares
- Os Cursos 1, 2 e 3 desta série feitos, ou o equivalente: operar um serviço de IA em produção com retries e custo controlados, alguma prática com RAG sobre dados próprios, e alguma noção de avaliação e observability.
- Python sólido: funções, classes, try/except, e à vontade a ler e escrever dicionários e listas aninhadas (o formato normal de uma resposta de API).
- Opcional: uma chave OPENAI_API_KEY tua, criada em platform.openai.com, só para correres o exemplo real de function calling do capítulo 2. Os dois exercícios práticos não precisam dela.
Um agente sem limite de passos não é autónomo, é um script à espera de uma fatura cara.
Por onde começar, consoante a tua situação
Índice
9 capítulos, com código a sério em cada um. É o quarto e último curso desta série: pressupõe os três anteriores feitos, ou as competências equivalentes indicadas acima.
01O Que É um Agente, na Prática
O ciclo pensar, agir, observar
Um agente, no sentido em que este curso usa a palavra, é um sistema onde o modelo decide dinamicamente qual é o próximo passo, com base no que já aconteceu até aqui, em vez de seguir uma sequência de passos já escrita de antemão. A técnica mais comum para isto chama-se ReAct (de "reasoning and acting", raciocinar e agir): o modelo recebe o pedido e o histórico do que já foi feito, decide uma ação ("pensar"), o teu código executa essa ação ("agir"), o resultado volta para o modelo ("observar"), e o ciclo repete até o modelo decidir que já tem o suficiente para responder.
Na forma mais simples, isto parece-se com um ciclo normal, só que uma das decisões dentro dele não é tua, é do modelo:
python
historico = []
for numero_passo in range(max_passos):
decisao = perguntar_ao_modelo(pedido, historico) # pensar
if decisao["ferramenta"] == "responder_final":
resposta = decisao["argumentos"]["texto"]
break
resultado = executar_ferramenta(decisao["ferramenta"], decisao["argumentos"]) # agir
historico.append({"ferramenta": decisao["ferramenta"], "resultado": resultado}) # observarEste bloco é ilustrativo, para veres a forma do ciclo: perguntar_ao_modelo() ainda não existe. A partir do capítulo 2 vais ver a chamada real que a substitui, e no capítulo 4 vais construir uma versão completa e testada, com uma decisão determinística em vez de uma chamada a um modelo.
A parte que mais surpreende quem nunca construiu um agente: o modelo não sabe, à partida, quantas voltas o ciclo vai dar. Um pedido simples pode resolver-se numa volta (o modelo já tem tudo o que precisa e responde de imediato). Um pedido mais complexo pode precisar de três, cinco, ou mais voltas, cada uma a decidir a ação seguinte com base no que as anteriores devolveram. É essa incerteza sobre "quantos passos" que motiva praticamente todo o resto deste curso: limites (capítulo 5), validação (capítulo 6), gestão de memória (capítulo 7) e controlo de custo (capítulo 8) só fazem sentido porque o número de passos não é fixo.
A diferença clara para uma cadeia fixa de prompts
Um workflow determinístico (às vezes chamado de "cadeia", ou pipeline) também pode envolver várias chamadas a um modelo, uma a seguir à outra. A diferença não está em quantas chamadas há, está em quem decide a ordem e a existência de cada uma: num workflow determinístico, és tu, no código, antes de correr; num agente, é o modelo, a cada passo, com base no que já aconteceu.
python
# workflow determinístico: os passos e a ordem já estão decididos no código
def resumir_e_traduzir(texto):
resumo = chamar_modelo(f"Resume este texto: {texto}")
traducao = chamar_modelo(f"Traduz para inglês: {resumo}")
return traducao
# agente: quantos passos há, e quais, é o modelo que decide a cada volta
def responder_com_agente(pedido):
historico = []
while True:
decisao = perguntar_ao_modelo(pedido, historico)
if decisao["ferramenta"] == "responder_final":
return decisao["argumentos"]["texto"]
resultado = executar_ferramenta(decisao["ferramenta"], decisao["argumentos"])
historico.append({"ferramenta": decisao["ferramenta"], "resultado": resultado})resumir_e_traduzir() chama o modelo duas vezes, sempre pela mesma ordem, sempre com o mesmo número de chamadas: é previsível como qualquer outra função. responder_com_agente() pode terminar na primeira volta ou na sexta, consoante o que o modelo decidir a cada passo, com base no pedido concreto que recebeu; isso é o que ganhas em flexibilidade, e o que perdes em previsibilidade. O capítulo 3 aprofunda esse trade-off; para já, fica só a distinção: cadeia fixa, decisão tua, feita antes; agente, decisão do modelo, feita a cada passo.
02Function Calling / Tool Use
Dar ao modelo um conjunto de ferramentas
Function calling (também chamado tool use) é o mecanismo que torna um agente possível de verdade: dás ao modelo uma lista de "ferramentas" que ele pode invocar, cada uma descrita por um nome, uma descrição em linguagem natural do que faz, e um schema dos argumentos que aceita. O modelo, na resposta, devolve qual ferramenta quer chamar e com que argumentos, em formato estruturado. O que o modelo nunca faz é correr essa ferramenta: quem executa é sempre o teu código, depois de leres essa decisão da resposta.
Vale a pena reler a frase anterior devagar, porque é a que mais gente entende ao contrário: o modelo não corre código nenhum. Ele devolve uma estrutura ("quero chamar consultar_catalogo com nome_produto='teclado mecânico'"), tal como devolveria texto normal; é o teu processo Python que lê essa estrutura e decide se e como executar.
Uma ferramenta descreve-se com um schema JSON, o mesmo formato que já viste noutros contextos de validação de dados: um objeto com propriedades tipadas e uma lista do que é obrigatório.
python
tools = [
{
"type": "function",
"function": {
"name": "consultar_catalogo",
"description": "Consulta o preço e o stock de um produto do catálogo, pelo nome exato.",
"parameters": {
"type": "object",
"properties": {
"nome_produto": {
"type": "string",
"description": "Nome exato do produto, tal como aparece no catálogo.",
}
},
"required": ["nome_produto"],
},
},
}
]A description da ferramenta e a description de cada argumento não são comentários decorativos: é o texto que o modelo lê para decidir se esta ferramenta serve para o pedido em causa, e como preencher os argumentos. Uma descrição vaga ("consulta dados") produz decisões más com a mesma facilidade que um prompt vago produz respostas más.
A chamada real, com a API da OpenAI
python
import json
from openai import OpenAI
client = OpenAI()
resposta = client.chat.completions.create(
model="gpt-4o-mini",
messages=[{"role": "user", "content": "Qual é o preço do teclado mecânico?"}],
tools=tools,
)
mensagem = resposta.choices[0].message
if mensagem.tool_calls:
tool_call = mensagem.tool_calls[0]
nome_ferramenta = tool_call.function.name # "consultar_catalogo"
argumentos = json.loads(tool_call.function.arguments) # {"nome_produto": "teclado mecânico"}
resultado = executar_ferramenta(nome_ferramenta, argumentos)
else:
# o modelo decidiu que não precisava de nenhuma ferramenta
print(mensagem.content)mensagem.tool_calls é uma lista, porque um modelo pode pedir para chamar mais do que uma ferramenta na mesma resposta; para o caso normal, uma ferramenta de cada vez, o primeiro elemento chega. tool_call.function.arguments vem sempre como uma string JSON, não como um dicionário já pronto: json.loads() é obrigatório antes de usares os argumentos, e tal como qualquer JSON vindo de fora, vale a pena confirmar que os campos esperados lá estão antes de os usares (o capítulo 6 aprofunda essa validação).
Depois de executares a ferramenta, o resultado tem de voltar para o modelo, para ele poder continuar (ou responder de vez). Isso faz-se com uma mensagem role="tool", associada ao tool_call.id original:
python
resposta_seguinte = client.chat.completions.create(
model="gpt-4o-mini",
messages=[
{"role": "user", "content": "Qual é o preço do teclado mecânico?"},
mensagem,
{"role": "tool", "tool_call_id": tool_call.id, "content": json.dumps(resultado)},
],
)Isto é o corpo do ciclo do capítulo 1
Repara que este bloco de código, chamada com tools, ler tool_calls, executar, devolver com role="tool", é exatamente o "pensar / agir / observar" do capítulo 1, só que agora com a chamada real em vez de perguntar_ao_modelo(). O que falta para ser um agente completo é repetir isto num ciclo, com um limite: é isso que os capítulos 4 e 9 constroem, de duas formas diferentes.
03Workflows Determinísticos vs Agentes Autónomos
Quando um grafo fixo de passos já chega
Nem todo o problema que envolve um modelo precisa de um agente. Se sabes, antes de correr, quais são os passos e a ordem deles (ler um documento, extrair um campo, validar esse campo, gerar um resumo), um workflow determinístico resolve isso com uma função normal, como a resumir_e_traduzir() do capítulo 1. Não há nada a ganhar em deixar um modelo "decidir" uma sequência que já sabes de antemão, e há bastante a perder.
O que perdes é concreto, não é só uma questão de estilo:
| Workflow determinístico | Agente autónomo | |
|---|---|---|
| Previsibilidade | Sabes sempre quantas chamadas um pedido faz, antes de o correres | O número de passos varia por pedido, só se sabe depois |
| Custo por pedido | Fixo e fácil de orçamentar | Variável; precisa de um limite explícito (capítulo 8) |
| Testar | Testas cada passo isoladamente, como qualquer função | Precisas de testar decisões diferentes consoante o histórico |
| Flexibilidade | Só resolve o que já previste no código | Lida com pedidos cuja sequência de passos não sabias de antemão |
Um workflow determinístico ganha em previsibilidade, custo e facilidade de teste; um agente ganha em flexibilidade perante um pedido cuja forma não conseguias prever ao escrever o código. Nenhum dos dois é estritamente melhor: é uma troca real, não uma hierarquia. "Os agentes são o futuro" é marketing; "os agentes servem para um tipo específico de problema" é engenharia.
Quando precisas mesmo de um agente
A pergunta a fazer não é "isto seria mais impressionante como agente?", é: a sequência certa de ações depende de informação que só se sabe a meio da execução, e essa sequência não cabe razoavelmente num número fixo e pequeno de ramos if/else? Um assistente de apoio ao cliente que às vezes precisa de consultar um pedido, às vezes de verificar stock, às vezes de nenhum dos dois, consoante o que a pessoa realmente perguntou, é um bom candidato: escrever à mão todas as combinações possíveis de "se perguntar X, verifica Y, depois Z" cresce depressa e fica frágil. Um pipeline que sempre lê um ficheiro, sempre valida contra o mesmo schema, e sempre grava no mesmo sítio não precisa de nada disto.
Resposta fraca, e porquê
“Vamos usar um agente porque é mais moderno e a equipa quer aprender a tecnologia.”
"Mais moderno" não é um requisito de produto. Se a sequência de passos já é conhecida e fixa, um agente troca previsibilidade e custo controlado por flexibilidade que ninguém vai usar, só para ficar bem no currículo do projeto.
Um sinal prático de que um workflow determinístico ainda chega: consegues desenhar o fluxograma completo, com todos os ramos, numa folha de papel, sem nenhuma seta a dizer "e aqui o modelo decide". No dia em que essa seta aparece porque a decisão depende mesmo de algo que só se sabe a correr, é aí que um agente começa a fazer sentido.
04Prática: o Teu Primeiro Agente
Exercício 1: agente simples de decisão de ferramenta
Objetivo: Escrever agente_ferramentas.py: dadas duas ferramentas simuladas por funções Python (um catálogo de produtos em memória, e um cálculo de total), decidir qual delas serve para um pedido em texto livre, executá-la, e devolver o resultado.
- Um dicionário CATALOGO com pelo menos 3 produtos, cada um com preco e stock.
- consultar_catalogo(nome_produto): devolve o dicionário do produto (preco/stock), ou None se não existir no catálogo.
- calcular_total(quantidade, preco_unitario): devolve quantidade * preco_unitario, arredondado a 2 casas decimais. Função pura, sem estado.
- decidir_ferramenta_e_argumentos(pedido): função determinística que decide, com base em palavras-chave do pedido, qual ferramenta chamar e com que argumentos. Devolve (nome_ferramenta, argumentos) ou None se nenhuma ferramenta servir. Não faz nenhuma chamada de rede: é o que, numa versão real com a API (capítulo 2), seria o tool_calls da resposta do modelo.
- executar_ferramenta(nome_ferramenta, argumentos): chama a função certa com os argumentos certos. É sempre o teu código a executar, nunca o modelo, mesmo numa versão real.
- agente_simples(pedido): junta tudo: decide, executa se houver decisão, e devolve um dicionário com ferramenta, argumentos, resultado e mensagem (só preenchida quando não há decisão).
Dica: Para decidir qual produto do catálogo o pedido menciona, o mais simples é percorrer as chaves de CATALOGO e verificar se alguma aparece como substring do pedido em minúsculas. Para o cálculo, uma expressão regular como r"(\d+(?:[.,]\d+)?)\s*(?:vezes|x)\s*(\d+(?:[.,]\d+)?)" apanha "3 vezes 24.50" e "2,5 x 219".
Ver solução
import re
CATALOGO = {
"teclado mecânico": {"preco": 79.90, "stock": 12},
"rato sem fios": {"preco": 24.50, "stock": 30},
"monitor 27 polegadas": {"preco": 219.00, "stock": 5},
}
def consultar_catalogo(nome_produto):
"""Ferramenta 1: consulta o catálogo em memória pelo nome exato do produto.
Devolve o dicionário com preco/stock, ou None se não existir."""
return CATALOGO.get(nome_produto.lower())
def calcular_total(quantidade, preco_unitario):
"""Ferramenta 2: calcula o total de uma compra. Função pura, sem estado."""
return round(quantidade * preco_unitario, 2)
FERRAMENTAS = {
"consultar_catalogo": consultar_catalogo,
"calcular_total": calcular_total,
}
def decidir_ferramenta_e_argumentos(pedido):
"""Decisão determinística e barata, com base em palavras-chave do pedido.
Faz aqui o papel que, numa chamada real à API com tools=[...], seria o
tool_calls devolvido na resposta: decide qual ferramenta chamar e com que
argumentos. Não executa nada, só decide.
Devolve (nome_ferramenta, argumentos) ou None se nenhuma ferramenta serve."""
pedido_lower = pedido.lower()
for nome_produto in CATALOGO:
if nome_produto in pedido_lower:
return "consultar_catalogo", {"nome_produto": nome_produto}
match = re.search(r"(\d+(?:[.,]\d+)?)\s*(?:vezes|x)\s*(\d+(?:[.,]\d+)?)", pedido_lower)
if match:
quantidade = float(match.group(1).replace(",", "."))
preco_unitario = float(match.group(2).replace(",", "."))
return "calcular_total", {"quantidade": quantidade, "preco_unitario": preco_unitario}
return None
def executar_ferramenta(nome_ferramenta, argumentos):
"""O código, nunca o modelo, é que corre a ferramenta de facto."""
funcao = FERRAMENTAS[nome_ferramenta]
return funcao(**argumentos)
def agente_simples(pedido):
decisao = decidir_ferramenta_e_argumentos(pedido)
if decisao is None:
return {
"ferramenta": None,
"argumentos": None,
"resultado": None,
"mensagem": "Não encontrei nenhuma ferramenta capaz de responder a este pedido.",
}
nome_ferramenta, argumentos = decisao
resultado = executar_ferramenta(nome_ferramenta, argumentos)
return {"ferramenta": nome_ferramenta, "argumentos": argumentos, "resultado": resultado, "mensagem": None}
if __name__ == "__main__":
for pedido in [
"Qual é o preço do teclado mecânico?",
"Quanto custa 3 vezes 24.50?",
"Que horas são?",
]:
print(pedido, "->", agente_simples(pedido))Repara no que decidir_ferramenta_e_argumentos() e executar_ferramenta() já separam, mesmo sem nenhuma chamada a um modelo: decidir é uma coisa, executar é outra. Numa versão real com a API da OpenAI, só decidir_ferramenta_e_argumentos() mudaria de corpo (passaria a ler tool_calls em vez de palavras-chave); o resto do agente, executar_ferramenta() e agente_simples(), fica exatamente igual. É essa separação, e não a chamada a um modelo em si, que é a parte que vale a pena reter deste exercício.
05Loops e Limites
O perigo real: um agente preso a repetir a mesma ação
Imagina uma ferramenta enviar_email(destinatario, assunto, corpo) que falha com um erro de rede transitório, do mesmo tipo que já viste no Curso 1 desta série. Um agente sem nenhum limite pode ver esse erro, "pensar" que a chamada falhou e devia tentar outra vez, chamar a mesma ferramenta com os mesmos argumentos, ver o mesmo erro, e repetir isto indefinidamente. Ao contrário de um retry normal (Curso 1, capítulo 2), aqui não és tu que decides quantas vezes tentar: é o modelo, a cada passo, e nada o impede de decidir "tentar outra vez" para sempre.
Isto não é um cenário raro nem exagerado. É o comportamento normal de um sistema que otimiza para "resolver a tarefa", sem nenhuma noção embutida de "já tentei isto e não resultou, algo está estruturalmente errado". Cada volta do ciclo é, no mínimo, uma chamada ao modelo mais a execução da ferramenta; sem limite, um agente preso neste padrão gera custo real, continuamente, sem produzir nunca uma resposta.
max_steps: o limite de iterações
A defesa mais simples e mais eficaz é um limite fixo ao número de voltas do ciclo, normalmente chamado max_steps: o agente para, sempre, ao fim de N passos, tenha ou não chegado a uma resposta.
python
for numero_passo in range(1, max_steps + 1):
decisao = decidir_proximo_passo(pedido, historico)
if decisao["ferramenta"] == "responder_final":
return {"concluido": True, "resposta": decisao["argumentos"]["texto"]}
resultado = executar_ferramenta(decisao["ferramenta"], decisao["argumentos"])
historico.append({"ferramenta": decisao["ferramenta"], "resultado": resultado})
# o for correu até ao fim sem nenhum "return": o limite foi atingido
return {"concluido": False, "motivo": "max_steps atingido", "log": historico}Um max_steps demasiado baixo corta pedidos legítimos que precisavam mesmo de mais passos; demasiado alto deixa o problema do início deste capítulo continuar a acontecer, só que mais devagar. Não há um número universal: depende de quantos passos as tuas tarefas mais complexas costumam precisar na prática, algo que só descobres a medir agentes já a correr (é o mesmo raciocínio do p95 de latência do Curso 1, aplicado agora a número de passos em vez de segundos).
Timeout global: o limite de tempo
max_steps limita quantas voltas o ciclo dá; não limita quanto tempo cada volta demora. Uma ferramenta lenta (uma chamada de rede a um sistema externo, por exemplo) pode fazer um agente com poucos passos demorar minutos na mesma. Um timeout global, medido desde o início do ciclo, cobre esse caso: independentemente de quantos passos já foram dados, se o tempo total ultrapassar o limite, o agente para.
python
import time
inicio = time.time()
for numero_passo in range(1, max_steps + 1):
if time.time() - inicio > timeout_segundos:
return {"concluido": False, "motivo": "timeout", "log": historico}
# ... resto do passoO que fazer quando o limite é atingido sem resposta
A tentação, ao atingir max_steps ou o timeout, é pedir ao modelo uma última vez que "resuma o que conseguiu até agora numa resposta final", para o utilizador não ficar sem nada. Isto tem um risco concreto: se o agente estava preso porque uma ferramenta não devolvia o que era preciso, pedir uma resposta final força o modelo a preencher essa lacuna com o que soa plausível, o que é exatamente a definição de alucinação. É mais honesto devolver o log de passos até ao momento e um estado explícito de "não concluído", do que fabricar uma resposta com um limite de confiança que ninguém pediu.
O log de passos não é opcional aqui
Quando um agente atinge o limite sem terminar, o log estruturado de cada passo (capítulo 9) é o que te permite perceber porquê: que ferramenta foi chamada repetidamente, com que argumentos, e que resultado recebeu de cada vez. Sem esse log, "o agente não respondeu" é tudo o que sabes; com ele, sabes exatamente onde ficou preso.
06Validação de Planos e Ações
Confirmar que é seguro executar, antes de executares
Dar ao modelo acesso a ferramentas reais (capítulo 2) significa dar-lhe, na prática, o poder de as invocar. O teu código continua a ser quem executa, mas se executares cegamente qualquer decisão que a resposta contenha, sem verificar nada, a validação de facto está a ser feita pelo modelo, não por ti. Um agente sob controlo confirma duas coisas antes de correr qualquer ação: a ferramenta pedida está mesmo autorizada, e os argumentos têm a forma esperada.
Allowlist: só o que está explicitamente permitido
Uma allowlist é um conjunto fixo de nomes de ferramentas que o agente pode mesmo chamar. Não é redundante com a lista de tools=[...] que já deste ao modelo: essa lista descreve o que existe, a allowlist decide o que é seguro correr neste contexto específico, e pode ser mais restrita (por exemplo, um agente de apoio ao cliente pode ter acesso à função de consultar uma encomenda mas não à de a cancelar, mesmo que ambas existam no código).
python
def validar_acao(ferramenta, argumentos, allowlist):
"""Confirma que é seguro correr esta ação antes de a correres: a ferramenta
tem de estar na allowlist, e os argumentos têm de ser um dicionário.
Devolve (aprovada, motivo_rejeicao). Não executa nada."""
if ferramenta not in allowlist:
return False, f"ferramenta '{ferramenta}' não está na allowlist"
if not isinstance(argumentos, dict):
return False, "argumentos têm de ser um dicionário"
return True, NoneEsta verificação corre sempre antes de executar_ferramenta(), nunca depois. Uma vez que uma ferramenta correu, já não há validação que desfaça o efeito (um email já foi enviado, um registo já foi apagado); a validação só vale alguma coisa como gate, antes da execução, não como auditoria depois dela.
Human-in-the-loop para ações irreversíveis
Nem toda a ação que passa numa allowlist devia correr sem mais ninguém ver. consultar_catalogo() é reversível por definição: correu, leste o resultado, nada mudou no mundo. enviar_email() ou apagar_registo() não são: uma vez executadas, não há "desfazer" automático. Para ações deste tipo, a validação certa não é só técnica (está na allowlist? os argumentos têm a forma certa?), é de negócio: alguém tem de aprovar antes de a ação correr de facto.
python
ACOES_IRREVERSIVEIS = {"enviar_email", "apagar_registo"}
def precisa_de_aprovacao_humana(ferramenta):
return ferramenta in ACOES_IRREVERSIVEISNa prática, isto significa que o ciclo do agente, ao propor uma ação marcada como irreversível, não a executa de imediato: pausa, mostra a ação proposta (ferramenta e argumentos) a uma pessoa, e só continua depois de uma aprovação explícita, ou termina se for rejeitada. É mais lento do que execução automática cega, de propósito: para uma ação sem retorno, a lentidão de esperar por uma pessoa é o preço correto a pagar.
Resposta fraca, e porquê
“O modelo já tem instruções no prompt para pedir confirmação antes de apagar nada, por isso não precisamos de validação no código.”
Uma instrução no prompt é uma sugestão, não uma garantia: nada impede o modelo de a ignorar num caso de borda, ou de um prompt injection a anular. A allowlist e o gate de aprovação humana vivem no teu código, fora do alcance do que o modelo decide escrever, e é por isso que seguram mesmo quando o prompt falha.
07Memória e Estado entre Passos
O que persistir entre iterações
Num chat normal, a "memória" é a lista de mensagens trocadas: o que o utilizador disse, o que o modelo respondeu. Num agente multi-step, isso não chega: o modelo também precisa de saber que ferramentas já chamou, com que argumentos, e que resultado cada uma devolveu, para não repetir um passo já dado nem ignorar informação já obtida. O historico do capítulo 1 (uma lista de {ferramenta, argumentos, resultado}, um item por passo) é essa memória: não é a conversa, é o registo de ações e observações.
Isto tem uma consequência prática direta: a cada novo passo, o que envias ao modelo inclui o pedido original mais o historico completo até aqui, para ele decidir a ação seguinte com conhecimento do que já aconteceu. É o que decidir_proximo_passo() faz no exercício deste módulo: olha para o pedido e para historico, não só para o pedido sozinho.
O problema do contexto que cresce a cada passo
Se reenvias o historico completo a cada passo, e o historico cresce um item por passo, o que enviaste ao modelo no passo 1 é uma fração pequena do que envias no passo 8. Cada passo cobra pelo prompt inteiro que lhe deste, não só pela decisão nova; um agente com muitos passos paga, repetidamente, para o modelo reler resultados que já lhe tinha mostrado antes. Em casos extremos, isto pode mesmo chegar ao limite de tokens que o modelo aceita numa única chamada, e a chamada falha por esse motivo sozinho, sem nada de errado com a lógica do agente.
Duas mitigações comuns, nenhuma delas complicada de implementar:
- Manter só os passos recentes: em vez do historico completo, enviar só os últimos N passos. Perde-se detalhe de passos antigos, mas para a maioria das tarefas a decisão seguinte depende sobretudo do que aconteceu há pouco, não do passo 1 de uma sequência de 10.
- Resumir os passos antigos: substituir os detalhes completos de passos já distantes por uma frase de resumo ("consultado o catálogo, produto encontrado, preço 79.90"), mantendo os passos recentes na íntegra. Mais trabalho de implementar, mas perde menos informação do que simplesmente cortar.
python
def historico_recente(historico, mantidos=5):
"""Mantém só os últimos 'mantidos' passos no contexto enviado ao modelo,
para o prompt não crescer sem limite a cada passo do agente."""
return historico[-mantidos:]Qual das duas estratégias usar (ou nenhuma, se as tuas tarefas nunca passam de 3 ou 4 passos) depende de quantos passos os teus agentes costumam mesmo precisar na prática. Para o exercício deste módulo, com no máximo 5 passos, o historico completo cabe sempre sem problema; a mitigação só compensa a partir do momento em que max_steps sobe o suficiente para o crescimento linear começar a doer, em tokens ou em euros.
08Custo e Latência de Agentes Multi-Step
Cada passo é, no mínimo, uma chamada ao modelo
No Curso 1 desta série, custo e latência foram introduzidos para uma única chamada: um pedido, uma resposta, um valor de tokens de entrada e saída, um tempo de resposta. Num agente multi-step, cada volta do ciclo pensar/agir/observar é, no mínimo, mais uma dessas chamadas: o "pensar" é sempre um pedido ao modelo, com o mesmo custo por token e a mesma latência de rede mais geração que já conheces. Um agente que dá 4 voltas até responder não custa o mesmo que uma chamada única, custa, na melhor das hipóteses, 4 vezes mais em tokens de entrada (o prompt cresce a cada passo, capítulo 7) e em tempo de round-trip.
python
custo_por_chamada = 0.0003 # exemplo; depende do modelo e do tamanho do prompt em cada passo
latencia_por_chamada_segundos = 1.4 # exemplo; idem
def estimar_custo_e_latencia(numero_de_passos):
return {
"custo_estimado": round(custo_por_chamada * numero_de_passos, 4),
"latencia_estimada_segundos": round(latencia_por_chamada_segundos * numero_de_passos, 2),
}
print(estimar_custo_e_latencia(1)) # uma chamada única, como no Curso 1
print(estimar_custo_e_latencia(4)) # um agente com 4 passos até responderOs valores de custo_por_chamada e latencia_por_chamada_segundos acima são só ilustrativos, para o cálculo fazer sentido; confirma sempre os preços atuais do fornecedor, tal como já foi dito no Curso 1. O que interessa reter não é o número exato, é a multiplicação: o mesmo raciocínio de custo por token que já sabias aplicar a uma chamada aplica-se agora, sem alterações, a cada passo do ciclo, não uma vez só por pedido.
Um limite de iterações também é controlo de custo
O capítulo 5 apresentou max_steps como proteção contra um agente preso num loop. É também, e ao mesmo nível de importância, um teto explícito de quanto um único pedido pode custar: com max_steps=5 e um custo estimado de 0.0003 por passo, sabes que nenhum pedido custa mais do que 0.0015, mesmo no pior caso em que o agente nunca converge para uma resposta. Sem esse limite, não há teto nenhum: um pedido perfeitamente normal pode, por uma decisão do modelo a meio, encadear passos indefinidamente, e a fatura reflete isso sem aviso prévio.
Tal como a latência de uma única chamada (Curso 1, capítulo 5), o número de passos de um agente varia por pedido: a média engana aqui tanto como enganava lá. Um agente que normalmente resolve em 2 passos mas ocasionalmente precisa de 6 tem um custo p95 bem acima do custo médio; é o p95 de passos, não a média, que devia moldar o teu max_steps e o teu orçamento por pedido.
09Prática: Agente Multi-Step Sob Controlo
Exercício 2: agente multi-step com limites, validação e log estruturado
Objetivo: Escrever agente_multi_step.py: um agente que resolve um pedido de orçamento em vários passos (consultar um produto, calcular um total, responder), com limite de iterações, validação de cada ação contra uma allowlist, e um log estruturado de cada passo dado.
- CATALOGO e ALLOWLIST_FERRAMENTAS: o catálogo do exercício 1, e um conjunto com os nomes das ferramentas permitidas (consultar_catalogo, calcular_total, responder_final).
- consultar_catalogo(nome_produto), calcular_total(quantidade, preco_unitario) e responder_final(texto): as ferramentas. responder_final() é a ação que o agente chama para terminar, devolvendo o texto da resposta.
- decidir_proximo_passo(pedido, historico): decide a próxima ação com base no pedido e no historico de passos já dados (não só no pedido sozinho). Devolve {"ferramenta": ..., "argumentos": {...}}. Não faz nenhuma chamada de rede.
- validar_acao(ferramenta, argumentos, allowlist): confirma que a ferramenta está na allowlist e que argumentos é um dicionário. Devolve (aprovada, motivo_rejeicao).
- registar_passo(log, numero, ferramenta, argumentos, resultado, aprovada, motivo_rejeicao=None): acrescenta ao log uma entrada estruturada com esses campos, e devolve-a.
- correr_agente(pedido, max_steps=5, timeout_segundos=10): o ciclo completo. A cada passo: verifica o timeout global, pede a próxima decisão, valida-a, executa só se for aprovada, regista o passo no log. Termina quando a ferramenta chamada for responder_final, quando o timeout for excedido, quando uma ação for rejeitada, ou quando max_steps for atingido sem resposta.
Dica: Guarda no historico só os passos aprovados e já executados (para decidir_proximo_passo() poder confiar nos resultados que lá encontra); uma ação rejeitada termina o agente de imediato, tal como um timeout, em vez de continuar o ciclo.
Ver solução
import re
import time
CATALOGO = {
"teclado mecânico": {"preco": 79.90, "stock": 12},
"rato sem fios": {"preco": 24.50, "stock": 30},
"monitor 27 polegadas": {"preco": 219.00, "stock": 5},
}
ALLOWLIST_FERRAMENTAS = {"consultar_catalogo", "calcular_total", "responder_final"}
def consultar_catalogo(nome_produto):
return CATALOGO.get(nome_produto.lower())
def calcular_total(quantidade, preco_unitario):
return round(quantidade * preco_unitario, 2)
def responder_final(texto):
return texto
FERRAMENTAS = {
"consultar_catalogo": consultar_catalogo,
"calcular_total": calcular_total,
"responder_final": responder_final,
}
def decidir_proximo_passo(pedido, historico):
"""Decisão determinística: olha para o pedido e para o histórico de passos
já dados (não só a conversa) e decide a próxima ação, uma de cada vez.
Faz aqui o papel do modelo num ciclo ReAct real. Não faz chamadas de rede."""
pedido_lower = pedido.lower()
passo_catalogo = next(
(p for p in historico if p["ferramenta"] == "consultar_catalogo" and p["aprovada"]),
None,
)
if passo_catalogo is None:
for nome_produto in CATALOGO:
if nome_produto in pedido_lower:
return {"ferramenta": "consultar_catalogo", "argumentos": {"nome_produto": nome_produto}}
return {
"ferramenta": "responder_final",
"argumentos": {"texto": "Não encontrei nenhum produto do catálogo neste pedido."},
}
if passo_catalogo["resultado"] is None:
return {"ferramenta": "responder_final", "argumentos": {"texto": "Esse produto não existe no catálogo."}}
passo_calculo = next(
(p for p in historico if p["ferramenta"] == "calcular_total" and p["aprovada"]),
None,
)
if passo_calculo is None:
match = re.search(r"(\d+(?:[.,]\d+)?)\s*unidades?", pedido_lower)
quantidade = float(match.group(1).replace(",", ".")) if match else 1.0
preco_unitario = passo_catalogo["resultado"]["preco"]
return {
"ferramenta": "calcular_total",
"argumentos": {"quantidade": quantidade, "preco_unitario": preco_unitario},
}
return {
"ferramenta": "responder_final",
"argumentos": {"texto": f"Orçamento: {passo_calculo['resultado']}€."},
}
def validar_acao(ferramenta, argumentos, allowlist):
"""Confirma que é seguro correr esta ação antes de a correres: a ferramenta
tem de estar na allowlist, e os argumentos têm de ser um dicionário.
Devolve (aprovada, motivo_rejeicao). Não executa nada."""
if ferramenta not in allowlist:
return False, f"ferramenta '{ferramenta}' não está na allowlist"
if not isinstance(argumentos, dict):
return False, "argumentos têm de ser um dicionário"
return True, None
def registar_passo(log, numero, ferramenta, argumentos, resultado, aprovada, motivo_rejeicao=None):
"""Um log estruturado, não um print solto: cada passo fica pesquisável
depois (ferramenta escolhida, argumentos, resultado, se foi aprovada). É a
mesma disciplina de logging estruturado do Curso 3 desta série, aplicada
agora a cada passo de um agente, não a um pedido único."""
entrada = {
"passo": numero,
"ferramenta": ferramenta,
"argumentos": argumentos,
"resultado": resultado,
"aprovada": aprovada,
}
if motivo_rejeicao is not None:
entrada["motivo_rejeicao"] = motivo_rejeicao
log.append(entrada)
return entrada
def correr_agente(pedido, max_steps=5, timeout_segundos=10):
"""O ciclo pensar/agir/observar, com limite de iterações e timeout global
(capítulo 5), e validação de cada ação antes de a correr (capítulo 6)."""
log = []
inicio = time.time()
for numero_passo in range(1, max_steps + 1):
if time.time() - inicio > timeout_segundos:
registar_passo(log, numero_passo, None, None, None, False, motivo_rejeicao="timeout global atingido")
return {"concluido": False, "motivo": "timeout", "log": log}
decisao = decidir_proximo_passo(pedido, log)
ferramenta = decisao["ferramenta"]
argumentos = decisao["argumentos"]
aprovada, motivo_rejeicao = validar_acao(ferramenta, argumentos, ALLOWLIST_FERRAMENTAS)
if not aprovada:
registar_passo(log, numero_passo, ferramenta, argumentos, None, False, motivo_rejeicao)
return {"concluido": False, "motivo": "acao_rejeitada", "log": log}
resultado = FERRAMENTAS[ferramenta](**argumentos)
registar_passo(log, numero_passo, ferramenta, argumentos, resultado, True)
if ferramenta == "responder_final":
return {"concluido": True, "resposta": resultado, "log": log}
return {"concluido": False, "motivo": "max_steps atingido", "log": log}
if __name__ == "__main__":
resultado = correr_agente("Quero orçamento para 3 unidades do teclado mecânico")
print(resultado["resposta"])
for passo in resultado["log"]:
print(" ", passo)Correndo o script: o agente dá 3 passos (consultar o teclado mecânico, calcular 3 × 79.90, responder "Orçamento: 239.7€.") e log fica com uma entrada por passo, cada uma com ferramenta, argumentos, resultado e aprovada. Com max_steps=2 no mesmo pedido, o agente para ao fim do segundo passo sem chegar a responder_final, e o motivo é "max_steps atingido": o limite de iterações do capítulo 5 a funcionar exatamente como descrito, não só em teoria.
O teu progresso
Marca os exercícios à medida que os fores fazendo a sério, não só a ler a solução. Fica guardado só neste browser.
Conclusão
Em nove capítulos: o ciclo pensar/agir/observar que distingue um agente de uma cadeia fixa de prompts, function calling a sério com a API da OpenAI (tools=[...], tool_calls, e a mensagem role="tool" a devolver o resultado), o trade-off real entre um workflow determinístico e um agente autónomo, limite de iterações e timeout global contra o perigo de um agente preso a repetir a mesma ação, allowlist e human-in-the-loop antes de correr uma ação irreversível, o que persistir entre passos e o problema do contexto que cresce a cada volta, e como custo e latência se multiplicam a cada passo do ciclo, não só uma vez por pedido.
Se fizeste os dois exercícios a sério, construíste primeiro um agente simples que decide qual ferramenta chamar com base num pedido, e depois um agente multi-step completo, com limite de passos, validação de cada ação antes de a correr, e um log estruturado que te diz exatamente o que aconteceu em cada volta do ciclo, mesmo quando o agente não chega a uma resposta. É a diferença entre "dar a um modelo acesso a ferramentas" e construir um sistema que aguenta correr sem supervisão constante.
Se ficares só com uma decisão deste curso, fica com esta: autonomia sem limite não é um agente melhor, é um sistema sem controlo, e o controlo (quantos passos, que ações são permitidas, o que persiste entre passos, quanto custa) é trabalho de engenharia, não um extra opcional que se acrescenta depois se sobrar tempo.
E é aqui que a série "De 0 a AI Engineer" fecha: de uma chamada única envolta num serviço real, a dados próprios com RAG, a avaliar e observar o que um sistema de IA faz de facto, a um agente que decide os seus próprios passos sob controlo apertado. Não há um quinto curso a seguir a este; o que fica é aplicar os quatro juntos, no teu próprio problema.
Se ainda não fizeste os anteriores: Curso 1, Curso 2 e Curso 3.
Isto ajudou-te?
Fontes, e o que é só observação minha
O resto
- É o quarto e último curso da série "De 0 a AI Engineer", ancorado na experiência real de construir e operar sistemas de IA em produção (moderação de conteúdo com LLM, deteção de plágio por embeddings, e sistemas com decisão autónoma de próximo passo). Não é investigação, é o programa com que ensino isto a sério.