Streaming de réponses depuis LLM API avec Java 21 et Spring Boot
1. Aperçu
Une longue réponse d’une LLM peut prendre 10 à 30 secondes pour être générée. Si vous attendez la phrase complète avant de la retourner, l’utilisateur regardera un écran vide pendant tout ce temps. Le streaming vous permet de recevoir chaque token dès que le modèle les génère, donc le premier caractère apparaît généralement en moins d’une seconde.
Cet article vous guide pour lire un flux depuis une API compatible OpenAI Chat Completions en utilisant java.net.http.HttpClient, puis Spring WebClient. Ensuite, vous envoyez ce flux au navigateur via Server-Sent Events (SSE) et écrivez des tests pour tout le pipeline.
2. Format du streaming d’une LLM API
Lorsque vous envoyez une requête avec "stream": true, l’API ne retourne pas un seul JSON. Au lieu de cela, elle retourne Content-Type: text/event-stream, et chaque événement est une ligne data: :
data: {"id":"c1","choices":[{"index":0,"delta":{"role":"assistant","content":""}}]}
data: {"id":"c1","choices":[{"index":0,"delta":{"content":"Xin"}}]}
data: {"id":"c1","choices":[{"index":0,"delta":{"content":" chào"}}]}
data: {"id":"c1","choices":[{"index":0,"delta":{},"finish_reason":"stop"}]}
data: [DONE]
Il y a trois points à retenir :
- Le texte se trouve à
choices[0].delta.content. Certains chunks n’ont pas ce champ, par exemple le premier chunk n’a querole, ou le dernier n’a quefinish_reason. - Les événements sont séparés par une ligne vide.
data: [DONE]signale la fin du streaming. Cette valeur n’est pas du JSON, vous ne devez donc pas la passer à Jackson.
3. Extraire la logique de parsing dans une classe séparée
La logique de parsing doit se trouver dans une classe indépendante pour être facilement testable. Cette classe n’a besoin que de Jackson, qui est déjà inclus dans tout starter web de Spring Boot :
package dev.nonla.chat;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import java.util.Optional;
public class ChatChunkParser {
public static final String DONE = "[DONE]";
private final ObjectMapper mapper = new ObjectMapper();
/** Reçoit une ligne SSE complète, par exemple : data: {...} */
public Optional<String> parseLine(String line) {
if (!line.startsWith("data:")) {
return Optional.empty();
}
return parsePayload(line.substring(5).trim());
}
/** Reçoit la partie payload après "data:" */
public Optional<String> parsePayload(String payload) {
if (payload.isEmpty() || DONE.equals(payload)) {
return Optional.empty();
}
try {
JsonNode content = mapper.readTree(payload)
.path("choices").path(0).path("delta").path("content");
return content.isTextual() && !content.asText().isEmpty()
? Optional.of(content.asText())
: Optional.empty();
} catch (JsonProcessingException e) {
throw new IllegalStateException("Chunk invalide : " + payload, e);
}
}
}
Voici comment fonctionne cette classe :
parseLineignore les lignes vides et les lignes commeevent:ou: keep-alive.- La méthode
path(...)de Jackson ne lève pas d’exception si un nœud est manquant. Si le chunk n’a pas decontent, le résultat estOptional.empty(). [DONE]est bloqué avant d’appelerreadTree, donc il ne cause pas d’erreur de parsing.
4. Streaming avec java.net.http.HttpClient
Si vous avez besoin d’une simple CLI ou d’un job unique, le HttpClient inclus dans le JDK suffit. BodyHandlers.ofLines() retourne un Stream<String> qui est lu progressivement au fur et à mesure que les données arrivent du réseau, au lieu d’attendre le chargement complet du corps :
package dev.nonla.chat;
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.net.http.HttpResponse.BodyHandlers;
import java.time.Duration;
import java.util.Optional;
import java.util.stream.Collectors;
import java.util.stream.Stream;
public class StreamingChatDemo {
public static void main(String[] args) throws Exception {
String baseUrl = System.getenv("LLM_BASE_URL");
String apiKey = System.getenv("LLM_API_KEY");
String body = """
{"model": "gpt-4o-mini", "stream": true,
"messages": [{"role": "user",
"content": "Expliquer la relation entre Order et Customer en 3 phrases"}]}
""";
HttpRequest request = HttpRequest.newBuilder(URI.create(baseUrl + "/v1/chat/completions"))
.header("Authorization", "Bearer " + apiKey)
.header("Content-Type", "application/json")
.header("Accept", "text/event-stream")
.timeout(Duration.ofSeconds(60))
.POST(HttpRequest.BodyPublishers.ofString(body))
.build();
HttpClient client = HttpClient.newHttpClient();
HttpResponse<Stream<String>> response = client.send(request, BodyHandlers.ofLines());
if (response.statusCode() != 200) {
String error = response.body().collect(Collectors.joining("\n"));
throw new IllegalStateException("HTTP " + response.statusCode() + ": " + error);
}
ChatChunkParser parser = new ChatChunkParser();
try (Stream<String> lines = response.body()) {
lines.takeWhile(line -> !line.equals("data: [DONE]"))
.map(parser::parseLine)
.flatMap(Optional::stream)
.forEach(token -> {
System.out.print(token);
System.out.flush();
});
}
System.out.println();
}
}
Explication des lignes principales :
- Le code de statut doit être vérifié en premier. Lorsque l’API retourne une erreur (401, 429…), le corps est un JSON d’erreur, pas du SSE.
takeWhilearrête le flux lorsqu’il rencontre[DONE]. Le bloctry-with-resourcesferme le flux, et la connexion est libérée en conséquence.System.out.flush()assure que le texte s’affiche immédiatement. Sans cette ligne, le terminal peut mettre en buffer la sortie et l’afficher plus tard.
Le résultat est un texte qui s’affiche progressivement sur le terminal :
Un Client peut passer plusieurs Commandes, tandis que chaque Commande n'appartient qu'à exactement un Client...
Remarque : Le
timeout(...)d’HttpRequestne s’applique que jusqu’à la réception des headers de réponse. Il ne limite pas le temps de lecture du corps. Si le modèle « se fige » à mi-chemin, la boucle attendra indéfiniment. C’est une raison de migrer versWebClientdans une véritable application.
5. Streaming avec Spring WebClient
Dans un service Spring Boot, WebClient vous donne un Flux<String> avec support de la pression arrière (backpressure), timeouts et annulation. Vous devez ajouter la dépendance :
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-webflux</artifactId>
</dependency>
La configuration de la connexion est placée dans application.properties :
llm.base-url=${LLM_BASE_URL}
llm.api-key=${LLM_API_KEY}
llm.model=gpt-4o-mini
Ensuite, déclarez le bean WebClient :
@Configuration
class LlmClientConfig {
@Bean
WebClient llmWebClient(@Value("${llm.base-url}") String baseUrl,
@Value("${llm.api-key}") String apiKey) {
return WebClient.builder()
.baseUrl(baseUrl)
.defaultHeader(HttpHeaders.AUTHORIZATION, "Bearer " + apiKey)
.build();
}
}
Le service appelle l’API et retourne un Flux contenant les tokens :
@Service
public class ChatStreamService {
private static final Logger log = LoggerFactory.getLogger(ChatStreamService.class);
private static final ParameterizedTypeReference<ServerSentEvent<String>> SSE_TYPE =
new ParameterizedTypeReference<>() {};
private final WebClient llmWebClient;
private final String model;
private final ChatChunkParser parser = new ChatChunkParser();
public ChatStreamService(WebClient llmWebClient, @Value("${llm.model}") String model) {
this.llmWebClient = llmWebClient;
this.model = model;
}
public Flux<String> stream(String question) {
Map<String, Object> body = Map.of(
"model", model,
"stream", true,
"messages", List.of(Map.of("role", "user", "content", question)));
return llmWebClient.post()
.uri("/v1/chat/completions")
.contentType(MediaType.APPLICATION_JSON)
.accept(MediaType.TEXT_EVENT_STREAM)
.bodyValue(body)
.retrieve()
.bodyToFlux(SSE_TYPE)
.mapNotNull(ServerSentEvent::data)
.takeWhile(data -> !ChatChunkParser.DONE.equals(data))
.mapNotNull(data -> parser.parsePayload(data).orElse(null))
.timeout(Duration.ofSeconds(30))
.doOnCancel(() -> log.info("Le client a annulé le flux pour la question : {}", question));
}
}
Chaque étape du pipeline de traitement :
bodyToFlux(SSE_TYPE)laisse Spring séparer automatiquement les événements par des lignes vides et éliminer le préfixedata:. Vous n’avez pas à gérer le buffer vous-même.takeWhilecomplète leFluxquand il rencontre[DONE].timeout(Duration.ofSeconds(30))s’applique à l’intervalle entre deux éléments consécutifs, y compris le premier. Si aucun nouveau token n’arrive dans les 30 secondes, leFluxsignale uneTimeoutException. C’est ce qui manquait àHttpClientà la section 4.- Lorsque l’API retourne 4xx/5xx,
retrieve()lève uneWebClientResponseExceptionavant tout token.
5.1. Enregistrer la réponse complète après la fin du streaming
Vous avez souvent besoin d’enregistrer la réponse complète pour l’historique ou calculer les frais. StringBuilder doit être créé séparément pour chaque souscription. Vous enveloppez donc le pipeline de traitement dans Flux.defer :
public Flux<String> streamAndRecord(String question) {
return Flux.defer(() -> {
StringBuilder full = new StringBuilder();
return stream(question)
.doOnNext(full::append)
.doOnComplete(() -> log.info("Réponse complète ({} caractères) : {}",
full.length(), full));
});
}
Si StringBuilder était déclaré en dehors de defer, deux requêtes simultanées écriraient dans le même buffer. Cette erreur est difficile à détecter lors d’un test manuel car vous n’envoyez généralement qu’une requête à la fois.
6. Envoyer le flux au navigateur
Le contrôleur retourne un Flux<ServerSentEvent<...>> avec produces = text/event-stream :
public record ChatChunk(String text) {}
@RestController
class ChatController {
private final ChatStreamService chatStreamService;
ChatController(ChatStreamService chatStreamService) {
this.chatStreamService = chatStreamService;
}
@GetMapping(path = "/api/chat/stream", produces = MediaType.TEXT_EVENT_STREAM_VALUE)
Flux<ServerSentEvent<ChatChunk>> stream(@RequestParam("q") String question) {
return chatStreamService.streamAndRecord(question)
.map(token -> ServerSentEvent.builder(new ChatChunk(token)).event("token").build())
.concatWith(Mono.just(ServerSentEvent.builder(new ChatChunk("")).event("done").build()))
.onErrorResume(ex -> Mono.just(ServerSentEvent
.builder(new ChatChunk("Le modèle est occupé, veuillez réessayer."))
.event("error").build()));
}
}
Vous pouvez tester l’endpoint avec curl -N. Le drapeau -N désactive la mise en buffer de curl :
curl -N "http://localhost:8080/api/chat/stream?q=Order%20est%20quoi"
event:token
data:{"text":"Order"}
event:token
data:{"text":" est"}
event:token
data:{"text":" une"}
event:done
data:{"text":""}
Il y a trois décisions de conception à noter :
- Les tokens sont enveloppés dans du JSON (
ChatChunk), pas envoyés sous forme de chaîne brute. Selon la spécification SSE, le destinataire supprimera un espace immédiatement aprèsdata:. Le token" est"envoyé brut deviendrait"est"dans le navigateur, et les mots se collent. Les tokens contenant\nse diviseraient aussi en plusieurs lignesdata:. L’enveloppe JSON évite les deux erreurs. - Les erreurs sont envoyées sous forme d’événement
error. Une fois que le premier byte est envoyé, le statut 200 est déjà dans les headers. Vous ne pouvez pas le changer en 500. Le client doit écouter l’événementerror. - L’événement
doneest envoyé explicitement. L’EventSourcedu navigateur se reconnecte automatiquement lorsque le flux se ferme. Le client a besoin de l’événementdonepour savoir que la réponse est terminée et appelerclose()lui-même.
Lorsque l’utilisateur ferme l’onglet, WebFlux annule l’abonnement. Le signal d’annulation remonte à WebClient, Reactor Netty ferme la connexion à l’API LLM, et le fournisseur arrête de générer des tokens. De cette façon, vous ne payez pas pour la partie de la réponse que personne ne lit.
7. Écrire des tests
7.1. Test du parser
class ChatChunkParserTest {
private final ChatChunkParser parser = new ChatChunkParser();
@Test
void givenContentChunk_whenParseLine_thenReturnsToken() {
String line = "data: {\"choices\":[{\"index\":0,\"delta\":{\"content\":\" bonjour\"}}]}";
assertThat(parser.parseLine(line)).contains(" bonjour");
}
@Test
void givenDoneOrRoleOnlyChunk_whenParseLine_thenReturnsEmpty() {
assertThat(parser.parseLine("data: [DONE]")).isEmpty();
assertThat(parser.parseLine(
"data: {\"choices\":[{\"delta\":{\"role\":\"assistant\"}}]}")).isEmpty();
assertThat(parser.parseLine(": keep-alive")).isEmpty();
}
@Test
void givenBrokenJson_whenParseLine_thenThrows() {
assertThatThrownBy(() -> parser.parseLine("data: {oops"))
.isInstanceOf(IllegalStateException.class)
.hasMessageContaining("Chunk invalide");
}
}
Le premier test confirme que l’espace au début du token est conservé. C’est le détail le plus facile à perdre dans tout le pipeline.
7.2. Test du contrôleur avec WebTestClient
Vous mocquez ChatStreamService pour que le test n’appelle pas l’API réelle. @MockitoBean est disponible depuis Spring Boot 3.4. Avec les versions antérieures, vous utilisez @MockBean.
@WebFluxTest(ChatController.class)
class ChatControllerTest {
@Autowired
WebTestClient webTestClient;
@MockitoBean
ChatStreamService chatStreamService;
@Test
void whenStreaming_thenSendsTokensThenDone() {
when(chatStreamService.streamAndRecord("hi"))
.thenReturn(Flux.just("Bonjour", " le", " monde"));
List<ServerSentEvent<String>> events = webTestClient.get()
.uri("/api/chat/stream?q=hi")
.accept(MediaType.TEXT_EVENT_STREAM)
.exchange()
.expectStatus().isOk()
.returnResult(new ParameterizedTypeReference<ServerSentEvent<String>>() {})
.getResponseBody()
.collectList()
.block(Duration.ofSeconds(5));
assertThat(events).extracting(ServerSentEvent::event)
.containsExactly("token", "token", "token", "done");
assertThat(events.get(1).data()).isEqualTo("{\"text\":\" le\"}");
}
@Test
void whenUpstreamFails_thenSendsErrorEvent() {
when(chatStreamService.streamAndRecord("hi"))
.thenReturn(Flux.concat(Flux.just("Bonjour"),
Flux.error(new TimeoutException())));
List<ServerSentEvent<String>> events = webTestClient.get()
.uri("/api/chat/stream?q=hi")
.accept(MediaType.TEXT_EVENT_STREAM)
.exchange()
.returnResult(new ParameterizedTypeReference<ServerSentEvent<String>>() {})
.getResponseBody()
.collectList()
.block(Duration.ofSeconds(5));
assertThat(events).extracting(ServerSentEvent::event)
.containsExactly("token", "error");
}
}
Le second test simule une erreur survenant à mi-chemin. Le client reçoit le token généré, puis l’événement error, et la réponse conserve le statut 200.
8. Erreurs courantes et quand ne pas utiliser le streaming
- Utiliser
bodyToMono(String.class). Cette approche récupère l’intégralité du corps avant de le retourner, vous perdez donc tous les avantages du streaming. - Le reverse proxy met en buffer la réponse. Nginx met en buffer la réponse par défaut, et le client recevra tous les tokens à la fois à la fin. Vous devez désactiver
proxy_bufferingpour cette route, ou ajouter l’en-têteX-Accel-Buffering: no. La compression gzip au niveau du proxy peut aussi causer une mise en buffer. - Utiliser
.block()dans un contrôleur Spring MVC. Si votre projet utilisespring-boot-starter-web(Servlet), vous pouvez toujours retourner unFluxou utiliserSseEmitter. Ne faites pas.block()puis écrivez par parties, car chaque requête conserverait un thread pendant 30 secondes. - Afficher une sortie structurée avant de la recevoir complètement. Si vous demandez au modèle de retourner du JSON pour le parser ou appeler un outil, les chunks incomplets ne peuvent pas être parsés. Vous pouvez toujours faire du streaming, mais vous devez accumuler assez avant de valider. Si vous n’avez pas besoin d’afficher progressivement, une requête normale (sans streaming) est plus simple.
- Compter l’utilisation des tokens. Par défaut, les chunks en streaming n’ont pas de champ
usage. De nombreuses API supportent une option comme"stream_options": {"include_usage": true}pour envoyer un chunk final supplémentaire avecusageetchoicesvide. Le parser de la section 3 ignore ce chunk sans danger carpath(0)n’existe pas.
Remarque : Ne vous relancez pas automatiquement après avoir envoyé une partie des tokens à l’utilisateur. Une nouvelle tentative génèrera une réponse différente, qui se concaténera à l’ancienne, créant un contenu brouillé. Relancez-vous seulement si l’erreur se produit avant le premier token, par exemple en cas de 429 ou 503 de la part de
retrieve().
9. Conclusion
Le streaming de réponses depuis une LLM API est essentiellement la lecture d’événements Server-Sent Events. Vous lisez chaque ligne data:, extrayez choices[0].delta.content, et arrêtez quand vous rencontrez [DONE]. Le HttpClient du JDK suffit pour les petits outils. Dans un service Spring Boot, WebClient combiné avec Flux vous donne des timeouts entre les tokens et la possibilité d’annuler jusqu’aux services en amont.
Lorsque vous renvoyez le flux au navigateur, vous devez envelopper les tokens dans du JSON pour préserver les espaces et les sauts de ligne. Vous devez aussi envoyer explicitement les événements done et error, car le code de statut est fixé dès le premier byte. Enfin, vérifiez votre reverse proxy pour vous assurer qu’il ne met pas silencieusement en buffer toute la réponse.