Exécuter un LLM en local : quand auto-héberger
1. Vue d’ensemble
Appeler l’API d’un fournisseur de LLM sur le cloud est le moyen le plus rapide d’intégrer l’IA dans une application. De plus en plus d’équipes veulent cependant exécuter le modèle sur leur propre infrastructure, que ce soit un ordinateur portable, un serveur interne ou un GPU loué.
Dans cet article, nous allons comprendre ce que signifie concrètement « exécuter un LLM en local » et pourquoi cette tendance attire l’attention. Vous allez ensuite mettre en place un modèle local, l’appeler depuis une application Spring Boot et écrire des tests pour l’intégration. En conclusion, nous verrons les critères pour décider quand auto-héberger et quand ne pas le faire.
2. Que signifie « exécuter un LLM en local » ?
2.1. Trois composants fondamentaux
Un système LLM auto-hébergé comprend généralement trois couches :
- Model weights : le fichier contenant les poids du modèle. Vous téléchargez généralement des modèles open-weight depuis des dépôts publics.
- Inference runtime : un logiciel qui charge les poids en RAM ou VRAM et génère les tokens. Par exemple Ollama, llama.cpp ou vLLM.
- API layer : un endpoint HTTP pour que votre application l’appelle. De nombreux runtimes fournissent une API compatible avec la norme OpenAI, donc le code client change à peine.
Votre application ne voit que la troisième couche. C’est essentiel pour pouvoir basculer plus tard entre local et cloud sans réécrire la logique métier.
2.2. Quantification et mémoire
Le facteur décisif pour « ma machine peut-elle faire tourner cela » est la mémoire. Vous pouvez estimer grossièrement la mémoire des poids ainsi :
Mémoire des poids ≈ nombre de paramètres × octets par paramètre
16-bit (FP16) : 2 octets / paramètre
8-bit : 1 octet / paramètre
4-bit : ~0.5 octet / paramètre
Au-delà des poids, vous avez besoin d’espace pour le KV cache. Cette portion augmente avec la longueur du contexte et le nombre de requêtes parallèles. La quantification est une technique qui réduit la précision numérique, par exemple de 16-bit à 4-bit, pour réduire considérablement la taille du modèle. En contrepartie, la qualité des réponses diminue légèrement. Pour de nombreuses tâches comme la classification, le résumé ou l’extraction d’informations, cette dégradation est généralement acceptable.
3. Pourquoi auto-héberger un LLM attire l’attention ?
Il y a quatre motivations principales :
- Confidentialité et conformité : les données clients, contrats ou code source interne ne quittent pas votre infrastructure.
- Coûts prévisibles : les API cloud facturent à la consommation de tokens. Avec une charge stable et importante, le coût fixe d’une machine GPU peut être moins cher.
- Faible latence et capacité offline : pas d’aller-retour sur Internet, pas de limite de débit d’un tiers, fonctionne dans un environnement air-gapped.
- Modèles open-weight toujours meilleurs : des petits modèles suffisent pour de nombreuses tâches spécialisées. Vous pouvez aussi les fine-tuner pour votre domaine.
Remarque : « Local » ne signifie pas forcément votre ordinateur portable. Un cluster GPU dans un VPC privé compte aussi comme auto-hébergement. Le cœur est que vous contrôlez le modèle et les données.
4. Mettre en place un modèle local avec Ollama
Ollama est le choix simple pour commencer. Il empaquette le runtime, gère les modèles et fournit une API HTTP. Vous pouvez le lancer avec Docker Compose :
services:
ollama:
image: ollama/ollama
ports:
- "11434:11434"
volumes:
- ollama-data:/root/.ollama
restart: unless-stopped
volumes:
ollama-data:
Le volume ollama-data conserve les modèles téléchargés. Vous n’avez pas besoin de les retélécharger à chaque redémarrage du conteneur. Le port 11434 est le port par défaut de l’API Ollama.
Ensuite, vous téléchargez un modèle et l’essayez :
docker compose up -d
docker compose exec ollama ollama pull llama3.2
curl http://localhost:11434/api/chat -d '{
"model": "llama3.2",
"messages": [{"role": "user", "content": "Résumé : commande livrée 3 jours en retard, le client veut un remboursement."}],
"stream": false
}'
Vous pouvez remplacer llama3.2 par n’importe quel modèle adapté à votre matériel. Avec "stream": false, le résultat est un JSON unique :
{
"model": "llama3.2",
"message": { "role": "assistant", "content": "Le client demande un remboursement car sa commande a été livrée avec 3 jours de retard." },
"done": true,
...
}
Si votre machine a un GPU NVIDIA, vous devez installer le NVIDIA Container Toolkit et accorder le GPU au conteneur. Sinon, le modèle tourne en CPU. C’est possible mais beaucoup plus lent.
5. Appeler un modèle local depuis Spring Boot
L’exemple dans cette section est un cas réel : générer des brouillons de réponses pour les messages de support client. Les données de messages restent entièrement dans votre infrastructure.
5.1. Dépendance et configuration
Vous n’avez besoin que de RestClient qui vient avec Spring Web. Aucune librairie IA supplémentaire :
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
Déclarez la configuration dans application.properties :
local-llm.base-url=http://localhost:11434
local-llm.model=llama3.2
local-llm.timeout=60s
Le timeout doit être généreux. Un modèle local, surtout en CPU, peut mettre des dizaines de secondes pour générer une réponse longue.
5.2. Properties et RestClient
@ConfigurationProperties(prefix = "local-llm")
public record LocalLlmProperties(String baseUrl, String model, Duration timeout) {}
@Configuration
@EnableConfigurationProperties(LocalLlmProperties.class)
public class LlmClientConfig {
@Bean
RestClient ollamaRestClient(RestClient.Builder builder, LocalLlmProperties props) {
HttpClient httpClient = HttpClient.newBuilder()
.connectTimeout(Duration.ofSeconds(5))
.build();
JdkClientHttpRequestFactory factory = new JdkClientHttpRequestFactory(httpClient);
factory.setReadTimeout(props.timeout());
return builder
.baseUrl(props.baseUrl())
.requestFactory(factory)
.build();
}
}
Les deux timeouts ici ont des rôles différents. Le connect timeout court (5 secondes) aide à détecter rapidement si Ollama n’est pas lancé. Le read timeout long permet d’attendre que le modèle finisse de générer la réponse.
5.3. DTO et service
public record ChatMessage(String role, String content) {}
public record ChatRequest(String model, List<ChatMessage> messages, boolean stream) {}
@JsonIgnoreProperties(ignoreUnknown = true)
public record ChatResponse(ChatMessage message, boolean done) {}
@Service
public class SupportReplyService {
private static final String SYSTEM_PROMPT =
"Vous êtes un agent de support client. Répondez poliment, brièvement, en français.";
private final RestClient ollama;
private final String model;
public SupportReplyService(RestClient ollamaRestClient, LocalLlmProperties props) {
this.ollama = ollamaRestClient;
this.model = props.model();
}
public String draftReply(String customerMessage) {
ChatRequest request = new ChatRequest(model, List.of(
new ChatMessage("system", SYSTEM_PROMPT),
new ChatMessage("user", customerMessage)
), false);
ChatResponse response = ollama.post()
.uri("/api/chat")
.contentType(MediaType.APPLICATION_JSON)
.body(request)
.retrieve()
.body(ChatResponse.class);
if (response == null || response.message() == null) {
throw new IllegalStateException("Le LLM n'a pas retourné de contenu");
}
return response.message().content().strip();
}
}
Le flux de draftReply fonctionne ainsi :
- Le service combine un message
systempour orienter le ton, puis ajoute le message du client en tant que messageuser. streamest défini àfalse, donc Ollama retourne la réponse complète dans une seule réponse.@JsonIgnorePropertiesignore les champs que le service n’utilise pas, comme les statistiques ou le nombre de tokens.- Une réponse vide devient une exception explicite, plutôt qu’un
NullPointerExceptiondifficile à comprendre.
6. Tester l’intégration
Vous ne devez pas faire dépendre vos tests unitaires d’un vrai modèle, car c’est lent et les résultats ne sont pas déterministes. À la place, utilisez MockRestServiceServer pour vérifier que le service envoie la bonne requête et lit la bonne réponse :
class SupportReplyServiceTest {
private MockRestServiceServer server;
private SupportReplyService service;
@BeforeEach
void setUp() {
RestClient.Builder builder = RestClient.builder().baseUrl("http://localhost:11434");
server = MockRestServiceServer.bindTo(builder).build();
var props = new LocalLlmProperties("http://localhost:11434", "llama3.2", Duration.ofSeconds(60));
service = new SupportReplyService(builder.build(), props);
}
@Test
void givenCustomerMessage_whenDraftReply_thenReturnsAssistantContent() {
server.expect(requestTo("http://localhost:11434/api/chat"))
.andExpect(method(HttpMethod.POST))
.andExpect(jsonPath("$.model").value("llama3.2"))
.andExpect(jsonPath("$.stream").value(false))
.andExpect(jsonPath("$.messages[1].content").value("Où est ma commande ?"))
.andRespond(withSuccess("""
{"model":"llama3.2",
"message":{"role":"assistant","content":" Nous vérifions votre commande. "},
"done":true,
"total_duration":123456}
""", MediaType.APPLICATION_JSON));
String reply = service.draftReply("Où est ma commande ?");
assertThat(reply).isEqualTo("Nous vérifions votre commande.");
server.verify();
}
}
Ce test vérifie quatre points :
- La requête est envoyée au bon endpoint
/api/chatavec la méthode POST. - Le body contient le bon nom de modèle et
stream=false. - Le message du client se trouve au deuxième message.
- Le service ignore le champ inconnu
total_durationet supprime les espaces inutiles au début et à la fin de la réponse.
Pour tester la qualité de la réponse, créez une suite de tests séparée qui s’exécute avec le vrai modèle, par exemple avec Testcontainers. Cette suite devrait tourner régulièrement, pas à chaque build.
7. Maintenir la flexibilité entre local et cloud
Ollama supporte aussi un endpoint compatible OpenAI à /v1/chat/completions. Si vous écrivez votre client selon cette norme, basculer entre local et cloud devient juste une question de configuration par profil :
# application-local.properties
llm.base-url=http://localhost:11434/v1
llm.model=llama3.2
# application-cloud.properties
llm.base-url=https://api.your-provider.example/v1
llm.model=your-cloud-model
llm.api-key=${LLM_API_KEY}
Placez les appels au LLM derrière une interface dédiée, par exemple ReplyDrafter. Vous pouvez alors utiliser un modèle local pour les données sensibles et un modèle cloud pour les tâches exigeant la meilleure qualité, tandis que la logique métier reste agnostique.
8. Quand auto-héberger et quand ne pas le faire ?
8.1. Auto-héberger quand
- Les données sont sensibles ou la réglementation interdit d’envoyer des données à un tiers.
- La charge est importante et stable, ce qui rend le coût fixe d’une GPU moins cher que la facturation à la consommation de tokens.
- Les tâches sont ciblées et répétitives comme classifier des tickets, extraire des champs de factures ou résumer des logs. Un petit modèle suffit généralement.
- Vous devez fonctionner offline, dans un environnement air-gapped ou en edge.
- Vous devez fine-tuner en profondeur ou contrôler strictement la version du modèle pour des résultats stables dans le temps.
8.2. Ne pas auto-héberger quand
- Vous avez besoin des meilleures capacités de raisonnement pour des tâches complexes. Les gros modèles sur le cloud surpassent généralement les modèles locaux.
- Le trafic est faible ou imprévisible. Une GPU oisive la plupart du temps coûte probablement plus cher que de payer à la consommation.
- Votre équipe manque d’expérience en exploitation de GPU, monitoring, autoscaling et mises à jour de sécurité du runtime.
- Vous êtes en phase d’expérimentation. Validez d’abord la valeur avec une API cloud, puis optimisez les coûts.
Une simple comparaison de coûts :
Coût auto-hébergement / mois = infrastructure (amortissement ou location) + électricité/hosting + coût humain
Coût cloud / mois = tokens / mois × prix unitaire token
Auto-héberger n'est pertinent que si les coûts sont nettement plus bas à votre charge réelle, pas à la charge espérée.
8.3. Pièges courants
Remarque : Ollama n’écoute par défaut que sur localhost et n’a pas d’authentification. Si vous ouvrez le port sur le réseau, placez-le derrière un reverse proxy avec authentification. Ne l’exposez jamais directement sur Internet.
- Évaluer un modèle au sentiment : tester quelques phrases, c’est bon, puis déployer en production. Vous avez besoin d’un ensemble d’évaluation fixe pour votre tâche.
- Oublier le KV cache : le modèle tient en VRAM mais la mémoire s’épuise avec un contexte long ou plusieurs requêtes parallèles.
- Timeout trop court : le premier appel après démarrage doit charger le modèle, donc c’est très lent. Vous pouvez « réchauffer » le modèle au lancement de l’application.
- Mal choisir le runtime pour la production : Ollama est très pratique pour le développement et la charge modérée. Avec beaucoup d’utilisateurs simultanés, étudiez des runtimes optimisés pour le débit comme vLLM.
9. Conclusion
Auto-héberger un LLM n’est ni « gratuit » ni « mieux » par défaut. C’est un compromis : vous gagnez le contrôle des données, des coûts prévisibles et une faible latence, mais vous devez gérer l’exploitation et accepter généralement de plus petits modèles.
Dans cet article, vous avez lancé Ollama avec Docker et l’avez appelé depuis Spring Boot avec RestClient. Vous avez écrit des tests pour l’intégration sans modèle réel, et su comment concevoir pour basculer entre local et cloud par configuration. L’approche la plus pragmatique est souvent d’utiliser les deux : un modèle local pour les données sensibles et les tâches répétitives, un modèle cloud pour ce qui demande la meilleure qualité.