Pular para o conteúdo
João Netto.
PT EN
LangChain na BEAM

Parte 5 de 6

A conversa que volta amanhã

O processo guarda a conversa enquanto ela está viva; o banco guarda o que cada rodada fechou. Gravar e remontar a chain custa duas funções.

8 min Ler em English

Selecione um trecho para perguntar à assistente.

A primeira parte fechou com um aviso: reiniciou o nó, a conversa evaporou. Em produção isso não é hipótese — é o deploy de terça-feira à tarde apagando todas as conversas abertas naquele momento. Se o produto promete que o usuário volta amanhã e encontra o histórico, o processo sozinho não sustenta a promessa.

A resposta não é tirar a conversa do processo. É dividir os papéis, como o fim da primeira parte adiantou: o banco vira o registro do que fechou; o processo continua sendo onde a conversa acontece. Esta parte constrói as duas pontas — gravar cada rodada, e remontar a chain quando o usuário volta.

Um transcript de duas colunas

O que precisa sobreviver é menos do que parece. Não é a struct da chain, nem os callbacks, nem o modelo: é quem falou e o que disse.

defmodule MyApp.Repo.Migrations.CreateTranscriptEntries do
  use Ecto.Migration

  def change do
    create table(:transcript_entries) do
      add :conversation_id, :string, null: false
      add :role, :string, null: false
      add :content, :text, null: false
      timestamps(updated_at: false)
    end

    create index(:transcript_entries, [:conversation_id])
  end
end
defmodule MyApp.Transcript do
  import Ecto.Query
  alias MyApp.Repo

  defmodule Entry do
    use Ecto.Schema

    schema "transcript_entries" do
      field :conversation_id, :string
      field :role, Ecto.Enum, values: [:user, :assistant]
      field :content, :string
      timestamps(updated_at: false)
    end
  end

  def record(conversation_id, role, content) do
    Repo.insert!(%Entry{conversation_id: conversation_id, role: role, content: content})
  end

  def entries(conversation_id) do
    Repo.all(from e in Entry, where: e.conversation_id == ^conversation_id, order_by: e.id)
  end
end

Repare que não existe tabela de conversas. O conversation_id é o mesmo id que o Registry da primeira parte já usa como chave, e criar uma segunda identidade para a mesma conversa seria burocracia. E o order_by é pelo id, não por inserted_at: as duas escritas de uma rodada podem cair no mesmo microssegundo, e o id sequencial não empata.

Gravar quando a rodada fecha

A escrita mora no handle_call da primeira parte, no ramo em que a rodada fechou — com alias LangChain.Message.ContentPart junto dos aliases que o módulo já tinha:

@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} ->
      MyApp.Transcript.record(state.id, :user, text)

      MyApp.Transcript.record(
        state.id,
        :assistant,
        ContentPart.content_to_string(chain.last_message.content)
      )

      {:reply, {:ok, chain.last_message}, %{state | chain: chain}, @idle_timeout}

    {:error, _chain, error} ->
      {:reply, {:error, error}, state, @idle_timeout}
  end
end

O estado ganhou o próprio id — o init/1 da próxima seção guarda %{id: id, chain: chain} — porque até aqui o processo nunca precisou saber como se chamava; quem sabia era o Registry.

A pergunta do usuário só entra no registro junto com a resposta. É a regra que o ramo de erro da primeira parte estabeleceu para a chain — o estado fica no último ponto em que uma rodada fechou por inteiro — agora valendo também para o banco. A alternativa, gravar a pergunta na chegada e marcar a rodada como aberta, compra uma máquina de estados: todo lugar que lê o transcript passa a decidir o que fazer com uma pergunta sem resposta. Duas escritas adiadas custam menos que isso.

O content_to_string/1 resolve o outro detalhe: a resposta do modelo carrega o texto numa lista de ContentPart, o formato que apareceu nos deltas da terceira parte e nos asserts da quarta, e é essa função da biblioteca que achata a lista de volta para string.

Remontar é reler

@impl true
def init(opts) do
  id = Keyword.fetch!(opts, :id)

  llm =
    Keyword.get_lazy(opts, :llm, fn ->
      ChatAnthropic.new!(%{model: "claude-sonnet-5"})
    end)

  chain =
    %{llm: llm}
    |> LLMChain.new!()
    |> LLMChain.add_message(Message.new_system!(Keyword.fetch!(opts, :system_prompt)))
    |> replay(MyApp.Transcript.entries(id))

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

defp replay(chain, entries) do
  Enum.reduce(entries, chain, fn entry, chain ->
    message =
      case entry.role do
        :user -> Message.new_user!(entry.content)
        :assistant -> Message.new_assistant!(entry.content)
      end

    LLMChain.add_message(chain, message)
  end)
end

O system prompt não veio do banco, e isso é uma escolha: ele continua chegando pelos opts, evolui com o código, e a conversa remontada amanhã ganha a versão de amanhã, não a fóssil. Por isso o role do transcript só conhece :user e :assistant.

Para a chain, não existe diferença entre mensagem nova e mensagem relida — o modelo recebe a lista inteira e responde como se a conversa nunca tivesse parado. Se um dia a leitura ficar pesada, handle_continue tira a consulta do caminho de quem chamou start_link; com dezenas de mensagens por conversa, o reduce acima não aparece em gráfico nenhum.

O que o registro perde

O transcript guarda texto, e uma conversa com tools troca mais do que texto. Quando a segunda parte entregou lookup_order ao modelo, o pedido dele e o resultado da função viraram mensagens na chain — e nada disso vai para o banco. Na remontagem, o modelo vê a resposta que citou o pedido A-4471, mas não vê mais o resultado bruto que a produziu.

Para conversa de suporte, esse recorte costuma bastar: o que importava do resultado está embutido na resposta. Quando não bastar, o caminho é gravar as structs inteiras — a chain anota em exchanged_messages tudo o que a última rodada trocou, incluindo as chamadas de tool — e o serializador vira trabalho seu, porque a biblioteca não traz um. É mais fidelidade por mais código; adie até precisar.

Um deploy que o teste ensaia

A quarta parte deixou o fake pronto, e ele prova a remontagem inteira sem tocar a rede:

defmodule MyApp.ConversationRestartTest do
  use MyApp.DataCase, async: false

  alias LangChain.Message
  alias LangChain.Message.ContentPart
  alias MyApp.{Conversation, FakeModel}

  test "a conversa remontada continua de onde parou" do
    fake =
      FakeModel.new!(%{
        reply: fn messages, _tools ->
          {:ok, Message.new_assistant!("recebi #{length(messages)} mensagens")}
        end
      })

    start_supervised!({Conversation, id: "t-3", system_prompt: "seja breve", llm: fake})
    {:ok, _first} = Conversation.ask("t-3", "oi")

    stop_supervised!(Conversation)
    start_supervised!({Conversation, id: "t-3", system_prompt: "seja breve", llm: fake})

    {:ok, reply} = Conversation.ask("t-3", "continua")

    assert [%ContentPart{content: "recebi 4 mensagens"}] = reply.content
  end
end

O stop_supervised! no meio do teste é o deploy: o processo morre com tudo o que tinha em memória. O quatro na resposta é a prova — system prompt, a pergunta antiga e a resposta antiga relidas do banco, a pergunta nova. Sem a remontagem, seriam duas.

O async: false não é capricho. Quem consulta o banco é o processo da conversa, não o do teste, e o DataCase que o Phoenix gera só compartilha a conexão do sandbox com outros processos quando o teste não é assíncrono. É o preço de testar através de um processo: este arquivo corre sozinho, e se paga na primeira linha.

O banco agora paga a promessa da primeira parte: o usuário volta amanhã e a conversa está lá. Mas repare no que a remontagem faz — relê tudo, sempre. Uma conversa que atravessa semanas remonta uma chain cada vez maior, e a quarta parte provou que essa lista inteira viaja ao modelo a cada rodada. O histórico virou um custo que só cresce, e ele é o assunto da última parte.