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

Parte 6 de 6

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.

7 min Ler em English

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.