Todos os posts
Inteligência Artificialquinta-feira, 01 de outubro de 2026

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

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
end

O 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
end

A 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
end

Sobra 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
end

Quando 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
end

O 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
end

Para 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 David

Custo: 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
end

Quando 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
end

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

IARuby on RailsRubyLLMSidekiq
Leonardo David

Escrito por Leonardo David

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