mozak.tech Engenharia de IA Corporativa 32%

Parte II — Sistemas que Raciocinam e Agem de Forma Autônoma

2.3 — Chamada de Funções e Ferramentas: Projeto e Implementação (Function Calling)

Objetivo da Aula

Ao concluir esta aula, você será capaz de:

Projetar schemas de tools com descrições que guiam o LLM corretamente

Implementar tools de negócio com validação de inputs e erros informativos

Construir um agente de e-commerce com tools de busca, cálculo e criação de pedido

Adicionar validações de estoque e endereço na tool criar_pedido

Aplicar princípios de idempotência em tools de mutação

Por que isso importa

O design de tools é a interface entre o LLM e o mundo real. Uma tool mal nomeada ou com descrição vaga vai ser usada incorretamente. Uma tool que lança exceção sem mensagem clara vai travar o agente.

Mas o impacto vai além da correção técnica. Tools bem projetadas são: - Auto-documentadas: o LLM sabe quando usar cada uma sem instrução extra - Compostas: o agente pode encadear buscar_produto → calcular_preco → criar_pedido sem que você escreva scripts para cada fluxo - Seguras: validações impedeem que dados inválidos cheguem ao sistema

O starter desta unidade simula um agente de atendimento de e-commerce. Em produção, as mesmas tools poderiam fazer chamadas HTTP para a API real da loja — a estrutura é idêntica.

Conceitos Fundamentais

Anatomia de um Schema de Tool

{

    'name': 'calcular_preco',           # snake_case, verbo_objeto

    'description': 'Calcula o preço total com desconto de cupom',  # O QUE faz

    'input_schema': {

        'type': 'object',

        'properties': {

            'produto_id': {

                'type': 'string',       # tipo preciso

            },

            'quantidade': {

                'type': 'number',

                'minimum': 1,           # constraint de validação

            },

            'cupom': {

                'type': 'string',

                'description': 'Código de cupom (opcional)',  # hint para o LLM

            },

        },

        'required': ['produto_id', 'quantidade'],  # cupom é opcional

    },

}

4 decisões de design aqui: 1. Nome: calcular_preco é claro — verbo + objeto. Evite tool1, process_data, handle_request 2. Description: “Calcula o preço total com desconto de cupom” — o LLM usa isso para decidir quando chamar 3. Tipos e constraints: 'minimum': 1 para quantidade — o LLM vai respeitar o schema 4. required vs opcional: cupom fora do required → o LLM não vai inventar um cupom

Por que Description é Mais Importante que Nome

Decisão de Arquitetura: Design de Tools para LLM

Quatro decisões não-óbvias ao projetar tools para agentes:
1. A description é o mecanismo de controle: o LLM decide qual tool chamar baseado principalmente na description, não no nome. Descreva quando usar, não só o que faz.
2. Erros são para o LLM, não para humanos: mensagens de erro devem sugerir a correção. "Parâmetro 'data' deve ser YYYY-MM-DD" ajuda o modelo a corrigir e tentar de novo; "invalid parameter" não ajuda.
3. Idempotência é obrigatória: agentes podem chamar a mesma tool múltiplas vezes. Operações de criação/modificação precisam de idempotency key.
4. Limite de ~10 tools: estudos mostram degradação de qualidade acima de 10 tools por agente. Se você precisa de mais, decomponha em sub-agentes especializados, cada um com conjunto menor.

O LLM escolhe qual tool usar baseado principalmente na description. Experimento mental:

# Tool A: o LLM pode confundir com "listar preços"

{'name': 'calcular_preco', 'description': 'Preço do produto'}



# Tool B: clara sobre o fluxo esperado

{'name': 'calcular_preco', 'description': 'Calcula preço total com desconto opcional. Use ANTES de criar_pedido para mostrar o valor ao cliente.'}

A Tool B instrui o LLM sobre o fluxo correto: calcular antes de criar. Isso elimina a necessidade de scripts rígidos — o agente vai fazer a coisa certa.

Validação de Inputs na Tool

def criar_pedido(produto_id: str, quantidade: int, endereco: str) -> str:

    produto = next((p for p in PRODUTOS if p['id'] == produto_id), None)

    if not produto:

        return f'Produto {produto_id} não encontrado'  # ← mensagem que o LLM pode usar para se corrigir

    

    # TODO do starter: validar estoque e endereco

    if produto['estoque'] == 0:

        return f'Produto "{produto["nome"]}" sem estoque. Não é possível criar pedido.'

    

    if quantidade > produto['estoque']:

        return (f'Estoque insuficiente: {produto["estoque"]} unidade(s) disponíveis, '

                f'você pediu {quantidade}. Ajuste a quantidade ou escolha outro produto.')

    

    if not endereco or len(endereco.strip()) < 10:

        return 'Endereço inválido. Forneça o endereço completo (mínimo 10 caracteres).'

    

    # Criar pedido apenas se tudo válido

    pedido = {

        'id': f'PED{len(pedidos)+1:04d}',

        'produto': produto['nome'],

        'quantidade': quantidade,

        'endereco': endereco,

        'total': produto['preco'] * quantidade,

        'status': 'confirmado',

    }

    pedidos.append(pedido)

    # Decrementar estoque

    produto['estoque'] -= quantidade

    return json.dumps(pedido, ensure_ascii=False)

A mensagem de erro é projetada para o LLM, não para o humano. “Estoque insuficiente: X unidades disponíveis” dá ao agente a informação necessária para se auto-corrigir — ele pode sugerir uma quantidade menor.

O Ciclo Completo de Tool Use

User: "Quero comprar 2 mouses. Preço com cupom DESCONTO10?"



Iter 1 (tool_use):

  → buscar_produto({'query': 'mouse'})

  ← [{'id':'PROD002','nome':'Mouse Sem Fio','preco':89.90,'estoque':50}]



Iter 2 (tool_use):

  → calcular_preco({'produto_id':'PROD002','quantidade':2,'cupom':'DESCONTO10'})

  ← {"subtotal":179.80,"desconto":"10%","total":161.82}



Iter 3 (end_turn):

  "2 Mouses Sem Fio com cupom DESCONTO10: R$161,82 (de R$179,80).

   Deseja confirmar o pedido? Se sim, me passe o endereço de entrega."

O agente encadeou 3 passos de forma autônoma. O “script” não foi necessário — a estrutura das tools guiou o comportamento.

Aprofundamento Técnico

Idempotência em Tools de Criação

Uma tool de criação (como criar_pedido) chamada duas vezes com os mesmos argumentos deve ser segura — não criar 2 pedidos duplicados.

def criar_pedido(produto_id: str, quantidade: int, endereco: str) -> str:

    # Verificar duplicata: mesmo produto + endereço criado nos últimos 60s

    import time

    agora = time.time()

    duplicata = next(

        (p for p in pedidos 

         if p.get('produto_id') == produto_id 

         and p.get('endereco') == endereco

         and agora - p.get('criado_em', 0) < 60),

        None

    )

    if duplicata:

        return json.dumps({'aviso': 'Pedido duplicado detectado', 'pedido_existente': duplicata})

    

    # ... criar pedido normalmente

Em produção, use um idempotency_key como hash dos argumentos + timestamp da sessão.

Cupons: Case-Insensitive e Normalização

CUPONS = {'DESCONTO10': 0.10, 'FRETE20': 0.20, 'BLACK30': 0.30}



def calcular_preco(produto_id: str, quantidade: int, cupom: str = '') -> str:

    # O usuário pode digitar 'desconto10', 'Desconto10', etc.

    cupom_normalizado = cupom.upper().strip() if cupom else ''

    desconto = CUPONS.get(cupom_normalizado, 0)

    

    if cupom and not desconto:

        return f'Cupom "{cupom}" inválido. Cupons válidos: {", ".join(CUPONS.keys())}'

    # ...

Isso permite que o LLM passe o cupom exatamente como o usuário digitou — sem precisar normalizar antes de chamar.

Tool com Resultado Enriquecido

def buscar_produto(query: str) -> str:

    encontrados = [p for p in PRODUTOS if query.lower() in p['nome'].lower() or query.lower() in p['categoria'].lower()]

    

    if not encontrados:

        return json.dumps({

            'erro': f'Nenhum produto encontrado para "{query}"',

            'sugestao': 'Tente: eletrônicos, periféricos, notebook, mouse, teclado, monitor',

        })

    

    # Enriquecer com status de estoque

    for p in encontrados:

        p['status_estoque'] = 'disponível' if p['estoque'] > 0 else 'esgotado'

    

    return json.dumps({

        'total_encontrado': len(encontrados),

        'produtos': encontrados,

    }, ensure_ascii=False)

O LLM usa status_estoque para já informar ao usuário se o produto está disponível, sem precisar de tool call adicional.

Exemplos Anotados

Exemplo 1: criar_pedido com Validações Completas (TODO do Starter)

def criar_pedido(produto_id: str, quantidade: int, endereco: str) -> str:

    # 1. Encontrar produto

    produto = next((p for p in PRODUTOS if p['id'] == produto_id), None)

    if not produto:

        return f'Produto {produto_id} não encontrado. IDs válidos: {[p["id"] for p in PRODUTOS]}'

    

    # 2. Validar estoque (TODO do starter)

    if produto['estoque'] == 0:

        return json.dumps({

            'erro': f'Produto "{produto["nome"]}" está esgotado',

            'acao_sugerida': 'Escolha outro produto ou aguarde reposição',

        }, ensure_ascii=False)

    

    if quantidade > produto['estoque']:

        return json.dumps({

            'erro': f'Quantidade solicitada ({quantidade}) excede estoque disponível ({produto["estoque"]})',

            'maximo_disponivel': produto['estoque'],

        }, ensure_ascii=False)

    

    # 3. Validar endereço (TODO do starter)

    if not endereco or not endereco.strip():

        return 'Endereço não pode ser vazio. Forneça: Rua, número, cidade e estado.'

    

    if len(endereco.strip()) < 15:

        return f'Endereço muito curto ("{endereco}"). Forneça o endereço completo com rua, número e cidade.'

    

    # 4. Criar pedido

    produto['estoque'] -= quantidade  # decrementar estoque

    pedido = {

        'id': f'PED{len(pedidos)+1:04d}',

        'produto_id': produto_id,

        'produto': produto['nome'],

        'quantidade': quantidade,

        'endereco': endereco,

        'preco_unitario': produto['preco'],

        'total': round(produto['preco'] * quantidade, 2),

        'status': 'confirmado',

    }

    pedidos.append(pedido)

    

    return json.dumps({

        'pedido': pedido,

        'mensagem': f'Pedido {pedido["id"]} criado com sucesso! Total: R${pedido["total"]:.2f}',

    }, ensure_ascii=False)

Exemplo 2: Agente Lidando com Estoque Zerado

User: "Quero comprar 3 teclados mecânicos."



Iter 1:

  → buscar_produto({'query': 'teclado mecânico'})

  ← {"produtos": [{"id":"PROD003","estoque":0,"status_estoque":"esgotado"}]}



Iter 2 (end_turn):

  "O Teclado Mecânico RGB está esgotado no momento.

   Posso verificar outra opção? Temos:

   - Mouse Sem Fio (50 em estoque, R$89,90)

   - Notebook Pro 15\" (10 em estoque, R$5.999,99)"

O agente usou o status_estoque do response enriquecido e sugeriu alternativas sem que você programasse esse fluxo explicitamente.

Padrões e Armadilhas

Padrões

Padrão 1: Erro como JSON com acao_sugerida

return json.dumps({

    'erro': 'Produto esgotado',

    'acao_sugerida': 'Verifique outros produtos com buscar_produto',

})

O campo acao_sugerida instrui o LLM sobre o próximo passo, reduzindo iterações desnecessárias.

Padrão 2: Tool names como verbos de ação

buscar_produto     ✓ (verbo + objeto)

calcular_preco     ✓

criar_pedido       ✓

produto_busca      ✗ (confuso)

tool_ecommerce     ✗ (vago)

Padrão 3: Sempre retornar string da tool

# ERRADO: retorna dict

def buscar_produto(query: str) -> dict:

    return {'produtos': [...]}



# CORRETO: serializar para string

def buscar_produto(query: str) -> str:

    return json.dumps({'produtos': [...]}, ensure_ascii=False)

Armadilhas

⚠️ Armadilha 1: Exception não tratada quebra o agente

# PERIGOSO: KeyError vai propagar

def calcular_preco(produto_id: str, quantidade: int, cupom: str = '') -> str:

    produto = {p['id']: p for p in PRODUTOS}[produto_id]  # KeyError se não encontrado!



# SEGURO: tratamento explícito

def calcular_preco(produto_id: str, quantidade: int, cupom: str = '') -> str:

    produto = next((p for p in PRODUTOS if p['id'] == produto_id), None)

    if not produto:

        return f'Produto {produto_id} não encontrado'

⚠️ Armadilha 2: Description ambígua gera tool call errada

# AMBÍGUO: o LLM pode usar para listar, buscar ou calcular preço

'description': 'Produto da loja'



# CLARO: ação + contexto + quando usar

'description': 'Busca produtos por nome ou categoria. Use para encontrar o ID do produto antes de calcular preço ou criar pedido.'

⚠️ Armadilha 3: Muitas tools → LLM confuso Estudos mostram degradação de performance com 10+ tools. Para e-commerce, 5-7 tools é o limite prático. Se precisar de mais, agrupe em “categorias” de tools ou use sub-agentes especializados.

⚗ Laboratório prático — mozak.tech
Se não for realizar o laboratório, pule para o próximo capítulo.

Ponte para o Lab

O TODO do starter está em criar_pedido:

def criar_pedido(produto_id: str, quantidade: int, endereco: str) -> str:

    produto = next((p for p in PRODUTOS if p['id'] == produto_id), None)

    if not produto:

        return f'Produto {produto_id} não encontrado'

    # TODO: adicione validação de estoque e endereco

    pedido = {...}

Implemente as validações: 1. produto['estoque'] == 0 → retornar erro de estoque esgotado 2. quantidade > produto['estoque'] → retornar erro com máximo disponível 3. not endereco or len(endereco.strip()) < 10 → retornar erro de endereço inválido

Após as validações, decremente o estoque: produto['estoque'] -= quantidade.

Teste com a query “Crie um pedido de 100 Mouse Sem Fio” — deve retornar erro de estoque insuficiente (50 disponíveis).

Agora você está pronto para o lab.