Skip to content

Instructor — Le couteau suisse de la structured generation

TL;DR — Instructor est une fine couche au-dessus des SDK LLM (OpenAI, Anthropic, Mistral, Gemini, Groq, Ollama…) qui injecte ton schéma Pydantic (Python) ou Zod (TS) dans l'appel, valide la réponse, et re-prompt automatiquement en cas d'erreur de validation. Pas de DSL, pas de graphe, pas de magie — juste response_model= et tu as un objet typé. C'est le default choice 2026 pour toute extraction structurée en freelance (TJM 1000-1500 €/j) : tu factures de l'extraction fiable, pas du framework. Mode TOOLS par défaut (function calling), fallback JSON/MD_JSON pour les modèles open-source. Supporte streaming partiel pour UI réactive et retry exponentiel sur ValidationError.


🧠 Mental model

                        ┌─────────────────────────┐
                        │   ton Pydantic / Zod    │
                        │   class Invoice(...)    │
                        └────────────┬────────────┘


          ┌──────────────────────────────────────────────┐
          │  Instructor.patch(client)                    │
          │  ────────────────────────────                │
          │  1. schema  → JSON Schema (tool/function)    │
          │  2. inject  → request.tools = [schema]       │
          │  3. call    → provider SDK natif             │
          │  4. parse   → JSON → Pydantic instance       │
          │  5. validate → raise ValidationError ?       │
          │       └─ retry (re-prompt avec l'erreur)     │
          │  6. return  → Invoice(numero=..., ...)       │
          └──────────────────────────────────────────────┘


                        ┌─────────────────────────┐
                        │  objet typé, prêt prod  │
                        └─────────────────────────┘

Analogie — Instructor est au LLM ce que Zod/Pydantic est à une réponse HTTP : tu déclares ta forme, et tu refuses tout ce qui n'est pas conforme. La différence : ici on dit au serveur (LLM) à l'avance la forme attendue, et on lui demande de corriger s'il se trompe. C'est le pattern "fail loud, retry smart" appliqué au non-déterminisme.

Position vs concurrents :

  • Pydantic AI = Instructor + agents + tools + DI (framework complet)
  • Outlines = contrainte au niveau du token (logit bias) — plus strict mais nécessite hébergement
  • Marvin = Instructor + magie décorateur (@ai_fn)
  • Raw SDK + tool calling = ce que tu fais à la main, en pire (pas de retry, pas de validation, pas de streaming partiel)

🛠️ Code minimal

Python

python
# pip install instructor openai pydantic
from openai import OpenAI
import instructor
from pydantic import BaseModel, Field

client = instructor.from_openai(OpenAI())

class Person(BaseModel):
    name: str
    age: int = Field(ge=0, le=130)
    email: str | None = None

person = client.chat.completions.create(
    model="gpt-4.1-mini",
    response_model=Person,
    messages=[{"role": "user", "content": "Jean Dupont, 34 ans, [email protected]"}],
    max_retries=3,
)
print(person)  # Person(name='Jean Dupont', age=34, email='[email protected]')

TypeScript

ts
// pnpm add @instructor-ai/instructor openai zod
import OpenAI from "openai";
import Instructor from "@instructor-ai/instructor";
import { z } from "zod";

const oai = new OpenAI();
const client = Instructor({ client: oai, mode: "TOOLS" });

const PersonSchema = z.object({
  name: z.string(),
  age: z.number().int().min(0).max(130),
  email: z.string().email().nullable(),
});

const person = await client.chat.completions.create({
  model: "gpt-4.1-mini",
  messages: [{ role: "user", content: "Jean Dupont, 34 ans, [email protected]" }],
  response_model: { schema: PersonSchema, name: "Person" },
  max_retries: 3,
});

Mode patching (très important en 2026)

ModeProvider supportéQuand utiliser
TOOLSOpenAI, Anthropic, MistralDéfaut. Function calling natif, le + fiable.
JSONOpenAI, Together, FireworksModèles avec response_format: json_object.
MD_JSONTous (incl. Ollama, Groq)Modèles sans tool calling. Parse bloc json.
ANTHROPIC_TOOLSClaude 4.x (Opus 4.8, Sonnet 4.6, Haiku 4.5)Claude avec tool use natif.
PARALLEL_TOOLSOpenAIPlusieurs schémas en parallèle (Iterable[Union]).

🔬 Le retry — ce qui se passe vraiment (et pourquoi ça coûte)

C'est le mécanisme qu'un senior doit savoir défendre en entretien, parce que c'est là que se cachent le coût, la latence et les boucles infinies.

appel #1 ──► LLM ──► JSON ──► Pydantic.model_validate()

                          ┌────────┴────────┐
                          │                 │
                       valide          ValidationError
                          │                 │
                       return    re-prompt = messages + [
                                   {role: assistant, content: <JSON erroné>},
                                   {role: user, content: <texte de l'erreur Pydantic>}
                                 ]

                              appel #2 ──► … (jusqu'à max_retries)

Ce que ça implique, qu'un junior ne voit pas :

  1. Chaque retry est un appel LLM complet — tu repayes tout le contexte (prompt + texte source + schéma) à chaque tentative. max_retries=3 sur un échec systématique = 4× le coût de l'appel. Un schéma mal foutu qui échoue 30 % du temps multiplie ta facture par ~1,3 silencieusement.
  2. Le message d'erreur Pydantic EST le prompt de correction. C'est pour ça qu'un raise ValueError("SIRET échoue le Luhn, recompte les 14 chiffres") corrige, alors qu'un assert ou une Exception nue ne déclenche aucun retry (Instructor ne capture que ValidationError / ValueError). Le wording de ton validator est de l'ingénierie de prompt déguisée.
  3. Le contexte gonfle à chaque tour — le JSON erroné + l'erreur s'empilent. Au 3ᵉ retry tu peux avoir doublé le contexte, ce qui dégrade la qualité et le coût. Au-delà de 2-3, le problème n'est jamais le LLM : c'est ton schéma (trop profond, description manquant, contrainte impossible).
  4. Distinction critique : max_retries (Instructor, sur ValidationError) ≠ max_retries du SDK (Anthropic/OpenAI, sur 429/5xx/timeout réseau). Les deux existent et se cumulent. Un senior configure les deux : instructor.from_anthropic(AsyncAnthropic(max_retries=4)) gère le réseau, response_model=..., max_retries=2 gère la validation.

Mental model : Instructor transforme une erreur de type en tour de conversation. C'est élégant mais ce n'est pas une garantie — contrairement à Outlines/XGrammar qui contraignent au niveau du token (le LLM ne peut pas produire d'invalide). Instructor = "valide-after-the-fact + supplie". Pour du 100 %, change d'outil.


🎬 Cas d'usage concrets (France 2026)

1. Extraction CV pour ATS RH (cabinet recrutement Lyon)

Contexte — Cabinet de chasse 50 recruteurs, reçoit ~2000 CV/mois en PDF/DOCX. Avant : saisie manuelle 8 min/CV par assistante = 250 h/mois @ 22 €/h = 5 500 €/mois. Mission freelance 18 j @ 1 250 € = 22 500 € pour pipeline auto.

Stackunstructured pour OCR PDF → texte → Instructor + GPT-4.1-mini → Pydantic Candidate → Postgres → frontend Vue.

python
class WorkExperience(BaseModel):
    company: str
    title: str
    start: str = Field(description="YYYY-MM")
    end: str | None = Field(description="YYYY-MM ou null si en cours")
    description: str | None = None

class Candidate(BaseModel):
    full_name: str
    email: EmailStr | None
    phone: str | None
    years_experience: int = Field(ge=0, le=60)
    skills: list[str]
    languages: list[Language]
    experiences: list[WorkExperience]
    education: list[Education]
    desired_salary_eur: int | None = Field(ge=0, le=500_000)

ROI — Coût LLM 0,003 €/CV × 2000 = 6 €/mois. Économie nette ~5 400 €/mois. Payback 4 mois.

2. Parsing fiche produit Shopify (e-commerce mode Paris)

Contexte — Pure-player mode 12k SKU, fournisseurs envoient fiches Excel sales inconsistantes (FR/IT/EN, formats différents). Besoin : normaliser vers schéma Shopify (title, vendor, product_type, variants, options, metafields).

PatternIterable[ProductRow] Instructor pour streamer ligne par ligne, écriture progressive dans Shopify API. Streaming partial : l'UI montre les produits parsés en temps réel pendant que le LLM travaille.

python
from instructor import Partial

class ProductDraft(BaseModel):
    title: str
    vendor: str
    product_type: str
    variants: list[Variant]  # taille × couleur
    materials: list[str]
    care_instructions: str | None

# Streaming partial — UI réactive
for partial in client.chat.completions.create_partial(
    model="gpt-4.1-mini",
    response_model=ProductDraft,
    messages=[...],
):
    yield partial  # SSE vers frontend

Facturation — Forfait 14 k€ + maintenance 800 €/mois.

3. Extraction facture comptable (expert-comptable PME Bordeaux)

Contexte — Cabinet 8 collaborateurs, 400 clients PME, 10k factures/mois à saisir. Concurrence DEXT/Pennylane à 25 €/client/mois. Cabinet veut son propre module pour différencier.

Pattern — Multimodal (Claude Sonnet 4.6 vision) → Instructor → Invoice → export FEC compatible.

python
class LineItem(BaseModel):
    description: str
    quantity: Decimal
    unit_price_ht: Decimal
    vat_rate: Literal["0.00", "0.055", "0.10", "0.20"]
    total_ht: Decimal

class Invoice(BaseModel):
    supplier_name: str
    supplier_siret: str | None = Field(pattern=r"^\d{14}$")
    invoice_number: str
    invoice_date: date
    due_date: date | None
    currency: Literal["EUR", "USD", "GBP"]
    lines: list[LineItem]
    total_ht: Decimal
    total_vat: Decimal
    total_ttc: Decimal
    
    @model_validator(mode="after")
    def check_totals(self):
        sum_ht = sum(l.total_ht for l in self.lines)
        if abs(sum_ht - self.total_ht) > Decimal("0.02"):
            raise ValueError(f"total_ht mismatch: {sum_ht} vs {self.total_ht}")
        return self

Le validator force le LLM à recompter si la somme ne tombe pas — pattern crucial.

4. KYC depuis photo CNI (fintech néobanque Lille)

Contexte — Néobanque B2C, 500 inscriptions/jour. Avant : Onfido/Veriff 2,80 €/KYC = 42 k€/mois. Objectif : pré-extraction maison + Onfido seulement pour le check anti-fraude (économie 60 %).

PatternMRZParser (zone OCR bas de CNI) + Instructor pour structurer + cross-check noms.

python
class CNIData(BaseModel):
    document_type: Literal["CNI", "PASSPORT", "TITRE_SEJOUR"]
    surname: str
    given_names: list[str]
    date_of_birth: date
    place_of_birth: str | None
    document_number: str
    expiry_date: date
    nationality: str = "FR"
    mrz_line_1: str | None
    mrz_line_2: str | None
    
    @field_validator("expiry_date")
    def not_expired(cls, v):
        if v < date.today():
            raise ValueError("Document expiré")
        return v

ROI — Économie 25 k€/mois, mission 22 j @ 1 400 € = 30 800 €. Payback 5 semaines.


🛠️ Exemple end-to-end — Pipeline CV → ATS → Job Matching

Pipeline complet 150 lignes : parse CV PDF, extraction Instructor avec retry + streaming partial, matching job via embeddings, ranking final.

python
# file: cv_pipeline.py
# pip install instructor openai pydantic unstructured[pdf] qdrant-client tenacity
from __future__ import annotations
import asyncio
import logging
from datetime import date
from decimal import Decimal
from pathlib import Path
from typing import Iterable, Literal

import instructor
from openai import AsyncOpenAI
from pydantic import BaseModel, EmailStr, Field, field_validator
from qdrant_client import AsyncQdrantClient
from qdrant_client.models import Distance, PointStruct, VectorParams
from tenacity import retry, stop_after_attempt, wait_exponential
from unstructured.partition.auto import partition

log = logging.getLogger("cv_pipeline")
client = instructor.from_openai(AsyncOpenAI())
qdrant = AsyncQdrantClient(url="http://localhost:6333")
oai = AsyncOpenAI()

# ---------- Schémas Pydantic ----------
class Language(BaseModel):
    name: str
    level: Literal["A1", "A2", "B1", "B2", "C1", "C2", "native"]

class WorkExperience(BaseModel):
    company: str
    title: str
    start: str = Field(pattern=r"^\d{4}-\d{2}$")
    end: str | None = Field(default=None, pattern=r"^\d{4}-\d{2}$")
    description: str | None = None
    tech_stack: list[str] = Field(default_factory=list)

class Education(BaseModel):
    school: str
    degree: str
    field: str | None = None
    year: int = Field(ge=1950, le=date.today().year + 1)

class Candidate(BaseModel):
    full_name: str
    email: EmailStr | None = None
    phone: str | None = None
    linkedin: str | None = None
    location: str | None = None
    years_experience: int = Field(ge=0, le=60)
    summary: str = Field(max_length=500)
    skills: list[str] = Field(min_length=1)
    languages: list[Language]
    experiences: list[WorkExperience]
    education: list[Education]
    desired_salary_eur: int | None = Field(default=None, ge=0, le=500_000)

    @field_validator("skills")
    @classmethod
    def normalize_skills(cls, v: list[str]) -> list[str]:
        return [s.strip().lower() for s in v if s.strip()]

class JobMatch(BaseModel):
    job_id: str
    score: float = Field(ge=0, le=1)
    reasoning: str = Field(max_length=300)

# ---------- Extraction Instructor ----------
@retry(stop=stop_after_attempt(3), wait=wait_exponential(min=1, max=10))
async def extract_candidate(text: str) -> Candidate:
    """Extraction CV → Candidate avec retry exponentiel."""
    return await client.chat.completions.create(
        model="gpt-4.1-mini",
        response_model=Candidate,
        max_retries=3,  # retry au niveau Instructor (validation)
        messages=[
            {
                "role": "system",
                "content": (
                    "Tu extrais un CV en JSON strict. "
                    "Dates au format YYYY-MM. Si info absente, mets null. "
                    "years_experience = somme des durées d'expérience pro (hors stages). "
                    "Réponds en français."
                ),
            },
            {"role": "user", "content": text[:15_000]},  # cap context
        ],
    )

async def extract_candidate_streaming(text: str) -> Iterable[Candidate]:
    """Variante streaming pour UI réactive — yields Partial[Candidate]."""
    stream = client.chat.completions.create_partial(
        model="gpt-4.1-mini",
        response_model=Candidate,
        messages=[
            {"role": "system", "content": "Extract CV as JSON."},
            {"role": "user", "content": text[:15_000]},
        ],
    )
    async for partial in stream:
        yield partial

# ---------- Indexation Qdrant ----------
async def embed(texts: list[str]) -> list[list[float]]:
    resp = await oai.embeddings.create(
        model="text-embedding-3-small", input=texts
    )
    return [d.embedding for d in resp.data]

async def index_candidate(candidate: Candidate, cv_id: str) -> None:
    profile = (
        f"{candidate.summary}\n"
        f"Skills: {', '.join(candidate.skills)}\n"
        f"Experience: {candidate.years_experience} years\n"
        f"Last role: {candidate.experiences[0].title} @ {candidate.experiences[0].company}"
        if candidate.experiences
        else candidate.summary
    )
    [vector] = await embed([profile])
    await qdrant.upsert(
        collection_name="candidates",
        points=[
            PointStruct(
                id=cv_id,
                vector=vector,
                payload=candidate.model_dump(mode="json"),
            )
        ],
    )

# ---------- Job matching ----------
class Job(BaseModel):
    id: str
    title: str
    description: str
    required_skills: list[str]
    location: str
    salary_min: int
    salary_max: int

async def rank_jobs_for_candidate(
    candidate: Candidate, jobs: list[Job], top_k: int = 5
) -> list[JobMatch]:
    """Ranking final via LLM (re-rank après recall vectoriel)."""
    prompt = (
        f"Candidat: {candidate.full_name}, {candidate.years_experience} ans XP.\n"
        f"Skills: {candidate.skills}\n"
        f"Salaire visé: {candidate.desired_salary_eur} EUR\n\n"
        f"Jobs candidats:\n"
        + "\n".join(
            f"[{j.id}] {j.title}{j.required_skills} — "
            f"{j.salary_min}-{j.salary_max} EUR — {j.location}"
            for j in jobs
        )
        + f"\n\nRetourne les {top_k} meilleurs matches avec un score 0-1 et une justification."
    )
    matches = await client.chat.completions.create(
        model="gpt-4.1",
        response_model=list[JobMatch],
        messages=[{"role": "user", "content": prompt}],
        max_retries=2,
    )
    return sorted(matches, key=lambda m: -m.score)[:top_k]

# ---------- Pipeline complet ----------
async def process_cv(pdf_path: Path, jobs: list[Job]) -> dict:
    log.info("processing %s", pdf_path.name)
    elements = partition(filename=str(pdf_path))
    text = "\n".join(e.text for e in elements if e.text)

    candidate = await extract_candidate(text)
    await index_candidate(candidate, cv_id=pdf_path.stem)
    matches = await rank_jobs_for_candidate(candidate, jobs)

    return {
        "candidate": candidate.model_dump(mode="json"),
        "matches": [m.model_dump() for m in matches],
    }

# ---------- Bootstrap ----------
async def main() -> None:
    await qdrant.recreate_collection(
        collection_name="candidates",
        vectors_config=VectorParams(size=1536, distance=Distance.COSINE),
    )
    jobs = [
        Job(id="J1", title="Senior Python Backend", description="...",
            required_skills=["python", "fastapi", "postgres"],
            location="Paris", salary_min=65_000, salary_max=85_000),
        Job(id="J2", title="ML Engineer", description="...",
            required_skills=["python", "pytorch", "mlops"],
            location="Remote", salary_min=70_000, salary_max=95_000),
    ]
    result = await process_cv(Path("./cvs/dupont_jean.pdf"), jobs)
    print(result["candidate"]["full_name"], "→", len(result["matches"]), "matches")

if __name__ == "__main__":
    logging.basicConfig(level=logging.INFO)
    asyncio.run(main())

Ce que ce pipeline montre :

  • max_retries=3 au niveau Instructor → re-prompt automatique sur ValidationError
  • field_validator normalise les skills sans retoucher le prompt
  • Streaming partial dispo pour UI (cf. extract_candidate_streaming)
  • Pattern extract → embed → rerank LLM = standard 2026 pour matching

🎯 Patterns courants

Pattern "Strict + Lenient" double pass

python
# Pass 1 — strict, échoue souvent
try:
    invoice = client.chat.completions.create(
        response_model=StrictInvoice, max_retries=2, ...
    )
except ValidationError:
    # Pass 2 — lenient, on accepte champs optionnels
    invoice = client.chat.completions.create(
        response_model=LenientInvoice, max_retries=3, ...
    )
    queue_for_human_review(invoice)

Pattern Iterable pour streaming d'extraction

python
from typing import Iterable
# Stream extraction multi-entité : 1 par 1, dès qu'un objet est complet
stream = client.chat.completions.create_iterable(
    response_model=Person,  # devient Iterable[Person] côté API
    messages=[{"role": "user", "content": "Liste les personnes mentionnées..."}],
)
for person in stream:
    db.insert(person)  # write-through

Pattern Discriminated Union (Literal type)

python
class CardPayment(BaseModel):
    type: Literal["card"]
    card_last4: str
class SepaPayment(BaseModel):
    type: Literal["sepa"]
    iban: str

Payment = Annotated[CardPayment | SepaPayment, Field(discriminator="type")]

Le LLM sait exactement quel sous-schéma remplir.

Pattern Validator → Retry-aware

python
@field_validator("siret")
def check_siret(cls, v: str) -> str:
    if not is_valid_luhn(v):
        # Le message d'erreur EST le prompt de retry
        raise ValueError(f"SIRET '{v}' échoue le contrôle Luhn. Vérifie les chiffres.")
    return v

Instructor injecte le ValueError dans le re-prompt → le LLM corrige.

Pattern Hooks (observabilité)

python
client.on("completion:kwargs", lambda **kw: log.debug("prompt: %s", kw))
client.on("completion:response", lambda resp: tokens_counter.inc(resp.usage.total_tokens))
client.on("parse:error", lambda err: sentry_sdk.capture_exception(err))

🔄 Versions & écosystème 2026

LibVersion 2026-05Note
instructor (py)1.9.xAsync natif, hooks, mode parallèle tools
@instructor-ai/instructor (ts)2.0.xRefonte mai 2026, support Gemini natif
Pydantic2.11+model_validator(mode='after') requis
Zod4.xZod 4 perf 3× supérieure, breaking minor
OpenAI SDK2.xresponses.parse() natif rivalise
Anthropic SDK0.50+Tool use natif intégré

Tendances mai 2026 :

  • OpenAI responses.parse() + Anthropic client.messages.parse() (schéma Pydantic natif, validation côté SDK, sans hand-rolling de XML/JSON) rendent Instructor moins indispensable côté providers majeurs… mais Instructor reste roi en provider-agnostique (Mistral, Groq, Ollama, Together, Fireworks, vLLM).
  • Pydantic AI prend des parts côté agents (mais reste compatible Instructor pour la partie extraction).
  • Instructor + DSPy = combo bullshit-proof pour pipelines critiques (optimisation prompt par DSPy, validation par Instructor).

Instructor vs SDK natif — quand laisser tomber la lib

Sur un provider majeur mono-fournisseur, le SDK natif fait déjà le job sans dépendance supplémentaire. La structured generation est désormais native chez Anthropic (client.messages.parse()) et OpenAI (client.responses.parse()).

python
# Anthropic natif — pas d'Instructor, structured output côté SDK
from anthropic import Anthropic
from pydantic import BaseModel, Field

client = Anthropic()

class Person(BaseModel):
    name: str
    age: int = Field(ge=0, le=130)
    email: str | None = None

msg = client.messages.parse(
    model="claude-opus-4-8",        # flagship ; Haiku 4.5 pour du high-volume cheap
    max_tokens=1024,
    messages=[{"role": "user", "content": "Jean Dupont, 34 ans, [email protected]"}],
    output_config={"format": {"type": "json_schema", "schema": Person.model_json_schema()}},
)
person = msg.parsed_output   # déjà un Person typé (None si l'output ne valide pas)
CritèreInstructorSDK natif (messages.parse)
Multi-provider / A-B testing✅ une API pour tous❌ une API par provider
Retry automatique sur validationmax_retries= intégré❌ à coder à la main (boucle + re-prompt)
Streaming partial (Partial[T])✅ first-class⚠️ via parse incrémental, plus verbeux
Garantie de schéma⚠️ valide-after-the-fact + retry✅ contraint au niveau décodage
Dépendance+1 lib (suit les breaking de Pydantic)0 (déjà le SDK)
Caching / params fins du provider⚠️ via le client sous-jacent✅ accès direct

Règle de staff : mono-provider + schéma simple + besoin de caching fin → SDK natif. Multi-provider, retry sur validation métier, streaming UI → Instructor. Le natif garantit la forme par décodage contraint ; Instructor supplie puis re-valide. Ne facture pas une lib quand le SDK suffit, mais ne réinvente pas le retry-loop quand Instructor le donne gratuitement.


⚠️ Pitfalls

  1. Schémas trop profonds → coût + lenteur. Au-delà de 4 niveaux d'imbrication, le LLM ralentit (50-70 % overhead) et le taux de validation chute. Aplatir avec Annotated + flat tables.
  2. max_retries trop élevé silencieux → factures qui explosent. Mets max_retries=2-3 max et log chaque retry. Au-delà, c'est ton schéma qui cloche.
  3. Validator qui throw Exception au lieu de ValueError → Instructor ne retry pas. Toujours raise ValueError(msg_explicite).
  4. description= manquant sur champs critiques = LLM hallucine. Mets toujours Field(description="Format YYYY-MM-DD, jamais en relatif") quand c'est ambigu.
  5. Mode MD_JSON sur modèle qui supporte TOOLS = -30 % qualité. Toujours préférer TOOLS quand dispo (OpenAI, Anthropic, Mistral Large, Gemini).
  6. Streaming partial + champs required = ton frontend crashe pendant le stream. Utilise Partial[Candidate] (= tout devient optionnel) côté UI, valide le Candidate final côté API.
  7. Oublier model_validator(mode='after') pour les invariants cross-fields (ex: total_ht == sum(lines.total_ht)). Le LLM passe à côté sans validator.
  8. Logs leak PII. Hook completion:response qui dump tout → RGPD violation. Masque emails/IBAN avant log.
  9. Async + sync mix : instructor.from_openai(AsyncOpenAI()) vs instructor.from_openai(OpenAI()) — ne se mélangent pas, await plante en sync.
  10. Faire trop confiance à years_experience ou autres champs calculés par le LLM. Pour les chiffres, ajoute ge=/le= + validator de cohérence avec les experiences.

💰 Pricing / ROI client

Coût LLM par extraction (mai 2026)

Modèle$/1M in$/1M outCV ~5k tokens in / 1k out10 000 CV/mois
GPT-4.1-mini0,401,600,0036 $36 $/mois
GPT-4.12,008,000,018 $180 $
Claude Haiku 4.51,005,000,010 $100 $
Claude Sonnet 4.63,0015,000,030 $300 $
Claude Opus 4.85,0025,000,050 $500 $
Mistral Medium 30,402,000,004 $40 $
Gemini Flash 2.50,301,200,003 $30 $/mois

Prompt caching — le levier que 90 % des freelances oublient. Sur un pipeline d'extraction, le schéma JSON + le system prompt sont identiques à chaque appel — c'est exactement le préfixe stable que le caching adore. Côté Anthropic, cache_control: {type: "ephemeral"} sur le bloc system+tools fait tomber le coût du préfixe répété à ~0,1× (lecture) après une écriture à ~1,25×. Sur 10 000 CV/mois avec un schéma de 1 500 tokens, ça transforme 15 M tokens de schéma facturés plein pot en ~1,5 M équivalent. Instructor ne pose pas le cache_control pour toi : tu dois passer extra_headers / cache_control via le SDK sous-jacent (from_anthropic), ou descendre au SDK natif pour l'extraction haut-volume. C'est le premier argument quand un client te dit "ça coûte trop cher".

Règle de pouce 2026 : si tu factures < 0,50 €/extraction au client final, GPT-4.1-mini ou Gemini Flash 2.5 = marge 90 %+.

Facturation type freelance senior

MissionDuréeTJMTotal
POC extraction (1 schéma + démo)5 j1 100 €5 500 €
Production pipeline (eval + monitoring)15-20 j1 300 €~22 000 €
Pipeline + UI streaming + 5 schémas30 j1 400 €42 000 €
Forfait packagé (refactor extraction legacy)25-50 k€

Argument commercial : "Je remplace votre fournisseur X à 25 €/utilisateur/mois par un pipeline à 80 €/mois fixed cost. Payback : 4-6 mois."


🧪 Testing / Eval

Tests unitaires Pydantic (gratuits, rapides)

python
def test_invoice_totals_consistency():
    with pytest.raises(ValidationError, match="total_ht mismatch"):
        Invoice(
            lines=[LineItem(quantity=1, unit_price_ht=100, total_ht=100, vat_rate="0.20", description="x")],
            total_ht=Decimal("999"),  # incohérent
            ...
        )

Tests d'extraction (golden dataset)

python
@pytest.mark.parametrize("cv_file,expected", load_golden("./tests/golden/"))
def test_cv_extraction(cv_file: Path, expected: Candidate):
    text = read_pdf(cv_file)
    extracted = asyncio.run(extract_candidate(text))
    assert extracted.full_name == expected.full_name
    assert set(extracted.skills) >= set(expected.skills)
    assert abs(extracted.years_experience - expected.years_experience) <= 1

Eval LLM-as-judge

python
class CVQualityScore(BaseModel):
    completeness: float = Field(ge=0, le=1)
    accuracy: float = Field(ge=0, le=1)
    issues: list[str]

judge = instructor.from_openai(OpenAI())
score = judge.chat.completions.create(
    model="gpt-4.1",  # juge ≥ extractor
    response_model=CVQualityScore,
    messages=[{"role": "user", "content": f"Source: {raw}\n\nExtraction: {extracted}\n\nJuge."}],
)

Métriques production à tracker

  • % retry par schéma (alarme si > 5 %)
  • Latence p95 extraction (cap à 8 s en interactif)
  • Field-level accuracy (par champ, sur sample humain hebdo)
  • Cost per extraction vs budget cible

🔁 Quand utiliser / éviter

✅ Utiliser Instructor

  • Extraction d'1 schéma fixe (CV, facture, ticket, KYC, fiche produit…)
  • Besoin de provider-agnostique (multi-LLM A/B testing)
  • Pipeline simple sans état/orchestration
  • POC rapide à montrer au client en 1-2 jours
  • Frontend qui veut du streaming partial (loading states)

❌ Éviter Instructor

  • Tu fais des agents avec tools + état → prends Pydantic AI ou LangGraph
  • Tu veux garantie 100 % (logit-level constraints) → Outlines ou XGrammar
  • Tu utilises uniquement OpenAI et n'as pas besoin de portabilité → client.responses.parse() natif suffit
  • Tu fais de la multi-step orchestration complexe (loops, branches, parallel) → LangGraph / Mastra / Agno
  • Ton modèle est fine-tuné sur ton format JSON → re-injecter le schéma double coûte sans bénéfice

Decision tree express

Besoin structured output ?
├─ Multi-provider ou switch fréquent ?      → Instructor
├─ Single OpenAI, schéma simple ?            → openai.responses.parse() natif
├─ Agents avec tools + DI ?                  → Pydantic AI
├─ Token-level guarantees critiques ?        → Outlines / XGrammar
└─ Orchestration multi-step ?                → LangGraph / Mastra / Agno

🏋️ Exercices

Progressifs, durs, et orientés production. Chacun a un Objectif et un Indice/Solution. L'idée n'est pas de changer une constante — c'est de casser le truc puis de le réparer proprement.

Exo 1 — Le validator qui force le LLM à recompter

Objectif : écrire une Invoice (Pydantic) où le total TTC doit être cohérent avec total_ht + total_vat ± 0,02 €, ET où chaque ligne respecte total_ht == quantity × unit_price_ht. Le LLM doit corriger de lui-même sur une facture où il a mal lu un chiffre.

Indice/Solution : deux niveaux — @model_validator(mode="after") pour l'invariant cross-fields global, et un validator par ligne. Le message d'erreur doit être actionnable : raise ValueError(f"Ligne 2 : {quantity}×{unit_price_ht}={attendu} mais total_ht={total_ht}. Recompte cette ligne."). Teste avec max_retries=2 et logge chaque retry pour voir le LLM se reprendre. Question piège : pourquoi Decimal et pas float ? (0.1 + 0.2 != 0.3 → ta validation de TVA part en vrille.)

Exo 2 — Casse le streaming partial, puis répare

Objectif : monter un endpoint SSE (NestJS ou FastAPI) qui stream un ProductDraft via create_partial. Faire crasher volontairement le frontend, puis corriger.

Indice/Solution : avec un schéma à champs required (title: str, variants: list[Variant] non vide), pendant le stream le frontend reçoit des objets incomplets qui ne valident pas → crash. Fix : côté UI, type avec Partial[ProductDraft] (tout devient optionnel) ; côté API, valide le ProductDraft final strict avant écriture DB. Question de suivi : que se passe-t-il si le stream est coupé à mi-chemin (client ferme l'onglet) ? → tu dois gérer l'annulation et ne rien persister de partiel.

Exo 3 — Le double-pass strict/lenient + file de revue humaine

Objectif : pipeline KYC qui tente StrictCNI (tous champs obligatoires, dates non expirées, MRZ Luhn-checked) puis retombe sur LenientCNI (champs optionnels) et route vers revue humaine au lieu d'échouer.

Indice/Solution : try: parse(StrictCNI, max_retries=2) except ValidationError: parse(LenientCNI, max_retries=3); queue_for_human_review(...). Le piège senior : mesurer le taux de bascule strict→lenient comme métrique de qualité. S'il dépasse ~10 %, ce n'est pas le LLM, c'est ton OCR amont ou ton schéma. Bonus : ne logge jamais la MRZ/le n° de document en clair (RGPD) — masque avant le hook d'observabilité.

Exo 4 — Rends-le production-grade (le gros)

Objectif : prendre le pipeline CV de la fiche et le rendre déployable : async + parallélisme, retries réseau distincts des retries de validation, observabilité du coût, et caching.

Indice/Solution :

  • instructor.from_anthropic(AsyncAnthropic(max_retries=4)) (réseau) + response_model=..., max_retries=2 (validation) — les deux, et sache expliquer la différence.
  • Parallélise N CV avec asyncio.gather + un asyncio.Semaphore(8) pour ne pas exploser le rate-limit.
  • Hook completion:response → incrémente un compteur usage.input_tokens / output_tokens par schéma ; expose en Prometheus. Alarme si % retry > 5 % ou p95 latence > 8 s.
  • Caching : pose cache_control sur le bloc system+schéma stable (préfixe). Vérifie cache_read_input_tokens > 0 sur le 2ᵉ appel — sinon un invalidateur silencieux (timestamp, JSON non trié) casse le cache.
  • Typage des exceptions : capture RateLimitError / OverloadedError / APITimeoutError séparément de ValidationError. Une 429 n'est pas un problème de schéma.

Exo 5 — Défends le chiffre (entretien-killer)

Objectif : un client conteste ton devis "500 €/mois de LLM, c'est cher". Construis le calcul qui prouve que c'est en réalité ~50 € avec caching, et écris une note de 5 lignes qui le défend.

Indice/Solution : pars du tableau de pricing. 10 000 CV × (5k in + 1k out) sur Haiku 4.5 = 50 M in × 1 $/M + 10 M out × 5 $/M = 50 + 50 = 100 $. Avec caching du schéma (1,5k tokens × 10 000 = 15 M tokens de préfixe qui passent de plein-tarif à ~0,1×), tu récupères l'essentiel du coût input fixe. Le vrai argument commercial : "je remplace votre fournisseur SaaS à 25 €/utilisateur/mois par 80-100 €/mois fixe, payback 4 mois". Sache citer resp.usage comme preuve mesurée, pas comme estimation.

Exo 6 — Casse la garantie, puis choisis le bon outil

Objectif : construire un cas où Instructor échoue en boucle (validation jamais satisfaite) et démontrer que le problème est structurel, pas un tuning de max_retries.

Indice/Solution : impose une contrainte que le modèle ne peut pas tenir (ex : iban valide ET cohérent avec un bic que le texte source ne contient pas). Instructor va brûler max_retries appels complets puis lever. Le diagnostic senior : ce n'est pas un cas Instructor — soit la donnée n'est pas dans la source (le LLM ne peut pas inventer du vrai), soit tu as besoin d'une garantie token-level (Outlines/XGrammar) si la forme est non négociable. Conclusion à savoir verbaliser : Instructor valide-after-the-fact et supplie ; il ne contraint pas le décodage. Pour du 100 % sur la forme, change de couche.


🎤 En entretien

  • « Comment Instructor garantit un output valide ? » → Il ne le garantit pas. Il valide la réponse contre le schéma Pydantic/Zod après génération et, sur ValidationError, re-prompte le LLM avec le message d'erreur comme correctif (jusqu'à max_retries). Pour une garantie réelle (forme impossible à violer), il faut une contrainte au niveau du token : Outlines / XGrammar.
  • « Quel est l'impact coût d'un max_retries élevé ? » → Chaque retry est un appel LLM complet : tu repayes tout le contexte. max_retries=3 sur un échec systématique = 4× le coût. Au-delà de 2-3 le problème est toujours le schéma (trop profond, description manquant, contrainte impossible), pas le modèle — donc augmenter max_retries ne fait que brûler du budget.
  • « Pourquoi raise ValueError et pas assert / Exception dans un validator ? » → Instructor ne déclenche le retry que sur ValidationError/ValueError, et réinjecte le message dans le re-prompt. Un assert ou une Exception nue ne retry pas, et un message vague ("invalide") ne corrige rien — le wording du ValueError est de l'ingénierie de prompt.
  • « Instructor ou le SDK natif (messages.parse() / responses.parse()) ? » → Mono-provider + schéma simple + besoin de caching/params fins → SDK natif (zéro dépendance, garantie par décodage contraint). Multi-provider, retry sur validation métier, ou streaming partial pour l'UI → Instructor. Ne facture pas une lib quand le SDK suffit ; ne réinvente pas le retry-loop quand Instructor le donne gratuitement.

🔗 Liens

Bibliothèque tech perso — Achref