Skip to content

DSPy 2026 — declarative LLM programming

TL;DR — DSPy renverse le paradigme : au lieu d'écrire des prompts à la main et de les itérer manuellement, tu décris la tâche en signatures typées Python (input → output), tu fournis un eval set (50-500 exemples), et un compilateur (MIPRO v2, BootstrapFewShot, BootstrapFinetune) optimise automatiquement les prompts, few-shot examples, et même le fine-tuning. Ça brille sur les tâches structurées avec eval clair (classification, extraction, multi-step deterministic) — et ça échoue lamentablement sur la génération créative ou les workflows agentic complexes. Stanford NLP heritage : papier "DSPy: Compiling Declarative Language Model Calls into Self-Improving Pipelines" (Khattab et al.). En 2026, c'est l'outil indispensable pour les freelances qui font de l'optim de pipelines existants — clients qui ont déjà un POC LLM bricolé et veulent passer à du sérieux mesuré.

🧠 Mental model

              ┌──────────────────────────────────────────────────┐
              │              DSPY 2026 PARADIGM                   │
              └──────────────────────────────────────────────────┘

       PROGRAMMATION CLASSIQUE              vs        DSPY
       ─────────────────────────                     ─────────

       "Tu es un expert RH.                          class SeniorityClassif(dspy.Signature):
        Voici un CV.                                     """Classifie le niveau de séniorité."""
        Le seniority level est :                         cv_text: str = dspy.InputField()
        - Junior si < 2 ans                              seniority: Literal["junior","mid","senior","lead"] = \
        - Mid si 2-5 ans                                     dspy.OutputField()
        - Senior si 5-10 ans
        - Lead si > 10 ans + management.                 classify = dspy.ChainOfThought(SeniorityClassif)
        Réponds en JSON: …"                              
                                                         # Optimisation auto
       └─ Itération manuelle ──┐                         compiled = MIPROv2(metric=accuracy).compile(
                               │                            classify, trainset=cv_dataset
                               │                         )

       Prompt v2, v3, v4…                                # Prompts + few-shots générés automatiquement
       Tweak, test, redo                                 # Mesuré, reproductible

                              FLUX DSPY
                              ─────────

   ┌──────────────┐     ┌──────────────┐     ┌──────────────────┐
   │  Signatures  │────▶│   Modules    │────▶│    Compilers     │
   │  (I/O specs) │     │  (Predict,   │     │  • MIPROv2       │
   └──────────────┘     │   ChainOfTh, │     │  • BootstrapFS   │
                        │   ReAct,     │     │  • BootstrapFT   │
                        │   custom)    │     │  • COPRO         │
                        └──────┬───────┘     │  • Ensemble      │
                               │             └────────┬─────────┘
                               ▼                      │
                       ┌──────────────┐               │
                       │  Metric fn   │◀──────────────┘
                       │  (eval-driven│
                       └──────────────┘

Analogie : DSPy = compilateur PyTorch pour prompts.
   - Signature ≈ nn.Module définition (forward signature)
   - Compiler ≈ optimizer.step()
   - Metric ≈ loss function
   - trainset ≈ dataset
   - On déclare l'architecture, on laisse l'optim chercher les bons "poids" (prompts + examples)

Différence essentielle : LangChain/LlamaIndex/Vercel = frameworks d'exécution.
DSPy = framework d'OPTIMISATION + exécution. C'est ML, pas software engineering.

🛠️ Code minimal

python
# pip install dspy-ai
import dspy
from typing import Literal

# 1. Setup LM
# DSPy passe par LiteLLM en backend. Pour Anthropic 2026, l'extended thinking
# à budget_tokens est SUPPRIMÉ sur Opus 4.8/4.7 (HTTP 400). Pas de temperature
# non plus sur ces modèles (paramètre retiré). Pour le déterminisme on s'appuie
# sur effort="low" + un prompt serré, pas sur temperature=0.
lm = dspy.LM("anthropic/claude-opus-4-8", max_tokens=4096)
dspy.configure(lm=lm)

# 2. Signature (la "spec" de la tâche)
class ClassifyComplaint(dspy.Signature):
    """Classifie une plainte client assurance dans la bonne catégorie."""
    complaint: str = dspy.InputField(desc="Texte brut de la plainte client")
    category: Literal["sinistre", "tarif", "service", "resiliation", "autre"] = \
        dspy.OutputField()
    urgency: Literal["low", "medium", "high"] = dspy.OutputField()

# 3. Module (l'exécutable)
classify = dspy.ChainOfThought(ClassifyComplaint)

# 4. Usage direct (sans optim) — comme un classique prompt
result = classify(complaint="Mon dossier sinistre est ouvert depuis 4 mois, aucune réponse...")
print(result.category, result.urgency, result.reasoning)

# 5. Optimisation automatique
from dspy.teleprompt import MIPROv2
trainset = [...]  # liste de dspy.Example
metric = lambda gold, pred, trace=None: (
    gold.category == pred.category and gold.urgency == pred.urgency
)
optimizer = MIPROv2(metric=metric, auto="medium")
compiled = optimizer.compile(classify, trainset=trainset, requires_permission_to_run=False)
compiled.save("classify_v1.json")

🎬 Cas d'usage concrets

Cas 1 — Classifier CV → seniority level (RH-tech Paris, ATS B2B)

Contexte — Startup ATS (Applicant Tracking System) B2B, 200 clients PMI. Veulent enrichir automatiquement chaque CV reçu avec un seniority level (junior/mid/senior/lead) pour aider les recruteurs à pré-trier. POC en GPT-4 prompt classique → 71 % accuracy, beaucoup de mid → senior erreurs.

Approche DSPy — Dataset annoté de 800 CVs (4 niveaux équilibrés, validation par 3 recruteurs). Signature simple (cv_text → seniority + justif). Module ChainOfThought (le LLM raisonne avant de classer). Compilateur MIPROv2 mode "heavy" sur 600 train + 200 test.

Résultats mesurés :

  • Baseline prompt manuel GPT-4 : 71 % accuracy.
  • DSPy ChainOfThought Claude Opus 4.8 non-optimisé : 78 %.
  • DSPy MIPROv2 compilé sur 600 ex (8 h compute, ~$45 budget) : 91 %.
  • DSPy BootstrapFinetune Haiku 4.5 sur 600 ex (12 h, ~$80) : 89 % mais 8x moins cher en inference.

Choix final client : Haiku 4.5 fine-tuné via DSPy. Coût inference / CV : 0.0008 € (vs Opus 4.8 à 0.014 €). Sur 50K CV/mois traités → économie de ~700 €/mois vs Opus.

Note pricing 2026 (input / output par M tokens) : Opus 4.8 (claude-opus-4-8) = 5 $ / 25 $ ; Sonnet 4.6 (claude-sonnet-4-6) = 3 $ / 15 $ ; Haiku 4.5 (claude-haiku-4-5) = 1 $ / 5 $. Le delta de coût d'inference Opus→Haiku (~5x sur le prix unitaire) est ce qui rend BootstrapFinetune intéressant quand DSPy arrive à transférer la qualité Opus vers Haiku.

ROI mission — 18 j × 1300 € = 23 400 € HT. Économies en inference projetées : 8 400 €/an + gain qualité +20 points = up-sell tier "Premium" sur 30 % des clients.

Cas 2 — Pipeline juridique multi-step optimisé (legaltech Toulouse)

Contexte — Editeur d'un produit "résumé automatique d'arrêts de cour". Pipeline existant : extract entities → classify domain → summarize → cite legal articles. Bricolé en LangChain, 67 % de "résumés acceptables" selon les juristes. Veulent passer à 85 %+ sans tout réécrire.

Approche DSPy — Conversion du pipeline en modules DSPy chaînés (chaque step = signature + module). Une metric composite : 30 % entity-recall + 20 % domain accuracy + 30 % summary ROUGE-L vs gold + 20 % citation correctness. Eval set : 250 arrêts annotés par 2 docteurs en droit.

CompilateurMIPROv2 au niveau du pipeline complet, pas step-by-step. La magie : MIPROv2 optimise jointement les prompts et few-shots de chaque step pour maximiser la metric finale. Pas équivalent en LangChain.

Résultats :

  • Score composite avant : 0.67.
  • Score composite après DSPy compile : 0.86.
  • Coût compile : ~$120, 14 h de search.
  • Coût inference par arrêt traité : -18 % (prompts compilés plus concis).

Pitfall réel — Le client voulait "expliquer pourquoi le résumé est meilleur". Réponse honnête : DSPy ne donne pas d'explication interprétable des prompts choisis (c'est juste "ces few-shots + cette formulation marchent mieux"). Tradeoff acceptable pour eux car la metric est claire et auditable.

ROI — 28 j × 1400 € = 39 200 € HT. Le produit passe de "intéressant" à "vendable enterprise", contrat signé avec un cabinet pour 80 K€/an.

Cas 3 — Router intent helpdesk (B2C télécom challenger FR)

Contexte — Helpdesk d'un opérateur télécom challenger FR, ~3K tickets/jour. Premier triage humain coûte 4€/ticket. Veulent un router auto qui dispatche : "tech-mobile", "tech-fixe", "facturation", "résiliation", "commercial", "autre". POC initial en regex + keywords : 58 % accuracy.

Approche DSPy — Signature simple, module Predict (pas besoin de CoT, c'est de la classification rapide), modèle Haiku 4.5. Train set : 2 000 tickets historiques annotés. Compilateur BootstrapFewShotWithRandomSearch (plus rapide que MIPROv2 pour tâches simples).

Résultats :

  • Regex baseline : 58 %.
  • Haiku 4.5 prompt naïf : 79 %.
  • DSPy compiled Haiku 4.5 : 94 % (8 few-shots auto-sélectionnés).
  • Coût inference / ticket : 0.0002 €. Latence p50 : 280 ms.

Bonus — En analysant les exemples sélectionnés par DSPy, on a découvert un cluster "résiliation déguisée en plainte commerciale" → permis de créer une nouvelle catégorie pour le service rétention. ROI indirect.

ROI mission — 12 j × 1300 € = 15 600 € HT. Économies : 3000 tickets × 4 €/jour × 70 % automatisés × 220 j = 1.85 M€/an. Payback en < 1 mois. (Bien sûr, le freelance ne capture que les 15K€ du build — leçon : facturer en value-based sur ce type de mission ou prendre un % d'économies.)

🛠️ Exemple end-to-end

Mission réelle : pipeline DSPy compilé "extract + classify + summarize" pour plaintes assurance d'une mutuelle de 400K sociétaires.

python
"""
DSPy pipeline assurance — extract → classify → summarize → priorité.
Mission : Mutuelle Niort. 500 plaintes/jour. Eval set : 500 plaintes annotées
par 4 agents séniors (acceptable inter-annotateur agreement κ=0.78).
"""
import dspy
from dspy.teleprompt import MIPROv2, BootstrapFewShotWithRandomSearch
from dspy.evaluate import Evaluate
from typing import Literal, Optional
from pydantic import BaseModel
import json, random

# ────────────────────────────────────────────────────────────────────────────
# 0. SETUP
# ────────────────────────────────────────────────────────────────────────────
# Anthropic 2026 : sur Opus 4.8/4.7, `temperature` et `budget_tokens` sont
# RETIRÉS (HTTP 400). On ne passe donc NI temperature NI thinking budget ici.
# La "diversité" du prompt_model (autrefois temperature=0.8) se contrôle via
# l'instruction de proposition de MIPROv2 (init_temperature interne, voir §5),
# pas via le param temperature du LM Anthropic.
TASK_LM = dspy.LM("anthropic/claude-opus-4-8", max_tokens=1500)
PROMPT_LM = dspy.LM("anthropic/claude-opus-4-8", max_tokens=2000)
dspy.configure(lm=TASK_LM)

# ────────────────────────────────────────────────────────────────────────────
# 1. SIGNATURES
# ────────────────────────────────────────────────────────────────────────────
class ExtractEntities(dspy.Signature):
    """Extrait les entités clés d'une plainte client assurance santé.
    Reste fidèle au texte source. Si une entité est absente, retourne null."""
    complaint_text: str = dspy.InputField()
    contract_id: Optional[str] = dspy.OutputField(desc="ID contrat si présent")
    sinistre_id: Optional[str] = dspy.OutputField(desc="N° sinistre si présent")
    money_amount_eur: Optional[float] = dspy.OutputField(desc="Montant en € si évoqué")
    parties_involved: list[str] = dspy.OutputField(desc="Personnes/entités mentionnées")
    date_event: Optional[str] = dspy.OutputField(desc="Date événement YYYY-MM-DD")

class ClassifyPlainte(dspy.Signature):
    """Classifie la plainte par catégorie et urgence métier."""
    complaint_text: str = dspy.InputField()
    entities_summary: str = dspy.InputField(desc="Résumé des entités extraites")
    category: Literal[
        "remboursement_lent", "refus_prise_charge", "erreur_tarification",
        "service_client", "resiliation", "fraude_suspectee", "autre"
    ] = dspy.OutputField()
    severity: Literal["S1_critique", "S2_haute", "S3_normale", "S4_basse"] = \
        dspy.OutputField()
    requires_juridique: bool = dspy.OutputField(
        desc="True si demande légale, mise en demeure, médiateur."
    )

class SummarizePlainte(dspy.Signature):
    """Résumé pour fiche agent : 3-5 phrases factuelles + action recommandée."""
    complaint_text: str = dspy.InputField()
    entities_summary: str = dspy.InputField()
    category: str = dspy.InputField()
    severity: str = dspy.InputField()
    summary: str = dspy.OutputField(desc="3 à 5 phrases factuelles")
    recommended_action: str = dspy.OutputField(desc="Action concrète à faire")

# ────────────────────────────────────────────────────────────────────────────
# 2. PIPELINE MODULE
# ────────────────────────────────────────────────────────────────────────────
class PlaintePipeline(dspy.Module):
    def __init__(self):
        super().__init__()
        self.extract = dspy.ChainOfThought(ExtractEntities)
        self.classify = dspy.ChainOfThought(ClassifyPlainte)
        self.summarize = dspy.ChainOfThought(SummarizePlainte)

    def forward(self, complaint_text: str):
        ents = self.extract(complaint_text=complaint_text)
        ents_summary = (
            f"Contract={ents.contract_id} | Sinistre={ents.sinistre_id} | "
            f"Montant={ents.money_amount_eur}€ | Parties={ents.parties_involved} | "
            f"Date={ents.date_event}"
        )
        cls = self.classify(
            complaint_text=complaint_text, entities_summary=ents_summary,
        )
        summ = self.summarize(
            complaint_text=complaint_text, entities_summary=ents_summary,
            category=cls.category, severity=cls.severity,
        )
        return dspy.Prediction(
            contract_id=ents.contract_id,
            sinistre_id=ents.sinistre_id,
            money_amount_eur=ents.money_amount_eur,
            category=cls.category,
            severity=cls.severity,
            requires_juridique=cls.requires_juridique,
            summary=summ.summary,
            recommended_action=summ.recommended_action,
        )

# ────────────────────────────────────────────────────────────────────────────
# 3. DATASET LOAD
# ────────────────────────────────────────────────────────────────────────────
def load_dataset(path: str) -> list[dspy.Example]:
    with open(path) as f:
        rows = json.load(f)
    examples = []
    for r in rows:
        ex = dspy.Example(
            complaint_text=r["text"],
            contract_id=r["contract_id"],
            sinistre_id=r.get("sinistre_id"),
            money_amount_eur=r.get("amount"),
            category=r["category"],
            severity=r["severity"],
            requires_juridique=r["requires_juridique"],
            summary=r["gold_summary"],
            recommended_action=r["gold_action"],
        ).with_inputs("complaint_text")
        examples.append(ex)
    return examples

examples = load_dataset("./plaintes_dataset_500.json")
random.seed(42); random.shuffle(examples)
trainset = examples[:300]
devset = examples[300:400]
testset = examples[400:]

# ────────────────────────────────────────────────────────────────────────────
# 4. METRIC COMPOSITE
# ────────────────────────────────────────────────────────────────────────────
# Juge instancié UNE fois (réutilisé par tous les appels de metric). Modèle bon
# marché pour ne pas faire exploser le coût du search.
JUDGE_LM = dspy.LM("anthropic/claude-sonnet-4-6", max_tokens=8)

def metric(gold: dspy.Example, pred: dspy.Prediction, trace=None) -> float:
    """Composite : entité (30%), classification (40%), résumé (30%)."""
    # Entités
    contract_ok = (gold.contract_id == pred.contract_id) if gold.contract_id else 1
    amount_ok = (abs((gold.money_amount_eur or 0) - (pred.money_amount_eur or 0)) < 1.0)
    ent_score = (contract_ok + amount_ok) / 2

    # Classification
    cat_ok = gold.category == pred.category
    sev_ok = gold.severity == pred.severity
    jur_ok = gold.requires_juridique == pred.requires_juridique
    cls_score = (cat_ok * 0.5 + sev_ok * 0.3 + jur_ok * 0.2)

    # Résumé : LLM-as-judge (binaire). On prend un modèle MOINS cher que le task
    # model pour le juge (Sonnet 4.6) — sinon le coût de compile explose : la
    # metric est appelée des milliers de fois pendant le search MIPROv2.
    # ⚠️ Anti-pattern courant : instancier le judge_lm DANS la metric (un objet
    # LM neuf par appel = pas de réutilisation de cache, GC pressure). On le sort
    # en module-level (voir JUDGE_LM ci-dessous) et on le réutilise.
    judge_prompt = (
        f"Gold résumé : {gold.summary}\n"
        f"Predicted résumé : {pred.summary}\n"
        f"Le predicted résumé est-il sémantiquement équivalent au gold (capture les "
        f"mêmes faits clés) ? Réponds uniquement OUI ou NON."
    )
    judge_out = JUDGE_LM(judge_prompt)[0].strip().upper()
    sum_score = 1.0 if judge_out.startswith("OUI") else 0.0

    return 0.3 * ent_score + 0.4 * cls_score + 0.3 * sum_score

# ────────────────────────────────────────────────────────────────────────────
# 5. OPTIMISATION  (MIPROv2 sur le pipeline complet)
# ────────────────────────────────────────────────────────────────────────────
def optimize_pipeline():
    base = PlaintePipeline()

    # Eval baseline avant compile
    evaluator = Evaluate(devset=devset, metric=metric, num_threads=8,
                         display_progress=True, display_table=5)
    base_score = evaluator(base)
    print(f"Baseline score: {base_score:.3f}")

    # Compile MIPROv2
    optimizer = MIPROv2(
        metric=metric,
        prompt_model=PROMPT_LM,
        task_model=TASK_LM,
        auto="medium",                # "light" / "medium" / "heavy"
        num_threads=8,
    )
    compiled = optimizer.compile(
        base,
        trainset=trainset,
        valset=devset,
        max_bootstrapped_demos=4,
        max_labeled_demos=4,
        requires_permission_to_run=False,
    )

    compiled_score = evaluator(compiled)
    print(f"Compiled score: {compiled_score:.3f} (+{compiled_score-base_score:.3f})")

    # Eval finale sur testset
    test_eval = Evaluate(devset=testset, metric=metric, num_threads=8)
    final = test_eval(compiled)
    print(f"Test score: {final:.3f}")

    compiled.save("plainte_pipeline_v1.json")
    return compiled

# ────────────────────────────────────────────────────────────────────────────
# 6. RUN INFERENCE
# ────────────────────────────────────────────────────────────────────────────
def run_inference(compiled, complaint_text: str):
    pred = compiled(complaint_text=complaint_text)
    return {
        "contract_id": pred.contract_id,
        "sinistre_id": pred.sinistre_id,
        "amount": pred.money_amount_eur,
        "category": pred.category,
        "severity": pred.severity,
        "requires_juridique": pred.requires_juridique,
        "summary": pred.summary,
        "action": pred.recommended_action,
    }

if __name__ == "__main__":
    compiled = optimize_pipeline()
    sample = (
        "Bonjour, je relance pour le 4e fois mon dossier sinistre n° SN-2026-04532. "
        "Mon hospitalisation du 15/02/2026 (chirurgie genou, prothèse, 8 200 €) "
        "n'a toujours pas été remboursée. Mon contrat est le MUT-78441. "
        "Sans nouvelle d'ici 8 jours, je saisis le médiateur."
    )
    print(json.dumps(run_inference(compiled, sample), indent=2, ensure_ascii=False))

Résultats observés (Niort, mai 2026) :

MétriqueManuel promptDSPy non-comp.DSPy MIPROv2
Score composite0.620.740.88
Entité contract_id81 %88 %96 %
Catégorie correcte67 %76 %92 %
Sévérité correcte58 %71 %84 %
Résumé acceptable65 %78 %89 %
Coût compile~$135
Time-to-compile9 h
Coût inference / case0.012 €0.014 €0.011 €

Le gain qualité (+26 points) a permis de basculer 70 % des plaintes en traitement automatique avec contrôle agent (vs 100 % humain avant). Gain ~12 ETP back-office soit ~700 K€/an. Mission 35 j × 1400 € = 49 K€ HT.

🎯 Patterns courants

1. ChainOfThought pour raisonnement implicite

python
predict = dspy.ChainOfThought(MySignature)
# DSPy ajoute automatiquement un champ "reasoning" avant les outputs.

2. ReAct pour agents avec tools

python
def search_tool(query: str) -> str:
    return google_search(query)
agent = dspy.ReAct(QASignature, tools=[search_tool], max_iters=4)

3. ProgramOfThought pour calculs

python
pot = dspy.ProgramOfThought(MathSignature)
# DSPy génère du code Python, l'exécute, et utilise le résultat.

4. MultiChainComparison pour ensembling

python
mcc = dspy.MultiChainComparison(MySignature, M=5)  # 5 chains, vote majoritaire.

5. BootstrapFewShot rapide vs MIPROv2 lent

  • BootstrapFewShot : quelques minutes, +5-10 points typique.
  • BootstrapFewShotWithRandomSearch : 30 min - 2 h, +8-15 points.
  • MIPROv2 light : 1-3 h, +10-20 points.
  • MIPROv2 heavy : 4-12 h, +15-25 points.
  • BootstrapFinetune : 6-24 h, peut beaucoup baisser le coût inference.

6. Custom Modules

python
class HybridRAG(dspy.Module):
    def __init__(self, retriever):
        super().__init__()
        self.retriever = retriever
        self.generate = dspy.ChainOfThought(QASignature)
    def forward(self, question):
        ctx = self.retriever(question, k=5)
        return self.generate(context=ctx, question=question)

7. Assertions DSPy (validation runtime)

python
dspy.Suggest(len(pred.summary.split()) < 100, "Résumé trop long, raccourcis.")
dspy.Assert(pred.category in VALID_CATEGORIES, "Catégorie invalide.")

8. Caching disk natif — DSPy cache les appels LLM sur disque (~/.dspy_cache/) → re-runs eval gratuits.

🔄 Versions & écosystème 2026

  • dspy-ai 2.6.x — API stabilisée, breaking changes finis. Compat Python 3.10+.
  • MIPROv2 — algorithme phare, optimization par Bayesian search sur prompts + few-shots. Paper Khattab et al. 2024.
  • BootstrapFinetune — fine-tuning auto basé sur les traces collectées par le compiler. Marche bien avec Anthropic (via Bedrock fine-tuning) et OpenAI.
  • dspy.LM — supporte tous les providers via LiteLLM en backend (Anthropic, OpenAI, Google, Mistral, Cohere, Bedrock, Vertex, Together, Groq, Ollama local).
  • Ensemble — agréger plusieurs compiled programs pour robustesse.
  • MLflow integration — tracking des compilations + métriques natif depuis 2.5.
  • DSPy Cloud (annoncé 2025, en beta 2026) — managed compile service avec compute géré.
  • Concurrent : Outlines, Instructor, Guidance — structured outputs sans optim. Pydantic AI — typed agents sans optim. DSPy = le seul à combiner les deux (declarative + optim).

⚠️ Pitfalls

  1. Dataset trop petit — < 50 exemples : compile ne fait rien d'utile, l'optimizer overfit. Minimum 100, idéal 300-500 train + 100 dev + 100 test.
  2. Metric subjective — si la metric n'est pas objective (ex: "le résumé est-il bon ?"), passer par LLM-as-judge. Mais alors valider que le judge corrèle avec l'humain sur ~50 ex avant compile.
  3. Coût compile sous-estimé — MIPROv2 heavy sur Opus 4.8 peut coûter $200-500 facilement. Toujours mettre un budget (auto="light" pour démarrer). Calcul de tête : nb_trials × taille_valset × (tokens_in × 5 $ + tokens_out × 25 $) / 1M, plus le coût du prompt_model qui génère les instructions, plus le coût du juge si LLM-as-judge. C'est le poste qui surprend les clients — toujours afficher une estimation AVANT de lancer un heavy.
  4. Signatures trop flouesdspy.OutputField(desc="résultat") sans contraintes typées (Literal, bool, int) marche mal. Toujours typer strictement.
  5. Cache pollué — entre deux essais avec prompts différents, le cache disk peut rester. Nettoyer ~/.dspy_cache/ ou désactiver dspy.configure(cache=False) pendant debug.
  6. requires_permission_to_run=True par défaut dans MIPROv2 — bloque les batchs CI. Passer False explicitement quand on sait ce qu'on fait.
  7. Génération créative ≠ DSPy — écrire un poème, brainstormer, rédiger marketing : pas de metric objective, DSPy ne sert à rien.
  8. Multi-turn conversation — DSPy gère mal les conversations stateful longues. Le sweet spot = pipelines déterministes single-shot.
  9. Compiled programs non-portables entre LMs — un programme compilé pour Opus 4.8 ne sera pas optimal sur Haiku 4.5 (few-shots et prompts choisis pour le modèle cible). Le .json sauvegardé encode des instructions et démos tunées pour un modèle ; changer de modèle sans recompiler = régression silencieuse. Versionne le couple (programme, model_id) ensemble.
  10. Pas d'introspection humaine facile — les prompts générés sont stockés en JSON dans le programme. Lisibles mais pas conçus pour audit ligne-à-ligne par un PM non-tech.

💰 Pricing / ROI client

DSPy — open source MIT, gratuit. Pas de licence.

Coûts inhérents :

  • Compile cost : 1 run MIPROv2 medium ≈ $30-150 selon modèle/taille dataset.
  • Inference cost : identique au LLM choisi (Opus / Sonnet / Haiku / fine-tuné).
  • Compute optim : peut être lourde, prévoir 1 VM cloud avec un soir pour les heavy compile.

ROI freelance FR (le sweet spot DSPy) :

  • Mission "optim pipeline LLM existant" — client a un POC qui marche à 60-70 %, mais pas vendable à 85 %+. DSPy compile dessus.

    • 10-15 j × 1400 € = 14-21 K€ HT.
    • Gain client : permet de signer l'enterprise deal qui exigeait 85 %+ (souvent 100-300 K€/an).
  • Mission "ré-architecture pipeline" — refactor d'un script LangChain bricolé en DSPy propre + eval-driven dev installé.

    • 25-35 j × 1400 € = 35-49 K€ HT.
    • Gain : qualité +20 points + maintenance future divisée par 3.
  • Mission "fine-tune cheap model" — BootstrapFinetune sur Haiku ou Llama → mêmes perfs qu'Opus à 1/10 du prix inference.

    • 15-25 j × 1400 € = 21-35 K€ HT.
    • Gain client : sur 1M req/mois, économie typique 5-15 K€/mois en inference.

Argumentaire commercial — "Si vous avez une tâche avec une metric d'eval claire, je peux vous garantir +15-25 points de qualité en 3 semaines, mesurés et reproductibles. C'est du ML rigoureux, pas du prompt-engineering au feeling."

🧪 Testing / Eval

L'eval est au cœur de DSPy — pas une post-occupation. Workflow type :

python
from dspy.evaluate import Evaluate

evaluator = Evaluate(
    devset=devset,
    metric=metric,
    num_threads=8,
    display_progress=True,
    display_table=10,           # affiche 10 cas worst+best
    return_outputs=True,
)
score, results = evaluator(compiled, return_outputs=True)

# Analyse des échecs
failures = [(ex, pred, m) for ex, pred, m in results if m < 0.5]
for ex, pred, m in failures[:5]:
    print(f"--- score={m:.2f}")
    print(f"complaint: {ex.complaint_text[:200]}")
    print(f"gold cat={ex.category} | pred cat={pred.category}")

Best practices :

  • Dataset stratifié (classes équilibrées) ou sample weights dans la metric.
  • Toujours un test set vu une seule fois après compile final.
  • LLM-as-judge avec sanity check humain sur 30-50 cas.
  • Tracking MLflow : log chaque compile run avec score + cost + prompts générés.
  • Re-run eval mensuellement en prod (concept drift).

🏭 Production : ce qu'un staff regarde avant de déployer

DSPy résout le problème de la qualité au moment du build. Il ne résout rien des problèmes de prod. Un programme compilé qui sort à 0.88 sur le testset peut être un désastre en production si on ne s'occupe pas de ces axes.

Le compiled .json est un artefact, traite-le comme tel

compiled.save("v1.json") produit un fichier qui contient les prompts et few-shots gagnants. C'est ton modèle entraîné. Conséquences pour un staff :

  • Versionne-le dans le repo (ou dans un model registry / S3 versionné), pas dans /tmp. Le couple (programme.py, v1.json, model_id, dataset_hash) doit être reproductible.
  • Pin le model_id exact à côté du .json. Un compiled tuné pour claude-opus-4-8 rechargé sur un autre modèle dégrade en silence (cf. pitfall #9).
  • CI de non-régression : chaque PR qui touche le programme ou le dataset relance l'eval sur le testset gelé et breake si régression > 2 points. Sans ça, un dev "améliore" une signature et casse 6 points sans le voir.

Latence — DSPy ne la masque pas, il l'aggrave parfois

ChainOfThought ajoute un champ reasoning → tokens output en plus → latence en plus. Un pipeline 3 étapes en ChainOfThought = 3 appels LLM séquentiels.

  • Mesure p50 ET p95/p99 par étape. La moyenne ment ; c'est le p99 qui fait timeout ton endpoint.
  • Parallélise ce qui est indépendant. En prod (pas pendant le compile), si deux étapes ne dépendent pas l'une de l'autre, lance-les en concurrence. Côté SDK Anthropic brut, c'est AsyncAnthropic + asyncio.gather. DSPy ne parallélise pas l'intérieur d'un forward pour toi.
  • Streaming pour les longues sorties (résumés). Au-delà de ~16K tokens en non-stream, on risque des timeouts SDK ; passe en streaming et récupère via le helper get_final_message().
  • Prompt caching : un compiled DSPy stable a un préfixe (instructions + few-shots) identique à chaque appel → candidat parfait pour le cache. Sur l'API Anthropic, pose cache_control sur le préfixe stable (system + démos) ; vérifie usage.cache_read_input_tokens non nul. DSPy ne pose pas ce breakpoint pour toi par défaut — c'est un gain coût/latence laissé sur la table si tu l'oublies.

Observabilité — sans elle tu es aveugle après le déploiement

  • Log resp.usage (input/output/cache tokens) par appel → dashboard coût réel par requête. C'est la seule façon de détecter une dérive de coût (un few-shot qui a grossi, un retry en boucle).
  • MLflow (intégration native DSPy depuis 2.5) : log chaque compile run avec score + coût + prompts générés. En prod, trace les inputs/outputs pour rejouer les échecs.
  • Eval monitoring continu : sample 1 % du trafic prod + un label humain hebdo. Le concept drift est réel (nouveau vocabulaire client, nouvelle catégorie de plainte) — un programme à 0.88 peut tomber à 0.80 en 3 mois sans qu'aucune alerte technique ne se déclenche. La metric d'eval est ton détecteur.

Robustesse des appels — gérer les pannes de l'API

DSPy passe par LiteLLM, mais en prod tu dois penser comme si tu appelais l'API directement :

  • Retries typés : RateLimitError (429) et OverloadedError (529) sont retryables avec backoff exponentiel ; BadRequestError (400) ne l'est pas (ex : un budget_tokens envoyé par erreur à Opus 4.8 → 400 permanent, inutile de retry). Le SDK Anthropic retry 429/5xx automatiquement (max_retries), mais log-les pour les voir.
  • Timeout par appel : un appel qui traîne dans un pipeline 3-étapes bloque toute la requête. Pose un timeout par étape.
  • Fail-open vs fail-closed : si l'étape classify échoue, route-t-on vers "humain" (fail-safe) ou vers une catégorie par défaut (fail-fast risqué) ? Décision métier, à acter avec le client.
  • PII dans les traces : les few-shots collectés par Bootstrap peuvent contenir des données réelles (n° contrat, montants, noms). Le .json compilé embarque ces exemples. Si tu le versionnes, tu versionnes potentiellement de la PII → anonymise les démos ou restreins l'accès au registry.
  • LLM-as-judge auditable : sur des décisions à enjeu (refus de prise en charge), la metric et le juge doivent être documentés et reproductibles pour l'audit. "Ces few-shots marchent mieux" n'est pas une justification acceptable devant un régulateur.

🔁 Quand utiliser / éviter

Utiliser DSPy quand :

  • Tâche structurée (classification, extraction, multi-step deterministic)
  • Eval set disponible ou faisable à créer (100-500 cases labellisés)
  • Metric quantifiable objective (accuracy, F1, exact match, ROUGE) ou semi-objective (LLM-judge avec validation humaine)
  • Besoin de +10-25 points de qualité vs prompt manuel
  • Volume inference important → fine-tune Haiku/Llama via BootstrapFinetune pour économies

Éviter DSPy quand :

  • Génération créative pure (rédaction, marketing, brainstorm)
  • Pas d'eval set possible (tâches trop subjectives ou nouvelles)
  • POC de < 5 jours (overhead setup trop lourd)
  • Conversation multi-turn longue et stateful (préférer LangGraph / Workflows)
  • Equipe sans culture data/ML (DSPy demande de penser en train/dev/test, metric, baseline)

Combo qui marche : LangGraph orchestre les nœuds + chaque nœud "déterministe" implémenté en module DSPy compilé + raw SDK pour les nœuds de pure génération.

🎓 Approfondissement — MIPROv2 sous le capot

Comprendre comment MIPROv2 fonctionne aide à débugger les compilations qui ne convergent pas. L'algorithme combine 3 sous-routines :

  1. Bootstrap — exécute le programme non-optimisé sur le trainset, collecte les traces (input → reasoning → output). Filtre les traces dont la metric > seuil. Ces traces deviennent des candidats few-shot.
  2. Proposer — utilise un LLM "prompt model" (souvent le même que le task model en plus créatif) pour générer N variations d'instructions pour chaque signature. Chaque signature peut avoir 10-20 candidats d'instructions.
  3. Bayesian Search — explore l'espace produit cartésien {instructions × few-shots} avec un algorithme d'optimisation Bayesien (Optuna/HyperOpt sous le capot). À chaque trial, il assemble un programme, l'évalue sur le valset, et utilise le résultat pour orienter la prochaine recherche.
Espace de recherche typique :
  - 5 signatures × 10 candidats instructions = 10^5 combinaisons d'instructions
  - 5 signatures × C(50, 4) few-shots = encore plus
  → total : ~10^15 programmes possibles
  → MIPROv2 en explore ~50-200 (auto="medium") ou ~500-1000 (heavy)

Le prompt model (PROMPT_LM) génère les variations d'instructions ; le task model (TASK_LM) exécute le programme. ⚠️ Sur Anthropic 2026 (Opus 4.8/4.7), temperature est retiré (HTTP 400) — on ne contrôle donc PAS la diversité via le temperature du LM. La diversité des instructions proposées se règle via le paramètre interne de MIPROv2 init_temperature (voir config manuelle ci-dessous, ex. init_temperature=1.4), qui pilote l'échantillonnage côté optimizer, pas un appel temperature= sur le LM Anthropic. Pour le déterminisme du task model, on s'appuie sur effort="low" + un prompt serré (cf. §0 du code end-to-end), pas sur temperature=0.

Modes auto

  • auto="light" : ~15 trials, 30-60 min, +5-10 points typiquement.
  • auto="medium" : ~50 trials, 2-4 h, +10-20 points.
  • auto="heavy" : ~200 trials, 8-15 h, +15-25 points.

Ou config manuelle :

python
MIPROv2(
    metric=metric,
    prompt_model=PROMPT_LM,
    task_model=TASK_LM,
    num_candidates=15,             # N instructions per signature
    init_temperature=1.4,          # temperature for prompt generation
    minibatch_size=35,
    minibatch_full_eval_steps=10,
)
.compile(program, trainset=trainset, valset=valset, num_trials=80,
         max_bootstrapped_demos=4, max_labeled_demos=4)

Quand MIPROv2 ne converge pas

Diagnostic typique sur un compile qui n'améliore pas la baseline :

  1. Dataset bruité — vérifier inter-annotator agreement sur 50 ex. Si κ < 0.6, MIPROv2 ne peut pas faire mieux que les annotateurs.
  2. Metric mal calibrée — la metric ne mesure pas ce que vous voulez. Test : sample 20 cas avec metric haute → sont-ils tous bons pour un humain ? Si non, metric à revoir.
  3. Signature trop floueOutputField(desc="...") sans typing strict laisse trop de marge. Ajouter Literal[...], Annotated[..., Field(...)], validations.
  4. Modèle plafonné — Haiku ne peut pas faire ce qu'Opus fait. Vérifier la perf Opus avant de descendre.
  5. Cache staledspy.configure(cache=False) pendant le debug.

BootstrapFinetune (l'arme secrète coût)

Une fois un programme compilé qui marche bien sur Opus 4.8, on peut :

python
from dspy.teleprompt import BootstrapFinetune

# 1. Collecter beaucoup de traces avec le programme compilé (1000-5000 ex)
# 2. Fine-tuner un petit modèle (Haiku ou Llama-3-8B) sur ces traces
finetune = BootstrapFinetune(metric=metric, num_threads=8)
finetuned_program = finetune.compile(
    compiled_program,
    trainset=extended_trainset,
    target="anthropic/claude-haiku-4-5",  # ou un modèle local Llama (vLLM/Ollama)
)

Gain typique : qualité 90-95 % de l'Opus, coût inference ~1/5 sur le prix unitaire (Opus 4.8 = 5 $/25 $ par M tok vs Haiku 4.5 = 1 $/5 $), davantage si on passe sur un modèle open-weight self-hosté. Sur volumes 100K+ req/mois, économies de plusieurs milliers d'€/mois. Mental model staff : BootstrapFinetune, c'est de la distillation — le gros modèle compilé est le teacher, le petit modèle apprend à reproduire ses sorties sur la distribution de tâche. Ça ne marche que si (a) la tâche est étroite, (b) le teacher est déjà bon, (c) tu as assez de traces variées (1K minimum, 5K idéal).

🎓 Approfondissement — culture eval-driven dev

DSPy force une culture eval-first qui dépasse l'outil lui-même. Les freelances qui l'adoptent durablement constatent un changement de méthode :

  • Toujours commencer par l'eval set — avant le 1er prompt. 20-30 minutes à interviewer le client pour collecter ses critères, puis créer un mini-dataset de 30 ex à la main.
  • Baseline obligatoire — mesurer le "prompt naïf en 5 lignes" avant tout. C'est le strict minimum à battre.
  • Versionner les programmescompiled.save("v1.json"), v2.json, etc. + scores associés dans un README.
  • CI eval — chaque PR doit faire tourner l'eval set, breaker si régression > 2 points.
  • Eval monitoring prod — sampler 1 % des inputs prod + un label humain hebdo pour détecter concept drift.

Ce mindset = différenciateur freelance. Beaucoup de devs LLM travaillent encore "au feeling". Apporter eval-driven dev élève le niveau du projet et facilite les renouvellements de mission.

🏋️ Exercices

Progression du "je sais faire compiler" au "je sais défendre le chiffre et casser/réparer en prod". Chacun est dur exprès. Utilise claude-opus-4-8 comme task model et claude-haiku-4-5 ou claude-sonnet-4-6 comme juge/cheap path.

Exercice 1 — Baseline honnête + premier compile (mise en jambe)

Objectif — Construire un classifieur DSPy (intent helpdesk, 5 classes) et prouver le gain du compile contre une baseline non bidonnée.

Crée un dataset de 200 tickets (150 train / 50 test) annotés à la main ou semi-synthétiques. Mesure 3 points sur le même testset : (1) prompt naïf 5 lignes, (2) dspy.Predict non compilé, (3) BootstrapFewShotWithRandomSearch compilé. Sors un tableau accuracy + intervalle de confiance (bootstrap sur 50 ex est étroit, calcule l'IC à 95 %).

Indice/Solution — Le piège est l'IC : à n=50, ±1 erreur ≈ ±2 points. Si ton "gain" du compile est < l'IC, tu n'as rien prouvé. Augmente le testset ou refais le calcul. C'est exactement la conversation que tu auras avec un client qui te demande "et c'est significatif, +3 points ?".

Exercice 2 — Metric composite + LLM-as-judge calibré (intermédiaire)

Objectif — Écrire une metric composite (extraction + classif + résumé) où le résumé est jugé par LLM, ET prouver que le juge corrèle avec l'humain avant de l'utiliser.

Reprends le pipeline assurance. Avant de compiler : sur 50 paires (gold, predicted), fais juger par claude-sonnet-4-6 et par toi. Calcule l'accord (κ de Cohen). Si κ < 0.7, le juge est inutilisable — ajuste son prompt jusqu'à κ ≥ 0.7. Ensuite seulement, branche-le dans la metric.

Indice/Solution — Un juge binaire OUI/NON est bruité ; un juge sur échelle 1-5 avec rubrique explicite corrèle mieux. Le coût caché : la metric est appelée des milliers de fois en compile → instancie le juge UNE fois (module-level), prends le modèle le moins cher qui tient κ≥0.7, et mets max_tokens minimal (8 tokens suffisent pour OUI/NON). Mesure le coût total du juge sur un auto="light" avant de lancer un heavy.

Exercice 3 — Distillation Opus → Haiku via BootstrapFinetune (avancé)

Objectif — Transférer un programme compilé Opus 4.8 vers Haiku 4.5 et défendre le chiffre : combien de qualité perdue, combien de coût gagné, à partir de quel volume le finetune est rentable.

Compile sur Opus 4.8, collecte 2000 traces, fine-tune Haiku 4.5. Mesure : Δaccuracy (Opus compilé vs Haiku finetuné) et Δcoût inference par requête (pricing réel : Opus 5 $/25 $, Haiku 1 $/5 $ par M tok). Calcule le point mort : à quel volume mensuel le coût du finetune (compute + ta journée) est amorti par les économies d'inference ?

Indice/Solution — Le piège : tu compares un Haiku finetuné à un Opus compilé, pas à un Haiku naïf — sinon tu surévalues le gain DSPy. Et le point mort dépend du volume : à 1K req/mois, le finetune ne sera jamais rentable (les économies sont en centimes) ; à 1M req/mois, il l'est en jours. Sors la formule, pas juste un chiffre.

Exercice 4 — Casse-le, puis répare (production-grade)

Objectif — Provoquer 4 modes de défaillance réels d'un pipeline DSPy en prod et écrire le garde-fou pour chacun.

Prends ton pipeline et reproduis : (a) un BadRequestError 400 (envoie un budget_tokens à Opus 4.8 via le LM — confirme que c'est non-retryable), (b) un cache ~/.dspy_cache/ pollué qui fausse deux runs d'eval consécutifs, (c) un compiled .json rechargé sur le mauvais model_id (mesure la régression silencieuse), (d) un concept drift simulé (injecte 20 % de tickets d'une catégorie absente du train). Pour chaque, écris la détection + le fix.

Indice/Solution — (a) wrappe l'appel, distingue retryable (429/529) de non-retryable (400) ; (b) dspy.configure(cache=False) en debug ou nettoie le cache, et vérifie que deux eval identiques donnent le même score ; (c) versionne (programme, .json, model_id) ensemble et asserte le model_id au chargement ; (d) c'est la metric de monitoring prod qui doit chuter — si elle ne bouge pas, ton eval set n'est pas représentatif.

Exercice 5 — Défends le ROI devant un sceptique (staff/closing)

Objectif — Construire le pitch chiffré complet d'une mission DSPy et tenir face aux 3 objections classiques.

À partir d'un cas (au choix : helpdesk, assurance, legal), produis : le tableau avant/après, le coût de compile, le coût d'inference projeté à 12 mois, le point mort, et la réponse à : (1) "pourquoi pas juste un meilleur prompt manuel ?", (2) "vos prompts générés ne sont pas auditables", (3) "et si le modèle Anthropic change dans 6 mois ?".

Indice/Solution — (1) montre la baseline prompt manuel dans ton tableau — c'est ce que tu bats de +15-25 points, mesurés ; (2) vrai, DSPy n'est pas interprétable au niveau prompt, mais la metric et l'eval set le sont, c'est ça l'auditabilité côté décision ; (3) recompile — c'est 1 run, le pipeline (signatures + metric + dataset) est l'asset durable, pas le .json. Si tu ne sais pas répondre à (3), tu n'as pas compris que DSPy déplace la valeur du prompt vers le dataset+metric.

🎤 En entretien

  • « Pourquoi DSPy plutôt que de soigner ses prompts à la main ? » Parce qu'il transforme le prompt-engineering en problème d'optimisation mesuré : tu déclares la tâche (signatures) + une metric + un eval set, et le compilateur cherche les meilleurs prompts/few-shots. Tu gagnes la reproductibilité, la non-régression en CI, et un gain typique de +15-25 points — au prix d'avoir besoin d'un eval set objectif.
  • « Quand DSPy n'apporte rien, voire nuit ? » Génération créative et conversation multi-turn stateful : pas de metric objective → l'optimizer n'a rien à maximiser. Et sur un POC < 5 jours, l'overhead (dataset, metric, baseline) ne se rentabilise pas. Le sweet spot, c'est la tâche structurée single-shot avec eval clair.
  • « Comment MIPROv2 optimise-t-il, concrètement ? » Trois sous-routines : Bootstrap (collecte des traces réussies → candidats few-shot), Proposer (un prompt_model génère des variantes d'instructions par signature), et un Bayesian Search (Optuna) qui explore le produit cartésien {instructions × few-shots} et oriente la recherche par le score sur le valset. Il n'explore que ~50-200 (medium) à ~500-1000 (heavy) programmes sur un espace de ~10^15.
  • « Un compiled DSPy en prod : qu'est-ce qui te ferait perdre le sommeil ? » Le concept drift (la metric monitoring prod doit le détecter, pas une alerte technique), la non-portabilité entre modèles (le .json est tuné pour un model_id précis), le coût de compile sous-estimé sur Opus, et la PII embarquée dans les few-shots du .json versionné. La qualité au build ne garantit rien en prod.

🔗 Liens

Bibliothèque tech perso — Achref