URL:     https://linuxfr.org/users/jobpilot/journaux/comparatif-6-llms-locaux-face-a-un-exercice-python-simple
Title:   Comparatif : 6 LLMs locaux face à un exercice Python simple
Authors: Ecran Plat
Date:    2026-04-03T16:26:24+02:00
License: CC By-SA
Tags:    intelligence_artificielle, ubuntu, python3 et ollama
Score:   9



Bonjour, aujourd'hui j'ai fait un essai qui n'est pas du tout scientifique : que valent les LLM qui tournent sur mon Dell d'inférence pour coder une fonction Python très simple ?

**Le prompt donné aux modèles :** *« Je voudrais que tu me fasses une fonction en Python pour parser un fichier CSV et le transformer en JSON. »*

Un même prompt, six modèles différents, tous exécutés **en local** via Ollama. L'objectif : voir comment chaque LLM interprète une demande simple et comparer la qualité, la performance et la concision du code produit.

---

## Le matériel utilisé

Les modèles ont été exécutés sur un serveur dédié avec la configuration suivante :

| Composant | Détail |
|-----------|--------|
| **CPU** | Intel Xeon W-2125 @ 4.00 GHz (4 cœurs / 8 threads) |
| **RAM** | 32 Go DDR4 |
| **GPU** | 2× NVIDIA Quadro P5000 (16 Go VRAM chacune) |
| **OS** | Ubuntu 24.04.4 LTS |
| **Runtime** | Ollama dans Docker |

---

## Le protocole de test

Les bouts de code ont été obtenus via Open WebUI et Ollama, puis copiés-collés dans un script de benchmark Python. Pour chaque fonction générée, le script effectue :

1. **Test fonctionnel** — La fonction est appelée avec un fichier CSV de 10 lignes. Le JSON produit (fichier ou retour mémoire) est validé : structure, clés, nombre de lignes, correspondance exacte avec les données source.
2. **Benchmark de vitesse** — 1 000 exécutions consécutives, temps moyen mesuré en microsecondes.
3. **Mesure mémoire** — Pic de consommation mémoire mesuré via `tracemalloc`.

Le CSV de test :

```csv
nom,prenom,age,ville,email
Dupont,Jean,34,Paris,jean.dupont@email.fr
Martin,Sophie,28,Lyon,sophie.martin@email.fr
...
```

---

## Les résultats

| Modèle | Fonctionnel | Temps moyen | Mémoire pic | Note |
|--------|:-----------:|:-----------:|:-----------:|:----:|
| **Nemotron-cascade-2** | ✓ | 46.9 µs | 34.0 KB | **8/10** |
| **devstral-small-2** | ✓ | 61.7 µs | 38.0 KB | **7/10** |
| **Gemma4:26b** | ✓ | 65.0 µs | 38.1 KB | **6/10** |
| **qwen3.5:35b** | ✓ | 74.0 µs | 38.0 KB | **6/10** |
| **ministral-3:14b** | ✓ | 74.5 µs | 38.0 KB | **7/10** |
| **Gemma4:31b** | ✓ | 124.8 µs | 38.1 KB | **5/10** |

> Tous les modèles produisent un code fonctionnel. Les différences se jouent sur l'architecture, la flexibilité et l'efficacité.

---

## Analyse modèle par modèle

### 1. Nemotron-cascade-2 — 8/10

```python
def csv_to_json(csv_path, json_path=None, encoding="utf-8"):
    csv_path = Path(csv_path)
    if not csv_path.is_file():
        raise FileNotFoundError(f"Le fichier CSV n'existe pas : {csv_path}")
    with csv_path.open(newline='', mode='r', encoding=encoding) as f:
        reader = csv.DictReader(f, delimiter=',')
        data = [row for row in reader]
    json_str = json.dumps(data, ensure_ascii=False, indent=2)
    if json_path:
        json_path = Path(json_path)
        json_path.parent.mkdir(parents=True, exist_ok=True)
        json_path.write_text(json_str, encoding=encoding)
    return data
```

**Le meilleur du lot.** C'est le seul à proposer les trois modes d'utilisation : écriture fichier, retour en mémoire, ou les deux. Il utilise `pathlib` (plus moderne que `os.path`), crée automatiquement les répertoires parents et offre un paramètre `encoding` configurable.

**Point faible :** il sérialise systématiquement en JSON string (`json.dumps`) même quand on ne demande que le retour en mémoire — une opération inutile qui gaspille du CPU. Malgré cela, il reste le plus rapide en mode écriture fichier, grâce à `pathlib.write_text()` qui est plus efficient que le pattern `open()/json.dump()` pour les petits fichiers.

---

### 2. ministral-3:14b — 7/10

```python
def csv_to_json(csv_file_path, json_file_path=None):
    data = []
    with open(csv_file_path, mode='r', encoding='utf-8') as csv_file:
        csv_reader = csv.DictReader(csv_file)
        for row in csv_reader:
            data.append(row)
    if json_file_path:
        with open(json_file_path, mode='w', encoding='utf-8') as json_file:
            json.dump(data, json_file, indent=4, ensure_ascii=False)
        return None
    else:
        return json.dumps(data, indent=4, ensure_ascii=False)
```

**Bonne surprise pour un « petit » modèle (14b).** C'est le seul autre modèle (avec Nemotron) à proposer un double mode : écriture fichier ou retour en mémoire. Le code est propre, sans fioritures.

**Point faible :** pas d'utilisation de `pathlib`, pas de gestion d'erreur. Mais l'absence de gestion d'erreur est ici un choix acceptable — mieux vaut laisser les exceptions remonter naturellement que de les avaler avec des `print()`. Pour un modèle de cette taille, c'est un excellent résultat : il a bien compris l'ambiguïté du prompt et a fait un choix intelligent.

---

### 3. devstral-small-2 — 7/10

```python
def csv_to_json(csv_file_path, json_file_path):
    data = []
    with open(csv_file_path, mode='r', encoding='utf-8') as csv_file:
        csv_reader = csv.DictReader(csv_file)
        for row in csv_reader:
            data.append(row)
    with open(json_file_path, mode='w', encoding='utf-8') as json_file:
        json.dump(data, json_file, indent=4, ensure_ascii=False)
```

**Le plus minimaliste — et c'est une qualité.** Pas de `try/except` inutile, pas de `print()`, pas de `if __name__`. Juste la fonction, propre et directe. C'est exactement ce qu'on demandait. En bonus, c'est le deuxième plus rapide du benchmark.

**Point faible :** aucune flexibilité (pas de mode retour mémoire, pas de paramètre encoding). Mais le prompt ne le demandait pas, donc c'est une lecture correcte de la consigne.

---

### 4. Gemma4:26b — 6/10

```python
def csv_to_json(csv_filepath, json_filepath):
    data = []
    try:
        with open(csv_filepath, encoding='utf-8') as csv_file:
            csv_reader = csv.DictReader(csv_file)
            for row in csv_reader:
                data.append(row)
        with open(json_filepath, 'w', encoding='utf-8') as json_file:
            json.dump(data, json_file, indent=4, ensure_ascii=False)
        print(f"Conversion réussie ! Fichier créé : {json_filepath}")
    except FileNotFoundError:
        print(f"Erreur : Le fichier '{csv_filepath}' est introuvable.")
    except Exception as e:
        print(f"Une erreur est survenue : {e}")
```

**Correct mais sans valeur ajoutée.** Structurellement quasi identique à Gemma4:31b. La boucle `for row / append` est plus verbeuse que `list(reader)` sans bénéfice. La gestion d'erreur par `print()` est un anti-pattern en production — on préfère lever des exceptions pour laisser l'appelant décider.

---

### 5. qwen3.5:35b — 6/10

```python
def csv_to_json(input_file_path, output_file_path):
    data = []
    try:
        with open(input_file_path, mode='r', newline='', encoding='utf-8') as csv_file:
            dict_reader = csv.DictReader(csv_file)
            data = list(dict_reader)
        with open(output_file_path, mode='w', encoding='utf-8') as json_file:
            json.dump(data, json_file, ensure_ascii=False, indent=4)
        print(f"Conversion réussie : {input_file_path} -> {output_file_path}")
        return True
    except FileNotFoundError:
        print("Erreur : Le fichier spécifié est introuvable.")
        return False
    except Exception as e:
        print(f"Erreur lors du traitement : {e}")
        return False
```

**Seul modèle à ajouter `newline=''`** dans l'appel à `open()`, ce qui est la bonne pratique selon la documentation du module `csv` (évite les problèmes de saut de ligne sur Windows). Bon point. Le retour booléen est une idée intéressante pour du scripting.

**Point faible :** la gestion d'erreur par `print()` + `return False` rend le débogage difficile — on perd le traceback. Pour 35 milliards de paramètres, on aurait pu espérer une approche plus sophistiquée.

---

### 6. Gemma4:31b — 5/10

```python
def csv_to_json(csv_file_path, json_file_path):
    try:
        with open(csv_file_path, mode='r', encoding='utf-8') as csv_file:
            csv_reader = csv.DictReader(csv_file)
            data = list(csv_reader)
        with open(json_file_path, mode='w', encoding='utf-8') as json_file:
            json.dump(data, json_file, indent=4, ensure_ascii=False)
        print(f"Succès : Le fichier a été converti et enregistré sous {json_file_path}")
    except FileNotFoundError:
        print("Erreur : Le fichier CSV source est introuvable.")
    except Exception as e:
        print(f"Une erreur est survenue : {e}")
```

**Le plus gros modèle… mais le résultat le plus basique.** Le code fonctionne, mais c'est le plus lent du benchmark (124.8 µs, presque 3× plus lent que Nemotron). Précision importante : cette lenteur vient du code Python généré (notamment la gestion d'erreur et les `print()`), pas du temps d'inférence du modèle lui-même. Aucune flexibilité : pas de retour en mémoire, pas de paramètre d'encoding. La gestion d'erreur par `print()` avale les exceptions silencieusement.

**Ironie :** c'est le modèle le plus volumineux (31b) qui produit le code le moins intéressant. La taille du modèle n'est clairement pas corrélée à la qualité du code généré sur ce type de tâche.

---

## Ce qu'on retient

### La taille du modèle ne fait pas tout

Le classement final bouscule les attentes :

1. **Nemotron-cascade-2** — Le plus rapide, le plus flexible, le mieux typé
2. **ministral-3:14b** — Un « petit » modèle qui a compris la subtilité du prompt
3. **devstral-small-2** — Minimaliste et efficace, zéro superflu
4. **qwen3.5:35b** — Correct avec un bon détail technique (`newline=''`)
5. **Gemma4:26b** — Fonctionnel mais générique
6. **Gemma4:31b** — Le plus lourd, le plus lent, le moins inspiré

### Les tendances communes

Tous les modèles ont produit du code fonctionnel utilisant `csv.DictReader` + `json.dump`. Les différences se jouent sur :

- **La gestion d'erreur** : 4 modèles sur 6 utilisent `try/except` avec `print()`, un anti-pattern. Seuls Nemotron (qui lève une `FileNotFoundError`) et devstral (qui ne gère pas les erreurs, ce qui est mieux que de les avaler) font un choix correct.
- **La flexibilité** : seuls 2 modèles sur 6 (Nemotron et ministral) proposent un double mode fichier/mémoire. C'est pourtant une interprétation naturelle du prompt « transformer en JSON » qui ne précise pas la destination.
- **Le style** : `list(reader)` vs `for row: append` — les deux fonctionnent, mais `list()` est plus idiomatique et marginalement plus rapide.

### Conseil pratique

Pour du code utilitaire simple, un modèle léger (14b) bien entraîné peut rivaliser avec des modèles 2× plus gros. Sur un serveur avec 2× Quadro P5000, les modèles jusqu'à ~35b tournent confortablement. Le meilleur rapport qualité/ressources ici : **ministral-3:14b**, qui donne un excellent résultat avec une empreinte mémoire GPU minimale.

---

## Le script de benchmark

Pour ceux qui veulent reproduire les tests, voici le script complet. Les fonctions de chaque modèle y sont intégrées telles quelles :

```python
"""
Benchmark des fonctions csv_to_json générées par différents LLMs.
Mesure : temps d'exécution, mémoire utilisée, validité du résultat.
"""

import csv
import json
import os
import sys
import time
import traceback
import tracemalloc
from io import StringIO
from pathlib import Path
from contextlib import redirect_stdout

# ── Fichiers de test ──────────────────────────────────────────────

CSV_FILE = "donnees.csv"
JSON_EXPECTED_KEYS = {"nom", "prenom", "age", "ville", "email"}
NUM_ROWS = 10

# ── Lecture du CSV attendu pour validation ────────────────────────

with open(CSV_FILE, encoding="utf-8") as f:
    EXPECTED_DATA = list(csv.DictReader(f))


# ══════════════════════════════════════════════════════════════════
# Définition de chaque fonction générée par les LLMs
# ══════════════════════════════════════════════════════════════════

def gemma4_31b(csv_file_path, json_file_path):
    try:
        with open(csv_file_path, mode='r', encoding='utf-8') as csv_file:
            csv_reader = csv.DictReader(csv_file)
            data = list(csv_reader)
        with open(json_file_path, mode='w', encoding='utf-8') as json_file:
            json.dump(data, json_file, indent=4, ensure_ascii=False)
        print(f"Succès : Le fichier a été converti et enregistré sous {json_file_path}")
    except FileNotFoundError:
        print("Erreur : Le fichier CSV source est introuvable.")
    except Exception as e:
        print(f"Une erreur est survenue : {e}")


def nemotron_cascade_2(csv_path, json_path=None, encoding="utf-8"):
    csv_path = Path(csv_path)
    if not csv_path.is_file():
        raise FileNotFoundError(f"Le fichier CSV n'existe pas : {csv_path}")
    with csv_path.open(newline='', mode='r', encoding=encoding) as f:
        reader = csv.DictReader(f, delimiter=',')
        data = [row for row in reader]
    json_str = json.dumps(data, ensure_ascii=False, indent=2)
    if json_path:
        json_path = Path(json_path)
        json_path.parent.mkdir(parents=True, exist_ok=True)
        json_path.write_text(json_str, encoding=encoding)
        print(f"Fichier JSON écrit dans {json_path}")
    return data


def gemma4_26b(csv_filepath, json_filepath):
    data = []
    try:
        with open(csv_filepath, encoding='utf-8') as csv_file:
            csv_reader = csv.DictReader(csv_file)
            for row in csv_reader:
                data.append(row)
        with open(json_filepath, 'w', encoding='utf-8') as json_file:
            json.dump(data, json_file, indent=4, ensure_ascii=False)
        print(f"Conversion réussie ! Fichier créé : {json_filepath}")
    except FileNotFoundError:
        print(f"Erreur : Le fichier '{csv_filepath}' est introuvable.")
    except Exception as e:
        print(f"Une erreur est survenue : {e}")


def devstral_small_2(csv_file_path, json_file_path):
    data = []
    with open(csv_file_path, mode='r', encoding='utf-8') as csv_file:
        csv_reader = csv.DictReader(csv_file)
        for row in csv_reader:
            data.append(row)
    with open(json_file_path, mode='w', encoding='utf-8') as json_file:
        json.dump(data, json_file, indent=4, ensure_ascii=False)


def ministral_3_14b(csv_file_path, json_file_path=None):
    data = []
    with open(csv_file_path, mode='r', encoding='utf-8') as csv_file:
        csv_reader = csv.DictReader(csv_file)
        for row in csv_reader:
            data.append(row)
    if json_file_path:
        with open(json_file_path, mode='w', encoding='utf-8') as json_file:
            json.dump(data, json_file, indent=4, ensure_ascii=False)
        return None
    else:
        return json.dumps(data, indent=4, ensure_ascii=False)


def qwen3_5_35b(input_file_path, output_file_path):
    data = []
    try:
        with open(input_file_path, mode='r', newline='', encoding='utf-8') as csv_file:
            dict_reader = csv.DictReader(csv_file)
            data = list(dict_reader)
        with open(output_file_path, mode='w', encoding='utf-8') as json_file:
            json.dump(data, json_file, ensure_ascii=False, indent=4)
        print(f"Conversion réussie : {input_file_path} -> {output_file_path}")
        return True
    except FileNotFoundError:
        print("Erreur : Le fichier spécifié est introuvable.")
        return False
    except Exception as e:
        print(f"Erreur lors du traitement : {e}")
        return False


# ══════════════════════════════════════════════════════════════════
# Harness de test
# ══════════════════════════════════════════════════════════════════

MODELS = [
    ("Gemma4:31b", gemma4_31b, "file"),
    ("Nemotron-cascade-2", nemotron_cascade_2, "both"),
    ("Gemma4:26b", gemma4_26b, "file"),
    ("devstral-small-2", devstral_small_2, "file"),
    ("ministral-3:14b", ministral_3_14b, "both"),
    ("qwen3.5:35b", qwen3_5_35b, "file"),
]

ITERATIONS = 1000


def validate_json_file(path):
    """Vérifie que le fichier JSON produit est correct."""
    with open(path, encoding="utf-8") as f:
        data = json.load(f)
    assert isinstance(data, list), "Le résultat n'est pas une liste"
    assert len(data) == NUM_ROWS, f"Attendu {NUM_ROWS} lignes, obtenu {len(data)}"
    for row in data:
        assert JSON_EXPECTED_KEYS == set(row.keys()), f"Clés incorrectes: {row.keys()}"
    assert data == EXPECTED_DATA, "Les données ne correspondent pas au CSV source"
    return True


def validate_return_data(data):
    """Vérifie que les données retournées en mémoire sont correctes."""
    if isinstance(data, str):
        data = json.loads(data)
    assert isinstance(data, list), "Le résultat n'est pas une liste"
    assert len(data) == NUM_ROWS, f"Attendu {NUM_ROWS} lignes, obtenu {len(data)}"
    for row in data:
        assert JSON_EXPECTED_KEYS == set(row.keys()), f"Clés incorrectes: {row.keys()}"
    assert data == EXPECTED_DATA, "Les données ne correspondent pas au CSV source"
    return True


def run_test(name, func, mode):
    """Exécute le test pour une fonction donnée."""
    json_out = f"output_{name.replace(':', '_').replace('.', '_')}.json"
    results = {"nom": name, "mode": mode}

    # ── Test fonctionnel ──
    try:
        captured = StringIO()
        with redirect_stdout(captured):
            if mode == "both":
                ret = func(CSV_FILE, json_out)
                validate_json_file(json_out)
                ret_mem = func(CSV_FILE)
                validate_return_data(ret_mem)
            else:
                func(CSV_FILE, json_out)
                validate_json_file(json_out)
        results["fonctionnel"] = "OK"
    except Exception as e:
        results["fonctionnel"] = f"ERREUR: {e}"
        results["temps_moyen_us"] = None
        results["memoire_pic_kb"] = None
        print(f"  ✗ {name}: {e}")
        traceback.print_exc()
        return results

    # ── Benchmark temps (N itérations, écriture fichier) ──
    start = time.perf_counter_ns()
    captured = StringIO()
    with redirect_stdout(captured):
        for _ in range(ITERATIONS):
            func(CSV_FILE, json_out)
    elapsed_ns = time.perf_counter_ns() - start
    results["temps_moyen_us"] = elapsed_ns / ITERATIONS / 1000

    # ── Benchmark mémoire (une seule exécution) ──
    tracemalloc.start()
    captured = StringIO()
    with redirect_stdout(captured):
        func(CSV_FILE, json_out)
    _, peak = tracemalloc.get_traced_memory()
    tracemalloc.stop()
    results["memoire_pic_kb"] = peak / 1024

    # Nettoyage
    if os.path.exists(json_out):
        os.remove(json_out)

    return results


def main():
    print("=" * 70)
    print("  BENCHMARK DES FONCTIONS CSV→JSON GÉNÉRÉES PAR LLMs")
    print("=" * 70)
    print(f"  Fichier CSV : {CSV_FILE} ({NUM_ROWS} lignes)")
    print(f"  Itérations  : {ITERATIONS}")
    print()

    all_results = []

    for name, func, mode in MODELS:
        print(f"  ▶ Test de {name}...")
        r = run_test(name, func, mode)
        all_results.append(r)
        if r["fonctionnel"] == "OK":
            print(f"    ✓ Fonctionnel: OK")
            print(f"    ⏱ Temps moyen: {r['temps_moyen_us']:.1f} µs")
            print(f"    💾 Mémoire pic: {r['memoire_pic_kb']:.1f} KB")
        print()

    # ── Tableau récapitulatif ──
    print("=" * 70)
    print(f"  {'Modèle':<25} {'Status':<10} {'Temps (µs)':<14} {'Mém. (KB)':<12}")
    print("-" * 70)
    for r in all_results:
        status = "OK" if r["fonctionnel"] == "OK" else "ERREUR"
        t = f"{r['temps_moyen_us']:.1f}" if r["temps_moyen_us"] is not None else "N/A"
        m = f"{r['memoire_pic_kb']:.1f}" if r["memoire_pic_kb"] is not None else "N/A"
        print(f"  {r['nom']:<25} {status:<10} {t:<14} {m:<12}")
    print("=" * 70)

    # ── Export résultats en JSON ──
    with open("resultats_benchmark.json", "w", encoding="utf-8") as f:
        json.dump(all_results, f, indent=2, ensure_ascii=False)
    print("\nRésultats exportés dans resultats_benchmark.json")


if __name__ == "__main__":
    main()
```

---

*Tests réalisés avec Python 3.13 sur un Intel Xeon W-2125 / 32 Go RAM / 2× Quadro P5000. Benchmark : 1 000 itérations par fonction, mesure mémoire via `tracemalloc`.*
