Rotas LLM explícitas por node: por que "provider default" é um bug de arquitetura

A dor em produção
O incidente começa sem stack trace útil. Um node de classificação passa a devolver texto livre em vez de JSON; outro node de planning "pensa" demais e estoura latência; o embedder muda a dimensão do vetor e o índice deixa de aceitar writes. Ninguém deployou um modelo novo de propósito. O que mudou foi um default: OPENAI_API_KEY apontando para um gateway, um SDK que escolheu outro deployment, um fallback que "só usou o client global".
Em sistemas agênticos com vários nodes, a rota LLM é contrato. Se o contrato for implícito - "usa o provider default" - você não tem arquitetura; tem sorte operacional.
Tese
Provider default é bug de arquitetura, não conveniência. Cada node que chama um modelo deve declarar, de forma explícita e versionada, qual rota LLM usa. Um router central valida compatibilidade entre as dimensões da rota e falha fechado (fail-closed) se o node não estiver cadastrado ou se a combinação for inválida. Não há fallback silencioso para "qualquer modelo que responda".
Essa regra vale para chat/completion, structured output, reasoning e embeddings. Capability não é atributo da marca do provider; é atributo do deployment concreto.
As cinco dimensões (não colapse nenhuma)
Tratar "OpenAI" ou "Anthropic" como um único botão mistura cinco eixos distintos. Colapsá-los é a raiz da maioria dos bugs de roteamento.
- Provider (credencial / endpoint). Quem autentica e para onde a request vai: API key, project, base URL, tenant, region. Provider é conta e endpoint, não o nome do modelo. O mesmo "modelo comercial" pode existir em cloud do vendor, em Azure/Bedrock/Vertex, ou atrás de um gateway interno - com credenciais e SLAs diferentes.
- Identidade do LLM. O que você quer semanticamente: família e papel ("classificador barato", "reasoner forte", "summarizer"). Identidade é intenção de produto/engenharia, estável no desenho do grafo. Não é string de API.
- Model / deployment. A unidade real de runtime:
model id+ versão + região + quota + flags (context window, multimodal, defaults de sampling do deployment, etc.). Temperature na request não é a mesma coisa que capability pinada no cadastro da rota. Em clouds gerenciados, "deployment name" ≠ "model family name". É aqui que vivem custo, latência e disponibilidade. - Protocolo de transporte. O wire format da conversa: Chat Completions OpenAI-compatible, Anthropic Messages, Responses API, gRPC interno, etc. Protocolo define headers, schema de messages/tools, streaming e códigos de erro. Protocolo ≠ provider. Um gateway OpenAI-compatible pode falar protocolo OpenAI contra backends que não são OpenAI.
- Engine / SDK. A biblioteca que monta a request: SDK oficial, cliente HTTP fino, abstração LangChain (
BaseChatModel), runtime próprio. O SDK traduz capabilities para parâmetros; se ele esconde defaults, ele vira parte do bug.
Regra prática: se você só consegue nomear "provider = openai", falta mapear as outras quatro dimensões. Sem isso, logs e feature flags mentem.
Config por node + router fail-closed
Config explícita. Cada node do grafo (ou cada role reutilizável) carrega uma referência imutável a uma rota cadastrada - por exemplo route_id: classifier.v3. A definição da rota declara as cinco dimensões e as capabilities exigidas:
- provider + endpoint
- identidade lógica
- model/deployment pinado
- protocolo
- engine/SDK
- capabilities:
structured_output,tools,reasoning,vision,embeddings, dimensão do vetor, etc.
Não existe "se não especificar, usa o default do ambiente". Ambiente fornece secrets e endpoints disponíveis; não escolhe a rota do node.
Router fail-closed. Na inicialização (preferível) e em cada resolve:
- Node pede
route_id. - Router busca o cadastro; ausência = erro duro.
- Valida compatibilidade: provider × deployment × protocolo × capabilities pedidas pelo node.
- Só então instancia/recupera o client.
Fail-closed significa: node não cadastrado, deployment sem a capability pedida, ou protocolo incompatível com o SDK → a aplicação não sobe, ou a chamada aborta com erro explícito. Nunca "tenta outro modelo". Fallback silencioso transforma incidente de config em drift de comportamento - pior classe de falha em agentes, porque o grafo continua "funcionando" com semântica errada.
Em grafos (LangGraph e equivalentes), o node é a unidade de trabalho; a escolha do modelo deve ser input de configuração do node, não efeito colateral de um singleton global. A abstração de chat model (ex.: BaseChatModel no LangChain) ajuda a invocar de forma uniforme; ela não autoriza um único client compartilhado a decidir qual deployment roda em qual etapa.
Structured output e reasoning são do deployment
A documentação da OpenAI descreve Structured Outputs como garantia de aderência a um JSON Schema em modelos/APIs que suportam o recurso (via response_format / modo strict, conforme a superfície da API). A Anthropic documenta a Messages API com parâmetros e capacidades por modelo (tools, thinking, etc.). Em ambos os casos, o ponto de engenharia é o mesmo:
Capability ≠ logo do provider.
Distinga duas superfícies que costumam ser confundidas: Structured Outputs (aderência a um JSON Schema - tipicamente via modo strict) versus JSON mode (garante JSON válido, mas não que o payload respeite o schema). Um gateway OpenAI-compatible pode aceitar o parâmetro de structured/strict e, no backend, degradar para JSON mode ou pior - o protocolo "parece certo"; a garantia some.
- Structured Outputs / schema adherence pode existir no deployment A e não no B, mesmo sob o mesmo vendor comercial.
- "Reasoning" / extended thinking depende de modelo e de flags de API - não de
provider: anthropicno YAML. - Se o node exige schema tipado, a rota precisa declarar capability de Structured Outputs (não só "JSON"). Aceitar o parâmetro sem a garantia é o mesmo anti-padrão do fallback silencioso.
Portanto, o cadastro da rota deve marcar capabilities no deployment, e o node que exige JSON tipado deve falhar se a rota não declarar structured_output: true (ou o equivalente versionado). Prompt engineering do tipo "responda só em JSON" e JSON mode puro não substituem essa checagem: são contratos mais fracos.
O mesmo raciocínio vale para tool calling: suporte a tools, parallel tools, strict schemas e streaming de tool calls variam por deployment. Tratar "o provider X tem tools" como booleano global é o mesmo anti-padrão com outro nome.
Embeddings: o mesmo padrão, com dimensão
Embeddings são rotas LLM com contrato ainda mais rígido: modelo + dimensão + normalização + task type (quando existir). Trocar silenciosamente text-embedding-3-small por outro deployment "compatível" quebra:
- índices vetoriais (dimensão),
- thresholds de similaridade,
- caches de embedding por chave de conteúdo.
Cadastre rotas de embedding como nodes/roles (embed.docs.v2, embed.query.v2), pinando deployment e dimensão. O router falha se a dimensão declarada na rota ≠ dimensão esperada pelo store. Aqui, fallback silencioso não é só bug de qualidade - é corrupção de dados.
Anti-padrões
- Env escolhendo a rota.
LLM_PROVIDER=openai+MODEL=gpt-4ono.envde staging "para facilitar". Resultado: o mesmo código em produção resolve outra rota porque o secret manager injetou outro base URL. Env deve injetar credenciais e catálogo de endpoints; a ligação node→rota vive em config versionada (código ou manifesto), revisável em PR. - Fallback silencioso. "Se Claude falhar, tenta GPT". Útil como política explícita de resiliência com orçamento, métricas e semântica aceita. Perigoso como default do client. Se a política existir, ela é uma rota composta nomeada (
planner.primary_with_failover), não umexcept: use_default(). - Um client global. Singleton
openai.OpenAI()usado por todos os nodes. Além de esconder base URL/headers, impede isolation de timeout, retry e quota por papel. Prefira factory via router: mesmo código de invocação, clients distintos por rota. - Protocolo = provider. Assumir que "fala OpenAI API" implica "é a OpenAI" é o erro que gateways OpenAI-compatible amplificam. O protocolo unifica transporte; não unifica billing, data residency, model cards nem guarantees de structured output. Roteie por deployment cadastrado; use o protocolo só como dimensão de wiring.
- Nome comercial como capability.
if provider == "openai": use_json_schema(). Substitua porif route.capabilities.structured_output. O teste de integração sobe um deployment sem a flag e deve vermelhar o node que a exige.
Referências públicas
- OpenAI - Structured Outputs: o guia oficial descreve aderência a JSON Schema (incl. modo strict) e a superfície por modelo/API - trate suporte como propriedade do deployment exposto, não como premissa do SDK. Ver Structured Outputs. Para o contraste com JSON mode (JSON válido sem garantia de schema), veja o mesmo conjunto de guias de formato de resposta na documentação da plataforma.
- Anthropic - Messages API: a overview e a referência de Messages documentam endpoint, model IDs e capacidades por modelo (tools, thinking, etc.). Ver API overview e a referência de Messages.
- LangChain / LangGraph - abstração de modelo e Graph API: o núcleo LangChain separa a interface de chat models (
BaseChatModelemlangchain_core.language_models) das integrações concretas por provider; LangGraph trata nodes como unidades do grafo que invocam um model interface - a escolha de qual integração/deployment usar permanece responsabilidade da configuração do node, não da abstração em si. Ver langchain-core language models e a Graph API (LangGraph).
Conclusão acionável
- Modele cinco dimensões (provider, identidade, deployment, protocolo, engine) no cadastro de rotas - nunca um enum único
Provider. - Pin cada node a um
route_idversionado; remova defaults silenciosos de modelo/protocolo/provider. - Router fail-closed: node ausente ou incompatível aborta; zero fallback implícito.
- Capabilities no deployment: structured output, reasoning, tools, visão, embeddings (+ dimensão).
- Mesmo rigor para embeddings - dimensão e modelo pinados.
- Trate gateways OpenAI-compatible como protocolo, não como identidade de provider.
- Proíba client global único e proíba env como seletor de rota; env só entrega secrets e catálogo.
Se um node não consegue explicar, em uma linha, qual deployment e com quais guarantees ele chama, a rota ainda é um default - e default, em arquitetura de LLM, é bug.
