Inicio / Artículos / Fundamentos de Spring AI: ChatClient, streaming, memoria y herramientas con Ollama

Fundamentos de Spring AI: ChatClient, streaming, memoria y herramientas con Ollama

Arranca una app de IA con Spring Boot usando Spring AI y un modelo local de Ollama: ChatClient, plantillas de prompt, streaming, salida estructurada, memoria de chat y llamadas a herramientas.

1888 palabras

Imagina una funcionalidad de IA construida hoy sobre OpenAI. El trimestre que viene la factura crece, el equipo quiere probar Ollama en los portátiles o la empresa se estandariza en Azure OpenAI, Bedrock o Gemini. Sin una capa de abstracción, cambiar de proveedor puede propagarse por todo el código: configuración del cliente, forma de las peticiones, código de streaming, embeddings, definiciones de herramientas y el modo de parsear las respuestas. Spring AI ofrece a los desarrolladores de Spring Boot un único conjunto de APIs para todos los proveedores, y así ese impacto se queda pequeño.

Aquí nos centramos en lo básico y práctico: qué ofrece el framework, la configuración del proyecto, la configuración de Ollama, ChatClient, plantillas de prompt, streaming, salida estructurada, memoria de chat y llamadas a herramientas. La recuperación, los vector stores, el ETL, el filtrado, la observabilidad, la evaluación, la entrada multimodal, MCP y los consejos para producción quedan para otro tema.

Todos los ejemplos corren sobre Ollama, así que nadie necesita una API key para seguirlos. El proyecto de ejemplo usa Spring Boot 3.5.5 y Spring AI 1.0.9. También existe una línea estable Spring AI 2.0.x, pero está pensada para Spring Boot 4.0.x y 4.1.x, por lo que no conviene mezclar ambos conjuntos de versiones en una misma build.

Vocabulario antes del código

Para quien empieza, la terminología puede costar más que el código. Estos son los términos que aparecen más abajo.

  • LLM (modelo de lenguaje grande): el modelo que genera texto. GPT, Claude, Gemini y modelos locales como Llama entran en esta categoría. Entra texto y el modelo predice texto útil a la salida.
  • Modelo: el motor concreto al que se llama. Aquí lo ejecuta Ollama en local. En la nube podría estar en OpenAI, Anthropic, Azure OpenAI, Amazon Bedrock o Google Vertex AI.
  • Prompt: la instrucción o pregunta que se envía al modelo, por ejemplo «Explain Spring AI in one paragraph» (explica Spring AI en un párrafo). Los prompts claros y concretos, con suficiente contexto, obtienen mejores respuestas.
  • Token: una pequeña unidad de texto, que puede ser una palabra, parte de una palabra, un signo de puntuación o un espacio. Los precios, el tamaño del contexto y la longitud de salida se cuentan normalmente en tokens.
  • Ventana de contexto: el máximo de texto que el modelo puede tener en cuenta a la vez. El prompt, el historial de chat, los documentos recuperados, los resultados de herramientas y la respuesta cuentan dentro de ella.
  • ChatClient: la API fluida de Spring AI para modelos de chat. En lugar de montar a mano llamadas HTTP específicas de cada proveedor, el código se lee como chatClient.prompt().user(...).call().content().
  • Plantilla de prompt: un prompt reutilizable con marcadores. «Explain {topic} in {language}» se convierte en tiempo de ejecución en «Explain RAG in Java».
  • Salida estructurada: pedir al modelo datos con una forma predecible y mapearlos a un record o clase de Java. Útil cuando la aplicación necesita campos y no prosa.
  • Memoria de chat: el historial de la conversación que gestiona la aplicación. Los modelos no guardan estado por defecto, así que los mensajes anteriores hay que reenviarlos cada vez que el modelo deba recordarlos.
  • Advisor: un componente de Spring AI que modifica o enriquece una petición antes de que llegue al modelo. Un memory advisor, por ejemplo, añade los mensajes anteriores, y un RAG advisor, del que se hablará más adelante, añadiría documentos recuperados.
  • Llamada a herramientas: el modelo pide a la aplicación que ejecute un método de Java. Nunca accede por sí mismo a una base de datos o una API. Solicita la herramienta, la aplicación la ejecuta y el resultado vuelve al modelo.

Qué incluye Spring AI

El framework es más que un envoltorio de chat completions. Para equipos Java funciona como un framework de aplicaciones completo para cargas de IA. Sus piezas principales:

  • APIs de modelo portables para chat, embeddings, imágenes, voz a texto, texto a voz y moderación
  • integraciones con OpenAI, Anthropic, Azure OpenAI, Amazon Bedrock, Google Vertex AI y Gemini, Mistral, Ollama y más
  • ChatClient, una API fluida al estilo de WebClient y RestClient
  • plantillas de prompt y tipos de mensaje para contenido de sistema, usuario, asistente y multimedia
  • mapeo de las respuestas del modelo a records o clases de Java
  • streaming mediante Project Reactor
  • advisors para patrones recurrentes como la memoria y RAG
  • llamadas a herramientas, para que el modelo pueda solicitar métodos de Java
  • modelos de embeddings detrás de abstracciones de vector store
  • extras orientados a producción: ETL, observabilidad, evaluación y MCP

Es una superficie amplia, así que el recorrido de abajo se ciñe al flujo principal de la aplicación.

Configuración del proyecto

Crea un proyecto Maven con Java 21, Spring Boot 3.5.x, Spring Web y el starter de Spring AI para Ollama. Un bill of materials mantiene todos los módulos de Spring AI en versiones compatibles.

<properties>
    <java.version>21</java.version>
    <spring-ai.version>1.0.9</spring-ai.version>
</properties>
<dependencyManagement>
    <dependencies>
        <dependency>
            <groupId>org.springframework.ai</groupId>
            <artifactId>spring-ai-bom</artifactId>
            <version>${spring-ai.version}</version>
            <type>pom</type>
            <scope>import</scope>
        </dependency>
    </dependencies>
</dependencyManagement>

Para la demo local con Ollama, añade el starter web junto con el de Ollama.

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
    <groupId>org.springframework.ai</groupId>
    <artifactId>spring-ai-starter-model-ollama</artifactId>
</dependency>

Quien prefiera OpenAI puede cambiar el starter del modelo.

<dependency>
    <groupId>org.springframework.ai</groupId>
    <artifactId>spring-ai-starter-model-openai</artifactId>
</dependency>

Una advertencia: deja un solo starter de modelo de chat en el classpath salvo que haya configurado un bean ChatModel principal. Cuando están OpenAI y Ollama a la vez, Spring encuentra dos candidatos, no puede construir el ChatClient.Builder por defecto y el arranque falla.

Ejecutar un modelo local

Instala Ollama y descarga un modelo de chat.

ollama pull llama3.2

Después apunta application.properties hacia él.

spring.application.name=spring-boot-ollama
spring.ai.ollama.base-url=http://localhost:11434
spring.ai.ollama.chat.options.model=llama3.2

Arranca la aplicación.

mvn spring-boot:run

Escucha aquí:

http://localhost:8080

Chat básico con ChatClient

ChatClient es la API de alto nivel con la que deberían empezar la mayoría de aplicaciones Spring.

@RestController
public class SpringAiDemoController {

    private final ChatClient chatClient;

    public SpringAiDemoController(ChatClient.Builder builder) {
        this.chatClient = builder.build();
    }

    @GetMapping("/api/demo/simple")
    public String simpleChat(@RequestParam(defaultValue = "Tell me a joke") String message) {
        return chatClient.prompt()
                .user(message)
                .call()
                .content();
    }
}

Probándolo desde la terminal:

curl "http://localhost:8080/api/demo/simple?message=Explain%20Spring%20AI%20in%20one%20paragraph"

La llamada se desarrolla en cuatro pasos. prompt() abre una petición nueva, user(...) adjunta el mensaje, call() elige la ejecución bloqueante y content() devuelve el texto generado.

Plantillas de prompt

Los prompts escritos a fuego en el código se vuelven difíciles de mantener. Una plantilla guarda texto reutilizable con parámetros.

@GetMapping("/api/demo/template")
public String templateChat(
        @RequestParam(defaultValue = "Microservices") String topic,
        @RequestParam(defaultValue = "Java") String language) {

    PromptTemplate template = new PromptTemplate(
            "Explain the concept of {topic} using examples in {language}. Keep it brief.");

    String renderedPrompt = template.render(Map.of(
            "topic", topic,
            "language", language
    ));

    return chatClient.prompt()
            .user(renderedPrompt)
            .call()
            .content();
}

Llamando al endpoint con un tema y un idioma:

curl "http://localhost:8080/api/demo/template?topic=Retrieval-Augmented%20Generation&language=Java"

Las plantillas encajan mejor cuando el prompt forma parte del comportamiento de la aplicación y no es una cadena puntual.

Streaming

Las respuestas largas conviene enviarlas en streaming, para que el usuario vea la salida enseguida en lugar de esperar al texto completo.

@GetMapping(value = "/api/demo/stream", produces = "text/event-stream")
public Flux<String> streamChat(
        @RequestParam(defaultValue = "Write a short story about a brave robot.") String message) {return chatClient.prompt()
            .user(message)
            .stream()
            .content();
}

Pruébalo en el navegador o con curl en modo sin búfer.

curl -N "http://localhost:8080/api/demo/stream?message=Write%20a%20short%20Spring%20AI%20intro"

Spring MVC emite el Flux de cadenas como Server-Sent Events.

Salida estructurada

El texto sirve para chatear, pero el software de negocio suele necesitar datos estructurados. Spring AI puede pedir una respuesta que se mapee a un tipo de Java. Con modelos locales suele funcionar mejor detallar el formato JSON de forma explícita.

public record MovieReview(String title, double rating, String shortSummary) {
}

@GetMapping("/api/demo/structured")
public MovieReview structuredOutput(@RequestParam(defaultValue = "Inception") String movie) {
    return chatClient.prompt()
            .user(u -> u.text("Review the movie {movie}.")
                    .param("movie", movie))
            .call()
            .entity(MovieReview.class);
}

Pruébalo:

curl "http://localhost:8080/api/demo/structured?movie=The%20Matrix"

Al llamar a entity con la clase del record, Spring AI añade instrucciones de formato al prompt y convierte el JSON devuelto directamente en el record. Es más limpio y fiable que parsear cadenas JSON a mano.

Memoria de conversación

Los modelos no recuerdan las peticiones anteriores, así que la aplicación aporta el historial. Spring AI lo resuelve con abstracciones de memoria y advisors, registrados como predeterminados en el builder del cliente.

this.chatClient = chatClientBuilder
        .defaultAdvisors(
                MessageChatMemoryAdvisor.builder(
                        MessageWindowChatMemory.builder().build()
                ).build()
        )
        .build();

A partir de ahí, cada petición lleva un id de conversación.

@GetMapping("/api/demo/memory")
public String chatWithMemory(
        @RequestParam String message,
        @RequestParam(defaultValue = "chat_session_1") String chatId) {
        return chatClient.prompt()
            .user(message)
            .advisors(a -> a.param(ChatMemory.CONVERSATION_ID, chatId))
            .call()
            .content();
}

Dos llamadas que comparten id muestran el efecto: primero di un nombre y luego pregunta cuál era.

curl "http://localhost:8080/api/demo/memory?chatId=demo&message=My%20name%20is%20Yash"
curl "http://localhost:8080/api/demo/memory?chatId=demo&message=What%20is%20my%20name?"

En producción, sustituye el almacenamiento en memoria por un repositorio persistente como JDBC, Redis, Cassandra, MongoDB o Neo4j.

Llamadas a herramientas

Las herramientas permiten al modelo solicitar trabajo definido por la aplicación. El modelo no tiene acceso directo a una base de datos ni a una API. Lo pide, el código de la aplicación ejecuta la herramienta y el resultado vuelve al modelo.

Una herramienta es un método normal con descripciones.

public static class WeatherTools {
@Tool(description = "Get the current weather for a city")
    public WeatherResponse getCurrentWeather(
            @ToolParam(description = "City name") String city) {
        return new WeatherResponse(city, 22.5, "Sunny");
    }
}
public record WeatherResponse(
        String location,
        double temperatureCelsius,
        String condition) {
}

Se expone como bean.

@Bean
public WeatherTools weatherTools() {
    return new WeatherTools();
}

Luego una petición la habilita.

@GetMapping("/api/demo/function")
public String functionCalling(
        @RequestParam(defaultValue = "What is the weather in Paris?") String message) {
        return chatClient.prompt()
            .user(message)
            .tools(weatherTools)
            .call()
            .content();
}

Preguntar por el tiempo en una ciudad recorre el ciclo completo.

curl "http://localhost:8080/api/demo/function?message=What%20is%20the%20weather%20in%20Paris?"

Buenos usos: consultar inventario, obtener el tiempo, buscar un cliente, abrir un ticket de soporte o lanzar un flujo de trabajo. Las acciones destructivas exigen rigor: añade autorización, validación, auditoría y confirmación del usuario cuando haga falta.

Ejecutar la demo completa

Arranca de nuevo la aplicación.

mvn spring-boot:run

Después prueba los endpoints uno tras otro.

curl "http://localhost:8080/api/demo/simple?message=Explain%20Spring%20AI%20in%20one%20paragraph"
curl "http://localhost:8080/api/demo/template?topic=Retrieval-Augmented%20Generation&language=Java"
curl -N "http://localhost:8080/api/demo/stream?message=Write%20a%20short%20Spring%20AI%20intro"
curl "http://localhost:8080/api/demo/structured?movie=Inception"
curl "http://localhost:8080/api/demo/memory?chatId=demo&message=My%20name%20is%20Yash"
curl "http://localhost:8080/api/demo/memory?chatId=demo&message=What%20is%20my%20name?"
curl "http://localhost:8080/api/demo/function?message=What%20is%20the%20weather%20in%20Paris?"

Errores frecuentes

Añadir varios starters de modelo sin elegir uno provoca este error al arrancar:

No qualifying bean of type 'ChatModel' available: expected single matching bean but found 2

Deja un solo starter en la demo o indica explícitamente un modelo principal.

Usar la memoria de chat sin un id de conversación hace que cada llamada parezca un chat nuevo. Pasa el id como parámetro del advisor.

.advisors(a -> a.param(ChatMemory.CONVERSATION_ID, chatId))

La tercera trampa es exponer herramientas con demasiada libertad. Las herramientas son acciones reales de la aplicación, así que valida sus entradas, aplica autorización y evita operaciones destructivas salvo que exista un flujo de aprobación.

Dónde estamos

Con esto queda montada la base de una aplicación Spring AI: chat, prompts, streaming, salida estructurada, memoria y herramientas. Estas piezas ya cubren muchos escenarios útiles, como asistentes de chat, resumidores, endpoints de extracción estructurada y flujos controlados de llamadas a herramientas. Lo que viene después del chat es una capa de conocimiento basada en embeddings y vector stores, y encima RAG, pipelines ETL, filtros de metadatos, observabilidad, evaluación, entrada multimodal, MCP y el endurecimiento para producción.

Puedes clonar un proyecto de demo completo desde GitHub.

git clone https://github.com/Yash2462/blog-demos.git

Tras clonarlo, abre la carpeta spring-ai-demo y sigue el README que contiene.

Para seguir leyendo

  • la documentación de referencia de Spring AI
  • la guía de primeros pasos de Spring AI
  • la página del modelo de chat de Ollama en la documentación de Spring AI
  • la documentación de ChatClient
  • la documentación de llamadas a herramientas