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

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:
- Escolha explícita em elemento interativo oferecido pelo sistema → caminho determinístico.
- 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_idpresente 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_keydevolve o mesmoTurnResultpersistido - inclusive se o primeiro attempt ficoumodel_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.
- Inbound exige
Route.MODEL. - Port de LLM indisponível / timeout / erro de structured output → marcar conversa
model_pending, persistir texto pendente emessage_ref, responder copy canônica de indisponibilidade temporária. - 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
- Modele o turno como contrato binário: determinístico (clique/token exato) vs modelo (livre / fora de ordem / correção).
- Implemente o roteador antes do client LLM, com testes de tabela e fail-closed em
choice_idinválido. - Tire do prompt: confirmação, idempotência, tenant, efeito colateral; deixe o modelo propor patch tipado allowlisted.
- Cerque com guardas determinísticas de estado e de saída; bloqueio sem retry de contorno.
- Se o modelo cair:
model_pending+ mesma chave + resume - zero formulário teatro. - Pinese a rota LLM só nos nodes que de fato a usam (Ciclo 1); aqui você decide se o node roda.
- 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.
