IA em Produção com Rails: Lições de um Agente de Atendimento
O que aprendi colocando agentes de IA para atender clientes no WhatsApp com Rails, Sidekiq e RubyLLM: turnos idempotentes, política de erro, loops de tool calling, custo e RAG com contrato.
Leonardo David
23 min de leitura
Chamar uma API de LLM é a parte fácil. O trabalho de engenharia está em tudo ao redor: mensagens concorrentes, retries que duplicam respostas, modelos presos em loop de ferramentas e uma conta do provedor que ninguém consegue explicar.
Em uma plataforma de atendimento omnichannel, o agente de IA não é um chat de demonstração. Ele responde clientes reais no WhatsApp, consulta uma base de conhecimento, transfere para humanos, encerra atendimentos e faz tudo isso em paralelo para várias empresas na mesma infraestrutura. Cada erro dele chega ao cliente final.
A stack é Rails, PostgreSQL com pgvector, Redis, Sidekiq e a RubyLLM, uma gem que expõe OpenAI, Anthropic, Gemini e modelos self-hosted por uma única API em Ruby. Os exemplos abaixo são simplificados e escritos para este post, mas os problemas são todos reais.
O turno do agente é um job, não um request
Uma resposta do modelo leva de centenas de milissegundos a dezenas de segundos, e com tool calling pode envolver várias idas e voltas. Isso nunca roda dentro do webhook do WhatsApp: o webhook persiste a mensagem e enfileira um job. O problema é que, uma vez em fila, aparecem dois cenários que quase ninguém trata na primeira versão.
O primeiro é concorrência. Cliente de WhatsApp manda três mensagens curtas em sequência, e cada uma vira um job. Sem controle, três turnos do agente rodam ao mesmo tempo sobre a mesma conversa, criam históricos paralelos e respondem três vezes. O segundo é o retry: se o job falha depois de o agente já ter respondido, reexecutar o turno manda a resposta de novo.
# turno serializado por conversa e idempotente por mensagem
class AgentTurnJob
include Sidekiq::Job
sidekiq_options queue: :ai, retry: 3
# Erro terminal ou loop de tools não volta para a fila: vai direto para um humano.
sidekiq_retry_in do |count, error|
Ai::ErrorPolicy.retryable?(error) ? 10 * (count + 1) : :kill
end
sidekiq_retries_exhausted do |job, error|
Agent::HandOff.call(*job["args"], reason: error.class.name)
end
def perform(conversation_id, message_id)
acquired = ConversationLock.with(conversation_id, ttl: 120) do
reply = AiReply.find_by(source_message_id: message_id)
# Retry depois de responder não pode responder de novo.
# Resposta salva mas não enviada só precisa ser reenviada.
next DeliverReplyJob.perform_async(reply.id) if reply&.pending?
next if reply
# Rajada fora de ordem: um turno posterior já leu esta mensagem e respondeu a ela.
next if AiReply.where(conversation_id:).where("read_until_id >= ?", message_id).exists?
turn = Agent::Turn.call(conversation_id, message_id) # fora de transação
next if turn.handed_off? # a conversa agora é de um humano
reply = AiReply.create!(
conversation_id:, source_message_id: message_id, read_until_id: turn.read_until_id,
body: turn.content, status: :pending
)
DeliverReplyJob.perform_async(reply.id) # só depois do commit
end
# Outro turno da mesma conversa está rodando: tenta de novo em instantes.
self.class.perform_in(2, conversation_id, message_id) unless acquired
end
endO vínculo entre a resposta e a mensagem de origem resolve um caso sutil: checar apenas se existe alguma resposta mais recente que a mensagem confunde a resposta da primeira mensagem de uma rajada com a resposta da segunda, e a segunda é descartada em silêncio. Idempotência precisa ser por mensagem, não por tempo. Um índice único em source_message_id garante isso também no banco.
Duas decisões nesse job parecem detalhe, mas não são. A primeira é onde fica o lock. O caminho óbvio em Rails seria conversation.with_lock, mas isso abre uma transação e segura um row lock no Postgres durante toda a chamada ao modelo, que pode levar dezenas de segundos. Com vários turnos simultâneos, o pool de conexões esgota e o resto da aplicação começa a esperar. O lock da conversa fica no Redis, com TTL para nunca ficar preso se o processo morrer, e o banco só é tocado em escritas curtas.
O TTL não é um número mágico. Se ele expira no meio de um turno, outro job pega o lock e dois turnos rodam juntos, exatamente o que o lock deveria impedir. Por isso o turno tem um prazo total de 90 segundos, conferido entre os passos do agente, e cada chamada ao modelo tem timeout de 20 segundos já contando os retries internos da gem. No pior caso, um passo começa no segundo 89 e termina no 109, e os 120 segundos de TTL ainda cobrem. Se um dia o prazo do turno subir, o TTL sobe junto, ou o lock passa a ser renovado enquanto o turno roda.
Reagendar quem não pegou o lock também não preserva a ordem das mensagens: a segunda mensagem de uma rajada pode ser processada antes da primeira. Isso é aceitável porque o turno lê o histórico completo da conversa, então quem processa primeiro responde à rajada inteira. O custo é que o turno seguinte precisa saber que a sua mensagem já foi respondida. Para isso, cada resposta grava read_until_id, o id da última mensagem que o turno leu. Se alguma resposta já leu até a mensagem atual, o job termina sem chamar o modelo. A checagem continua sendo por mensagem, não por horário: uma resposta gravada depois da mensagem não prova que o turno a leu.
# lock distribuído por conversa no Redis
module ConversationLock
# Só remove a chave se o token ainda for nosso: se o TTL expirou e
# outro job pegou o lock, não podemos liberar o lock dele.
RELEASE = <<~LUA
if redis.call("get", KEYS[1]) == ARGV[1] then
return redis.call("del", KEYS[1])
end
return 0
LUA
def self.with(conversation_id, ttl:)
key = "lock:conversation:#{conversation_id}"
token = SecureRandom.uuid
return false unless REDIS.set(key, token, nx: true, ex: ttl)
begin
yield
true
ensure
REDIS.eval(RELEASE, keys: [key], argv: [token])
end
end
endA segunda decisão é a ordem entre salvar e enviar. Enviar a mensagem no WhatsApp é um efeito colateral externo que não participa de rollback. Se o envio acontece dentro de uma transação que depois falha, o cliente recebeu uma resposta que o sistema não registrou. Se a resposta é salva e o envio falha, o retry acha a resposta pronta e o cliente nunca a recebe. Por isso a resposta é gravada como pending e o envio vira um job próprio, enfileirado só depois do commit, idempotente pelo id da resposta e responsável por marcar a entrega. É o padrão outbox em versão enxuta.
Idempotente aqui precisa ser literal. Um retry do turno que encontra a resposta ainda pending não sabe se o job de envio chegou a ser enfileirado, então enfileira de novo, e dois envios da mesma resposta podem rodar ao mesmo tempo. Checar o status e depois enviar não resolve, porque os dois jobs leem pending antes de qualquer um gravar. A transição de estado precisa ser um único UPDATE condicional: só quem consegue mudar pending para sending envia.
# envio com claim atômico
class DeliverReplyJob
include Sidekiq::Job
sidekiq_options queue: :delivery, retry: 5
def perform(reply_id)
# Só um job vence a transição pending -> sending; os outros saem sem enviar.
claimed = AiReply.where(id: reply_id, status: :pending)
.update_all(status: :sending, updated_at: Time.current)
return if claimed.zero?
reply = AiReply.find(reply_id)
message_id = WhatsApp.send_text(reply.conversation, reply.body)
reply.update!(status: :delivered, provider_message_id: message_id)
rescue WhatsApp::DeliveryError
# A API recusou o envio: devolve para pending e deixa o retry tentar de novo.
AiReply.where(id: reply_id, status: :sending).update_all(status: :pending)
raise
end
endSobra uma janela que nenhum código fecha: o processo morrer depois de a API aceitar a mensagem e antes do update. A resposta fica presa em sending, e um job periódico revisa as que estão paradas há mais de alguns minutos, conferindo pelo provider_message_id e pelo webhook de status do WhatsApp se ela chegou. O sistema garante entrega pelo menos uma vez, e o caso raro de duplicidade vira algo visível e reconciliável, não silencioso.
Nem todo erro merece retry
A RubyLLM já faz retry com backoff para falhas transitórias de rede e de provedor. O Sidekiq fica como camada externa, e a pergunta passa a ser: depois que a gem desistiu, vale tentar de novo? A resposta depende do erro, e a classe da exceção nem sempre basta.
O caso clássico é o HTTP 429. A OpenAI usa o mesmo status para rate limit, que se resolve esperando, e para cota esgotada, que nunca vai se resolver sozinha. Tratar os dois como RateLimitError significa reprocessar por horas um job que nunca vai funcionar, enquanto o cliente espera sem resposta. A mensagem do erro decide antes da classe.
# política de erro explícita
module Ai
module ErrorPolicy
TRANSIENT = [
RubyLLM::RateLimitError,
RubyLLM::OverloadedError,
RubyLLM::ServerError,
Faraday::TimeoutError,
Faraday::ConnectionFailed
].freeze
TERMINAL = [
RubyLLM::PaymentRequiredError,
RubyLLM::UnauthorizedError,
RubyLLM::ForbiddenError,
Ai::VectorMismatch
].freeze
QUOTA = /insufficient_quota|exceeded your current quota|credit balance/i
module_function
def retryable?(error)
return false if terminal?(error)
TRANSIENT.any? { |klass| error.is_a?(klass) }
end
def terminal?(error)
TERMINAL.any? { |klass| error.is_a?(klass) } || error.message.match?(QUOTA)
end
end
endQuando o erro não é recuperável ou os retries acabam, o atendimento não pode simplesmente parar. O fluxo de falha, o sidekiq_retries_exhausted do job lá em cima, manda uma mensagem configurada pela empresa e transfere a conversa para a fila humana. Para o cliente, a pior experiência não é ser atendido por uma pessoa, é ficar sem resposta.
A resposta do modelo é entrada não confiável
Além do agente que conversa, existem chamadas pontuais de entendimento: classificar a intenção, extrair dados, decidir para qual setor encaminhar. Essas chamadas são efêmeras, não entram no histórico, usam temperatura zero e saída estruturada com schema. Mesmo assim, o resultado é tratado como entrada externa.
# classificação com schema e parse tolerante
module Ai
INTENTS = %w[duvida reclamacao compra suporte outro].freeze
class IntentSchema < Schematist::Schema
string :intent, enum: INTENTS
number :confidence, description: "Confiança entre 0 e 1"
end
class ClassifyIntent
def self.call(text, model:)
response = RubyLLM.chat(model:)
.with_temperature(0)
.with_instructions(RubyLLM.render_prompt("intents/classify"))
.with_schema(IntentSchema)
.ask(text.truncate(4_000))
data = parse(response)
intent = data["intent"]
confidence = Float(data["confidence"], exception: false)
raise InvalidOutput, response.content unless INTENTS.include?(intent) && confidence
{ intent:, confidence: confidence.clamp(0.0, 1.0) }
end
# Endpoints self-hosted nem sempre respeitam response_format:
# o JSON pode vir como texto, às vezes cercado de explicação.
def self.parse(response)
return response.parsed if response.parsed.is_a?(Hash)
JSON.parse(response.content[/\{.*\}/m] || "")
rescue JSON::ParserError
raise InvalidOutput, response.content.to_s.truncate(200)
end
end
endO parse tolerante existe por causa de modelos self-hosted. Quando a empresa usa um modelo próprio servido via vLLM ou Ollama, o endpoint compatível com OpenAI pode ignorar o schema e devolver o JSON no meio de um texto. O schema reduz o erro, a validação garante que ele não chega ao banco.
Tools: escopo do tenant e nunca exceção para o modelo
Tool calling é onde o agente deixa de falar e passa a agir: buscar dados do contato, transferir para um setor, encerrar a conversa. Três regras evitaram a maior parte dos problemas.
Primeiro, toda tool recebe a conversa no construtor e só enxerga dados daquela organização. O modelo nunca escolhe IDs de outras empresas porque a tool simplesmente não consegue buscá-los. Segundo, falha passageira de tool não vira exceção para o modelo: o erro vira um texto curto que o modelo consegue usar para se explicar ao cliente, e o erro real vai para o log. A exceção são os erros terminais da política acima, como cota esgotada: esses sobem, derrubam o turno e levam a conversa para um humano, porque esconder do modelo um problema permanente só o faria tentar de novo. Terceiro, ações que encerram o turno, como transferir para um humano, terminam o loop de forma limpa em vez de deixar o modelo continuar falando.
# base das tools do agente
class AgentTool < RubyLLM::Tool
def initialize(conversation)
@conversation = conversation
super()
end
# A RubyLLM chama tool.call(**arguments, tool_call:). Repassar com (...)
# preserva os keywords; def call(args) quebraria toda tool com ArgumentError.
def call(...)
super
rescue StandardError => e
raise if Ai::ErrorPolicy.terminal?(e)
Rails.error.report(e, context: { tool: name, conversation: conversation.id })
"Não foi possível concluir essa ação agora."
end
private
attr_reader :conversation
def organization = conversation.organization
end
class TransferToDepartment < AgentTool
description "Transfere o atendimento para um setor humano"
parameter :department, description: "Nome do setor"
def execute(department:)
target = organization.departments.find_by("name ILIKE ?", department.strip)
return "Setor não encontrado. Pergunte ao cliente qual setor ele procura." unless target
# O loop do agente confere handed_off? entre os passos e para aqui.
conversation.transfer_to!(target)
"Atendimento transferido para #{target.name}."
end
endPara ações irreversíveis de verdade, como estornos ou cancelamentos, a RubyLLM 2.0 tem requires_approval: a execução da tool pausa até que um humano aprove, e o histórico continua válido enquanto isso.
O loop de tool calling precisa de limite
Esse foi o incidente mais caro. Depois de cada tool call, a biblioteca chama o modelo de novo com o resultado, e repete enquanto houver tool calls na resposta. Sem limite, um modelo que entra em loop chama a mesma ferramenta com os mesmos argumentos centenas de vezes em um único turno, e cada volta reenvia o contexto inteiro. O cliente não recebe nada, e a conta do provedor recebe milhões de tokens.
A correção foi um guarda por turno com quatro limites: quantas vezes a mesma chamada pode se repetir, quantas tool calls no total, quantos tokens e quanto tempo. O limite de tempo é o mesmo prazo de 90 segundos que sustenta o TTL do lock lá no começo. A assinatura de repetição ordena as chaves dos argumentos, porque o modelo não garante ordem estável no JSON, e conta no turno inteiro, não só repetições consecutivas: alternar entre duas ferramentas é a forma mais comum de um agente travado.
O guarda não levanta exceção de dentro do callback. Interromper o loop no meio de uma tool call deixa no histórico uma chamada sem resultado, e a próxima requisição para o provedor é rejeitada por isso. O callback só registra; quem decide parar é o loop externo, entre um passo e outro, com o histórico sempre consistente.
# orçamento de passos no loop do agente
class Agent::LoopGuard
def initialize(max_identical: 3, max_calls: 20, max_tokens: 200_000, max_seconds: 90)
@deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + max_seconds
@max_identical = max_identical
@max_calls = max_calls
@max_tokens = max_tokens
@signatures = Hash.new(0)
@calls = 0
@tokens = 0
@mutex = Mutex.new # tools podem rodar em paralelo
end
def attach(chat)
chat.before_tool_call { |call| track_call(call) }
chat.after_message { |message| track_tokens(message) }
chat
end
# Chamado pelo loop entre os passos, nunca de dentro de um callback.
def tripped?
@mutex.synchronize do
@reason ||= :deadline if Process.clock_gettime(Process::CLOCK_MONOTONIC) > @deadline
!@reason.nil?
end
end
attr_reader :reason
private
def track_call(call)
# Ordena os argumentos: o JSON do modelo não tem ordem estável.
signature = [call.name, call.arguments.sort.to_h]
@mutex.synchronize do
@calls += 1
@signatures[signature] += 1
@reason ||= :identical_calls if @signatures[signature] > @max_identical
@reason ||= :max_calls if @calls > @max_calls
end
end
def track_tokens(message)
usage = message.tokens
return if usage.nil? # resultados de tool não consomem tokens
@mutex.synchronize do
@tokens += usage.input.to_i + usage.output.to_i
@reason ||= :max_tokens if @tokens > @max_tokens
end
end
end
MAX_STEPS = 25
# Até onde este turno lê o histórico; é isso que vira read_until_id na resposta.
read_until_id = conversation.messages.maximum(:id)
guard = Agent::LoopGuard.new
chat = guard.attach(Agent.chat_for(conversation)).ask_later(message.body)
MAX_STEPS.times do
chat.step
break if chat.complete? || conversation.reload.handed_off? || guard.tripped?
end
# Transferir para um humano é um fim legítimo do turno, não uma falha.
return Agent::TurnResult.handed_off if conversation.handed_off?
raise Agent::ToolLoopError, guard.reason || :max_steps unless chat.complete?
Agent::TurnResult.new(content: chat.messages.last.content, read_until_id:)Na RubyLLM 2.0, controlar o loop com ask_later e step deixa esse orçamento explícito, e o histórico continua válido quando o limite estoura. O mesmo ponto de checagem resolve a transferência: a tool muda o dono da conversa, o loop vê isso antes do próximo passo e para, e o job não grava nem envia resposta nenhuma. Sem essa checagem, o modelo receberia o resultado da tool e continuaria conversando com um cliente que já está na fila de um humano. O erro de loop nunca entra na lista de erros retryable: reexecutar só repetiria o loop e o custo, então ele cai direto no fluxo de falha e o cliente vai para um humano.
“Um sistema com IA confiável não é aquele em que o modelo nunca erra, mas aquele em que o erro do modelo tem custo baixo e é fácil de perceber.”
— Leonardo DavidCusto: prompt caching e rastreabilidade
Um agente de atendimento tem um system prompt grande e estável: instruções da empresa, tom de voz, regras de negócio, definições das tools. Reenviar tudo isso a cada mensagem é o maior custo do produto. Com prompt caching, o provedor reaproveita esse prefixo e cobra uma fração do preço pelos tokens lidos do cache.
O detalhe que faz o cache funcionar ou não é a ordem. O cache é por prefixo, então tudo que muda a cada request, como data e hora atuais ou dados do cliente, precisa ficar em um bloco depois da parte estável. Um timestamp no topo do prompt invalida o cache em toda chamada, e ninguém percebe até olhar a fatura. Também vale saber que a Anthropic ignora o cache em silêncio quando o prefixo é menor que o mínimo do modelo.
Para saber se isso está funcionando, toda resposta registra tokens de entrada, saída, leitura e escrita de cache, junto do modelo, da versão do prompt e da conversa. A RubyLLM expõe isso em response.tokens e response.cost, já somando retries e fallbacks. Com esses dados, custo por empresa, por assistente e por versão de prompt vira uma query SQL.
RAG: o espaço vetorial é um contrato
A base de conhecimento de cada assistente é indexada em chunks com embeddings no pgvector, e o agente consulta via uma tool de busca. Três cuidados fazem diferença.
O modelo de embedding e a dimensão são fixos e validados em toda chamada: um vetor de outro modelo ou outra dimensão não é um erro pequeno, ele corrompe a busca em silêncio. Os resultados passam por um limiar de distância, porque devolver os cinco chunks mais próximos de qualquer pergunta faz o modelo responder com confiança usando conteúdo irrelevante. E a query filtra pelo assistente e pela organização no próprio join, para que nenhum chunk de outra empresa possa aparecer.
# busca vetorial com contrato e limiar
class KnowledgeSearch < AgentTool
description "Busca informações na base de conhecimento da empresa"
parameter :query, description: "Pergunta ou termos de busca"
MODEL = "text-embedding-3-small"
DIMENSIONS = 1_536
MAX_DISTANCE = 0.75
def execute(query:)
vector = RubyLLM.embed(query, model: MODEL, dimensions: DIMENSIONS).vectors
raise Ai::VectorMismatch unless vector.size == DIMENSIONS
chunks = KnowledgeChunk
.ready
.joins(:assistant)
.where(assistant: conversation.assistant, embedding_model: MODEL)
.where(assistants: { organization_id: organization.id })
.nearest_neighbors(:embedding, vector, distance: "cosine")
.limit(5)
.select { |chunk| chunk.neighbor_distance <= MAX_DISTANCE }
return "Nada relevante encontrado na base de conhecimento." if chunks.empty?
chunks.map(&:content).join("\n\n---\n\n")
end
endQuando o provedor de embeddings está indisponível, a busca degrada: a tool responde que não conseguiu consultar a base, e o agente pode pedir para o cliente aguardar ou transferir. Já um vetor com dimensão errada ou cota esgotada não degradam. Os dois estão na lista TERMINAL da política de erro, então a base AgentTool os deixa passar em vez de convertê-los em texto: o turno cai, a conversa vai para um humano e o erro aparece no monitoramento, porque é um problema permanente. O Agent::HandOff ainda fecha no histórico a tool call que ficou sem resultado, para que o provedor aceite a conversa se o agente voltar a ela.
Testes: o modelo fica fora do teste unitário
Quase tudo que foi descrito até aqui é código determinístico, e é ali que os testes rendem mais. A política de erro, o guarda de loop, a idempotência do job e o contrato do vetor são testados sem nenhuma chamada real ao provedor. O modelo entra como uma fronteira stubada: Agent::Turn devolve uma resposta pronta, e o teste verifica o que o sistema faz com ela.
# spec da política de erro: 429 nem sempre é retryable
RSpec.describe Ai::ErrorPolicy do
def rate_limit(message)
RubyLLM::RateLimitError.new(message)
end
it "tenta de novo em throttling" do
expect(described_class.retryable?(rate_limit("Rate limit reached"))).to be(true)
end
it "não tenta de novo quando a cota acabou, mesmo sendo 429" do
error = rate_limit("You exceeded your current quota")
expect(described_class.retryable?(error)).to be(false)
expect(described_class.terminal?(error)).to be(true)
end
it "trata erro de pagamento como terminal" do
expect(described_class.retryable?(RubyLLM::PaymentRequiredError.new("billing"))).to be(false)
end
end# spec do job: retry não responde duas vezes
RSpec.describe AgentTurnJob do
let(:conversation) { create(:conversation) }
let(:message) { create(:message, conversation:) }
before do
allow(Agent::Turn).to receive(:call).and_return(
Agent::TurnResult.new(content: "Olá! Como posso ajudar?", read_until_id: message.id)
)
end
it "gera uma única resposta mesmo quando o job roda duas vezes" do
2.times { described_class.new.perform(conversation.id, message.id) }
replies = AiReply.where(source_message_id: message.id)
expect(Agent::Turn).to have_received(:call).once
expect(replies.count).to eq(1)
# O retry reenfileira o envio, mas sempre da mesma resposta; o claim atômico
# do DeliverReplyJob garante que só um deles envia.
expect(DeliverReplyJob.jobs.map { _1["args"] }.uniq).to eq([[replies.first.id]])
end
it "reenvia sem chamar o modelo quando a resposta ficou pendente" do
reply = create(:ai_reply, conversation:, source_message_id: message.id, status: :pending)
described_class.new.perform(conversation.id, message.id)
expect(Agent::Turn).not_to have_received(:call)
expect(DeliverReplyJob.jobs.last["args"]).to eq([reply.id])
end
endPara o fluxo completo com a RubyLLM, testes de integração com VCR gravam a resposta real do provedor uma vez e reproduzem depois, com filter_sensitive_data escondendo as chaves de API dos cassettes. Eles validam que o schema, as tools e os parâmetros chegam no formato que o provedor aceita, coisa que um stub nunca vai pegar. O guarda de loop ganha um teste com um chat falso que sempre pede a mesma tool, garantindo que o turno termina em ToolLoopError e não em uma conta de tokens.
Avalie com casos reais antes de trocar o prompt
Mudar um prompt é um deploy de comportamento. Mantenha um conjunto de conversas reais anonimizadas, com o resultado esperado de cada uma, e rode a versão nova contra elas antes de publicar. Toda vez que um atendente corrige uma classificação ou assume uma conversa que o agente conduziu mal, isso é um exemplo rotulado de graça.
Conclusão
Quase nada do que deu trabalho foi sobre prompt. Foi sobre concorrência, idempotência, classificação de erro, limites, custo e isolamento entre clientes, os mesmos problemas de qualquer sistema distribuído, agora com uma dependência lenta, cara e não determinística no meio. Rails, Sidekiq e a RubyLLM resolvem a infraestrutura. O resto é tratar o modelo como o que ele é: uma dependência poderosa que precisa de limites claros.

Escrito por Leonardo David
Engenheiro Full Stack. Atuo construindo produtos de alta performance na intersecção entre frontend, backend e estratégia de produto.