LangChain na BEAM

Parte 2 de 3

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ê.

4 min Ler em English

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.

1 pessoa está por aqui