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. ModeTOOLSpar défaut (function calling), fallbackJSON/MD_JSONpour les modèles open-source. Supporte streaming partiel pour UI réactive et retry exponentiel surValidationError.
🧠 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
# 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
// 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)
| Mode | Provider supporté | Quand utiliser |
|---|---|---|
TOOLS | OpenAI, Anthropic, Mistral | Défaut. Function calling natif, le + fiable. |
JSON | OpenAI, Together, Fireworks | Modèles avec response_format: json_object. |
MD_JSON | Tous (incl. Ollama, Groq) | Modèles sans tool calling. Parse bloc json. |
ANTHROPIC_TOOLS | Claude 4.x (Opus 4.8, Sonnet 4.6, Haiku 4.5) | Claude avec tool use natif. |
PARALLEL_TOOLS | OpenAI | Plusieurs 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 :
- Chaque retry est un appel LLM complet — tu repayes tout le contexte (prompt + texte source + schéma) à chaque tentative.
max_retries=3sur 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. - 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'unassertou uneExceptionnue ne déclenche aucun retry (Instructor ne capture queValidationError/ValueError). Le wording de ton validator est de l'ingénierie de prompt déguisée. - 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,
descriptionmanquant, contrainte impossible). - Distinction critique :
max_retries(Instructor, surValidationError) ≠max_retriesdu SDK (Anthropic/OpenAI, sur429/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=2gè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.
Stack — unstructured pour OCR PDF → texte → Instructor + GPT-4.1-mini → Pydantic Candidate → Postgres → frontend Vue.
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).
Pattern — Iterable[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.
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 frontendFacturation — 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.
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 selfLe 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 %).
Pattern — MRZParser (zone OCR bas de CNI) + Instructor pour structurer + cross-check noms.
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 vROI — É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.
# 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=3au niveau Instructor → re-prompt automatique surValidationErrorfield_validatornormalise 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
# 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
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-throughPattern Discriminated Union (Literal type)
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
@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 vInstructor injecte le ValueError dans le re-prompt → le LLM corrige.
Pattern Hooks (observabilité)
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
| Lib | Version 2026-05 | Note |
|---|---|---|
instructor (py) | 1.9.x | Async natif, hooks, mode parallèle tools |
@instructor-ai/instructor (ts) | 2.0.x | Refonte mai 2026, support Gemini natif |
| Pydantic | 2.11+ | model_validator(mode='after') requis |
| Zod | 4.x | Zod 4 perf 3× supérieure, breaking minor |
| OpenAI SDK | 2.x | responses.parse() natif rivalise |
| Anthropic SDK | 0.50+ | Tool use natif intégré |
Tendances mai 2026 :
- OpenAI
responses.parse()+ Anthropicclient.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()).
# 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ère | Instructor | SDK natif (messages.parse) |
|---|---|---|
| Multi-provider / A-B testing | ✅ une API pour tous | ❌ une API par provider |
| Retry automatique sur validation | ✅ max_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
- 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. max_retriestrop élevé silencieux → factures qui explosent. Metsmax_retries=2-3max et log chaque retry. Au-delà, c'est ton schéma qui cloche.- Validator qui throw
Exceptionau lieu deValueError→ Instructor ne retry pas. Toujoursraise ValueError(msg_explicite). description=manquant sur champs critiques = LLM hallucine. Mets toujoursField(description="Format YYYY-MM-DD, jamais en relatif")quand c'est ambigu.- Mode
MD_JSONsur modèle qui supporteTOOLS= -30 % qualité. Toujours préférerTOOLSquand dispo (OpenAI, Anthropic, Mistral Large, Gemini). - Streaming partial + champs
required= ton frontend crashe pendant le stream. UtilisePartial[Candidate](= tout devient optionnel) côté UI, valide leCandidatefinal côté API. - Oublier
model_validator(mode='after')pour les invariants cross-fields (ex:total_ht == sum(lines.total_ht)). Le LLM passe à côté sans validator. - Logs leak PII. Hook
completion:responsequi dump tout → RGPD violation. Masque emails/IBAN avant log. - Async + sync mix :
instructor.from_openai(AsyncOpenAI())vsinstructor.from_openai(OpenAI())— ne se mélangent pas,awaitplante en sync. - Faire trop confiance à
years_experienceou autres champs calculés par le LLM. Pour les chiffres, ajoutege=/le=+ validator de cohérence avec lesexperiences.
💰 Pricing / ROI client
Coût LLM par extraction (mai 2026)
| Modèle | $/1M in | $/1M out | CV ~5k tokens in / 1k out | 10 000 CV/mois |
|---|---|---|---|---|
| GPT-4.1-mini | 0,40 | 1,60 | 0,0036 $ | 36 $/mois |
| GPT-4.1 | 2,00 | 8,00 | 0,018 $ | 180 $ |
| Claude Haiku 4.5 | 1,00 | 5,00 | 0,010 $ | 100 $ |
| Claude Sonnet 4.6 | 3,00 | 15,00 | 0,030 $ | 300 $ |
| Claude Opus 4.8 | 5,00 | 25,00 | 0,050 $ | 500 $ |
| Mistral Medium 3 | 0,40 | 2,00 | 0,004 $ | 40 $ |
| Gemini Flash 2.5 | 0,30 | 1,20 | 0,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 lecache_controlpour toi : tu dois passerextra_headers/cache_controlvia 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
| Mission | Durée | TJM | Total |
|---|---|---|---|
| POC extraction (1 schéma + démo) | 5 j | 1 100 € | 5 500 € |
| Production pipeline (eval + monitoring) | 15-20 j | 1 300 € | ~22 000 € |
| Pipeline + UI streaming + 5 schémas | 30 j | 1 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)
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)
@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) <= 1Eval LLM-as-judge
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+ unasyncio.Semaphore(8)pour ne pas exploser le rate-limit. - Hook
completion:response→ incrémente un compteurusage.input_tokens / output_tokenspar schéma ; expose en Prometheus. Alarme si% retry > 5 %oup95 latence > 8 s. - Caching : pose
cache_controlsur le bloc system+schéma stable (préfixe). Vérifiecache_read_input_tokens > 0sur le 2ᵉ appel — sinon un invalidateur silencieux (timestamp, JSON non trié) casse le cache. - Typage des exceptions : capture
RateLimitError/OverloadedError/APITimeoutErrorséparément deValidationError. 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=3sur un échec systématique = 4× le coût. Au-delà de 2-3 le problème est toujours le schéma (trop profond,descriptionmanquant, contrainte impossible), pas le modèle — donc augmentermax_retriesne fait que brûler du budget. - « Pourquoi
raise ValueErroret pasassert/Exceptiondans un validator ? » → Instructor ne déclenche le retry que surValidationError/ValueError, et réinjecte le message dans le re-prompt. Unassertou uneExceptionnue ne retry pas, et un message vague ("invalide") ne corrige rien — le wording duValueErrorest 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
- Docs Python — https://python.useinstructor.com/
- Docs TS — https://js.useinstructor.com/
- GitHub Python — https://github.com/jxnl/instructor
- GitHub TS — https://github.com/instructor-ai/instructor-js
- Comparaison avec Pydantic AI — https://ai.pydantic.dev/why/#vs-instructor
- Talk Jason Liu "Pydantic is all you need" — référence fondatrice
- Cookbook 60+ patterns — https://python.useinstructor.com/examples/
- Discord Instructor — actif, mainteneurs réactifs
- Notre fiche
06-mastra.md— alternative TS full-stack - Notre fiche
02-pydantic-ai.md(à venir si pas encore) — successeur naturel quand tu ajoutes des agents