J'ai pris Spring AI pour un wrapper HTTP. J'avais tort.
Spring AI ressemble à un client HTTP. Un webclient ou un restTemplate qui fait appel à une API d’un model IA et le tour est joué. En réalité ce n'est pas un client HTTP.Un Hello World avec Spring AI ressemble à ceci :
chatClient.prompt(question).call().content();On dirait du WebClient avec un wrapper autour du LLM. A ce stade on peut se dire “Et si j’appelais simplement l’API de LLM puis parser moi-même le résultat pour extraire la réponse”. Et vous auriez sûrement raison pour un hello world cela marche. Et un hello word en entreprise ne dure pas longtemps 🙂.
Très rapidement donc on se retrouve à devoir faire ceci :
- Personnaliser les prompts pour répondre à différentes uses cases complexes.
- Gérer l’historique pour que le LLM se souviennent de la conversation.
- Donner la capacité à votre LLM à répondre à des questions sur des informations privées de l’entreprise.
- Effectuer des actions concrètes sur votre S.I. comme appeler une API ou écrire en base de donnée etc...
En résumé, gérer des fonctionnalités avancées soi-même se révèle vite compliqué. Et le pire, c'est qu'à force, vous allez réimplementer un bout de Spring AI en moins bien.
Cet article est le tour du framework que j'aurais voulu lire avant de commencer. J’explique le "pourquoi" derrière chaque abstraction, pas juste le "comment".
Mon hello world
Tout le code de cet article est sur GitHub. Si vous voulez juste lire, c'est inutile de configurer quoi que ce soit. Si vous voulez coder en parallèle, voici ce qui a été fait :
Le projet est généré sur spring intializr avec Spring Web et Anthropic claude comme dépendances, Ajoutez la Clé API Anthropic en variable d'environnement ANTHROPIC_API_KEY.
C’est la hess, Pas envie de payer ? Remplacez spring-ai-starter-model-anthropic par spring-ai-starter-model-ollama un modèle local. Tout le code de l'article fonctionne, à l'identique. C’est tout l’intérêt de Spring AI.
L'objectif est simple : un endpoint REST qui prend une question, appelle le LLM, renvoie la réponse, rien de plus simple.
A ce stade, la question que l’on peut se poser est la suivante : Est ce qu’on a besoin d’un framework pour appeler un LLM ?
Et c’est légitime parce qu’Anthropic (dans notre cas) expose une api v1/messages que l’on peut appeler en POST avec en paramètre un objet avec un modèle, des messages, un nombre max de tokens.
curl https://api.anthropic.com/v1/messages \
-H 'Content-Type: application/json' \
-H 'anthropic-version: 2023-06-01' \
-H "X-Api-Key:$ANTHROPIC_API_KEY" \
--max-time 600 \
-d '{
"max_tokens": 1024,
"messages": [
{
"content": "Hello, world",
"role": "user"
}
],
"model": "claude-opus-4-6"
}'
Un dev Spring expérimenté écrit le code java avec du RestTemplate ou du webclient qui permet d’appeler cet API en quelques minutes. Voici une version simple :
@GetMapping("/chat/direct")
public String chatDirect(@RequestParam String message) {
HttpHeaders headers = new HttpHeaders();
headers.setContentType(MediaType.APPLICATION_JSON);
headers.set("x-api-key", apiKey);
headers.set("anthropic-version", ANTHROPIC_VERSION);
MessageRequest request = new MessageRequest(
MODEL,
MAX_TOKENS,
List.of(new Message("user", message))
);
HttpEntity<MessageRequest> entity = new HttpEntity<>(request, headers);
MessageResponse response = restTemplate.postForObject(ANTHROPIC_API_URL, entity, MessageResponse.class);
if (response == null || response.content() == null || response.content().isEmpty()) {
return "";
}
return response.content().stream()
.filter(block -> "text".equals(block.type()))
.map(ContentBlock::text)
.reduce("", (acc, text) -> acc + text);
}
@JsonInclude(JsonInclude.Include.NON_NULL)
public record MessageRequest(String model, int max_tokens, List<Message> messages) {}
public record Message(String role, String content) {}
public record MessageResponse(String id, String model, String role, String stop_reason,
List<ContentBlock> content) {}
public record ContentBlock(String type, String text) {}
On crée un objet MessageRequest que l'on passe en paramètre à une requête Post via RestTemplate. Le résultat de l'appel est parsé et converti en MessageResponse puis filtré suivant le type text.
Honnêtement, ce code me rappelle le bon vieux temps, du code explicite, clair et qui répond à un besoin. Si le use case c’est juste un appel, une réponse basta, faites un commit et push en prod. Note: Je ne vous l'ai jamais dit.
Et ça marche

En analysant de plus près, ce code est complètement lié à Anthropic. La réponse d’un appel à un modèle openAI par exemple serait différente. Pour passer sur OpenAI, il faut tout réécrire : changer l’URL, structurer le payload, parser la réponse et idem pour passer sur Ollama en local.
La même version avec spring ai :
private final ChatClient chatClient;
public ChatController(ChatClient.Builder chatClientBuilder) {
this.chatClient = chatClientBuilder.build();
}
// correspond au code avec RestTemplate
@GetMapping("/chat")
public String chat(String message) {
String response = this.chatClient.prompt()
.user(message)
.call()
.content();
return response;
}Sans configuration, le model claude-opus-4 est utilisé par défaut au moment de l'écriture de cet article.
Le résultat est tout aussi magnifique et sans max token :

Si vous souhaitez changer de modele, il suffit de remplacer la dépendance et you're good to go.
Mon avis est que Spring AI ne pense pas en termes d'appels HTTP, il pense en termes de patterns IA. Et dès qu'on dépasse la formule "une question, une réponse", l'écart va se creuser brutalement.
On va voir dans cette partie 1 de notre série d'articles qui consiste à démystifier les concepts IA avec Spring AI : le system prompt et le chat memory.
System Prompt
Le LLM que vous utilisez sait répondre à peu prés toutes les questions. Si vous l'utilisez par exemple sur votre application rh "IA-powered" de votre entreprise, rien n'empêche à un collaborateur de lui poser la question "comment perdre 5 kilos sans bouger" et obtenir une réponse. Ce n'est pas idéal.
Un system prompt ajouté à votre chatClient.Builder vous permet de limiter le LLM.
public ChatController(ChatClient.Builder chatClientBuilder) {
this.chatClient = chatClientBuilder
.defaultSystem("Tu es un expert en gestion des resources humaines et tu réponds de manière concise et précise.")
.build();
}C'est quoi ChatClient ?
ChatClient est une interface de Spring AI qui permet de communiquer avec modele IA en mode stateless (C'est important de noter le stateless). Il permet entre autre de construire le prompt qui est envoyé en entrée au LLM.
Il existe 2 types de messages en entrée du LLM :User Messagequi représente ton message à toi en tant qu'utilisateur de l'application, etSystem Messagequi est généré par l'application pour limiter le contexte du LLM.
Sans Spring AI il faut adapter le System prompt vous même. Avec le model de claude, c'est un paramètre system à la racine du payload. Avec OpenAI, la structure n'est pas la même. Il est ajouté comme un message avec un rôle dédié (System ou developer selon le modèle) dans le tableau messages ou simplement avec le paramètre instructions. Même idée, deux conventions. En RestTemplate, c'est à vous de savoir laquelle s'applique.
Chat Memory
Un LLM n'a pas de mémoire. Pour qu'il "se souvienne" de votre conversation, il faut lui renvoyer tout l'historique à chaque requête.
Avec RestTemplate ça donnerait ceci :
@GetMapping("/chat/direct")
public String chatDirect(@RequestParam String message,
@RequestParam(defaultValue = DEFAULT_CONVERSATION) String conversationId) {
List<Message> history = conversations.computeIfAbsent(conversationId, k -> new ArrayList<>());
synchronized (history) {
history.add(new Message("user", message));
MessageRequest request = new MessageRequest(MODEL, MAX_TOKENS, List.copyOf(history));
MessageResponse response = restTemplate.postForObject(
ANTHROPIC_API_URL, new HttpEntity<>(request, buildHeaders()), MessageResponse.class);
String reply = extractText(response);
history.add(new Message("assistant", reply));
return reply;
}
}Dans la version RestTemplate, les messages s'empilent dans la liste d'objets Message. Sauf que notre context window grossit très vite. Au-delà d'un certain nombre de tokens, le LLM commence à halluciner et renvoie des réponses moins pertinentes. L'impact en coût, en latence, voire en erreurs du modèle, n'est pas négligeable. Spring AI propose une API ChatMemory ( à ne pas confondre avec un simple historique de chat ) qui stocke et restitue le contexte entre vos interactions avec le LLM, et gère la fenêtre de contexte pour vous.
Rajoutons un bean MessageChatMemoryAdvisor avec un MessageWindowChatMemory qui conserve une fenêtre de 10 messages. Lorsque le nombre de messages dépasse ce maximum, les messages les plus anciens sont supprimés.
@Bean
MessageChatMemoryAdvisor messageChatMemoryAdvisor() {
var messageWindowChatMemory = MessageWindowChatMemory
.builder()
.maxMessages(10)
.build();
return MessageChatMemoryAdvisor
.builder(messageWindowChatMemory)
.build();
}Il suffira de rajouter cet advisor au chatClient et le tour est joué.
public ChatController(ChatClient.Builder chatClientBuilder, MessageChatMemoryAdvisor messageChatMemoryAdvisor) {
this.chatClient = chatClientBuilder
.defaultSystem("Tu es un expert en gestion des resources humaines et tu réponds de manière concise et précise.")
.defaultAdvisors(messageChatMemoryAdvisor).build();
}
@GetMapping("/{user}/chat")
public String chat(@PathVariable String user, @RequestParam String message) {
String response = this.chatClient.prompt()
.user(message)
.advisors( advisorSpec -> advisorSpec.param(ChatMemory.CONVERSATION_ID, user))
.call()
.content();
return response;
}A partir de là, votre app a une mémoire qui lui permet de se "souvenir" de la conversation sur une fenêtre de 10 messages.

Mais vous n'allez pas envoyer ceci en prod. Le problème, lorsque vous redémarrez votre application vous perdez tout. Par defaut, Spring AI stocke les messages en mémoire en utilisant une mémoire de type InMemoryChatMemoryRepository. Ce repository stocke les messages dans une liste de type ConcurrentHashMap. Toutefois un repository de type JdbcChatMemoryRepository permet de stocker les messages dans une base de données relationnelle.
L'advisor sera réécrit comme suit :
@Bean
MessageChatMemoryAdvisor messageChatMemoryAdvisor(DataSource dataSource) {
var jdbcMemory = JdbcChatMemoryRepository
.builder()
.dataSource(dataSource)
.build();
var messageWindowChatMemory = MessageWindowChatMemory
.builder()
.chatMemoryRepository(jdbcMemory)
.maxMessages(10)
.build();
return MessageChatMemoryAdvisor
.builder(messageWindowChatMemory)
.build();
}Il faudra rajouter la dépendance suivantes :
<dependency>
<groupId>org.springframework.ai</groupId>
<artifactId>spring-ai-starter-model-chat-memory-repository-jdbc</artifactId>
</dependency>Mais alors qu'est ce qu'un Advisor ?

Un advisor fonctionne comme un intercepteur. Si vous avez déjà codé un interceptor sur spring ou un filtre spring security, vous avez compris ce que c'est un advisor. Il permet d'effectuer des opérations, manipuler ou transformer la data avant et/ou après l'appel vers le LLM. Un advisor interne Spring AI permet d'effectuer cet appel.
L'objectif des advisors ChatMemory est d'intercepter la requête, retrouver l'historique de conversation et l'ajouter à la requete avant d'envoyer le résultat vers le LLM. Pour cela selon le cas d'usage, Spring AI propose des advisors:

Conclusion
Faisons le point. En partant d'un appel HTTP, on a ajouté un system prompt pour cadrer le LLM, une mémoire persistante pour qu'il suive la conversation, et au passage on a croisé un Advisor qui ressemble furieusement à un HandlerInterceptor.
Vous constatez que la ligne chatClient.prompt(...).call().content() n'a pas changé. On ne fait que rajouter du comportement autour. C'est ce qui fait toute la différence avec Spring AI.
Jusqu'ici, notre LLM se contente de répondre. La prochaine fois, nous allons lui donner les clés pour appeler une API, écrire en base, déclencher un process. On passe du modèle "machine à prédire le mot suivant" à l'agent qui agit sur votre SI. En gros vous outillez votre LLM pour faire des choses à votre place. Ça s'appelle le tool calling, et c'est le sujet du prochain épisode.
À partir de là, parler de "wrapper HTTP" devient franchement gênant. La suite de la série sera implémentée uniquement avec Spring AI.