A tese desta página: um agente não é um prompt mais esperto é um laço de programa. O modelo recebe a pergunta e a lista de ferramentas, escolhe uma, o seu código executa a função, o resultado volta para o estado, e a mesma pergunta é feita de novo com o estado maior. Isso se repete até o modelo responder sem pedir ferramenta, ou até um contador estourar. LangChain entrega as peças (o modelo, a ferramenta, a mensagem); LangGraph entrega o laço: o estado que atravessa os nós, a aresta que decide o próximo passo, o checkpoint que permite rebobinar. As dez etapas seguintes constroem isso do zero: da conta no provedor até a conta do mês.
Explore cada etapa →
As contas, num só lugar
As 4 contas desta página. Clique em qualquer uma para pular direto para a etapa que a explica.
O que você vai instalar, e para que serve cada um
Python 3.11+ e uv o uv cria o ambiente e instala os pacotes sem você precisar decorar comandos de virtualenv.
langchain as peças: cliente de modelo com interface única, o decorador @tool, os tipos de mensagem.
langgraph o laço: estado tipado, nós, arestas condicionais, checkpoint e limite de recursão.
langsmith o rastro: cada chamada gravada com prompt, resposta, latência, tokens e custo. É o que substitui o print.
langgraph-cli o servidor local: langgraph dev sobe uma API e uma interface de depuração do seu grafo.
quatro comandos, do zero ao projeto pronto
# 1 · cria a pasta do projeto e entra nela
uv init agente-suporte && cd agente-suporte
# 2 · instala as peças (o uv cria o ambiente virtual sozinho)
uv add langchain langgraph langsmith python-dotenv
uv add --dev pytest "langgraph-cli[inmem]"# 3 · confere as versões que entraram anote-as
uv run pip show langchain langgraph | grep -E "Name|Version"# 4 · daqui em diante todo comando começa com uv run
uv run python -c "import langgraph; print('ok')"
# O mesmo, com as ferramentas que já vêm com o Python.
mkdir agente-suporte && cd agente-suporte
# ambiente virtual: isola as bibliotecas deste projeto das outras
python -m venv .venv
source .venv/bin/activate # Windows: .venv\Scripts\activate
pip install langchain langgraph langsmith python-dotenv
pip install pytest "langgraph-cli[inmem]"# o prompt do terminal passa a mostrar (.venv). Se não mostrar,# o activate não rodou e o pip instalou no Python do sistema.
Honestidade, antes de começar: esta página não executa Python e não chama modelo nenhum ela é HTML e JavaScript. O que roda de verdade aqui: a conta de acerto composto da etapa 1; o validador de .env da etapa 2, inteiro no seu navegador; o redutor de estado da etapa 4, aplicado atualização por atualização; o parser de assinatura Python e o gerador de schema da etapa 5; a simulação do grafo da etapa 6, inclusive o estouro de recursão; a soma de tokens da etapa 7; a combinatória de caminhos da etapa 8; e as contas de custo da etapa 9. O que é estimativa declarada: a contagem de tokens (regra de bolso de 4 caracteres por token, que erra para mais em português) e a latência da etapa 9. O que muda com o tempo: nomes de API. As bibliotecas do ecossistema LangChain se reorganizaram mais de uma vez; o código aqui segue a interface de 2026, e o comando da etapa 0 que imprime as versões instaladas existe justamente para você conferir contra a documentação do dia.
Antes de qualquer linha de código existe uma pergunta que decide o projeto inteiro: os passos da tarefa são conhecidos de antemão? Se são, você não quer um agente quer uma cadeia fixa, que é código comum chamando o modelo em pontos definidos. Cadeia fixa é mais barata, mais rápida, mais fácil de testar e nunca entra em laço. Agente só ganha quando o próximo passo depende do resultado do passo anterior e você não consegue enumerar os caminhos antes.
A segunda razão para o agente é menos óbvia e mais forte: ele vê o resultado da própria ação. Uma cadeia fixa que erra no passo 3 entrega a resposta errada no passo 7; um agente lê o erro que a ferramenta devolveu e tenta de novo. O simulador abaixo põe as duas coisas na mesma balança a repetição custa chamadas a mais, e a pergunta é se ela paga.
p_fim = pn
Em prosa: o acerto no fim é o acerto de um passo multiplicado por ele mesmo tantas vezes quantos forem os passos. p é a chance de um passo dar certo; n é o número de passos. A conta é impiedosa: 95% por passo em 10 passos dá 60% no fim. Na prática, 90% a 97% por passo é a faixa comum de uma ferramenta bem escrita, e é por isso que reduzir o número de passos vale mais que qualquer ajuste de modelo.
Quando usar agente, em uma lista: a tarefa tem número de passos desconhecido; o próximo passo depende de dado que só existe depois do anterior; existe um critério de sucesso verificável por código (a consulta achou, o arquivo compilou, o teste passou); e errar custa pouco, porque vai haver tentativa. Quando não usar: extração de campo de documento, classificação, resumo, tradução, geração de texto a partir de dado que você já tem em mãos. Tudo isso é uma chamada, e transformar em agente só adiciona latência, custo e caminhos para dar errado.
Esta é a etapa que os tutoriais pulam e que trava a maioria das pessoas na primeira hora. São seis paradas, cada uma com um endereço e um botão. Faça na ordem: a chave só funciona se houver crédito, e o crédito só entra se a conta existir.
Onde entrar e o que clicar
Sobre a chave, uma vez só: ela é uma senha que gasta dinheiro. Quem tem a chave faz chamadas na sua conta, e o provedor cobra. Por isso: uma chave por lugar onde ela vive (sua máquina, o servidor, o CI), nome que identifique esse lugar, e revogação imediata quando a máquina sai de uso. Chave não vai para o código, não vai para o print da tela, não vai para o chat do time, não vai para o repositório nem privado, porque repositório privado vira público por engano com uma frequência desconfortável.
O arquivo .env, linha a linha
Edite o texto abaixo e o validador aponta o problema de cada linha. Ele conhece os nomes que este projeto lê, os prefixos de cada provedor e os erros de digitação que mais custam tempo: espaço em volta do =, aspas que viram parte do valor, variável repetida.
Este validador roda inteiro no seu navegador. Nada é enviado e mesmo assim, teste com o texto de exemplo, não com uma chave real.
os três passos que fecham o cerco
# .gitignore crie ANTES do primeiro commit# Uma vez que o .env entra no histórico do git, apagar o arquivo# não resolve: o commit antigo continua lá, e a chave também.
.env
.env.*
!.env.example
.venv/
__pycache__/
.pytest_cache/
*.sqlite
# O .env não é lido por mágica: alguém precisa carregá-lo.# python-dotenv lê o arquivo e põe cada linha no ambiente.from dotenv import load_dotenv
load_dotenv() # procura .env subindo a partir do diretório atual# A partir daqui, os clientes de modelo encontram a chave sozinhos:# eles leem os.environ["ANTHROPIC_API_KEY"] por conta própria.# Você nunca passa a chave como argumento se está passando,# ela provavelmente está escrita em algum lugar que não devia.
# Teste de fumaça: uma chamada só, antes de escrever o agente.# Se esta falhar, o problema é chave ou crédito não é o seu código.from dotenv import load_dotenv
from langchain.chat_models import init_chat_model
load_dotenv()
modelo = init_chat_model("anthropic:claude-sonnet-5")
print(modelo.invoke("responda apenas: ok").content)
# Erros mais comuns, e o que cada um quer dizer:# 401 authentication_error → chave errada ou revogada# 400 credit balance too low → falta crédito no console# KeyError: ANTHROPIC_API_KEY → o load_dotenv não achou o .env
Antes de abrir o editor, três comandos criam o esqueleto: mkdir -p src/agente tests, touch src/agente/__init__.py e o .env da etapa anterior. Esse __init__.py vazio é o que transforma a pasta em pacote importável sem ele, from agente.ferramentas import ... falha com ModuleNotFoundError, o primeiro erro de quase todo projeto Python. Clique em cada arquivo para ver o conteúdo comentado, e marque o seu arranjo para filtrar a árvore.
Marque o seu arranjo
# pyproject.toml quem instala o quê, e em qual versão.# Sem este arquivo, "funciona na minha máquina" vira literal.
[project]
name = "agente-suporte"
version = "0.1.0"
requires-python = ">=3.11"
dependencies = [
"langchain>=1.0", # as peças: modelo, @tool, mensagens"langgraph>=1.0", # o laço: estado, nós, arestas, checkpoint"langsmith>=0.3", # o rastro de cada chamada"python-dotenv>=1.0", # lê o .env
]
[dependency-groups]
dev = [
"pytest>=8.0",
"langgraph-cli[inmem]>=0.2", # o comando langgraph dev
]
# Trave a versão maior (langchain>=1.0,<2) quando o projeto for# para produção: este ecossistema já renomeou pacote mais de uma vez.
# .env segredos, fora do código e fora do git.# Cada linha vira uma variável de ambiente do processo.
ANTHROPIC_API_KEY=sk-ant-api03-...
LANGSMITH_TRACING=true
LANGSMITH_API_KEY=lsv2_pt_...
LANGSMITH_PROJECT=agente-suporte
# Endereços internos também moram aqui: eles mudam por ambiente,# e ambiente não é assunto de código-fonte.
ERP_BASE_URL=https://erp.interno.local
# Guarde um .env.example COM as chaves vazias e sem segredo nenhum:# é a documentação de quais variáveis o projeto precisa.
# .gitignore escrito ANTES do primeiro commit.# Depois que o .env entra no histórico, apagar o arquivo não basta:# o commit antigo continua carregando a chave.
.env
.env.*
!.env.example
.venv/
__pycache__/
.pytest_cache/
*.sqlite
.langgraph_api/
# src/agente/estado.py# O contrato entre os nós: tudo que atravessa o grafo está aqui.from typing import Annotated
from typing_extensions import TypedDict
from langgraph.graph.message import add_messages
classEstado(TypedDict):
# Annotated[tipo, redutor]: o redutor decide o que fazer com o# pedaço devolvido pelo nó. add_messages ACUMULA e substitui pelo# id quando a mensagem já existe. Sem ele, cada nó SOBRESCREVE# a lista inteira e o agente esquece tudo entre um passo e outro.
messages: Annotated[list, add_messages]
# Campos sem redutor são substituídos, que é o certo para um valor# único. Guarde aqui o que o agente precisa lembrar mas não deve# reenviar ao modelo em toda chamada.
cliente: str
passos: int# Atalho equivalente, quando o estado é só a conversa:# from langgraph.graph import MessagesState# class Estado(MessagesState): cliente: str
# src/agente/ferramentas.py# Uma ferramenta é uma função comum com três coisas a mais:# tipos anotados, docstring que o modelo lê, e erro que não explode.import os
import httpx
from langchain.tools import tool
BASE = os.environ["ERP_BASE_URL"]
LIMITE = 1500# caracteres devolvidos ao modelo, no máximo@tooldefconsultar_pedido(numero: str, incluir_itens: bool = False) -> str:
"""Busca a situação de um pedido no ERP pelo número.
Use quando o usuário citar um número de pedido e quiser saber
status, data de envio ou código de rastreio. Não use para trocas
ou reembolso, que têm ferramenta própria.
numero: identificador impresso na nota, formato PED-000000.
incluir_itens: True traz a lista de itens, que é longa.
Devolve uma linha por campo, ou "não encontrado".
"""try:
# timeout SEMPRE: sem ele, a API lenta pendura o nó do grafo,# o servidor segura a conexão e a fila cresce atrás.
r = httpx.get(f"{BASE}/pedidos/{numero}", timeout=5.0)
if r.status_code == 404:
return"não encontrado"
r.raise_for_status()
except httpx.HTTPError as e:
# Erro VOLTA COMO TEXTO, não como exceção: o modelo lê,# entende e tenta outra coisa. Exceção mataria o turno.returnf"erro ao consultar o ERP: {e}"
dados = r.json()
linhas = [f"{k}: {v}"for k, v in dados.items() if k != "itens"]
if incluir_itens:
linhas += [f"item: {i['sku']} x{i['qtd']}"for i in dados["itens"]]
# Corte declarado: resposta gigante entra no histórico e fica lá,# sendo repaginada em TODOS os turnos seguintes.
saida = "\n".join(linhas)
return saida[:LIMITE] + ("\n…cortado"iflen(saida) > LIMITE else"")
FERRAMENTAS = [consultar_pedido] # a lista que o grafo importa
# src/agente/grafo.py o arquivo que não dá para não ter.from langchain.chat_models import init_chat_model
from langchain_core.messages import SystemMessage
from langgraph.graph import StateGraph, START, END
from langgraph.prebuilt import ToolNode, tools_condition
from langgraph.checkpoint.memory import InMemorySaver
from .estado import Estado
from .ferramentas import FERRAMENTAS
SISTEMA = SystemMessage("""Você atende o suporte de uma loja.
Responda em português, em no máximo 4 linhas.
Use as ferramentas para qualquer dado de pedido: nunca invente número,
data ou código de rastreio. Se a ferramenta falhar, diga isso.""")
# bind_tools é o que faz o modelo ENXERGAR as ferramentas: ele converte# cada função no schema JSON que vai junto com o prompt.
modelo = init_chat_model("anthropic:claude-sonnet-5").bind_tools(FERRAMENTAS)
defno_agente(estado: Estado) -> dict:
"""Um nó recebe o estado inteiro e devolve só o pedaço que mudou."""
resposta = modelo.invoke([SISTEMA] + estado["messages"])
return {"messages": [resposta], "passos": estado.get("passos", 0) + 1}
construtor = StateGraph(Estado)
construtor.add_node("agente", no_agente)
construtor.add_node("ferramentas", ToolNode(FERRAMENTAS))
construtor.add_edge(START, "agente")
# A ARESTA CONDICIONAL é o laço inteiro: tools_condition olha a última# mensagem se ela pede ferramenta, vai para "ferramentas"; se não,# vai para END. É uma função Python comum, e dá para escrever a sua.
construtor.add_conditional_edges(
"agente", tools_condition, {"tools": "ferramentas", END: END}
)
construtor.add_edge("ferramentas", "agente") # e volta: eis o ciclo# O checkpointer salva o estado a cada super-passo. É o que permite# continuar uma conversa, rebobinar e inspecionar passo a passo.
grafo = construtor.compile(checkpointer=InMemorySaver())
if __name__ == "__main__":
# thread_id é o identificador da conversa. Mesmo id = mesmo histórico.
config = {"configurable": {"thread_id": "cliente-a"}, "recursion_limit": 12}
estado = grafo.invoke(
{"messages": [{"role": "user", "content": "cadê o PED-000123?"}]},
config,
)
print(estado["messages"][-1].content)
# tests/test_ferramentas.py# Ferramenta é função comum: testa-se como função comum, sem modelo,# sem rede e sem gastar um centavo. Roda em milissegundos.import httpx
import pytest
from agente.ferramentas import consultar_pedido
deftest_pedido_inexistente(monkeypatch):
# monkeypatch troca a chamada de rede por uma resposta fixa
monkeypatch.setattr(httpx, "get", lambda *a, **k: httpx.Response(404))
# .invoke porque @tool embrulha a função; .func chama a originalassert consultar_pedido.invoke({"numero": "PED-000001"}) == "não encontrado"deftest_erro_de_rede_volta_como_texto(monkeypatch):
defexplode(*a, **k):
raise httpx.ConnectTimeout("tempo esgotado")
monkeypatch.setattr(httpx, "get", explode)
saida = consultar_pedido.invoke({"numero": "PED-000001"})
# A regra que este teste protege: ferramenta NUNCA levanta exceção# para o grafo. Erro é texto, e o modelo decide o que fazer com ele.assert"erro"in saida
deftest_resposta_longa_e_cortada(monkeypatch):
grande = {"campo": "x" * 5000, "itens": []}
monkeypatch.setattr(httpx, "get", lambda *a, **k: httpx.Response(200, json=grande))
assertlen(consultar_pedido.invoke({"numero": "PED-1"})) < 1600
// langgraph.json diz ao servidor local qual grafo publicar.// Sem ele, `langgraph dev` sobe e o Studio fica vazio.
{
"dependencies": ["."],
"graphs": {
// "nome-da-rota": "caminho/do/arquivo.py:variável_do_grafo""suporte": "./src/agente/grafo.py:grafo"
},
"env": ".env"
}
# src/agente/rastro.py o rastro é configuração, não código.# Com estas três variáveis no ambiente, TODA chamada de modelo e# TODO passo do grafo aparecem em smith.langchain.com, sem import.import os
from dotenv import load_dotenv
load_dotenv()
FALTANDO = [k for k in ("LANGSMITH_TRACING", "LANGSMITH_API_KEY") if k notin os.environ]
if FALTANDO:
# Falha cedo e em voz alta: agente sem rastro em produção é uma# caixa que às vezes responde errado e você não sabe por quê.raiseRuntimeError(f"rastro desligado, faltam: {FALTANDO}")
os.environ.setdefault("LANGSMITH_PROJECT", "agente-suporte")
# Para marcar uma função sua no mesmo rastro:# from langsmith import traceable# @traceable(name="normaliza_numero_pedido")# def normaliza(n: str) -> str: ...
# src/agente/persistencia.py# InMemorySaver perde tudo quando o processo reinicia ótimo para# estudar, inaceitável quando existe um usuário do outro lado.import os
from langgraph.checkpoint.postgres import PostgresSaver
# pip install langgraph-checkpoint-postgres psycopg[binary]
URL = os.environ["POSTGRES_URL"]
defabrir_checkpointer():
"""Context manager: use com `with` no ponto de entrada do processo."""
cm = PostgresSaver.from_conn_string(URL)
return cm
# Uma vez, na primeira subida, para criar as tabelas:# with abrir_checkpointer() as cp:# cp.setup()# grafo = construtor.compile(checkpointer=cp)## O que passa a persistir: todo o estado, a cada super-passo. Isso# inclui o conteúdo das mensagens trate o banco do checkpoint com# o mesmo cuidado que você trata a tabela de clientes.
# Dockerfile a mesma coisa, em qualquer máquina.FROM python:3.12-slim
# uv dentro da imagem: instala rápido e respeita o lockCOPY --from=ghcr.io/astral-sh/uv:latest /uv /usr/local/bin/uv
WORKDIR /app
COPY pyproject.toml uv.lock ./
RUN uv sync --frozen --no-dev
COPY src ./src
COPY langgraph.json ./
# A chave NÃO entra na imagem: ela entra como variável de ambiente# na hora de rodar docker run -e ANTHROPIC_API_KEY=... ENV PYTHONUNBUFFERED=1
EXPOSE 2024
CMD ["uv", "run", "langgraph", "dev", "--host", "0.0.0.0", "--port", "2024"]
# src/agente/supervisor.py# Quando dividir: um agente só começa a errar a escolha por volta de# 10 a 15 ferramentas no mesmo cinto. A saída não é prompt maior,# é mais de um agente, cada um com o seu punhado de ferramentas.from typing import Literal
from langgraph.graph import StateGraph, START, END
from langgraph.types import Command
from .estado import Estado
defsupervisor(estado: Estado) -> Command[Literal["pedidos", "financeiro", END]]:
"""Lê a conversa e decide QUEM continua. Não responde ao usuário."""
ultima = estado["messages"][-1].content.lower()
# Roteamento por regra quando dá: é grátis, instantâneo e testável.# Só chame o modelo para decidir quando a regra não resolver.if"reembolso"in ultima or"estorno"in ultima:
returnCommand(goto="financeiro")
if"pedido"in ultima or"entrega"in ultima:
returnCommand(goto="pedidos")
returnCommand(goto=END)
construtor = StateGraph(Estado)
construtor.add_node("supervisor", supervisor)
construtor.add_node("pedidos", grafo_pedidos) # um grafo pode ser nó
construtor.add_node("financeiro", grafo_financeiro)
construtor.add_edge(START, "supervisor")
supervisao = construtor.compile()
Por que src/agente/ e não tudo na raiz: com a pasta src, os testes só passam se o pacote estiver instalado do jeito certo é uma verificação de graça de que o projeto funciona fora da sua máquina. E a separação em quatro arquivos não é gosto: ferramentas.py precisa ser importável sem subir o grafo (senão o teste rápido chama o modelo), e estado.py precisa ser importável pelos dois sem ciclo de import.
Na prática, tudo isso aparece em uma linha de declaração de tipo. messages: list e messages: Annotated[list, add_messages] são dois programas diferentes, e o Python não acusa a diferença: quem lê o Annotated é o LangGraph, na hora de montar o grafo. A simulação abaixo aplica as duas regras sobre a mesma sequência de pedaços devolvidos pelos nós é o mesmo laço que roda lá dentro, escrito em dez linhas.
Com Annotated[list, add_messages]
Com list, sem redutor
o pedaço, não o estado inteiro
# Um nó recebe o estado INTEIRO e devolve um dicionário PARCIAL.# O que não está no dicionário devolvido simplesmente não muda.defno_agente(estado: Estado) -> dict:
resposta = modelo.invoke(estado["messages"])
return {"messages": [resposta]} # ← lista com UMA mensagem# Com add_messages, esse [resposta] é ANEXADO ao histórico.# Sem redutor, esse [resposta] VIRA o histórico e as anteriores# somem sem aviso, sem log e sem exceção.# O erro clássico, que parece mais correto e é o pior:defno_errado(estado: Estado) -> dict:
estado["messages"].append(resposta) # mutação no lugarreturn estado # devolve tudo de novo# Resultado: a mensagem entra duas vezes (a sua e a do redutor).
# Redutor é só uma função de duas entradas: o que já existia e o# que chegou. Dá para escrever o seu para qualquer campo.from typing import Annotated
from operator import add
defultimo_nao_vazio(atual: str, novo: str) -> str:
"""Mantém o valor anterior quando o nó devolve string vazia."""return novo or atual
classEstado(TypedDict):
messages: Annotated[list, add_messages]
custos: Annotated[list[float], add] # concatena listas
rastreio: Annotated[str, ultimo_nao_vazio] # regra sua
cliente: str# substitui, o padrão# Dois nós que rodam em paralelo e escrevem no MESMO campo sem# redutor levantam InvalidUpdateError. O redutor é o que diz ao# LangGraph como juntar as duas escritas por isso ele existe.
A consequência prática é imediata: a docstring é código de produção. Ela é reenviada em toda chamada, e por isso custa tokens; ela decide qual ferramenta o modelo escolhe, e por isso custa acerto; e é o único lugar onde dá para dizer quando não usar esta função. Edite a função abaixo e veja o bloco exato que sai daqui para o modelo, com o diagnóstico da descrição ao lado.
Edite a função e veja o que o modelo recebe
O schema que vai junto com o prompt
As cinco regras de uma ferramenta que o modelo usa direito:1) nome em verbo mais objeto (consultar_pedido, não pedido nem helper); 2) primeira linha da docstring resumindo a ação em uma frase, porque é ela que aparece na lista; 3) uma frase dizendo quando usar e isto rende mais que tudo uma dizendo quando não usar, apontando a ferramenta vizinha; 4) cada argumento descrito com formato e exemplo (PED-000000 corta metade das chamadas malformadas); 5) o que a função devolve, incluindo o caso de não achar nada. Ferramenta sem docstring é gaveta sem etiqueta: o modelo abre por ordem, não por conteúdo.
três refinamentos que valem a pena
# Quando o argumento tem formato, descreva-o com Pydantic: a# descrição entra no schema, e a validação acontece ANTES da sua# função rodar argumento malformado nem chega no seu código.from pydantic import BaseModel, Field
from langchain.tools import tool
classConsulta(BaseModel):
numero: str = Field(
description="número do pedido, formato PED-000000",
pattern=r"^PED-\d{6}$",
)
incluir_itens: bool = Field(False, description="lista de itens, longa")
@tool("consultar_pedido", args_schema=Consulta)
defconsultar_pedido(numero: str, incluir_itens: bool = False) -> str:
"""Busca a situação de um pedido no ERP pelo número."""
...
# O retorno da ferramenta entra no histórico e fica lá. Por isso:# RUIM: devolve o JSON cru da API 4 mil tokens, repaginados em# todos os turnos seguintes, e o modelo precisa garimpar.return json.dumps(resposta_da_api)
# BOM: devolve só o que responde à pergunta, em texto curto.returnf"status: {d['status']}\nenvio: {d['envio']}\nrastreio: {d['rastreio']}"# BOM também: devolve o caminho e deixa o dado gordo fora da janela.
caminho = Path("/tmp") / f"pedido-{numero}.json"
caminho.write_text(json.dumps(resposta_da_api))
returnf"salvo em {caminho} ({len(resposta_da_api['itens'])} itens)"# E o caso vazio SEMPRE explícito: "não encontrado" é informação.# Devolver "" faz o modelo achar que a ferramenta quebrou e repetir.
# Dá para testar a ESCOLHA da ferramenta sem rodar o grafo inteiro:# uma chamada de modelo, e você olha o que ele pediu.
modelo = init_chat_model("anthropic:claude-sonnet-5").bind_tools(FERRAMENTAS)
casos = [
("cadê o PED-000123?", "consultar_pedido"),
("quero meu dinheiro de volta", "abrir_reembolso"),
("vocês entregam no sábado?", None), # nenhuma: é política
]
for pergunta, esperada in casos:
r = modelo.invoke(pergunta)
pedida = r.tool_calls[0]["name"] if r.tool_calls elseNoneprint(f"{'ok ' if pedida == esperada else 'ERRO'} {pergunta} → {pedida}")
# Rode isso depois de mexer em QUALQUER docstring. Duas ferramentas# parecidas se confundem, e o sintoma aparece aqui, barato.
Duas coisas desse desenho costumam surpreender. A primeira: quem executa a ferramenta é o seu processo, não o modelo ele só devolve o nome e os argumentos em JSON, e o nó de ferramentas chama a função Python. A segunda: não existe "o agente está pensando" existe um contador de super-passos subindo e um teto que corta a execução quando ele estoura. Rode a simulação abaixo acompanhando os dois.
O grafo, aceso no nó que está rodando
START→agente⟳ferramentas→END
A seta curva entre agente e ferramentas vale nos dois sentidos: ida quando o modelo pede, volta sempre. É o único ciclo do grafo, e é onde o custo mora.
Por que "super-passo" e não "chamada": o LangGraph conta a execução em super-passos cada rodada de nós que roda junta. No grafo acima, cada ida ao modelo é um, cada execução de ferramenta é outro. Uma conversa que faz 3 chamadas de ferramenta gasta 7 super-passos, não 3. O recursion_limit padrão é 25, o que dá cerca de 12 chamadas de ferramenta: suficiente para quase tudo, e baixo o bastante para o laço infinito parar antes de virar fatura. Diminua-o para o que a sua tarefa realmente precisa: esse número é o seu freio de mão.
o mesmo laço, em três níveis de controle
# tools_condition faz isto. Vale escrever uma vez à mão para ver# que não tem mágica: é um if olhando a última mensagem.from langgraph.graph import END
defdecidir(estado: Estado) -> str:
ultima = estado["messages"][-1]
# O modelo "pede" uma ferramenta preenchendo tool_calls na resposta.ifgetattr(ultima, "tool_calls", None):
return"ferramentas"# Freio próprio, além do recursion_limit: regra de negócio, não# de framework. Aqui você pode devolver uma resposta educada em# vez de uma exceção.if estado.get("passos", 0) > 8:
return END
return END
construtor.add_conditional_edges("agente", decidir, ["ferramentas", END])
# invoke() devolve só o estado final. Para ver o laço acontecendo# (e é isto que você quer enquanto desenvolve), use stream().
config = {"configurable": {"thread_id": "cliente-a"}, "recursion_limit": 12}
for passo in grafo.stream(entrada, config, stream_mode="updates"):
# cada iteração é UM super-passo: {nome_do_nó: pedaço devolvido}for no, pedaco in passo.items():
msg = pedaco["messages"][-1]
print(f"[{no}] {getattr(msg, 'tool_calls', None) or msg.content[:80]}")
# Rebobinar: com checkpointer, todo estado intermediário ficou salvo.for snap in grafo.get_state_history(config):
print(snap.next, len(snap.values["messages"]))
# E dá para retomar de um ponto anterior mudando o estado:# grafo.update_state(config, {"messages": [...]}, as_node="agente")
# Depois de entender o laço, o atalho: create_agent monta esse# mesmo grafo agente + ferramentas + aresta condicional e# devolve o grafo já compilado.from langchain.agents import create_agent
agente = create_agent(
model="anthropic:claude-sonnet-5",
tools=FERRAMENTAS,
system_prompt="Você atende o suporte de uma loja.",
checkpointer=InMemorySaver(),
)
resposta = agente.invoke(
{"messages": [{"role": "user", "content": "cadê o PED-000123?"}]},
{"configurable": {"thread_id": "cliente-a"}},
)
# Use o atalho quando o laço é o padrão. Volte para o StateGraph# quando precisar de: mais de um modelo, pausa para aprovação# humana, ramo paralelo, ou um nó que não é o modelo.
Modelo não tem memória é o primeiro axioma de quem constrói em cima. O que existe é o histórico reenviado a cada chamada. No LangGraph isso vira um objeto concreto: o checkpointer, que grava o estado a cada super-passo, e o thread_id, a string que diz qual conversa carregar. Duas consequências, uma boa e uma cara: dá para continuar de onde parou e rebobinar; e o turno 20 paga pelos 19 anteriores.
Uma string separa duas conversas
Os dois botões chamam o mesmo grafo compilado, com o mesmo checkpointer. O que separa as conversas é o thread_id no config. Trocar essa string é trocar de conversa repetir a de outro usuário é vazar a conversa dele.
O custo de um turno é o prompt inteiro
o que segurar e o que jogar fora
# Mesmo thread_id = mesma conversa. O histórico volta sozinho:# você manda UMA mensagem, o grafo carrega o resto do checkpoint.
config = {"configurable": {"thread_id": "cliente-a"}}
grafo.invoke({"messages": [{"role": "user", "content": "cadê o PED-000123?"}]}, config)
grafo.invoke({"messages": [{"role": "user", "content": "e o frete?"}]}, config)
# ↑ a segunda entende "o frete" porque a primeira está no estado# Ver o que está guardado agora:
snap = grafo.get_state(config)
print(len(snap.values["messages"]), snap.next)
# REGRA DE OURO do thread_id: ele precisa ser derivado de quem está# falando (id do usuário + id da conversa), nunca de algo que possa# colidir. Dois usuários com o mesmo thread_id leem um ao outro.
# O histórico não se poda sozinho. Duas estratégias, e a escolha# depende de a conversa ter ou não fatos que precisam sobreviver.from langchain_core.messages import RemoveMessage, SystemMessage
# 1 · JANELA: guarda as N últimas. Barato, e esquece o começo.defpodar(estado: Estado) -> dict:
antigas = estado["messages"][:-10]
return {"messages": [RemoveMessage(id=m.id) for m in antigas]}
# 2 · RESUMO: troca o começo por um parágrafo. Custa uma chamada# de modelo a cada N turnos e mantém a conta linear em vez de# quadrática. É o que a simulação acima mede.defresumir(estado: Estado) -> dict:
antigas = estado["messages"][:-4]
iflen(antigas) < 8:
return {}
resumo = modelo_barato.invoke(
[{"role": "user", "content": "Resuma em 5 linhas, preservando números:"}]
+ antigas
)
return {"messages": [RemoveMessage(id=m.id) for m in antigas]
+ [SystemMessage(f"Resumo até aqui: {resumo.content}")]}
# O que NUNCA se poda: o prompt de sistema e o último tool_call# com a respectiva ToolMessage o par precisa ficar junto, ou o# provedor recusa a requisição.
A tentação é escrever um teste que manda uma pergunta e confere a resposta final. Ele é lento, caro e instável: passa e falha sem o código mudar, porque o modelo não é determinístico. A saída é a pirâmide de sempre, com uma diferença: aqui a base ferramenta e roteamento é código comum, e testar código comum já cobre a maior parte do que quebra.
A moral da combinatória: não existe cobrir todos os caminhos de um agente com 4 ferramentas e 3 idas já são 85 sequências, e o número explode com o expoente. O que se testa é outra coisa: cada ferramenta isolada (rápido, determinístico, cobre o que de fato quebra), o roteamento com respostas de modelo fixadas à mão, e um punhado de caminhos inteiros como teste de fumaça. Acerto de resposta é avaliação, não teste: roda num dataset versionado, com métrica, no ritmo de release não a cada commit.
os três níveis, em código
# O modelo vira um dublê com respostas fixas. O teste passa a ser# determinístico, roda em milissegundos e não gasta nada.from langchain_core.messages import AIMessage
from langchain_core.language_models import GenericFakeChatModel
deftest_roteia_para_ferramenta():
pedido = AIMessage(
content="",
tool_calls=[{"name": "consultar_pedido",
"args": {"numero": "PED-000123"},
"id": "1"}],
)
falso = GenericFakeChatModel(messages=iter([pedido, AIMessage("pronto")]))
# o que se testa aqui é a ARESTA, não o modelo:assertdecidir({"messages": [pedido]}) == "ferramentas"assertdecidir({"messages": [AIMessage("pronto")]}) == END
# Um punhado destes, não uma suíte inteira. Marque para poder# excluí-los do commit: pytest -m "not lento"import pytest
@pytest.mark.lentodeftest_fumaca_pedido():
config = {"configurable": {"thread_id": "teste-1"}, "recursion_limit": 8}
estado = grafo.invoke(
{"messages": [{"role": "user", "content": "status do PED-000123"}],
"cliente": "teste"},
config,
)
# Asserção sobre COMPORTAMENTO, não sobre texto: exigir uma frase# exata do modelo é garantir que o teste vai quebrar sozinho.
nomes = [m.name for m in estado["messages"] ifgetattr(m, "name", None)]
assert"consultar_pedido"in nomes
assertlen(estado["messages"]) < 10# não entrou em laço
# Avaliação: casos reais com resposta esperada, medidos por métrica.# Roda no ritmo de release, não a cada commit.from langsmith import Client
cliente = Client()
# 1 · o dataset é versionado: 12 casos hoje, 60 em seis meses.
dataset = cliente.create_dataset("suporte-v1")
cliente.create_examples(
dataset_id=dataset.id,
inputs=[{"pergunta": "status do PED-000123"}],
outputs=[{"ferramenta": "consultar_pedido"}],
)
# 2 · a métrica é sua, e precisa ser objetivadefchamou_a_ferramenta_certa(outputs: dict, reference_outputs: dict) -> bool:
return outputs["ferramenta"] == reference_outputs["ferramenta"]
# 3 · e o resultado é comparável entre versões do prompt
cliente.evaluate(rodar_agente, data="suporte-v1",
evaluators=[chamou_a_ferramenta_certa])
O que muda de figura fora da sua máquina não é o deploy é o que acontece quando o volume dobra e quando a ferramenta cai. Os três blocos abaixo cobrem o caminho inteiro: subir local com interface de depuração, chamar o agente por HTTP a partir do seu backend, e as três decisões que mudam quando existe um usuário do outro lado.
do terminal ao contêiner
# Na raiz do projeto, com o .env no lugar:
uv run langgraph dev
# O que aparece no terminal:# API: http://127.0.0.1:2024# Docs: http://127.0.0.1:2024/docs# Studio: https://smith.langchain.com/studio?baseUrl=http://127.0.0.1:2024## Abra o Studio: ele desenha o SEU grafo, deixa disparar uma# execução e mostra o estado a cada super-passo. É aqui que se# descobre que a aresta condicional está mandando para o lado errado.## Hot reload está ligado: salvar grafo.py recarrega o servidor.# O estado vive em memória e some ao reiniciar é modo de# desenvolvimento, e ele avisa isso na primeira linha.
# O servidor expõe threads e execuções como recursos HTTP.# É assim que o seu backend conversa com o agente.# 1 · cria a conversa (devolve um thread_id)
curl -s -X POST http://127.0.0.1:2024/threads \
-H "Content-Type: application/json" -d '{}'# 2 · roda o grafo "suporte" nessa thread e espera a resposta
curl -s -X POST http://127.0.0.1:2024/threads/$TID/runs/wait \
-H "Content-Type: application/json" \
-d '{"assistant_id": "suporte",
"input": {"messages": [{"role": "user", "content": "PED-000123"}]}}'# 3 · e do Python, com o SDK, quando o cliente é outro serviço:# from langgraph_sdk import get_client# client = get_client(url="http://127.0.0.1:2024")# await client.runs.wait(thread_id, "suporte", input={...})
# Três decisões que mudam de figura fora da sua máquina:# 1 · PERSISTÊNCIA: InMemorySaver vira Postgres. Sem isso, um# deploy no meio da tarde apaga a conversa de todo mundo.with PostgresSaver.from_conn_string(URL) as cp:
cp.setup()
grafo = construtor.compile(checkpointer=cp)
# 2 · SEGREDO: a chave entra como variável de ambiente no runtime,# nunca na imagem. docker run -e ANTHROPIC_API_KEY=$KEY ...# 3 · LIMITE: recursion_limit e timeout por requisição, sempre.
config = {
"configurable": {"thread_id": tid},
"recursion_limit": 10,
}
# E o que você vai querer no dia seguinte ao primeiro incidente:# um teto de gasto por thread, rastro ligado, e um alerta quando# a taxa de GraphRecursionError passar de zero.
A conta do mês
Armadilhas de produção
Quando um agente é a resposta certa: triagem e atendimento que precisa consultar dois ou três sistemas antes de responder; tarefas de código e dados em que existe verificação automática (compilou, o teste passou, a consulta retornou); pesquisa em que a próxima busca depende do que a anterior achou; operações internas em que um humano aprova o passo final. O traço comum: muitos caminhos possíveis e um critério objetivo de sucesso.
Quando não é: extrair campo de documento, classificar, resumir, traduzir, redigir a partir de dado que você já tem tudo isso é uma chamada só, e virar agente adiciona latência, custo e caminhos de erro. Também não é quando a tarefa é crítica e não há verificação possível: agente sem critério de sucesso é um gerador de confiança injustificada. E nunca é a primeira versão de um produto: comece com a cadeia fixa e promova a agente o pedaço que provar precisar.
As nove, de um relance
No fim: nada do que esta página construiu é inteligência artificial. O modelo é uma função que recebe texto e devolve texto, e tudo o mais o laço, o estado, a ferramenta, o teto, o checkpoint, o teste é programa comum, escrito por você. É por isso que agente bom parece chato por dentro: ele é feito de timeout, corte de string, erro tratado e limite de recursão. A parte que impressiona na demonstração é a que o modelo faz sozinho; a parte que sobrevive em produção é a que você escreveu em volta dele.
Continue na série
Os axiomas operacionais, as invariantes que explicam por que o histórico precisa voltar inteiro a cada turno
Cache de prompt, a técnica que derruba a conta da etapa 9 e os invalidadores que a estragam em silêncio