Des sorties structurées avec Ollama et .NET

Sortie JSON structurée d'un modèle Ollama vers un objet C# typé

Des sorties structurées avec Ollama et .NET

Partie 6 de la série “Développement IA avec Ollama et .NET” : Partie 1 – Ollama et .NET | Partie 2 – RAG local | Partie 3 – Agents IA | Partie 3.5 – Serveur MCP | Partie 4 – Microsoft Agent Framework | Partie 5 – Aspire | English

Demander à un modèle local de répondre en JSON directement dans le prompt, ça fonctionne la plupart du temps. Le problème, c’est le reste du temps : le parsing explose, et rarement au bon moment. Voici un truc simple pour obtenir du JSON fiable avec Ollama et .NET.

Un exemple complet et fonctionnel est disponible ici : mongeon/code-examples · structured-outputs-ollama-dotnet.

Le problème avec « réponds-moi en JSON »

Pour extraire des données d’un texte, le réflexe est d’écrire un prompt du genre :

Extrais les informations de cette facture et réponds uniquement en JSON
avec les champs "fournisseur", "montant" et "date". Pas d'autre texte.

La plupart du temps, le modèle retourne du JSON valide et JsonSerializer.Deserialize fait la job. Mais de temps en temps, la réponse arrive avec du texte autour (« Voici le JSON demandé : »), avec des clôtures ```json, avec un champ renommé ou avec une virgule de trop. On se retrouve donc à écrire du code de nettoyage comme celui-ci :

// On enlève les clôtures de code et le texte autour du JSON
var texte = reponse.Trim();
if (texte.StartsWith("```json"))
{
    texte = texte.Replace("```json", "").Replace("```", "");
}
var debut = texte.IndexOf('{');
var fin = texte.LastIndexOf('}');
texte = texte.Substring(debut, fin - debut + 1);

var facture = JsonSerializer.Deserialize<Facture>(texte);

Ce code fonctionne jusqu’à la prochaine variation dans la réponse du modèle. Le problème de base : on demande au modèle de produire du JSON, mais rien ne l’oblige à le faire.

Le champ format d’Ollama

Depuis la version 0.5, l’API d’Ollama accepte un schéma JSON complet dans le champ format de /api/chat. Un exemple avec curl :

curl http://localhost:11434/api/chat -d '{
  "model": "llama3.2",
  "messages": [
    { "role": "user", "content": "Extrais les infos: Facture de Hydro-Québec, 142.50$, le 15 juin 2026" }
  ],
  "stream": false,
  "format": {
    "type": "object",
    "properties": {
      "fournisseur": { "type": "string" },
      "montant": { "type": "number" },
      "date": { "type": "string" }
    },
    "required": ["fournisseur", "montant", "date"]
  }
}'

Avec ce champ, le schéma est appliqué au niveau du décodage : à chaque token généré, le moteur d’inférence élimine les tokens qui produiraient du JSON invalide par rapport au schéma. La réponse est donc toujours un objet JSON conforme, sans texte autour et sans clôtures de code.

À noter qu’Ollama accepte aussi "format": "json" tout court. Ce mode garantit du JSON valide, mais pas sa structure. Avec un schéma complet, la structure est garantie elle aussi.

La version .NET avec Microsoft.Extensions.AI

Écrire des schémas JSON à la main devient vite pénible. Microsoft.Extensions.AI peut les générer à partir d’un type C#. On installe les paquets et on télécharge un modèle :

dotnet add package Microsoft.Extensions.AI
dotnet add package OllamaSharp
ollama pull llama3.2

Comme dans les articles précédents de la série, OllamaApiClient implémente IChatClient, donc tout se branche directement. On définit un record qui décrit le résultat attendu :

using System.ComponentModel;
using Microsoft.Extensions.AI;
using OllamaSharp;

// Les attributs Description sont inclus dans le schéma envoyé au modèle
public record Facture(
    [property: Description("Le nom du fournisseur")] string Fournisseur,
    [property: Description("Le montant total, taxes incluses")] decimal Montant,
    [property: Description("La date au format AAAA-MM-JJ")] string Date);

Et on utilise la version générique de GetResponseAsync :

IChatClient client = new OllamaApiClient(
    new Uri("http://localhost:11434"), "llama3.2");

var reponse = await client.GetResponseAsync<Facture>(
    "Extrais les infos (date au format AAAA-MM-JJ) : " +
    "Facture de Hydro-Québec, 142.50$, le 15 juin 2026",
    new ChatOptions { Temperature = 0 }); // température à 0 pour des résultats plus stables

Facture facture = reponse.Result;
Console.WriteLine($"{facture.Fournisseur} : {facture.Montant} $");

GetResponseAsync<T> génère le schéma JSON à partir du type Facture, l’envoie dans le champ format et désérialise la réponse en objet C#. Plus besoin de code de nettoyage.

Si le modèle retourne quand même quelque chose d’inutilisable, TryGetResult évite l’exception :

if (reponse.TryGetResult(out var resultat))
{
    // On travaille avec un objet typé et valide
}
else
{
    // On log la réponse brute et on gère l'échec
}

Choisir la bonne approche

Il y a plusieurs façons d’obtenir des données structurées d’un modèle et le choix dépend de qui consomme la sortie.

Si la réponse est destinée à un humain, le prompt seul suffit. Pas besoin de contraindre une réponse que personne ne parse.

Le mode JSON ("format": "json") garantit du JSON valide, mais de forme variable. Je ne l’utilise plus vraiment : si on connaît la forme attendue, aussi bien fournir le schéma complet.

Le schéma JSON est le bon choix dès que la sortie est consommée par du code : extraction de données, classification, routage de tickets. C’est ce que GetResponseAsync<T> fait pour vous.

Le function calling sert à autre chose : il permet au modèle de choisir un outil à appeler avec des arguments typés. Si le modèle doit décider quoi faire, c’est du function calling, et j’en parle dans l’article sur les agents. S’il doit seulement répondre dans un format précis, une sortie structurée suffit.

Les limites avec les modèles locaux

Le schéma garantit la structure, mais pas le contenu. Un petit modèle comme llama3.2 forcé de remplir un champ required va le remplir même s’il n’a pas l’information, avec une chaîne vide ou une valeur inventée. Il faut donc valider le contenu côté C#, comme pour n’importe quelle entrée externe.

Les schémas complexes causent aussi des problèmes. Plus le schéma est gros (objets imbriqués, longues énumérations), plus un petit modèle local a de la misère à produire un résultat sensé. Si l’extraction déraille, simplifiez le schéma avant de changer de modèle. Dans mes tests, séparer une grosse extraction en deux appels avec des types simples donne de meilleurs résultats qu’un seul appel avec un gros type.

Pour les dates, précisez le format attendu dans la Description et répétez-le dans le prompt, parce qu’un petit modèle ignore volontiers l’attribut seul. Parsez-les ensuite vous-même, c’est plus prévisible que d’espérer un DateTime propre. Et le streaming n’apporte rien avec une sortie typée : un objet à moitié désérialisé ne sert à rien, alors GetResponseAsync<T> attend la réponse complète.

Bonne extraction! Vous pouvez maintenant supprimer votre code de nettoyage JSON, mais gardez votre validation.


Cet article a été rédigé avec l’aide de l’IA et révisé par moi.


Suggestions de lecture :