LangChain na BEAM

Parte 3 de 3

O LiveView não pode esperar

Streaming token a token, e o motivo de a chamada ao modelo precisar rodar em outro processo.

5 min Ler em English

Streaming muda menos coisa do que parece: a chain continua sendo o valor de retorno, e os pedaços chegam por fora, como efeito colateral. São duas linhas de configuração:

llm = ChatAnthropic.new!(%{model: "claude-sonnet-5", stream: true})

chain =
  %{llm: llm}
  |> LLMChain.new!()
  |> LLMChain.add_callback(%{
    on_llm_new_delta: fn _chain, deltas -> send(view_pid, {:deltas, deltas}) end,
    on_message_processed: fn _chain, message -> send(view_pid, {:done, message}) end
  })

Cada %MessageDelta{} é uma parte da resposta, com content e um status que vale :incomplete até o último, que vem :complete. A biblioteca já faz esse acúmulo: quando o run/2 retorna, a chain tem a mensagem inteira montada. Os deltas existem para a tela, e a lógica pode ignorá-los.

Por que não dentro do handle_event

O jeito óbvio de escrever isso é rodar a chain direto no handle_event do LiveView. Não funciona.

O LiveView é um processo, e enquanto ele está dentro do run/2 esperando o modelo, não processa mais nada, nem os próprios send dos deltas, que ficam empilhados na caixa de mensagens até a chamada terminar. O usuário passa quinze segundos olhando para uma tela parada e depois recebe a resposta inteira de uma vez.

A chamada precisa acontecer em outro processo:

def handle_event("send", %{"text" => text}, socket) do
  view = self()

  Task.Supervisor.start_child(MyApp.TaskSupervisor, fn ->
    MyApp.Conversation.ask(socket.assigns.conversation_id, text, view)
  end)

  {:noreply, assign(socket, streaming: true, buffer: "")}
end

O ask/2 da primeira parte ganhou um terceiro argumento, o pid que vai receber os deltas, e é ele que o GenServer usa para montar o add_callback antes de rodar a chain. Quem escuta muda a cada aba aberta; a conversa em si não muda.

O view = self() precisa ficar fora da função, porque dentro do bloco self() já seria a Task. E start_child/2 em vez de Task.async/1 porque aqui não existe resultado a esperar: a resposta vem pelos deltas, e uma Task supervisionada que morre não leva o LiveView junto.

Do lado do LiveView, dois handle_info resolvem:

def handle_info({:deltas, deltas}, socket) do
  text = Enum.map_join(deltas, "", &delta_text/1)
  {:noreply, assign(socket, buffer: socket.assigns.buffer <> text)}
end

def handle_info({:done, message}, socket) do
  {:noreply,
   socket
   |> assign(streaming: false, buffer: "")
   |> stream_insert(:messages, message)}
end

# O conteúdo chega como string ou como lista de ContentPart, dependendo do
# modelo e de a resposta ter partes de raciocínio.
defp delta_text(%{content: content}) when is_binary(content), do: content
defp delta_text(%{content: parts}) when is_list(parts), do: Enum.map_join(parts, "", & &1.content)
defp delta_text(_), do: ""

O buffer é uma string em assigns porque é conteúdo efêmero, reescrito muitas vezes por segundo. A mensagem finalizada vai para uma stream, que é onde mora a lista que só cresce.

Quando tem mais de uma tela olhando

O send direto para um pid funciona enquanto existe exatamente um espectador. Com duas abas abertas, ou um atendente humano acompanhando a conversa do cliente, o caminho é PubSub:

# na tool, ou onde a conversa é executada
Phoenix.PubSub.broadcast(MyApp.PubSub, "conversation:#{id}", {:deltas, deltas})

# no mount do LiveView
if connected?(socket), do: Phoenix.PubSub.subscribe(MyApp.PubSub, "conversation:#{id}")

Os handle_info continuam idênticos, muda só quem entrega a mensagem. Como a troca é barata, vale começar com send e migrar quando a segunda tela aparecer.

O desenho inteiro

Juntando as três partes:

LiveView (uma por aba)
   │  handle_event
   ▼
Task supervisionada ──► GenServer da conversa ──► LLMChain.run/2
                              (o estado)              │
   ◄────────── deltas ────────────────────────────────┘
   PubSub ou send

O GenServer guarda a conversa, a Task absorve a espera, o LiveView desenha. Nenhuma das três sabe como as outras funcionam por dentro, e qualquer uma pode quebrar sem levar as outras junto.

A LangChain entrou aqui só para o formato das mensagens e o loop de tools. O desenho em volta é OTP puro, e serve igual para qualquer chamada externa lenta: um pagamento que demora, um upload grande, um relatório que leva minutos para sair.