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