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