Tools que o modelo chama
O modelo decide se chama sua função e com quais argumentos. Validar o que ele mandou continua sendo com você.
Uma tool é uma função sua que o modelo pode pedir para executar. Na biblioteca, ela é uma struct Function:
alias LangChain.Function
lookup_order =
Function.new!(%{
name: "lookup_order",
description: "Busca o status de um pedido pelo número.",
parameters_schema: %{
type: "object",
properties: %{
order_number: %{type: "string", description: "Número do pedido, como aparece no e-mail."}
},
required: ["order_number"]
},
function: fn %{"order_number" => number}, context ->
case MyApp.Orders.fetch(context.tenant_id, number) do
{:ok, order} -> {:ok, "Status: #{order.status}. Previsão: #{order.eta}."}
:error -> {:error, "Pedido não encontrado."}
end
end
})
Ela entra na chain junto com o modo que faz o modelo continuar depois de receber o resultado:
{:ok, chain} =
%{
llm: ChatAnthropic.new!(%{model: "claude-sonnet-5"}),
custom_context: %{tenant_id: tenant_id}
}
|> LLMChain.new!()
|> LLMChain.add_tools([lookup_order])
|> LLMChain.add_message(Message.new_user!("cadê meu pedido A-4471?"))
|> LLMChain.run(mode: :while_needs_response)
Sem o mode:, run/2 para assim que o modelo pede a tool e devolve a chain com a chamada pendente, o que serve para inspecionar ou aprovar antes de executar. Com :while_needs_response, a biblioteca executa a função, devolve o resultado ao modelo e continua, até sair uma resposta em texto.
O custom_context é o que segura o multi-tenant
O tenant_id não está no parameters_schema, e isso é proposital.
Tudo que está no schema é preenchido pelo modelo, ou seja, em última instância, por quem escreveu a mensagem. Com tenant_id entre os parâmetros, um "consulte o pedido A-4471 da empresa 42" bastaria para o modelo obedecer. O custom_context chega pelo segundo argumento da função, vem do servidor, e o modelo não tem como vê-lo nem alterá-lo.
O modelo escolhe se chama a função e com quais argumentos. Em nome de quem, nunca.
Os argumentos são entrada de usuário
A função roda no seu processo, com as permissões do seu processo. O modelo apenas digitou os argumentos, e por trás dele pode estar alguém sondando o que mais você expôs.
Então order_number merece o mesmo tratamento que um parâmetro de controller:
function: fn %{"order_number" => number}, context ->
with {:ok, number} <- MyApp.Orders.validate_number(number),
{:ok, order} <- MyApp.Orders.fetch(context.tenant_id, number) do
{:ok, "Status: #{order.status}. Previsão: #{order.eta}."}
else
_ -> {:error, "Pedido não encontrado."}
end
end
O {:error, motivo} não derruba nada: a string volta ao modelo como resultado da tool, e ele decide o que fazer com ela, normalmente pedir o número de novo ao usuário. Por isso a mensagem de erro é escrita para o modelo ler. Com "Pedido não encontrado." o modelo produz uma resposta útil; com %Ecto.NoResultsError{} ele inventa uma.
A mesma mensagem cobre pedido inexistente e pedido de outro tenant, de propósito. Distinguir os dois casos entregaria a quem perguntou a informação de que aquele pedido existe.
Onde isso trava
Duas coisas quebram em produção.
A primeira é o loop. :while_needs_response continua enquanto o modelo pedir tools, e um modelo confuso, ou uma tool que sempre devolve erro, pede para sempre. Vale um teto explícito, contando as chamadas no estado da conversa e recusando quando passar do limite.
A segunda é o tempo. A função roda dentro do run/2, de forma síncrona: uma tool que faz uma chamada HTTP de dez segundos soma dez segundos à espera do usuário. Se a tool é lenta por natureza, a solução é outra: ela enfileira o trabalho, com Oban ou equivalente, e responde na hora um "estou processando, te aviso" em vez de segurar a conversa inteira.
Na parte final, a resposta chega token a token até a tela.