Pular para o conteúdo principal

Nem todo turno precisa de LLM: roteamento determinístico vs modelo no agente conversacional

· 7 min para ler
Gui
AI Engineer · Engenharia de Sistemas Multiagentes
Bifurcação entre turno determinístico (clique em opção) e turno de modelo (texto livre) com roteador fail-closed e turno pendente se o LLM cair

A dor do “LLM em todo turno”

O canal conversacional tem estado durável: thread, rascunho, passo atual, última chave de idempotência. Ainda assim, muitos times tratam cada mensagem inbound como invocação de modelo. Resultado previsível: custo sobe com volume de cliques em botão; latência aparece onde bastava um if; e o comportamento deixa de ser reproduzível em teste unitário porque a “inteligência” do turno vira amostragem.

Pior: o modelo passa a ser pedido para decidir o que o produto já sabe. Clique em “Confirmar” vira prompt. Correção explícita vira “interpretação”. Falha do provider vira formulário improvisado que finge ter compreendido. Em SaaS multi-tenant, o mesmo anti-padrão ainda mistura tenant, autorização e efeito colateral dentro do texto gerado - contrato frágil onde deveria haver regra dura.

O Ciclo 1 desta série tratou como pinar a rota LLM por node. Este texto trata o complemento: quando o modelo nem deveria ser chamado.

Tese

Em canal conversacional com estado durável, o contrato do turno é binário e testável:

  1. Escolha explícita em elemento interativo oferecido pelo sistema → caminho determinístico.
  2. Linguagem livre, fora de ordem ou correção que não casa com opção/token → turno de modelo.

O roteamento entre (1) e (2) também é determinístico. Classificar como “determinístico” um turno cujo choice_id (ou equivalente) não casa exatamente com uma opção oferecida no passo atual é defeito bloqueante - não “heurística útil”.

Confirmação de estado, idempotência, isolamento de tenant e efeito colateral (criar registro, promover caso, disparar notificação) ficam fora do prompt. Se o runtime de modelo cair, o turno fica pendente com a mesma chave e retoma depois - sem fallback que simule compreensão.

Um agente conversacional bem delimitado costuma bastar; multiagente por moda, sem lacuna de capacidade, é YAGNI operacional.

Dois tipos de turno

Turno determinístico. O usuário aciona um controle que o próprio sistema emitiu: botão, lista, reply rápido, payload de interactive message. O identificador chega estruturado (choice_id, token, button_payload). O runtime faz lookup exato no prompt interativo do passo corrente. Match → avança aresta, grava campo, ou executa comando canônico (privacidade, opt-out, emergência, “continuar”). Sem match → não “adivinha”; ou recusa/esclarece, ou - se houver texto livre - cai no segundo tipo.

Turno de modelo. Texto livre, correção fora do menu, resposta que não é token de aresta, ou preenchimento de campo sem opção estruturada. O modelo propõe patch tipado sobre o schema permitido (campos do grafo). Não cria o registro final, não confirma sozinho, não inventa slot fora do allowlist. A lacuna sai do grafo; a fala, do modelo.

Contrato mínimo do inbound (genérico):

@dataclass(frozen=True)
class TurnInbound:
tenant_id: str
conversation_id: str
idempotency_key: str
text: str
choice_id: str # vazio se não houve clique
message_ref: str

Regra de ouro: choice_id só autoriza caminho determinístico se existir em options do passo atual. Similaridade, fuzzy match e “o label parece perto” são bugs disfarçados de UX.

Roteador testável

O roteador vive antes do client LLM. Pseudocódigo de produção:

def route_turn(step, offered: InteractivePrompt | None, inbound: TurnInbound) -> Route:
if inbound.choice_id:
if offered is None:
raise RoutingDefect("choice_id sem prompt interativo no passo")
if inbound.choice_id not in {o.id for o in offered.options}:
raise RoutingDefect("choice_id fora das opções oferecidas")
return Route.DETERMINISTIC

if is_command_token(inbound.text): # privacidade, opt-out, etc.
return Route.DETERMINISTIC

if matches_edge_token(step, fold(inbound.text)):
return Route.DETERMINISTIC

return Route.MODEL

O que torna isso testável:

  • Tabela de casos com (passo, options, choice_id, text) → Route - zero flakiness.
  • Fail-closed no clique: choice_id presente e inválido não “cai no modelo para salvar”; é defeito de canal/UI ou resposta canônica de “opção inválida”, conforme política explícita. O que não pode acontecer é promover o clique inválido a sucesso determinístico.
  • Modelo só propõe: saída structured/schema-bound; merge no draft só em campos allowlisted pelo spec do grafo.
  • Replay: mesma idempotency_key devolve o mesmo TurnResult persistido - inclusive se o primeiro attempt ficou model_pending.

Em grafos com checkpointer (LangGraph e equivalentes), o thread_id / conversation id é o cursor durável. O roteador acima é regra de aplicação do turno inbound; não substitui interrupt/HITL - complementa: decide se este inbound precisa de modelo antes de gastar a rota pinada do Ciclo 1.

Guardas fora do prompt

Quatro classes de regra não se negociam com o modelo:

  • Confirmação / criticidade. Promover coleta a registro, disparar efeito irreversível ou mudar ownership exige passo de estado e, quando couber, confirmação explícita - token ou clique, não “o modelo achou que estava ok”.
  • Idempotência. Todo turno carrega chave; store devolve replay. At-least-once no canal (webhook, worker, retry) não pode duplicar efeito.
  • Tenant. Ausência ou mismatch falha fechada. Tenant não é hint no system prompt; é dimensão de toda leitura/escrita.
  • Efeito colateral. Criar agregado, enviar notificação, debitar cota: fora do completion. O modelo no máximo preenche rascunho tipado.

Duas guardas determinísticas tipicamente cercam o modelo:

  • Guarda de estado - o que o turno pode mudar no draft/passo (schema, predicados, arestas).
  • Guarda de saída - o que pode ser dito ao usuário (promessas proibidas, vazamento de prompt, placeholder). Bloqueio não se contorna com retry ao mesmo modelo.

Se a frase só é segura “porque o prompt pediu educadamente”, a invariante ainda não existe.

Quando o runtime de modelo falha

Política correta: pendência com a mesma chave, não degradação teatral.

  1. Inbound exige Route.MODEL.
  2. Port de LLM indisponível / timeout / erro de structured output → marcar conversa model_pending, persistir texto pendente e message_ref, responder copy canônica de indisponibilidade temporária.
  3. Retomada posterior reprocessa o mesmo material sob a mesma idempotency key (ou fila de resume explícita) quando o runtime volta.

O que não fazer: abrir formulário web que “continua a conversa”, inventar defaults nos campos, ou chamar outro modelo “qualquer” sem rota pinada (ver Ciclo 1). Formulário que finja compreensão quebra o contrato do canal e esconde a falha sob UX.

Há paralelo útil com interrupts duráveis: a documentação de LangGraph descreve pausar execução com interrupt(), persistir estado via checkpointer e retomar depois com Command(resume=...) no mesmo thread_id - e exige que efeitos antes do interrupt sejam idempotentes, porque o node reexecuta do início no resume. A lição de produto é a mesma: falha e espera são estados de primeira classe, não atalhos para fingir progresso.

Anti-padrões

  • LLM em todo turno. Clique em opção oferecida passando por completion “para manter o tom”.
  • Fuzzy como determinístico. choice_id ≈ label, embedding de similaridade, ou “o usuário quis dizer a opção 2”.
  • Confirmação no prompt. “Só crie o registro se tiver certeza” como instrução ao modelo no lugar de aresta/token de confirmação.
  • Fallback formulário. Modelo cai → HTML que coleta os mesmos campos e grava como se o diálogo tivesse entendido.
  • Efeito no completion. Tool ampla demais: o modelo “cria o caso” direto, sem guarda de estado nem idempotência de aplicação.
  • Tenant no texto. “Você é o assistente do tenant X” como único isolamento.
  • Multiagente precoce. Orquestrador + especialistas sem lacuna de capacidade mensurável - custo de coordenação sem ganho de contrato.
  • Retry que contorna guarda. Saída bloqueada pela guarda de saída → re-amostrar até “passar”. A guarda então vira sugestão.

Conclusão acionável

  1. Modele o turno como contrato binário: determinístico (clique/token exato) vs modelo (livre / fora de ordem / correção).
  2. Implemente o roteador antes do client LLM, com testes de tabela e fail-closed em choice_id inválido.
  3. Tire do prompt: confirmação, idempotência, tenant, efeito colateral; deixe o modelo propor patch tipado allowlisted.
  4. Cerque com guardas determinísticas de estado e de saída; bloqueio sem retry de contorno.
  5. Se o modelo cair: model_pending + mesma chave + resume - zero formulário teatro.
  6. Pinese a rota LLM só nos nodes que de fato a usam (Ciclo 1); aqui você decide se o node roda.
  7. Resista a multiagente até existir lacuna de capacidade que um spec/grafo não cubra.

Referências públicas úteis: LangGraph Interrupts (pause/resume com checkpointer, Command(resume=...), idempotência de efeitos pré-interrupt); visão geral de human-in-the-loop no ecossistema LangGraph; e o artigo anterior desta série - Rotas LLM explícitas por node.

Se o sistema não consegue explicar, em uma linha, por que este turno chamou o modelo, a chamada ainda é default - e default, em agente conversacional, é desperdício com risco de contrato.