O histórico que não para de crescer
A chain reenvia a conversa inteira a cada rodada. Medir o custo é um callback; conter é um resumo que substitui o miolo e preserva o fim.
Selecione um trecho para perguntar à assistente.
A quarta parte provou com dois números que a chain reenvia a conversa inteira a cada rodada: duas mensagens, depois quatro. A progressão não para aí. A rodada k envia 2k mensagens — o system prompt, as k perguntas, as k − 1 respostas — e uma conversa de cinquenta rodadas envia, somando tudo, 2.550. Cada uma cobrada como entrada, a maioria relida pela décima vez. E a quinta parte piorou o quadro de propósito: agora a conversa volta amanhã, e volta inteira.
O prompt caching amansa a curva — o histórico é um prefixo estável, e prefixo cacheado custa cerca de um décimo na releitura — mas não muda a forma dela. O cache expira em minutos, exige um tamanho mínimo de prefixo (o roteador que dobrava a conta mostra o que acontece quando ele não é atingido), e a janela de contexto do modelo continua sendo um teto. Chegando lá, a rodada não fica cara: fica impossível.
O número já vem na resposta
Antes de conter o custo, medir. Nenhuma conta manual: o provedor devolve a contagem de tokens em cada resposta, e a biblioteca a entrega de dois jeitos.
# no metadata da mensagem final
chain.last_message.metadata.usage
#=> %LangChain.TokenUsage{input: 9280, output: 164, ...}
# ou como callback, ao lado dos que a terceira parte registrou
|> LLMChain.add_callback(%{
on_llm_token_usage: fn _chain, usage ->
:telemetry.execute([:my_app, :llm, :usage], %{input: usage.input, output: usage.output})
end
})
O input é o número que interessa: ele é o tamanho da conversa que acabou de ser reenviada. Pendurar esse valor num :telemetry — ou num Logger.info, para começar — responde a pergunta que decide o resto desta parte: as suas conversas chegam a crescer? Um chat de suporte que fecha em seis rodadas não tem esse problema, e não se conserta problema que não existe.
Cortar é fácil e quebra fácil
O primeiro impulso é uma janela deslizante: manter o system prompt e as últimas n mensagens, um Enum.take(messages, -n) e pronto. Funciona até a primeira conversa com tools.
Quando o modelo pede lookup_order, como na segunda parte, o pedido vira uma mensagem do assistente com a chamada, seguida da mensagem com o resultado — um par que os provedores validam. O corte que fica com o resultado e joga fora a chamada produz uma sequência que a API rejeita inteira, e a conversa quebra justamente quando cresceu, que é quando estava indo bem. Dá para cortar certo, procurando uma fronteira de rodada e nunca separando o par; mas há um problema que corte nenhum resolve: o modelo esquece tudo o que saiu da janela, inclusive o nome que o usuário disse na primeira mensagem.
Resumir é trocar detalhe por teto
A biblioteca traz a alternativa pronta:
defmodule MyApp.Summarizer do
alias LangChain.Chains.SummarizeConversationChain
alias LangChain.ChatModels.ChatAnthropic
def maybe_compact(chain) do
%{
llm: ChatAnthropic.new!(%{model: "claude-haiku-4-5", stream: false}),
threshold_count: 20,
keep_count: 6
}
|> SummarizeConversationChain.new!()
|> SummarizeConversationChain.summarize(chain)
end
end
Três decisões nesse bloco. O modelo é o mais barato do catálogo, sem streaming, porque resumir é tarefa mecânica e ninguém está olhando. O threshold_count: 20 faz summarize/2 devolver a chain intacta, sem chamada nenhuma, enquanto a conversa não passa de vinte mensagens — chamar maybe_compact/1 toda rodada sai de graça na maioria delas. E o keep_count: 6 preserva as três últimas trocas textuais, que é onde mora a continuidade que o usuário percebe.
Acima do limite, a chain volta diferente: o system prompt fica, o miolo inteiro vira um par fabricado — uma pergunta pedindo o resumo, uma resposta do assistente contendo-o — e as seis mensagens finais seguem intactas. Para o modelo, a conversa ganhou um começo novo. Para o usuário, nada mudou: a tela mostra o transcript da quinta parte, que continua integral no banco. A chain virou memória de trabalho; o banco, a memória de verdade. A remontagem, aliás, devolve a chain inteira — quem volta amanhã paga uma primeira rodada cheia, e o resumo age na sequência.
Quando o resumidor falha, a biblioteca loga um aviso e devolve a chain original: a conversa não quebra, só não encolhe, e a rodada seguinte tenta novamente. Essa degradação silenciosa é confortável e esconde o termômetro — o callbacks: da própria chain aceita um on_llm_token_usage para o resumo aparecer na telemetria como qualquer outra chamada.
Falta decidir onde isso roda, e a resposta é a peça de OTP que faltava na série:
@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} ->
# as gravações do transcript (quinta parte) continuam aqui
{:reply, {:ok, chain.last_message}, %{state | chain: chain}, {:continue, :compact}}
{:error, _chain, error} ->
{:reply, {:error, error}, state, @idle_timeout}
end
end
@impl true
def handle_continue(:compact, state) do
{:noreply, %{state | chain: MyApp.Summarizer.maybe_compact(state.chain)}, @idle_timeout}
end
O {:continue, :compact} no lugar do timeout faz o GenServer responder primeiro e trabalhar depois: a latência do resumo sai da conta do usuário. O custo existe e tem endereço — mensagens são processadas em ordem, então a pergunta que chegar durante o resumo espera o resumo terminar, alguns segundos na rodada rara em que o limite dispara. E como :continue e timeout não viajam na mesma tupla, o @idle_timeout é rearmado na volta do handle_continue.
A prova continua de graça
O fake da quarta parte não sabe que existe para fingir conversa: ele implementa o behaviour do ChatModel, e o resumidor aceita qualquer implementação. No teste, o mesmo fake faz os dois papéis:
test "acima do limite, o miolo da conversa vira resumo" do
fake =
FakeModel.new!(%{
reply: fn _messages, _tools ->
{:ok, Message.new_assistant!("Resumo: cliente acompanha o pedido A-4471.")}
end
})
chain =
%{llm: fake}
|> LLMChain.new!()
|> LLMChain.add_message(Message.new_system!("seja breve"))
|> LLMChain.add_messages(
for n <- 1..4,
message <- [Message.new_user!("pergunta #{n}"), Message.new_assistant!("resposta #{n}")] do
message
end
)
summarized =
%{llm: fake, threshold_count: 6, keep_count: 2}
|> SummarizeConversationChain.new!()
|> SummarizeConversationChain.summarize(chain)
assert [_system, _request, summary, kept_user, kept_assistant] = summarized.messages
assert [%ContentPart{content: "Resumo: cliente acompanha o pedido A-4471."}] = summary.content
assert [%ContentPart{content: "pergunta 4"}] = kept_user.content
assert [%ContentPart{content: "resposta 4"}] = kept_assistant.content
end
Nove mensagens entram, cinco saem, e as asserções contam o resto: o resumo no meio, a última troca intacta no fim. O que o teste não afirma é a qualidade do resumo — isso é decisão do modelo real, e a régua da quarta parte continua valendo: o encanamento se prova com o fake, o comportamento se verifica com evals, fora daqui.
É aqui que a série fecha, agora por inteiro. O estado mora num GenServer que expira sozinho, as tools validam o que o modelo mandou, o streaming atravessa até a tela sem congelar o LiveView, o banco guarda cada rodada fechada, o histórico vivo tem teto — e a suíte prova tudo isso sem tocar a rede. A LangChain fez a parte dela; o resto sempre foi OTP.