DSPy na prática: o que muda quando o roteador já é LLM, mas ainda não é compilável
O problema que o DSPy resolve não é a ausência de IA no roteamento. É a ausência de contrato no output dessa IA.
Nos últimos posts falei sobre modelagem de dados no Redis Stack e sobre DSPy como framework que trata prompts como código compilável.
Hoje quero mostrar como esses dois assuntos se conectam na prática, usando como exemplo/domínio e-commerce, reproduzindo uma migração real que fiz em um assistente conversacional.
O ponto que quero deixar claro antes de começar: o problema que o DSPy resolve não é a ausência de IA no roteamento. É a ausência de contrato no output dessa IA.
O ponto de partida: Agno, tools com DTO, Redis como texto
O assistente original foi construído com o framework Agno. A arquitetura era elegante e funcionava: um agente com um system prompt detalhado, um conjunto de tools com DTOs tipados, e Redis como storage de estado (chave/valor, texto).
Cada tool tinha seu DTO de entrada:
Isso funciona. O LLM lê o system prompt, entende qual tool usar, constrói os argumentos, e o DTO valida na entrada. Para um catálogo com volume controlado e queries previsíveis, essa arquitetura entrega.
Onde a arquitetura Agno começa a mostrar limite
Com o crescimento do catálogo e da variedade de queries, três problemas emergiram. Nenhum deles é um bug. São limitações estruturais de um roteamento baseado em prompt livre.
Problema 1: o output do LLM não tem contrato verificável.
O Agno deixa o LLM decidir quais argumentos passar para a tool. O LLM é inteligente e geralmente acerta. Mas "geralmente" não é auditável. Quando o usuário pergunta "tênis masculino Nike até 300 reais com frete grátis" e a tool recebe {"categoria": "tênis", "preco_maximo": 300} sem o frete_gratis=true, você não tem um mecanismo sistemático para detectar isso, a não ser revisar logs manualmente.
Problema 2: prompt grande + model update = comportamento imprevisível.
Quando o provider atualizou a versão do modelo, algumas classificações mudaram silenciosamente. Queries que antes iam para BuscarProdutosTool começaram a ir para RankingCategoryTool, nenhuma exceção e nenhum teste quebrou. O comportamento simplesmente mudou, e foi descoberto por reclamação de usuário.
Problema 3: melhorar o roteamento é um processo de tentativa manual.
Adicionar um novo caso de uso (por exemplo, busca por compatibilidade de produto) significa editar o system prompt, testar manualmente os cenários afetados e torcer para que a ordem das instruções no prompt não quebre os casos que já funcionavam. Não existe dataset, não existe métrica, não existe replay de casos históricos.
A causa raiz dos três problemas é a mesma: o roteamento estava correto na intenção, mas opaco na execução.
O que o DSPy muda (e o que não muda)
Antes de mostrar o código, é importante dizer o que permanece igual.
O LLM continua sendo quem roteia. Não voltamos para if/elif. Não trocamos inteligência por regras. O que muda é como o output dessa decisão é especificado, validado e melhorado ao longo do tempo.
A mudança central é esta:
A Signature: especificando o contrato
A Signature não é um prompt. Não tem "responda assim", "seja objetivo", "não invente". É um contrato declarativo: isso entra, isso sai, essas são as regras de classificação. O DSPy monta o prompt real, incluindo os demos few-shot compilados, automaticamente.
O RouterOutput: output tipado antes de chegar na tool
No modelo Agno, o LLM construía os kwargs da tool diretamente. Se esquecesse frete_gratis, o DTO recebia None e a busca ignorava o filtro. Sem log, sem detecção.
Com o RouterOutput, o LLM produz um JSON que passa pela coerção antes de qualquer tool ser chamada. O campo frete_gratis ausente vira None explícito, sendo auditável, observável no trace do Langfuse, rastreável no dataset para recompilação.
A métrica assimétrica: onde a prioridade de negócio vira código
Para o optimizer funcionar, você define uma métrica. A tentação é usar acurácia simples: campos corretos / total de campos.
O problema: essa métrica aceita um absurdo. Um exemplo que errou scope mas acertou os outros 10 campos recebe 0.91 e é aceito como demo pelo BootstrapFewShot. O modelo aprende que escopo errado com filtros certos é aceitável.
Não é. scope errado significa tool errada. Tool errada significa que o Redis recebe uma query completamente diferente, e o usuário recebe uma resposta silenciosamente incorreta.
Essa assimetria é intencional e precisa ser documentada:
scopevale 2.50 porque é a decisão de roteamentosort_directionvale 0.50 porque desc vs asc numa busca tem impacto menor que enviar a query pro escopo errado
O BootstrapFewShot rejeita qualquer demo com scope errado, independente de quantos campos acertaram. É o optimizer agindo como um QA automatizado com as regras que você definiu.
A coerção de output: a camada que a documentação ignora
O LLM não entrega sempre o mesmo formato. Às vezes JSON limpo. Às vezes JSON dentro de markdown fence. Às vezes um campo scope com valor ligeiramente diferente do esperado ("buscar_produtos" em vez de "busca_catalogo").
Na arquitetura Agno, o Pydantic capturava isso na entrada da tool e retornava erro para o LLM tentar de novo. Com DSPy, a coerção acontece antes de qualquer tool ser chamada:
A diferença arquitetural importante: no modelo Agno, uma falha de parsing podia levar o LLM a tentar novamente com reformulação, adicionando latência e consumindo tokens extras. Aqui, a falha é absorvida na coerção e o grafo continua com scope=geral. O pior cenário é uma resposta genérica, não um erro visível para o usuário.
O que mudou no Redis junto com o DSPy
A migração para DSPy veio acompanhada da mudança de Redis texto para Redis Stack. As duas mudanças são complementares.
No modelo anterior, a tool buscava os produtos na API, serializava o resultado como string JSON e gravava no Redis como cache simples. O LLM recebia o JSON bruto e formatava a resposta.
Com Redis Stack, o documento é indexado no momento da ingestão:
O RouterOutput que o DSPy produz alimenta diretamente o QueryBuilder que monta a query do RediSearch. O escopo decide qual índice. Os filtros viram predicados. A projeção varia conforme a intenção (Slim para listagem, Fat via JSON.GET para detalhe).
A cadeia completa:
O que o LangGraph ganhou com isso
O router_node no LangGraph ficou declarativo:
O grafo não toma decisão de negócio. Lê um campo do estado. Quem tomou a decisão foi o DSPy, com um contrato verificável, uma métrica que captura a prioridade de negócio, e um processo de melhoria reprodutível.
Quando o provider atualizou o modelo, a resposta não foi editar 80 linhas de system prompt e torcer. Foi rodar o script de recompilação com o novo modelo como target e validar a métrica no validation set antes de fazer o deploy.
O que ficou mais complexo (mas vale a pena)
Esse modelo tem mais peças do que o Agno original:
- Signature declarativa em vez de system prompt
RouterOutputPydantic em vez de kwargs livres para a tool- Camada de coerção defensiva
- Métrica assimétrica com pesos documentados
- Dataset em NDJSON com exemplos supervisionados
- Script de recompilação versionado
Isso é complexidade real. Não tem sentido minimizar.
A troca que justifica essa complexidade: o comportamento do roteador passou de "eu acredito que o prompt está correto" para "eu tenho evidência de que o modelo classifica corretamente 94.7% das queries do validation set, com zero tolerância a erro de scope".
Para um assistente com volume baixo e casos de uso estáveis, o Agno com bom prompt resolve. Para um assistente que cresce em volume, muda de modelo periodicamente, e precisa rastrear exatamente onde o roteamento falhou, o DSPy resolve o que o prompt não consegue mais resolver sozinho.