De Dev Web a Ingenieur IA
De Dev Web à Ingénieur IA #3 — Structured Output & Validation Zod
JSON mode, Zod, safeParse : comment obtenir des réponses fiables d'un LLM

Intro
L’article précédent nous a appris à parler à un LLM via l’API Ollama, avec system prompt et température.
Mais il y a un problème : un LLM répond en texte brut. Pour l’utiliser dans une API, une base de données, ou une logique métier, on a besoin de données structurées — du JSON typé, pas du texte qu’il faudrait parser à la main.
C’est ce qu’on va résoudre aujourd’hui.
Si tu viens de l’article 2, copie le dossier :
cp -r article-02-prompt-engineering article-03-structured-output
cd article-03-structured-output && rm -rf node_modules package-lock.json
npm install
npm install zod
Si tu démarres ici, voir l’article 1 pour le setup initial.
Note sur le modèle : j’utilisais
qwen3:4b(2,5 Go) dans l’article précédent. Avec le modeformat: json, Ollama met plus de temps à générer le premier token (contrainte JSON). Le client HTTP de Node.js (undici) timeout avant la première réponse. J’ai donc basculé surqwen2.5:1.5b(986 Mo), bien plus rapide au chargement et suffisant pour ce qu’on fait. Si tu veux faire de même :ollama pull qwen2.5:1.5bPuis change
MODELdans le code. Tu peux aussi garder ton modèle actuel, mais les temps de réponse seront plus longs.

Le problème : du texte, pas des données
Quand on demande du JSON à un LLM sans précautions, il peut répondre :
- Du texte avec un bloc
```json - Du JSON avec des commentaires
- Du JSON avec des clés manquantes
- Du texte en markdown qui ressemble à du JSON
- N’importe quoi d’autre
On va voir comment forcer le modèle à produire du JSON valide, puis comment valider ce JSON avec Zod.
Le code
import { z } from "zod";
const MODEL = "qwen2.5:1.5b";
const OLLAMA_BASE = process.env.OLLAMA_HOST ?? "http://localhost:11434";
interface ChatMessage {
role: "system" | "user" | "assistant";
content: string;
}
interface LLMResponse {
message: ChatMessage;
}
async function chat(
model: string,
messages: ChatMessage[],
format?: "json",
): Promise<string> {
const body: Record<string, unknown> = { model, messages, stream: false };
if (format) body.format = format;
const res = await fetch(`${OLLAMA_BASE}/api/chat`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
});
if (!res.ok) throw new Error(`HTTP ${res.status}: ${await res.text()}`);
const data = (await res.json()) as LLMResponse;
return data.message.content;
}
async function withSpinner<T>(label: string, fn: () => Promise<T>): Promise<T> {
const frames = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
let i = 0;
const interval = setInterval(() => {
process.stdout.write(`\r${frames[i]} ${label}`);
i = (i + 1) % frames.length;
}, 80);
try {
const result = await fn();
process.stdout.write(`\r✓ ${label}\n`);
return result;
} catch (e) {
process.stdout.write(`\r✗ ${label}\n`);
throw e;
} finally {
clearInterval(interval);
}
}
const PersonSchema = z.object({
name: z.string(),
age: z.number(),
occupation: z.string(),
skills: z.array(z.string()),
});
type Person = z.infer<typeof PersonSchema>;
function extractJSON(raw: string): string {
const match = raw.match(/```(?:json)?\s*([\s\S]*?)```/);
return match?.[1]?.trim() ?? raw.trim();
}
async function main() {
console.log("");
console.log("=== 1. Sans JSON mode — texte brut avec markdown ===\n");
const prompt =
"Donne-moi une fiche JSON pour Alan Turing : nom, âge (s'il vivait encore), profession, et 3 compétences. Réponds uniquement en JSON valide.";
const raw = await withSpinner("Appel sans format:json...", () =>
chat(MODEL, [{ role: "user", content: prompt }]),
);
console.log(` Réponse brute:\n ${raw}\n`);
const extracted = extractJSON(raw);
console.log(` JSON extrait: ${extracted}\n`);
try {
const parsed: Person = JSON.parse(extracted);
console.log(` Parsé (sans Zod): ${parsed.name}, ${parsed.age} ans\n`);
} catch {
console.log(" ✗ Impossible de parser le JSON extrait\n");
}
console.log("---\n");
console.log("=== 2. Avec JSON mode — JSON propre ===\n");
const clean = await withSpinner("Appel avec format:json...", () =>
chat(MODEL, [{ role: "user", content: prompt }], "json"),
);
console.log(` Réponse: ${clean}\n`);
try {
const parsed: Person = JSON.parse(clean);
console.log(` Parsé (sans Zod): ${parsed.name}, ${parsed.age} ans\n`);
} catch {
console.log(" ✗ JSON invalide\n");
}
console.log("---\n");
console.log("=== 3. Avec Zod — parsing fiable ===\n");
const zodResult = PersonSchema.safeParse(JSON.parse(clean));
if (zodResult.success) {
const p = zodResult.data;
console.log(` ✓ Données valides: ${p.name}, ${p.age} ans — ${p.occupation}`);
console.log(` Compétences: ${p.skills.join(", ")}\n`);
} else {
console.log(` ✗ Erreurs Zod: ${zodResult.error.issues.map(i => i.path.join(".") + ": " + i.message).join("; ")}\n`);
}
console.log("---\n");
console.log("=== 4. Mauvais format — Zod rejette ===\n");
const badPrompt =
"Réponds en JSON : donne le nom 'Test', l'âge 'pas un nombre' (string au lieu de number), occupation null, et pas de skills.";
const badRaw = await withSpinner("Appel avec données invalides...", () =>
chat(MODEL, [{ role: "user", content: badPrompt }], "json"),
);
console.log(` Réponse: ${badRaw}\n`);
const badResult = PersonSchema.safeParse(JSON.parse(badRaw));
if (!badResult.success) {
console.log(` ✓ Zod a détecté les erreurs:`);
for (const issue of badResult.error.issues) {
console.log(` - ${issue.path.join(".")}: ${issue.message}`);
}
console.log("");
}
}
main().catch((err) => {
console.error(`\nErreur : ${err.message}`);
if (err instanceof Error && err.cause) {
console.error(` Cause: ${err.cause}`);
}
console.error(err);
process.exit(1);
});
Exécution
npm start
Voici le résultat complet du script :
=== 1. Sans JSON mode — texte brut avec markdown ===
✓ Appel sans format:json...
Réponse brute:
{
"person": {
"name": "Alan Turing",
"age_lived": 41,
"profession": "Mathématicien, cryptographeur, scientifique"
},
"skills": [
{ "type": "Mathematics", "description": "..." },
{ "type": "Cryptography", "description": "..." },
{ "type": "Computer Science", "description": "..." }
]
}
Parsé (sans Zod): undefined, undefined ans
---
=== 2. Avec JSON mode — JSON propre ===
✓ Appel avec format:json...
Réponse: {
"person": {
"nom": "Alan Turing",
"age": null,
"profession": "Ingénieur mathématique, cryptographe"
},
"competences": [
"Théorie des algorithmes",
"Cryptographie et sécurité informatique",
"Électronique"
]
}
Parsé (sans Zod): undefined, undefined ans
---
=== 3. Avec Zod — parsing fiable ===
✗ Erreurs Zod: name: Invalid input: expected string, received undefined;
age: Invalid input: expected number, received undefined;
occupation: Invalid input: expected string, received undefined;
skills: Invalid input: expected array, received undefined
---
=== 4. Mauvais format — Zod rejette ===
✓ Appel avec données invalides...
Réponse: {
"name": "Test",
"age": "pas un nombre",
"occupation": null,
"skills": null
}
✓ Zod a détecté les erreurs:
- age: Invalid input: expected number, received string
- occupation: Invalid input: expected string, received null
- skills: Invalid input: expected array, received null

Analyse test par test
Test 1 — Sans format:json : le modèle répond en markdown avec bloc ```json. La structure est imbriquée (person.name, skills[].type) avec des clés en anglais. Le script extrait le bloc markdown via extractJSON() et tente JSON.parse. Il réussit techniquement, mais parsed.name vaut undefined car la propriété attendue name n’existe pas à la racine — elle est dans person.name. Zéro erreur, zéro alerte, mais données inutilisables.
Test 2 — Avec format:json : plus de markdown, JSON propre directement. Mais les clés sont en français (nom, competences) et age vaut null au lieu d’un nombre. JSON.parse réussit, parsed.name est toujours undefined. Le format json garantit du JSON valide, pas un schéma correct.
Test 3 — Avec Zod safeParse() : on passe le JSON de l’étape 2 à PersonSchema.safeParse(). Zod liste précisément les 4 champs manquants/incorrects. On sait exactement ce qui ne va pas.
Test 4 — Mauvais format volontaire : on force le modèle à produire des données invalides (age en string, occupation: null). Zod détecte chaque erreur individuellement : age devrait être un nombre, occupation une string, skills un tableau.
Comment ça marche
Le pipeline est simple : LLM → JSON brut → Zod → données typées. Chaque étape résout un problème spécifique.
1. format: "json" — un paramètre qu’on passe dans le body de la requête Ollama. Sans lui, le LLM peut répondre avec du texte, du markdown, des blocs ```json, des commentaires. Avec lui, Ollama force le modèle à ne produire que du JSON valide. C’est la différence entre le Test 1 (markdown + structure libre) et le Test 2 (JSON propre). Mais ça ne garantit pas que les clés ou types correspondent à ce qu’on attend.
2. Zod — une librairie qui définit un schéma (PersonSchema) et valide le JSON reçu. On l’utilise avec safeParse() plutôt que parse() :
parse()→ lance une erreur si le JSON est invalidesafeParse()→ retourne un objet{ success, data, error }. Sisuccessest true,datacontient les données typées. Sinon,errorliste les problèmes
Pour du parsing de LLM, safeParse() est plus élégant car l’échec est fréquent et on veut pouvoir réagir sans catch.
3. extractJSON() — un helper pour le Test 1 uniquement. Sans format: "json", le modèle enveloppe souvent le JSON dans un bloc markdown ```json. Cette fonction extrait le JSON du bloc. Avec format: "json", plus besoin.
Le tableau récapitulatif :
| Méthode | JSON valide ? | Typé ? | Fiable ? |
|---|---|---|---|
| Texte brut + extraction manuelle | Parfois | Non | ❌ |
format: "json" seul |
Toujours | Non | ⚠️ |
format: "json" + Zod |
Toujours | Oui | ✅ |
Ce qu’on a appris
format: "json"— un paramètre simple d’Ollama qui force la sortie JSON. Indispensable pour du parsing fiable.- Zod — une lib de validation légère qui transforme du JSON brut en données typées et sécurisées.
safeParse()— la méthode à privilégier quand la source peut être imparfaite (toujours le cas avec un LLM).- Pipeline LLM → JSON → Zod — le pattern qu’on va réutiliser partout : tool calling, RAG, agents.
Prochain article
Comment donner au LLM des outils qu’il peut appeler. On va construire une boucle LLM → tool → LLM, la base des agents.