1. Visión general
En una respuesta REST estándar, el servidor espera hasta tener todo el payload antes de enviarlo de vuelta al cliente. Sin embargo, los modelos de lenguaje grandes (LLMs) generan salidas token por token, generalmente tomando un tiempo significativo para producir una respuesta completa.
Esto conduce a latencia al esperar una respuesta completa, especialmente cuando la salida involucra un gran número de tokens. Las respuestas en streaming abordan este problema enviando datos incrementalmente en pequeñas piezas.
En este tutorial, exploraremos cómo usar el ChatClient de Spring AI para devolver una respuesta de chat en streaming en lugar de enviar toda la respuesta de una sola vez.
2. Dependencias Maven
Comencemos añadiendo la dependencia de Spring AI OpenAI dependency a nuestro pom.xml:
<dependency>
<groupId>org.springframework.ai</groupId>
<artifactId>spring-ai-starter-model-openai</artifactId>
<version>1.0.2</version>
</dependency>
Necesitaremos un contenedor web para ilustrar el streaming de respuestas de chat. Podríamos elegir entre la dependencia spring-boot-starter-web o spring-boot-starter-webflux:
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-webflux</artifactId>
</dependency>
3. Componentes comunes
Antes de explorar diferentes enfoques de streaming, creemos un componente común para las secciones siguientes. La clase ChatRequest contiene la carga útil de nuestra llamada API:
public class ChatRequest {
@NotNull
private String prompt;
// constructor, getter y setter
}
Para las secciones siguientes, enviaremos la siguiente solicitud de chat a nuestros endpoints. Esto es intencional para que el modelo de chat produzca una respuesta larga y podamos demostrar el streaming:
{
"prompt": "Cuéntame una historia sobre una chica que ama a un chico, alrededor de 250 palabras"
}
Ahora, estamos listos y preparados para avanzar a los diferentes enfoques de streaming.
4. Streaming como Palabras
Para ofrecer una experiencia más realista, no queremos esperar a que la respuesta completa antes de devolverla al cliente. Podríamos enviar la respuesta al cliente en streaming. Spring AI transmite la respuesta de chat palabra por palabra por defecto.
Vamos a crear un ChatService para habilitar la respuesta de chat en streaming desde el ChatClient. El punto principal aquí es llamar a stream() y devolver la respuesta como un Flux<String>:
@Component
public class ChatService {
private final ChatClient chatClient;
public ChatService(ChatModel chatModel) {
this.chatClient = ChatClient.builder(chatModel)
.build();
}
public Flux<String> chat(String prompt) {
return chatClient.prompt()
.user(userMessage -> userMessage.text(prompt))
.stream()
.content();
}
}
Hay 2 condiciones para habilitar el streaming de la respuesta de chat. Primero, el controlador REST debe devolver un Flux<String> . Segundo, el tipo de contenido de la respuesta debe establecerse en text/event-stream :
@RestController
@Validated
public class ChatController {
private final ChatService chatService;
public ChatController(ChatService chatService) {
this.chatService = chatService;
}
@PostMapping(value = "/chat", produces = MediaType.TEXT_EVENT_STREAM_VALUE)
public Flux<String> chat(@RequestBody @Valid ChatRequest request) {
return chatService.chat(request.getPrompt());
}
}
Ahora, todo está configurado. Podríamos iniciar nuestra aplicación Spring Boot y usar Postman para enviar nuestra solicitud de chat al endpoint REST:

Al ejecutar, podríamos ver que el cuerpo de respuesta se muestra en Postman fila por fila, donde cada fila es un evento enviado por el servidor.
De la respuesta, podemos ver que Spring AI transmite la respuesta palabra por palabra. Esto permite que el cliente comience a consumir los resultados inmediatamente sin esperar la respuesta. De esta manera, ofrece una latencia muy baja, haciendo que los usuarios sientan que se está escribiendo en vivo.
5. Streaming como Fragmentos
Aunque el streaming por palabras es muy receptivo, podría aumentar la sobrecarga significativamente.
Podríamos reducir la sobrecarga recopilando las palabras juntas para formar un fragmento mayor y devolverlo en lugar de una sola palabra. Esto hace que el flujo sea más eficiente y retenga la experiencia de streaming progresivo.
Podemos modificar nuestro método chat() y llamar a transform en Flux<String> para recopilar el contenido hasta que el tamaño del fragmento alcance 100:
@Component
public class ChatService {
private final ChatClient chatClient;
public ChatService(ChatModel chatModel) {
this.chatClient = ChatClient.builder(chatModel)
.build();
}
public Flux<String> chat(String prompt) {
return chatClient.prompt()
.user(userMessage -> userMessage.text(prompt))
.stream()
.content()
.transform(flux -> toChunk(flux, 100));
}
private Flux<String> toChunk(Flux<String> tokenFlux, int chunkSize) {
return Flux.create(sink -> {
StringBuilder buffer = new StringBuilder();
tokenFlux.subscribe(
token -> {
buffer.append(token);
if (buffer.length() >= chunkSize) {
sink.next(buffer.toString());
buffer.setLength(0);
}
},
sink::error,
() -> {
if (buffer.length() > 0) {
sink.next(buffer.toString());
}
sink.complete();
}
);
});
}
}
En esencia, recopilamos cada palabra devuelta por Flux<String> y la añadimos a la StringBuilder. Una vez que el tamaño del búfer alcanza el mínimo de 100 caracteres, vaciamos el búfer como un fragmento para el cliente. Al final del flujo, vaciamos el búfer restante como el fragmento final.
Ahora, si enviamos la solicitud de chat al ChatService modificado, podríamos ver que el contenido en el evento enviado por el servidor tendrá al menos 100 caracteres, excepto el último fragmento:

6. Streaming como JSON
Si queremos transmitir la respuesta de chat en un formato estructurado, podríamos usar JSON delimitado por saltos de línea (NDJSON). NDJSON es un formato de streaming donde cada línea contiene un objeto JSON, y los objetos están separados por un carácter de nueva línea.
Para lograrlo, podríamos instruir al modelo de chat para que devuelva NDJSON añadiendo un mensaje de sistema, junto con un JSON de muestra para asegurar que el modelo entienda completamente el formato requerido y evite confusiones:
@Component
public class ChatService {
private final ChatClient chatClient;
public ChatService(ChatModel chatModel) {
this.chatClient = ChatClient.builder(chatModel)
.build();
}
public Flux<String> chat(String prompt) {
return chatClient.prompt()
.system(systemMessage -> systemMessage.text(
"""
Responde en formato NDJSON.
Cada objeto JSON debe contener alrededor de 100 caracteres.
Formato de objeto JSON de muestra: {"part":0,"text":"Once in a small town..."}
"""))
.user(userMessage -> userMessage.text(prompt))
.stream()
.content()
.transform(this::toJsonChunk);
}
private Flux<String> toJsonChunk(Flux<String> tokenFlux) {
return Flux.create(sink -> {
StringBuilder buffer = new StringBuilder();
tokenFlux.subscribe(
token -> {
buffer.append(token);
int idx;
if ((idx = buffer.indexOf("\n")) >= 0) {
String line = buffer.substring(0, idx);
sink.next(line);
buffer.delete(0, idx + 1);
}
},
sink::error,
() -> {
if (buffer.length() > 0) {
sink.next(buffer.toString());
}
sink.complete();
}
);
});
}
}
El método toJsonChunk() es similar al toChunk() de la sección anterior. La diferencia clave es la estrategia de vaciado. En lugar de vaciar los datos cuando el búfer alcanza el tamaño mínimo, vacía el contenido del búfer al cliente una vez que se encuentra el carácter de nueva línea en el token.
Hagamos otra solicitud de chat para ver los resultados:

Podemos ver que cada línea es un objeto JSON cuyo formato sigue el mensaje de sistema. JSON es ampliamente soportado por diferentes lenguajes de programación, lo que facilita a los clientes parsear y consumirlo cuando llegue el evento.
7. Sin Streaming
Ya hemos explorado diferentes enfoques de streaming. Ahora, echemos un vistazo al enfoque tradicional sin streaming.
Cuando devolvemos una respuesta de chat síncrona con la dependencia Maven spring-boot-starter-web, simplemente invocamos el método call() del ChatClient:
ChatClient chatClient = ...;
chatClient.prompt()
.user(userMessage -> userMessage.text(prompt))
.call()
.content()
Sin embargo, obtendremos la siguiente excepción si hacemos lo mismo con la dependencia spring-boot-starter-webflux:
org.springframework.web.client.ResourceAccessException: I/O error on POST request for "https://api.openai.com/v1/chat/completions": block()/blockFirst()/blockLast() are blocking, which is not supported in thread reactor-http-nio-3
Esto ocurre porque WebFlux es no bloqueante y no permite operaciones bloqueantes como call().
Para lograr la misma respuesta sin streaming en WebFlux, necesitaremos llamar a stream() en el ChatClient y combinar el flujo recolectado en una única respuesta:
@Component
public class ChatService {
private final ChatClient chatClient;
public ChatService(ChatModel chatModel) {
this.chatClient = ChatClient.builder(chatModel)
.build();
}
public Flux<String> chat(String prompt) {
return chatClient.prompt()
.user(userMessage -> userMessage.text(prompt))
.stream()
.content();
}
}
En el controlador, debemos convertir Flux<String> en Mono<String> recolectando las palabras y uniéndolas:
@RestController
@Validated
public class ChatController {
private final ChatService chatService;
public ChatController(ChatService chatService) {
this.chatService = chatService;
}
@PostMapping(value = "/chat")
public Mono<String> chat(@RequestBody @Valid ChatRequest request) {
return chatService.chat(request.getPrompt())
.collectList()
.map(list -> String.join("", list));
}
}
Con este enfoque, podríamos usar el modelo no bloqueante de WebFlux para devolver una respuesta sin streaming.
8. Conclusión
En este artículo, exploramos diferentes enfoques para transmitir respuestas de chat utilizando el ChatClient de Spring AI.
Incluimos streaming por palabras, streaming por fragmentos y streaming por JSON. Con estas técnicas, podemos reducir significativamente la latencia al devolver una respuesta de chat al cliente y mejorar la experiencia del usuario.
Newsletter Semanal de Java
Cada viernes recibe lo más nuevo del ecosistema Java: frameworks, herramientas y mejores prácticas.
Sin spam. Cancela cuando quieras.