Vicco LabsVicco Labs
Construindo um assistente conversacional em produção · Parte 6
De Agno para DSPy, com contrato verificável

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.

26 MAR 2026·7 min de leitura·DSPy / LLM Routing / Pydantic / Migration
DSPY

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:

  • scope vale 2.50 porque é a decisão de roteamento
  • sort_direction vale 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
  • RouterOutput Pydantic 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.