Para passar o atendimento de um agente de IA para um humano no WhatsApp sem confusão, trate a conversa como uma máquina de estados com um dono único, o bot ou o atendente. Dispare a transferência por regras determinísticas e também por uma tool que o próprio LLM pode chamar. Entregue ao atendente um resumo estruturado, para ele não repetir perguntas ao cliente. O que mais quebra em produção não é detectar a hora de transferir. É garantir que o bot pare de responder no instante em que o humano assume.
Sigo a ordem em que as coisas costumam dar errado num agente de atendimento rodando em Laravel, com Redis para locks e filas e a API do Claude por trás.
Por que o handoff é a parte mais negligenciada de um agente de IA no WhatsApp?
Handoff é a transferência do controle da conversa do agente automatizado para uma pessoa e, depois, de volta. Quase todo mundo constrói o agente pensando no caminho feliz: o cliente pergunta, o modelo chama uma ferramenta, responde e resolve. No caminho triste, o agente não sabe, não pode ou não deve resolver, e é aí que a experiência desanda.
Os sintomas típicos são estes:
- O cliente pede "quero falar com uma pessoa" três vezes e o bot continua oferecendo opções.
- O atendente assume, começa a digitar, e o bot responde por cima com uma mensagem que contradiz o humano.
- O atendente abre a conversa e precisa ler quarenta mensagens, ou pior, pergunta de novo o que o cliente já explicou.
- A conversa fica presa com humano para sempre e o bot nunca mais atende aquele número.
Em fintech o problema pesa mais. Assunto como contestação de cobrança, suspeita de golpe ou conta bloqueada não deveria ser tratado por LLM, mesmo que o modelo pareça capaz. Se você ainda não tem o agente em si, o post sobre como criar um agente de IA com tool calling do Claude no Laravel para agendamentos no WhatsApp cobre a base. Aqui parto do ponto em que ele já existe.
Quando o agente deve transferir para um humano?
Não confio só no modelo para decidir. Uso duas camadas. Regras determinísticas rodam antes de qualquer chamada ao LLM, e uma tool deixa o modelo pedir a transferência quando percebe que não vai resolver.
| Gatilho | Exemplo | Quem decide | Custo de errar |
|---|---|---|---|
| Pedido explícito | "quero falar com atendente" | Regra (regex) | Alto: ignorar irrita muito |
| Tema sensível | "não reconheço essa cobrança", "caí num golpe" | Regra | Muito alto: risco financeiro e jurídico |
| Falha de ferramenta | API de pedidos fora do ar duas vezes seguidas | Regra (contador) | Médio |
| Sem progresso | 4 turnos do bot sem resolver nada | Regra (contador) | Médio |
| Fora do escopo | pergunta que nenhuma tool cobre | LLM via tool | Baixo |
| Frustração | cliente irritado, ironia, caixa alta | LLM via tool | Médio |
As regras ficam antes do modelo por três motivos. São previsíveis, não custam token e não podem ser contornadas por prompt injection, que é quando o texto do usuário tenta mudar as instruções do modelo. Um falso positivo, como transferir porque alguém escreveu "humanos" num contexto qualquer, custa bem menos que um falso negativo num caso de fraude.
final class HandoffRules { private const EXPLICIT = '/(atendente|humano|pessoa de verdade|falar com algu[eé]m)/iu'; private const SENSITIVE = '/(contesta[çc][ãa]o|chargeback|n[ãa]o reconhe[çc]o|golpe|fraude|bloquead[ao])/iu'; public function check(string $text, Conversation $conversation): ?string { if (preg_match(self::EXPLICIT, $text)) { return 'pedido_explicito'; } if (preg_match(self::SENSITIVE, $text)) { return 'tema_sensivel'; } if ($conversation->consecutive_tool_failures >= 2) { return 'falha_de_ferramenta'; } if ($conversation->bot_turns_without_progress >= 4) { return 'sem_progresso'; } return null; } }
Os contadores consecutive_tool_failures e bot_turns_without_progress são colunas na tabela de conversas. O primeiro é incrementado quando uma tool lança exceção e zerado quando uma funciona. O segundo é zerado quando uma tool de ação conclui algo, como criar um agendamento ou enviar um boleto. É grosseiro, mas funciona melhor do que pedir ao modelo que avalie o próprio desempenho.
Como modelar o estado da conversa para o bot e o humano não responderem juntos?
A conversa tem um dono, e o dono só muda por transições explícitas. São quatro estados:
enum ConversationOwner: string { case Bot = 'bot'; case AwaitingHuman = 'awaiting_human'; case Human = 'human'; case Closed = 'closed'; }
As transições válidas são poucas:
bot→awaiting_human: regra ou tool pediu transferência.awaiting_human→human: um atendente clicou em "assumir".human→bot: o atendente devolveu a conversa ao bot.human→closed: o atendente encerrou, ou a conversa ficou inativa.closed→bot: o cliente mandou mensagem nova.
É o mesmo raciocínio que uso para status de pagamento. Se quiser algo mais formal, com transições centralizadas e testadas, o padrão do post sobre máquina de estados para impedir transições inválidas no Laravel se aplica igual aqui.
O detalhe importante é que a transição precisa ser atômica. Dois atendentes clicando em "assumir" ao mesmo tempo, ou a regra e a tool disparando juntas, não podem gerar dois handoffs. Resolvo com UPDATE condicional, que no PostgreSQL é atômico por linha:
final class HandoffService { public function requestHuman(Conversation $conversation, string $reason, string $source): bool { $changed = Conversation::whereKey($conversation->id) ->where('owner', ConversationOwner::Bot->value) ->update([ 'owner' => ConversationOwner::AwaitingHuman->value, 'handoff_reason' => $reason, 'handoff_source' => $source, 'handoff_requested_at' => now(), ]); if ($changed === 0) { return false; // já estava na fila ou com humano } GenerateHandoffSummary::dispatch($conversation->id)->afterCommit(); event(new HandoffRequested($conversation->id, $reason)); return true; } public function claim(Conversation $conversation, User $agent): bool { return Cache::lock("conversation:{$conversation->id}", 60) ->block(10, fn () => Conversation::whereKey($conversation->id) ->where('owner', ConversationOwner::AwaitingHuman->value) ->update([ 'owner' => ConversationOwner::Human->value, 'assigned_to' => $agent->id, 'claimed_at' => now(), ]) === 1); } }
Se o update retorna 0, outra pessoa ou outro processo chegou antes, e o painel mostra "conversa já assumida por fulano". O afterCommit() garante que o job de resumo só sai se a transição for persistida de fato.
Como deixar o próprio LLM pedir a transferência com tool calling?
Tool calling é o recurso em que o modelo, em vez de responder texto, devolve um pedido estruturado para executar uma função que você declarou. Declaro uma tool de transferência junto com as tools de negócio:
{ "name": "transferir_para_atendente", "description": "Transfere a conversa para um atendente humano. Use quando o cliente pedir, quando o assunto não for coberto pelas outras ferramentas, ou quando você não conseguir resolver após tentar. Não use para perguntas que as outras ferramentas respondem.", "input_schema": { "type": "object", "properties": { "motivo": { "type": "string", "enum": ["pedido_do_cliente", "fora_do_escopo", "cliente_frustrado", "nao_consegui_resolver"] }, "detalhe": { "type": "string", "description": "Uma frase explicando o motivo para o atendente." } }, "required": ["motivo", "detalhe"] } }
O enum no motivo é deliberado. Texto livre vira um monte de variações impossíveis de agregar em métrica. No loop do agente, quando a resposta tem stop_reason igual a tool_use e o bloco é essa tool, eu não devolvo o resultado ao modelo para ele gerar a mensagem final:
foreach ($response->json('content') as $block) { if ($block['type'] === 'tool_use' && $block['name'] === 'transferir_para_atendente') { $this->handoff->requestHuman( $conversation, $block['input']['motivo'], 'llm: '.$block['input']['detalhe'], ); return AgentReply::fixed($this->messages->handoff($conversation)); } }
A mensagem de transferência é um texto fixo, escrito pelo produto. Se o modelo escrever essa mensagem, cedo ou tarde ele promete "um atendente vai te responder em 5 minutos" num sábado à noite. O que o cliente lê na hora da transferência precisa ser controlado e honesto sobre o prazo.
Como evitar que o bot responda depois que o humano assumiu?
Esse é o bug clássico, e vem de uma corrida de tempo. A mensagem do cliente chega e o job do agente começa com o dono ainda bot. A chamada ao LLM leva alguns segundos. Nesse intervalo o atendente assume, e o job termina e envia a resposta por cima do humano.
Uso três proteções em conjunto:
- Lock por conversa no Redis, envolvendo o processamento da mensagem. Duas mensagens do mesmo cliente não são processadas em paralelo, e o
claimdo atendente espera o job terminar. - Checagem do dono ao entrar no job: se não for
bot, a mensagem só é gravada e aparece no painel. - Checagem do dono de novo antes de enviar, lendo do banco e não do model em memória.
public function handle(HandoffRules $rules, HandoffService $handoff, Agent $agent, WhatsApp $whatsapp): void { Cache::lock("conversation:{$this->conversationId}", 60)->block(10, function () use ($rules, $handoff, $agent, $whatsapp) { $conversation = Conversation::findOrFail($this->conversationId); if ($conversation->owner !== ConversationOwner::Bot) { return; } if ($reason = $rules->check($this->text, $conversation)) { $handoff->requestHuman($conversation, $reason, 'regra'); $whatsapp->send($conversation, $this->messages->handoff($conversation)); return; } $reply = $agent->respond($conversation, $this->text); $owner = Conversation::whereKey($conversation->id)->value('owner'); if ($owner !== ConversationOwner::Bot->value && ! $reply->isHandoff()) { Log::info('Resposta do bot descartada: conversa mudou de dono', ['id' => $conversation->id]); return; } $whatsapp->send($conversation, $reply->text); }); }
O trade-off é que o atendente pode esperar alguns segundos ao clicar em "assumir" se houver uma chamada ao LLM em andamento. Prefiro isso a ter duas vozes na conversa. Se o timeout do LLM for maior que o TTL do lock, a proteção falha. Por isso o TTL do lock precisa ser maior que o timeout da chamada HTTP somado à margem de envio. Descartar uma resposta já gerada também significa pagar tokens à toa, o que é aceitável quando acontece raramente.
Como gerar um resumo útil para o atendente com saída estruturada?
O atendente não deveria ler a conversa inteira. Disparo um job que pede ao Claude um resumo com formato fixo, usando saída estruturada: o modelo é obrigado a preencher um JSON que segue um schema. Na API da Anthropic faço isso forçando uma tool com tool_choice:
$response = Http::withHeaders([ 'x-api-key' => config('services.anthropic.key'), 'anthropic-version' => '2023-06-01', ])->timeout(30)->post('https://api.anthropic.com/v1/messages', [ 'model' => config('services.anthropic.summary_model'), 'max_tokens' => 800, 'system' => 'Você resume conversas de atendimento para um atendente humano. Seja factual. Não invente dados que não estão na conversa. Não repita documentos completos.', 'messages' => [ ['role' => 'user', 'content' => $transcript], ], 'tools' => [[ 'name' => 'registrar_resumo', 'description' => 'Registra o resumo do atendimento.', 'input_schema' => [ 'type' => 'object', 'properties' => [ 'motivo_contato' => ['type' => 'string'], 'dados_confirmados' => ['type' => 'array', 'items' => ['type' => 'string']], 'ja_tentado' => ['type' => 'string'], 'pendencia' => ['type' => 'string'], 'humor_cliente' => ['type' => 'string', 'enum' => ['calmo', 'impaciente', 'irritado']], ], 'required' => ['motivo_contato', 'dados_confirmados', 'ja_tentado', 'pendencia', 'humor_cliente'], ], ]], 'tool_choice' => ['type' => 'tool', 'name' => 'registrar_resumo'], ])->throw(); $summary = collect($response->json('content')) ->firstWhere('type', 'tool_use')['input'] ?? null;
Algumas decisões aqui:
- Modelo menor para o resumo. É uma tarefa simples, e um modelo mais barato e rápido dá conta. O nome do modelo fica em config para trocar sem deploy.
- O resumo nunca bloqueia o handoff. Se o job falhar, o painel mostra as últimas mensagens e um aviso de "resumo indisponível". A transferência já aconteceu antes.
- Valido o JSON antes de salvar, com o
Validatordo Laravel e as mesmas regras do schema. O modelo segue o schema quase sempre, mas não sempre. - Dados sensíveis mascarados no transcript antes de mandar ao modelo. O atendente tem acesso ao cadastro pelo painel e não precisa do CPF completo no resumo.
Se o agente já mantém um resumo da conversa para controlar o contexto, como no post sobre gerenciar a memória de um agente de IA com janela, resumo e fatos, aproveite os fatos já extraídos como entrada. O resumo do handoff fica mais barato e mais preciso.
O que fazer quando ninguém está disponível ou é fora do horário?
Transferir para uma fila que ninguém olha é pior do que não transferir. Na hora do handoff, verifico o horário de atendimento e se há atendentes online, e escolho entre duas mensagens fixas: "já te passo para alguém da equipe" ou "nosso time atende de tal a tal hora, sua mensagem fica registrada e respondemos assim que abrir".
Enquanto a conversa está em awaiting_human, o agente não roda. Fica um problema: o cliente manda mais mensagens e não recebe nada. Envio no máximo um aviso automático por janela de tempo, controlado com RateLimiter por conversa, só para confirmar que a mensagem chegou. Já testei deixar o bot "ajudando no que puder" durante a espera. Ele acabava respondendo justamente o assunto que motivou a transferência.
Para temas sensíveis fora do horário, em fintech, vale um caminho próprio. Se o cliente fala em golpe ou cartão perdido, a mensagem fixa já orienta a ação de segurança disponível no app, como bloquear o cartão, sem esperar o humano.
Quando e como devolver a conversa para o bot?
A volta tem duas portas:
- O atendente devolve explicitamente, com um botão "devolver ao bot". Nesse momento gravo na memória do agente uma nota de sistema, por exemplo "atendimento humano tratou: segunda via do boleto enviada". Assim o bot não reabre o assunto.
- Inatividade. Um comando agendado fecha conversas em
humanparadas há várias horas, passando paraclosed. A próxima mensagem do cliente abre um ciclo novo com o bot.
O que não faço é devolver automaticamente uma conversa em awaiting_human para o bot porque ninguém assumiu. Isso é abandonar quem pediu uma pessoa. Nesse caso o comando agendado dispara um alerta para a equipe.
Que métricas acompanhar no handoff do agente de IA?
Com handoff_reason, handoff_source, handoff_requested_at e claimed_at no banco, as métricas saem de uma query:
- Taxa de handoff por motivo. Muito
fora_do_escopoindica uma tool que falta. Muitonao_consegui_resolverindica um prompt ou uma tool ruim. - Handoffs por
falha_de_ferramenta. Cada um é um bug ou uma integração instável, não um problema de IA. - Tempo entre pedido e assunção. Se for alto, a fila humana é o gargalo, e ajustar o agente não vai resolver.
- Reincidência. O cliente que volta ao bot e pede humano de novo na mesma semana mostra que a devolução está acontecendo cedo demais.
- Respostas descartadas por troca de dono. Se esse log cresce, o lock ou a ordem das checagens tem falha.
Por onde começar se o seu agente ainda não tem handoff
Implementaria nesta ordem, porque cada passo reduz um risco maior que o seguinte. Primeiro, a coluna owner com o UPDATE condicional e a checagem antes de enviar. Só isso já impede o bot de falar por cima do humano. Depois, as regras determinísticas de pedido explícito e tema sensível, com mensagens fixas e honestas sobre o horário. Em seguida, a tool transferir_para_atendente com motivo em enum. Por último, o resumo estruturado e o painel de métricas. O resumo é o que o atendente mais elogia, mas é o único item cuja falha não causa estrago. Um agente de IA que sabe a hora de sair da conversa gera menos dor de cabeça do que um que tenta resolver tudo sozinho.