Streaming de resposta da LLM API com Java 21 e Spring Boot
1. Visão geral
Uma resposta longa de um LLM pode levar 10–30 segundos para ser gerada. Se você esperar a frase inteira pronta antes de devolver, o usuário verá uma tela em branco o tempo todo. Streaming permite receber cada token assim que o modelo o gera, então o primeiro caractere geralmente aparece em menos de um segundo.
Este artigo guia você a ler stream de uma API compatível com OpenAI Chat Completions usando java.net.http.HttpClient, depois usando Spring WebClient. A seguir, você vai encaminhar o stream para o navegador via Server-Sent Events (SSE) e escrever testes para todo o fluxo.
2. Formato do stream da LLM API
Quando você envia uma requisição com "stream": true, a API não retorna um único JSON. Em vez disso, ela retorna Content-Type: text/event-stream, e cada evento é uma linha 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]
Há três pontos que você precisa lembrar:
- O texto está em
choices[0].delta.content. Alguns chunks não têm este campo, por exemplo o primeiro chunk só temrole, ou o último chunk só temfinish_reason. - Os eventos são separados por uma linha em branco.
data: [DONE]sinalizando que o stream terminou. Este valor não é JSON, então você não deve passá-lo para Jackson.
3. Separar a lógica de parse em uma classe
A lógica de parse deve ficar em uma classe independente para facilitar testes. Esta classe só precisa de Jackson, que já vem incluído em qualquer Spring Boot starter web:
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();
/** Recebe uma linha SSE completa, por exemplo: data: {...} */
public Optional<String> parseLine(String line) {
if (!line.startsWith("data:")) {
return Optional.empty();
}
return parsePayload(line.substring(5).trim());
}
/** Recebe a parte payload apó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 inválido: " + payload, e);
}
}
}
Como esta classe funciona:
parseLineignora linhas em branco e linhas comoevent:ou: keep-alive.path(...)do Jackson não lança exception quando o nó está ausente. Se o chunk não temcontent, o resultado éOptional.empty().[DONE]é bloqueado antes de chamarreadTree, então não causa erro de parse.
4. Streaming com java.net.http.HttpClient
Se você só precisa de um CLI ou um job simples, o HttpClient nativo do JDK é suficiente. BodyHandlers.ofLines() retorna um Stream<String> que é lido gradualmente conforme os dados chegam da rede, em vez de esperar que o corpo todo seja carregado:
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": "Giải thích quan hệ Order và Customer trong 3 câu"}]}
""";
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();
}
}
Explicação das linhas principais:
- O status code deve ser verificado primeiro. Quando a API retorna um erro (401, 429…), o corpo é JSON de erro, não SSE.
takeWhileinterrompe o stream quando encontra[DONE]. O blocotry-with-resourcesfecha o stream e a conexão é liberada.System.out.flush()faz o texto aparecer imediatamente. Sem essa linha, o terminal pode agrupar o output e imprimir depois.
O resultado é o texto aparecendo gradualmente no terminal:
Một Customer có thể đặt nhiều Order, còn mỗi Order chỉ thuộc về đúng một Customer...
Nota:
timeout(...)deHttpRequestse aplica apenas até receber os headers de resposta. Ele não limita o tempo de leitura do corpo. Se o modelo ‘travar’ no meio, o loop vai esperar indefinidamente. Esta é uma razão para mudar paraWebClientem aplicações reais.
5. Streaming com Spring WebClient
Em um serviço Spring Boot, WebClient te dá um Flux<String> com suporte a backpressure, timeout e cancelamento. Você precisa adicionar a dependência:
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-webflux</artifactId>
</dependency>
A configuração da conexão é colocada em application.properties:
llm.base-url=${LLM_BASE_URL}
llm.api-key=${LLM_API_KEY}
llm.model=gpt-4o-mini
A seguir, declare o 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();
}
}
O service chama a API e retorna um Flux contendo os 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("Client cancelou o stream para a pergunta: {}", question));
}
}
Cada passo na cadeia de processamento:
bodyToFlux(SSE_TYPE)para Spring separar automaticamente os eventos por linhas em branco e remover o prefixodata:. Você não precisa lidar com o buffer manualmente.takeWhilecompleta oFluxquando encontra[DONE].timeout(Duration.ofSeconds(30))se aplica ao intervalo entre dois elementos consecutivos, incluindo o primeiro. Se não houver um novo token em 30 segundos,Fluxreporta umTimeoutException. Isso é o que falta emHttpClientna seção 4.- Quando a API retorna 4xx/5xx,
retrieve()lançaWebClientResponseExceptionantes de qualquer token.
5.1. Salvar a resposta completa após o stream terminar
Você geralmente precisa salvar a resposta completa para registrar histórico ou calcular custos. StringBuilder deve ser criado separadamente para cada assinatura. Portanto, você envolve a cadeia de processamento em Flux.defer:
public Flux<String> streamAndRecord(String question) {
return Flux.defer(() -> {
StringBuilder full = new StringBuilder();
return stream(question)
.doOnNext(full::append)
.doOnComplete(() -> log.info("Resposta completa ({} caracteres): {}",
full.length(), full));
});
}
Se StringBuilder for declarado fora de defer, duas requisições simultâneas vão escrever no mesmo buffer. Este erro é difícil de detectar em testes manuais porque você geralmente envia apenas uma requisição por vez.
6. Encaminhar o stream para o navegador
O controller retorna Flux<ServerSentEvent<...>> com 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("Modelo está ocupado, tente novamente."))
.event("error").build()));
}
}
Você pode testar o endpoint com curl -N. A flag -N desativa o buffer do curl:
curl -N "http://localhost:8080/api/chat/stream?q=Order%20la%20gi"
event:token
data:{"text":"Order"}
event:token
data:{"text":" là"}
event:token
data:{"text":" đơn hàng"}
event:done
data:{"text":""}
Há três decisões de design dignas de nota:
- Os tokens são encapsulados em JSON (
ChatChunk), não são enviados como strings brutas. De acordo com a especificação SSE, o receptor descarta um espaço logo apósdata:. Um token" là"enviado como string bruta se tornaria"là"no navegador, e as palavras ficariam juntas. Um token contendo\ntambém seria dividido em várias linhasdata:. Encapsular em JSON evita ambos os erros. - Os erros são enviados como um evento
error. Quando o primeiro byte foi enviado, o status 200 já está nos headers. Você não pode mudar para 500 mais. O cliente deve ouvir o eventoerror. - O evento
doneé enviado explicitamente.EventSourcedo navegador se reconecta automaticamente quando o stream fecha. O cliente precisa do eventodonepara saber que a resposta está completa e chamarclose()por si mesmo.
Quando o usuário fecha a aba, WebFlux cancela a assinatura. O sinal de cancelamento se propaga para WebClient, Reactor Netty fecha a conexão com a LLM API, e o provedor para de gerar tokens. Dessa forma, você não paga pela parte da resposta que ninguém lê.
7. Escrever testes
7.1. Testar o parser
class ChatChunkParserTest {
private final ChatChunkParser parser = new ChatChunkParser();
@Test
void givenContentChunk_whenParseLine_thenReturnsToken() {
String line = "data: {\"choices\":[{\"index\":0,\"delta\":{\"content\":\" chào\"}}]}";
assertThat(parser.parseLine(line)).contains(" chào");
}
@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 inválido");
}
}
O primeiro teste confirma que o espaço no início do token é preservado. Este é exatamente o detalhe mais fácil de perder em todo o fluxo.
7.2. Testar o controller com WebTestClient
Você faz mock de ChatStreamService para que o teste não chame a API real. @MockitoBean está disponível desde Spring Boot 3.4. Em versões mais antigas, você usa @MockBean.
@WebFluxTest(ChatController.class)
class ChatControllerTest {
@Autowired
WebTestClient webTestClient;
@MockitoBean
ChatStreamService chatStreamService;
@Test
void whenStreaming_thenSendsTokensThenDone() {
when(chatStreamService.streamAndRecord("hi"))
.thenReturn(Flux.just("Xin", " chào", "!"));
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\":\" chào\"}");
}
@Test
void whenUpstreamFails_thenSendsErrorEvent() {
when(chatStreamService.streamAndRecord("hi"))
.thenReturn(Flux.concat(Flux.just("Xin"),
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");
}
}
O segundo teste simula um erro ocorrendo no meio. O cliente recebe tokens já gerados, depois o evento error, e a resposta ainda tem status 200.
8. Erros comuns e quando não fazer streaming
- Usar
bodyToMono(String.class). Essa abordagem agrega o corpo inteiro antes de retornar, então você perde todos os benefícios do streaming. - Reverse proxy bufferiza a resposta. Nginx bufferiza a resposta por padrão, e o cliente receberá todos os tokens de uma vez no final. Você precisa desativar
proxy_bufferingpara esta rota ou adicionar o headerX-Accel-Buffering: no. A compressão gzip no nível de proxy também pode causar bufferização. - Usar
.block()em controller Spring MVC. Se o projeto usarspring-boot-starter-web(Servlet), você ainda pode retornarFluxou usarSseEmitter. Não façablock()e depois escreva parte por parte, pois cada requisição ocupará uma thread por 30 segundos. - Exibir saída estruturada quando ainda não recebeu o suficiente. Se você pedir ao modelo para retornar JSON para análise ou chamar uma ferramenta, chunks incompletos não podem ser analisados. Neste caso, você ainda pode fazer stream, mas precisa reunir o suficiente antes de validar. Se não precisar exibir gradualmente, uma requisição comum (sem stream) é mais simples.
- Calcular uso de tokens. Por padrão, chunks durante streaming não têm o campo
usage. Muitas APIs suportam uma opção como"stream_options": {"include_usage": true}para enviar um chunk final adicional comusageechoicesvazio. O parser da seção 3 ignora este chunk com segurança porquepath(0)não existe.
Nota: Você não deve fazer retry automático quando já enviou parte dos tokens para o usuário. Retry gerará uma resposta diferente, que se juntará à parte antiga e criará conteúdo embaralhado. Só faça retry quando o erro ocorrer antes do primeiro token, por exemplo ao encontrar 429 ou 503 de
retrieve().
9. Conclusão
Fazer streaming de resposta de um LLM API é essencialmente ler Server-Sent Events. Você lê cada linha data:, extrai choices[0].delta.content e para quando encontra [DONE]. HttpClient do JDK é suficiente para ferramentas pequenas. Em um serviço Spring Boot, WebClient combinado com Flux lhe dá timeout entre tokens e a capacidade de cancelamento que se propaga para upstream.
Ao encaminhar o stream para o navegador, você deve encapsular tokens em JSON para preservar espaços e quebras de linha. Você também deve enviar explicitamente os eventos done e error, pois o código de status é fixado desde o primeiro byte. Finalmente, verifique o reverse proxy para garantir que ele não bufferiza silenciosamente a resposta inteira.