Aller au contenu principal

Faire tourner des LLM gratuitement avec Ollama.

Découvrez comment faire fonctionner un grand modèle de langage localement avec Ollama. Apprenez à installer, à tirer un modèle et à l'intégrer dans votre application Symfony grâce à ce guide complet.

14 min de lecture
Sommaire · 7

Il est erroné de croire qu'il faut choisir entre payer un provider d'IA — Gemini, Anthropic, OpenAI, peu importe — ou investir dans une carte graphique qu'on voit passer à 1 800 € sur les comparateurs de prix. C'est faux, et c'est faux deux fois. J'ai sauté le pas en ajoutant Ollama dans un docker compose up, tiré un petit modèle pour tester, et fait répondre le LLM à « qu'est-ce que Symfony en une phrase » sans rien payer à personne. La facture totale de l'expérience, c'était quelques minutes de patience.

Si on est dev PHP/Symfony et qu'on a déjà entendu parler de Symfony AI, ce billet est fait pour ça. À la fin de la lecture, on saura ce qu'est Ollama, on aura un compose.yaml qui le démarre, on aura tiré un modèle et fait son premier appel, on aura branché le bridge Symfony AI Bundle dessus en une ligne d'alias DI et on aura évité quelques pièges. On ne va pas parler de tuning CPU détaillé, de comparaison qualité entre modèles, ni de RAG — ça viendra dans d'autres billets de la série.

Attention : gratuitement, ça ne veut pas dire zéro coût matériel. Faire tourner un modèle de plusieurs milliards de paramètres demande une infra qui suive, et on y revient dans la section Prérequis — sans citer une marque ni une référence, parce que le marché matériel bouge plus vite que les billets de blog.

Ollama, qu'est ce que c'est ?

Ollama est un runtime local pour les grands modèles de langage. Concrètement : on lui demande de tirer un modèle, il le télécharge depuis son registre — qui ressemble à celui de Docker Hub, mais pour des modèles —, il l'optimise pour le hardware qu'il détecte, et il expose une API HTTP locale sur le port 11434. À partir de là, le modèle répond comme répondrait OpenAI ou Gemini, sauf que la requête ne quitte pas la machine.

Ce qu'on gagne par rapport à appeler un provider externe se résume à trois choses qu'on confond souvent en une seule.

On gagne le coût marginal. Une fois Ollama installé, chaque génération ne paye que de l'électricité — pas de tokens facturés, pas de plafond mensuel, pas de carte bancaire à renseigner. On peut générer 10 000 SEO sur 10 000 articles fictifs pour tester un prompt sans regarder la facture grandir.

On gagne la confidentialité. Pour générer les balises SEO ou le Schema JSON-LD d'un billet par exemple, on doit envoyer son contenu au modèle comme entrée — c'est mécanique, le LLM doit lire le texte pour en extraire un résumé ou en cracher un graphe structuré. Quand ce LLM tourne chez un provider externe, le texte transite chez lui. Quand il tourne sur la machine qu'on contrôle, il ne sort pas. La nuance est petite mais elle compte : un brouillon de billet pas encore publié n'a aucune raison de quitter le VPS juste pour obtenir trois balises <meta>.

On gagne la portabilité. Le même compose.yaml qui fait tourner Ollama en dev sur le Mac le fait tourner sur le VPS de prod. Le code applicatif Symfony qui consomme l'API ne change pas, et c'est précisément ce qu'on va montrer dans la section bridge.

Ce qu'on perd, parce qu'il faut le dire, c'est la qualité absolue. Un Mistral Nemo 12B local en quantization Q4 n'est pas un GPT-4. Il est très bon sur du JSON structuré bien typé et sur la génération de métadonnées SEO en français. Le sweet spot du modèle local, c'est là où la donnée importe plus que la prose — extraction, classification, génération de métadonnées structurées, schémas JSON-LD. C'est précisément le terrain de Symfony AI Bundle dans les usages applicatifs réalistes : générer un <title>/<meta> à partir d'un billet existant, sortir un Schema JSON-LD valide à partir d'une fiche produit, taguer du contenu — pas écrire à la place de l'humain.

Il n'est pas question pour moi de déléguer la rédaction des billets par un LLM : à quoi bon développer un site pour y écrire des articles en loisir... si au final on ne fait rien.

Le compose Docker minimal

On commence par le bloc qu'on ajoute dans son compose.yaml (ou compose.override.yaml si on n'expose Ollama qu'en dev). C'est ce qui tourne sur la machine de prod du blog, à l'override des limites RAM près :

YAML
services:
  ollama:
    image: ollama/ollama:0.5.4
    restart: unless-stopped
    environment:
      OLLAMA_HOST: 0.0.0.0:11434
      OLLAMA_KEEP_ALIVE: 1h
      OLLAMA_MAX_LOADED_MODELS: "2"
      OLLAMA_NUM_PARALLEL: "3"
    volumes:
      - ollama_models:/root/.ollama
    healthcheck:
      test: ["CMD", "ollama", "list"]
      interval: 30s
      timeout: 10s
      retries: 3
      start_period: 60s
    profiles: [ai]
    deploy:
      resources:
        limits: { cpus: '8', memory: 14g }

volumes:
  ollama_models:

Trois flags méritent qu'on s'y arrête, parce qu'ils sont ceux qu'on découvre trop tard — pas dans la doc officielle, pas dans les vidéos de découverte, mais le jour où on se demande pourquoi le premier appel SEO de la matinée prend toujours 25 secondes alors qu'on a tiré le modèle la veille.

OLLAMA_KEEP_ALIVE=1h détermine combien de temps un modèle reste chargé en RAM après son dernier appel. Le défaut est à 5 minutes — c'est ridicule pour un usage applicatif. Sans ce flag, chaque génération espacée d'un quart d'heure retombe sur un chargement modèle de 15 à 25 secondes. En passant à 1h en dev (ou 24h en prod), on arbitre 9 Go de RAM occupés au repos contre une UX qui ne fait plus poireauter.

OLLAMA_MAX_LOADED_MODELS=2 autorise Ollama à garder deux modèles en mémoire simultanément. C'est utile dès qu'on veut un générateur (Mistral Nemo) et un encodeur d'embeddings (bge-m3, qui pèse 2,5 Go) cohabitant. Sans le flag, à chaque switch on décharge l'un pour charger l'autre, et on retombe sur 15 secondes d'attente à chaque alternance.

OLLAMA_NUM_PARALLEL=3 autorise trois inférences simultanées sur le même modèle. Utile dès que plusieurs workers Symfony Messenger consomment la queue ai_async en parallèle, ou qu'un visiteur déclenche une génération pendant qu'on est en train de regénérer un Schema depuis EasyAdmin.

Le profile ai est un détail qui change tout dans la vie quotidienne. En dev, on n'a pas envie qu'Ollama tourne en permanence — il prend 9 Go de RAM dès qu'un modèle est chargé. Avec le profile, on ne démarre Ollama qu'avec docker compose --profile ai up -d. Le reste du temps, le compose up standard ignore ce service.

Tirer un modèle et faire son premier appel

Une fois le container démarré, on tire son premier modèle. Mistral Nemo 12B est un bon point d'entrée — petit en RAM, bon en français, support natif de la sortie structurée JSON :

Terminal
docker compose exec ollama ollama pull mistral-nemo:12b-instruct-2407-q4_K_M

9 Go, le temps que la connexion soit prête — comptez 5 à 15 minutes selon la bande passante. Pendant que ça pull, on peut déjà préparer le curl qui validera que tout fonctionne :

Terminal
curl http://localhost:11434/api/generate -d '{
  "model": "mistral-nemo:12b-instruct-2407-q4_K_M",
  "prompt": "Réponds en français en une phrase : qu_est-ce que Symfony ?",
  "stream": false
}'

Si tout va bien, on récupère un JSON avec un champ response qui contient une phrase à peu près correcte. C'est le moment où on se rend compte que ça marche vraiment, qu'on n'a payé personne, et que rien n'est sorti de la machine. C'est satisfaisant à un point déraisonnable la première fois.

Pour le choix de modèle, le minimum qu'il faut savoir : les tags suivent le format nom:taille-variante-quantizationmistral-nemo:12b-instruct-2407-q4_K_M veut dire 12 milliards de paramètres, variante instruct, snapshot de juillet 2024, quantization Q4 K-quants Medium — c'est un bon compromis taille/qualité pour du CPU. Pour démarrer plus léger, llama3.2:3b est nettement plus petit et permet d'itérer rapidement sur un prompt, au prix d'une qualité de réponse inférieure ; c'est typiquement le modèle utile en dev quand on veut juste valider qu'un appel fonctionne avant de passer aux choses sérieuses.

Le bridge Symfony AI Bundle, en une ligne d'alias DI

C'est le moment où le billet devient intéressant pour un dev Symfony. Le bundle symfony/ai-bundle expose une abstraction PlatformInterface qui est la même quel que soit le provider — Gemini, OpenAI, Anthropic, Ollama. Côté service applicatif, on tape toujours la même phrase :

PHP
// src/Service/Ai/Generation/SeoGenerationService.php
final readonly class SeoGenerationService
{
    public function __construct(
        private PlatformInterface $platform,
        private AiConfig $aiConfig,
        // ...
    ) {}

    public function generate(string $title, string $content): ?AiSeoSuggestion
    {
        $messages = new MessageBag(
            Message::forSystem($systemPrompt),
            Message::ofUser($userPrompt),
        );

        return $this->platform->invoke($this->aiConfig->defaultModel, $messages, [
            'response_format' => AiSeoSuggestion::class,
        ])->asObject();
    }
}

$this->platform->invoke(...) est rigoureusement la même phrase pour Ollama ou pour n'importe quel autre provider. Ce qui change, c'est ce que l'autowiring branche derrière PlatformInterface. Deux fichiers de config, et c'est tout.

Le premier déclare la plateforme Ollama dans la config du bundle :

PHP
// config/packages/ai.php
return static function (ContainerConfigurator $c): void {
    $c->extension('ai', [
        'platform' => [
            'ollama' => [
                'endpoint'    => '%env(OLLAMA_BASE_URL)%',
                'http_client' => 'ai.ollama.http_client',
            ],
        ],
    ]);
};

Le second route l'alias d'autowiring :

PHP
// config/services.php (extrait)
$services->alias(PlatformInterface::class, 'ai.platform.ollama');

Avec OLLAMA_BASE_URL=http://ollama:11434 dans le .env (ou http://localhost:11434 si on tourne hors Docker), le tour est joué. $this->platform injecte la plateforme Ollama, qui parle au container Ollama, qui sert le modèle, qui répond. Aucun code applicatif n'a changé. On peut swapper Gemini pour Ollama, ou l'inverse, en touchant uniquement à cet alias.

C'est le bon endroit pour dire ce qu'il faut installer côté Composer :

Terminal
composer require symfony/ai-bundle symfony/ai-ollama-platform

Le second package est le bridge spécifique Ollama — il traduit les Message de Symfony AI en requête HTTP au format attendu par l'API Ollama. Sans lui, le bundle ne sait pas comment parler à Ollama et lève une erreur explicite au démarrage.

Quelques pièges qui peuvent être évités

Le ModelCatalog qui ne connaît pas tes modèles. Le bundle embarque un catalogue par défaut qui connaît llama3.2mistralqwen et quelques autres tags génériques. Il ne connaît pas les tags précis qu'on utilise réellement en prod — mistral-nemo:12b-instruct-2407-q4_K_Mqwen2.5:14b-instruct-q4_K_Mbge-m3:latest. Conséquence : au premier invokeModelNotFoundException, et on cherche pendant 45 minutes ce qu'on a raté côté OLLAMA_BASE_URL. La solution est d'overrider le ModelCatalog, mais avec une subtilité non négociable :

PHP
// config/services.php (extrait)
$services->set('ai.platform.model_catalog.ollama', OllamaModelCatalog::class)
    ->lazy()
    ->args([[
        'mistral-nemo:12b-instruct-2407-q4_K_M' => [
            'class' => Ollama::class,
            'capabilities' => [
                Capability::INPUT_MESSAGES,
                Capability::OUTPUT_TEXT,
                Capability::OUTPUT_STRUCTURED,
                Capability::TOOL_CALLING,
            ],
        ],
        'bge-m3:latest' => [
            'class' => Ollama::class,
            'capabilities' => [
                Capability::INPUT_TEXT,
                Capability::INPUT_MULTIPLE,
                Capability::EMBEDDINGS,
            ],
        ],
    ]]);

L'identifiant du service ai.platform.model_catalog.ollama est non négociable. Le bundle référence le catalog par cet id namespacé, pas par le FQCN. Déclarer le service par sa classe (->set(OllamaModelCatalog::class, ...)) crée un service orphelin que le bundle n'utilise jamais — il retombe silencieusement sur son catalogue par défaut, et on relit dix fois la déclaration en se demandant ce qui cloche. 

Le timeout HTTP par défaut à 60 secondes. Le client HTTP de Symfony coupe à 60 secondes par défaut. Mistral Nemo 12B en CPU peut sortir un JSON-LD Schema de 4 000 caractères en 60 à 90 secondes, parfois plus si le contenu d'entrée est long. On voit régulièrement, en prod, des Idle timeout reached for "http://ollama:11434/api/chat" sur les jobs de génération longs :

PHP
// config/services.php (extrait)
$services->set('ai.ollama.http_client', HttpClientInterface::class)
    ->factory([service('http_client'), 'withOptions'])
    ->args([['timeout' => 1800, 'max_duration' => 1800]]);

Trente minutes — c'est volontairement large. Ça couvre le pire cas observé (un Schema sur un post de 10 000 caractères) en gardant un cap absolu pour éviter qu'une connexion zombie reste ouverte si Ollama décide de partir en méditation transcendantale. Le client est branché depuis config/packages/ai.php via http_client: 'ai.ollama.http_client'. À revisiter quand on passera Ollama en streaming — chaque chunk reset le timer idle, et on n'aura plus besoin de cap large.

Le modèle qu'on tire à chaque déploiement. Quand on automatise via Ansible (ou un script shell, ou la CI), la tentation est d'ajouter un ollama pull <tag> à chaque déploiement pour garantir que le modèle est là. Mais Ollama est intelligent : il ne re-télécharge pas un modèle déjà présent. Sauf si le tag a bougé en amont (un latest qui pointe vers un nouveau snapshot), auquel cas il re-tire les 9 Go.

La parade côté repo est d'inscrire la liste des modèles en YAML pinnés (tags explicites, jamais latest pour les modèles de génération) et de skipper le pull si le modèle est déjà installé :

YAML
# devops/ansible/roles/ollama/tasks/main.yml (extrait)
- name: Pull each target model (skip if already present)
  ansible.builtin.command: >
    docker exec ollama ollama pull {{ item.name }}
  loop: "{{ ollama_models }}"
  when:
    - item.pull_on_provision | default(true)
    - item.name not in (ollama_list.stdout | default(''))
  async: 1800
  poll: 30

Le test item.name not in (ollama_list.stdout) regarde la sortie de ollama list avant de tirer. C'est l'équivalent d'un check before write en SQL — basique, mais c'est ce qui évite la régression silencieuse. On utilise bge-m3:latest côté embeddings parce que ce modèle bouge peu et qu'on accepte de re-tirer en cas de mise à jour, mais on pinne strictement les générateurs sur leur snapshot daté.

Les prérequis

Le critère qui décide presque tout, c'est la RAM disponible. Un modèle de quelques milliards de paramètres en quantization Q4 occupe plusieurs gigaoctets juste pour lui — auxquels il faut ajouter la marge nécessaire à l'OS, à l'application qui consomme l'API et, si on en charge plusieurs en parallèle, à la somme de leurs empreintes. La règle simple à retenir : on regarde la taille en gigaoctets que le registre Ollama annonce pour le modèle, on double pour la marge de fonctionnement, et on compare à la RAM libre de la machine cible. Si on est en dessous, on swappe sur disque et on attend longtemps. Si on est largement au-dessus, on peut faire cohabiter un générateur et un encodeur d'embeddings.

Côté CPU, Ollama utilise tous les cœurs disponibles par défaut. Le nombre de cœurs influence directement les latences d'inférence : plus on en a, plus la génération est rapide. L'ordre de grandeur utile à connaître : une génération courte (un titre, quelques balises) reste rapide, une génération longue (un JSON-LD complet sur un contenu volumineux) demande de la patience. C'est concrètement utilisable pour de l'admin asynchrone — un job dispatché via Messenger qui revient dans EasyAdmin une fois prêt — c'est inadapté à du synchrone visiteur, où personne n'attend la fin d'une inférence avant de cliquer.

Côté accélération matérielle, le terrain change selon l'architecture. Une machine équipée d'un GPU dédié bien supporté ou d'un SoC ARM moderne à mémoire unifiée tire bien meilleur parti d'Ollama qu'un serveur x86 sans accélération équivalente. C'est le genre de chose à mesurer chez soi avant de poser un chiffre — d'où la prudence de ne pas en avancer dans un billet de blog dont le contenu doit rester valable plus de six mois.

Le mot de la fin

Quand on cesse de payer un provider externe, ce qu'on récupère n'est pas que la facture. On récupère la liberté de bidouiller — tester un prompt, le relancer dix fois, comparer deux modèles, sans qu'un quota fonde quelque part. C'est dans les bidouilles qu'on apprend ce qu'un LLM sait vraiment faire et ce qu'il fait semblant de savoir faire.

Un LLM local, ce n'est pas une commodité technique : c'est un outil qu'on possède au lieu de louer. Avec tout ce que ça change quand on veut juste essayer une idée à 22h et surtout, sans demander la permission à personne

Activez uniquement ce que vous souhaitez. Vos choix sont conservés 6 mois.

Strictement nécessaires

Indispensables au fonctionnement du site (session, sécurité, préférence d'affichage). Aucune donnée n'est partagée à des tiers et aucun consentement n'est requis.

Toujours actif

Mesure d'audience

Statistiques anonymes via Umami Cloud (hébergement UE) : pages vues, source du trafic, navigateur. Pas de cookie tiers, pas de profilage, pas de partage commercial.