LangChain na BEAM

Parte 1 de 3

Uma conversa é um processo

A LLMChain é uma struct imutável, então alguém precisa guardá-la entre uma mensagem e a próxima. Esse alguém é um GenServer.

5 min Ler em English

A LLMChain é uma struct como qualquer outra, e run/1 devolve uma struct nova:

{:ok, chain} =
  %{llm: ChatAnthropic.new!(%{model: "claude-sonnet-5"})}
  |> LLMChain.new!()
  |> LLMChain.add_message(Message.new_user!("oi"))
  |> LLMChain.run()

Não existe objeto mutável guardando o histórico por baixo do pano. A conversa inteira está naquela variável chain, e descartá-la encerra a conversa. Daí a primeira decisão de arquitetura: quem segura essa struct entre uma mensagem do usuário e a próxima?

Onde não guardar

Nos assigns do LiveView, o histórico morre quando o usuário abre uma segunda aba. Numa tabela ETS global, você herda o problema de descobrir quando limpar. No banco, a cada mensagem, estado vivo vira I/O.

Na BEAM, estado vivo mora num processo.

Um GenServer por conversa

defmodule MyApp.Conversation do
  use GenServer

  alias LangChain.Chains.LLMChain
  alias LangChain.ChatModels.ChatAnthropic
  alias LangChain.Message

  # Uma conversa parada por 30 minutos não precisa de um processo vivo.
  @idle_timeout :timer.minutes(30)

  def start_link(opts) do
    GenServer.start_link(__MODULE__, opts, name: via(Keyword.fetch!(opts, :id)))
  end

  def ask(id, text) do
    GenServer.call(via(id), {:ask, text}, :timer.minutes(2))
  end

  defp via(id), do: {:via, Registry, {MyApp.ConversationRegistry, id}}

  @impl true
  def init(opts) do
    chain =
      %{llm: ChatAnthropic.new!(%{model: "claude-sonnet-5"})}
      |> LLMChain.new!()
      |> LLMChain.add_message(Message.new_system!(Keyword.fetch!(opts, :system_prompt)))

    {:ok, %{chain: chain}, @idle_timeout}
  end

  @impl true
  def handle_call({:ask, text}, _from, state) do
    case state.chain |> LLMChain.add_message(Message.new_user!(text)) |> LLMChain.run() do
      {:ok, chain} ->
        {:reply, {:ok, chain.last_message}, %{state | chain: chain}, @idle_timeout}

      {:error, _chain, error} ->
        # A chain com erro é descartada. O estado permanece no último ponto em
        # que uma rodada com o modelo fechou por inteiro.
        {:reply, {:error, error}, state, @idle_timeout}
    end
  end

  @impl true
  def handle_info(:timeout, state), do: {:stop, :normal, state}
end

Repare no timeout devolvido em toda tupla. Se nenhuma mensagem chegar em trinta minutos, o processo recebe :timeout e encerra sozinho, sem Process.send_after e sem varredura periódica. Conversa abandonada some sozinha.

O timeout de dois minutos no GenServer.call/3 também é deliberado. O default é cinco segundos, e um modelo com raciocínio estendido passa disso com folga. Quando isso acontece, quem chamou estoura por timeout enquanto o trabalho segue rodando do outro lado, sem ninguém para receber o resultado.

Uma conversa que ainda não existe

O via acima assume um Registry, e criar o processo sob demanda pede um DynamicSupervisor. Os dois entram na árvore de supervisão:

# lib/my_app/application.ex
children = [
  {Registry, keys: :unique, name: MyApp.ConversationRegistry},
  {DynamicSupervisor, name: MyApp.ConversationSupervisor, strategy: :one_for_one}
]

A função que abre uma conversa trata :already_started como sucesso, já que é rotina duas abas do mesmo usuário chegarem juntas:

def open(id, system_prompt) do
  spec = {MyApp.Conversation, id: id, system_prompt: system_prompt}

  case DynamicSupervisor.start_child(MyApp.ConversationSupervisor, spec) do
    {:ok, pid} -> {:ok, pid}
    {:error, {:already_started, pid}} -> {:ok, pid}
    error -> error
  end
end

Com strategy: :one_for_one, uma conversa que quebra derruba exatamente uma conversa. Num sistema multi-tenant, é o que impede que o bug de um cliente apareça na tela dos outros.

O limite do processo

Guardar a conversa num processo entrega o histórico já em memória, isolamento de graça e expiração pelo timeout do OTP. Cada uma dessas coisas custa trabalho quando o estado mora no banco: uma query por mensagem, isolamento que depende do seu código, um job de limpeza rodando por fora.

O banco ganha em um ponto só, e nesse ponto não tem meio-termo: reiniciou o nó, a conversa evaporou. Se o produto exige que o usuário volte no dia seguinte e encontre o histórico, você grava cada mensagem à medida que a rodada fecha, e remonta a chain a partir delas quando ele voltar. O banco vira o registro; o processo continua sendo onde a conversa acontece enquanto ela está viva.

A próxima parte entrega uma função sua para o modelo chamar.

1 pessoa está por aqui