O LiveView não pode esperar
Streaming token a token, e o motivo de a chamada ao modelo precisar rodar em outro processo.
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.