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

Parte 4 de 6

Testar sem gastar tokens

O modelo é a única peça do agente que você não controla. Um fake que implementa o behaviour do ChatModel devolve o controle à suíte.

6 min Ler em English

Selecione um trecho para perguntar à assistente.

O agente das três partes anteriores funciona — e qualquer teste que o atravesse chama a API de verdade. São três impostos por rodada de mix test: dinheiro, porque cada chamada é cobrada; tempo, porque cada uma leva segundos; e confiança, porque o modelo formula a resposta de um jeito diferente a cada execução, e um assert sobre o texto passa hoje e quebra amanhã sem nada ter mudado no seu código.

A saída está no jeito como a biblioteca usa o modelo:

# dentro da biblioteca, simplificado
%module{} = chain.llm
module.call(chain.llm, chain.messages, chain.tools)

Ela extrai o módulo do struct que está em llm e chama call/3 nele — logo depois de injetar os callbacks da chain nesse struct, detalhe que vai importar adiante. Qualquer struct serve, desde que o módulo implemente o behaviour LangChain.ChatModels.ChatModel. O ChatAnthropic é uma implementação. Um fake seu é outra.

A resposta vem do teste

defmodule MyApp.FakeModel do
  @behaviour LangChain.ChatModels.ChatModel

  # A chain injeta os callbacks dela com `%{llm | callbacks: ...}`;
  # sem o campo, a atualização do struct quebra.
  defstruct reply: nil, callbacks: []

  def new!(attrs \\ %{}), do: struct!(__MODULE__, attrs)

  @impl true
  def call(%__MODULE__{} = model, messages, tools), do: model.reply.(messages, tools)

  @impl true
  def retry_on_fallback?(_error), do: false

  @impl true
  def serialize_config(_model), do: %{}

  @impl true
  def restore_from_map(_data), do: {:error, "um fake não se restaura"}
end

São quatro callbacks obrigatórios, e três respondem o mínimo: o fake fica de fora de fallback e de serialização. O comportamento inteiro mora em reply, uma função que cada teste entrega pronta. O fake não decide nada, e essa é a diferença que interessa: no teste, quem faz o papel do modelo é você.

A costura no GenServer

O init/1 da primeira parte criava o ChatAnthropic dentro de si, e o que nasce dentro não se troca por fora. A mudança é o modelo virar opção com default:

@impl true
def init(opts) do
  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)))

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

Produção não passa :llm e continua com o mesmo ChatAnthropic de sempre; o teste passa o fake. É injeção de dependência completa em uma linha, sem biblioteca para isso. O get_lazy poupa a construção de um modelo que seria descartado sempre que o teste traz o seu.

O que dois números provam

defmodule MyApp.ConversationTest do
  use ExUnit.Case, async: true

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

  test "a chain reenvia a conversa inteira a cada rodada" do
    fake =
      FakeModel.new!(%{
        reply: fn messages, _tools ->
          {:ok, Message.new_assistant!("recebi #{length(messages)} mensagens")}
        end
      })

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

    {:ok, first} = Conversation.ask("t-1", "oi")
    {:ok, second} = Conversation.ask("t-1", "continua")

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

O fake devolve o tamanho da lista que recebeu, e os números contam a história. Primeira rodada: o system prompt e a pergunta, duas mensagens. Segunda: essas duas, mais a resposta anterior do modelo, mais a pergunta nova — quatro. Sem rede e em milissegundos, o teste acabou de provar que o GenServer acumula o histórico e o reenvia inteiro a cada rodada, que é exatamente o que a primeira parte prometeu.

O match numa lista de ContentPart não é capricho: é o formato em que a mensagem carrega texto por dentro, o mesmo que apareceu nos deltas da parte anterior. E o start_supervised! sobe a conversa sob o supervisor do próprio teste, que a derruba no fim; com um id único por teste, o Registry da aplicação não vê colisão nem com async: true.

O pior dia do provedor, em uma linha

test "erro do provedor não derruba a conversa" do
  fake =
    FakeModel.new!(%{
      reply: fn _messages, _tools ->
        {:error, LangChainError.exception(type: "overloaded", message: "provedor sobrecarregado")}
      end
    })

  pid = start_supervised!({Conversation, id: "t-2", system_prompt: "seja breve", llm: fake})

  assert {:error, %LangChainError{type: "overloaded"}} = Conversation.ask("t-2", "oi")
  assert Process.alive?(pid)
end

Esse caminho o modelo real quase nunca deixa ensaiar: o provedor não fica sobrecarregado no horário dos seus testes. Com o fake, o pior dia dele custa uma linha. O assert final é o que importa: a conversa engoliu o erro e segue viva — o comportamento que o handle_call da primeira parte escolheu quando descartou a chain com erro e ficou com o último estado bom.

E a reply alcança mais longe. Devolver uma mensagem com tool_calls faz o loop de tools da segunda parte rodar inteiro dentro do teste, com a sua validação no meio. Um Process.sleep antes de responder ensaia o modelo lento e os timeouts da primeira parte. Cada comportamento do provedor que já custou uma madrugada vira uma função de três linhas.

O que o fake não prova

O fake prova o encanamento: o histórico cresce, o erro não derruba, a tool executa, o delta chega. O que ele não prova é a única coisa que o modelo real faz — decidir. Se o prompt leva o modelo a chamar lookup_order na hora certa, se a resposta sai no tom que você pediu: isso só uma chamada real responde. Verificação de comportamento com chamadas reais tem nome próprio, evals, e é assunto para fora desta série.

A divisão que fica de pé é essa: o encanamento se prova com o fake, a cada mix test, de graça; o comportamento do modelo se verifica à parte, com chamadas reais, poucas e deliberadas.

O agente agora se prova de graça a cada mix test. O que ele ainda não faz é sobreviver: a primeira parte avisou que, reiniciado o nó, a conversa evapora — e deploy acontece em dia de semana, com conversas abertas. A próxima parte grava cada rodada no banco sem tirar o processo do centro.