Saltar para o conteúdo
Telmo Silva/hub
De 0 a AI Engineer · Módulo 4 de 4

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

Um agente sem limite de passos não é autónomo, é um script à espera de uma fatura cara.

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})  # observar

Este 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ísticoAgente autónomo
PrevisibilidadeSabes sempre quantas chamadas um pedido faz, antes de o correresO número de passos varia por pedido, só se sabe depois
Custo por pedidoFixo e fácil de orçamentarVariável; precisa de um limite explícito (capítulo 8)
TestarTestas cada passo isoladamente, como qualquer funçãoPrecisas de testar decisões diferentes consoante o histórico
FlexibilidadeSó resolve o que já previste no códigoLida 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 passo

O 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, None

Esta 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_IRREVERSIVEIS

Na 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é responder

Os 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?

Se isto te foi útil, o mais provável é ser útil a alguém que conheces. Partilha.

LinkedInWhatsApp

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.