Artículo

Una guía de sesiones de memoria a corto plazo en Spring AI

Una guía de sesiones de memoria a corto plazo en Spring AI

1. Visión general

Los modelos de lenguaje grande son sin estado, por lo que cada solicitud que enviamos es independiente a menos que reproduzcamos la conversación anterior nosotros mismos. Spring AI ha resuelto esto tradicionalmente con ChatMemory, pero a medida que las conversaciones crecen, reproducir cada mensaje de manera ingenua rápidamente supera la ventana de contexto del modelo.

En este tutorial, exploraremos Spring AI Session, una capa de memoria a corto plazo basada en eventos que almacena el historial de conversación y lo reduce inteligentemente cuando se vuelve demasiado grande. Crearemos e inspeccionaremos sesiones, conectaremos la memoria a un ChatClient mediante un asesor, y revisaremos las estrategias de compactación disponibles.

2. ¿Qué son las sesiones de memoria?

Una sesión es un contenedor para una sola conversación, identificada por un ID y opcionalmente vinculada a un usuario. En lugar de almacenar una lista plana de mensajes, registra una secuencia ordenada de objetos SessionEvent, cada uno envuelve un Message con una marca de tiempo, un ID único e información opcional de rama.

La biblioteca agrupa estos eventos en turnos. Un turno es un mensaje del usuario más cada respuesta del asistente, llamada a herramienta y resultado de herramienta que lo siguen, hasta el siguiente mensaje del usuario. Los turnos son la unidad atómica que la biblioteca nunca divide.

Ese último punto es la mejora clave sobre la antigua API ChatMemory. Cuando el historial crece demasiado, Spring AI Session lo compacta a lo largo de los límites de turno en lugar de eliminar los mensajes individuales más antiguos. De esta manera, nunca terminamos con una llamada a herramienta colgante cuyo resultado fue descartado. Para referencia, un MessageWindowChatMemory limitado a 20 mensajes se convierte en un TurnCountTrigger(20) emparejado con una estrategia de ventana deslizante.

Cada evento es inmutable y tiene una marca de tiempo. Esto permite que la misma sesión admita escenarios más avanzados, como aislar los historiales de agentes cooperativos mediante etiquetas de rama.

Las sesiones están actualmente en incubación en el proyecto spring-ai-community y están previstas para reemplazar a ChatMemory en una futura versión de Spring AI.

3. Configuración del proyecto

La API Session requiere Spring AI 2.x y Spring Boot 4.x. Añadimos el módulo principal, que incluye el repositorio en memoria y todos los bloques de construcción de compactación:

<dependency>
    <groupId>org.springaicommunity</groupId>
    <artifactId>spring-ai-session-management</artifactId>
    <version>0.5.0</version>
</dependency>

La última versión de spring-ai-session-management está disponible en el repositorio Maven.

Para comunicarnos con un modelo, añadimos un iniciador de chat de Spring AI. La capa de sesión es independiente del proveedor, por lo que OpenAI, Anthropic o un modelo local funcionan igual de bien. Aquí, usamos spring-ai-starter-model-google-genai con nuestra clave API de Gemini:

spring.ai.google.genai.api-key=${GEMINI_API_KEY}
spring.ai.google.genai.chat.options.model=gemini-3.5-flash

Para producción, normalmente cambiaríamos el almacén en memoria por uno relacional. Al añadir el iniciador spring-ai-starter-session-jdbc, se configura automáticamente un repositorio JDBC para PostgreSQL, MySQL, MariaDB o H2. En este tutorial, nos quedaremos con el repositorio en memoria para mantener los ejemplos auto-contenidos.

4. Creación y gestión de sesiones

Un SessionService es el punto de entrada para toda la API. Comenzamos exponiéndolo como un bean respaldado por un InMemorySessionRepository:

@Bean
public SessionService sessionService() {
    return DefaultSessionService.builder()
      .sessionRepository(InMemorySessionRepository.builder().build())
      .build();
}

Creemos una sesión, añadamos un par de mensajes y léanlos de nuevo como mensajes simples de Spring AI:

@Test
void givenSession_whenAppendingMessages_thenStoredInOrder() {
    Session session = sessionService.create(CreateSessionRequest.builder()
      .userId("alice")
      .build());

    sessionService.appendMessage(session.id(), new UserMessage("What is Spring AI?"));
    sessionService.appendMessage(session.id(),
      new AssistantMessage("It's an application framework for AI engineering."));

    List<Message> messages = sessionService.getMessages(session.id());
    assertThat(messages).hasSize(2);
    assertThat(messages.get(0).getMessageType()).isEqualTo(MessageType.USER);
    assertThat(messages.get(1).getMessageType()).isEqualTo(MessageType.ASSISTANT);
}

Para un acceso de nivel inferior, getEvents() devuelve la corriente más rica de SessionEvent, incluyendo marcas de tiempo y metadatos. El servicio también nos permite buscar una conversación más tarde con findById() o listar todo para un usuario a través de findByUserId() . Finalmente, podemos establecer un tiempo de vida útil en CreateSessionRequest para que las sesiones obsoletas expiren automáticamente.

5. Uso del SessionMemoryAdvisor

Gestionar el servicio manualmente es útil, pero la mayoría de las aplicaciones desean que la memoria funcione de forma transparente, al igual que los asesores que utilizamos al construir un asistente de IA. El SessionMemoryAdvisor conecta la sesión con la tubería del ChatClient, cargando el historial antes de cada llamada y añadiendo el nuevo intercambio después. Es un asesor estándar de Spring AI.

5.1. Configuración del bean del asesor

Ahora, exponemos el asesor como un bean, adjuntando un disparador de compactación y una estrategia:

@Bean
public SessionMemoryAdvisor sessionMemoryAdvisor(SessionService sessionService) {
    return SessionMemoryAdvisor.builder(sessionService)
      .defaultUserId("alice")
      .compactionTrigger(new TurnCountTrigger(20))
      .compactionStrategy(SlidingWindowCompactionStrategy.builder()
          .maxEvents(10)
          .build())
      .build();
}

Esta configuración mantiene los diez eventos más recientes y compacta una vez que una sesión supera veinte turnos.

5.2. Conexión con ChatClient

A continuación, registramos el asesor como predeterminado en un ChatClient, y luego identificamos la conversación con el parámetro session-ID en cada llamada:

@Component
public class ChatService {

    private final ChatClient chatClient;

    public ChatService(ChatModel chatModel, SessionMemoryAdvisor sessionMemoryAdvisor) {
        this.chatClient = ChatClient.builder(chatModel)
          .defaultAdvisors(sessionMemoryAdvisor)
          .build();
    }

    public String chat(String sessionId, String prompt) {
        return chatClient.prompt()
          .user(prompt)
          .advisors(a -> a.param(SessionMemoryAdvisor.SESSION_ID_CONTEXT_KEY, sessionId))
          .call()
          .content();
    }
}

El asesor almacena cada intercambio bajo ese ID de sesión, por lo que una pregunta de seguimiento se resuelve contra el contexto anterior. En la prueba de abajo, compartimos un nombre en el primer mensaje, luego lo pedimos de nuevo en un segundo. La afirmación confirma que la respuesta sigue recordando el nombre, demostrando que la sesión preservó el contexto a través de los dos turnos:

@Test
void givenSessionId_whenChattingAcrossTurns_thenContextIsRemembered() {
    chatService.chat("session-abc", "My name is Yadier, remember it.");

    String response = chatService.chat("session-abc", "What is my name?");

    assertThat(response).containsIgnoringCase("Yadier");
    assertThat(sessionService.getMessages("session-abc")).hasSize(4);
}

La segunda llamada responde con el nombre recordado, lo que confirma que el asesor reprodujo el primer turno de la sesión antes de llamar al modelo.

6. Estrategias de compactación

Un disparador de compactación decide cuándo reducir el historial, mientras que una estrategia decide cómo. Además de TurnCountTrigger, podemos disparar en tokens estimados con TokenCountTrigger, o combinar condiciones con CompositeCompactionTrigger.

6.1. Comparación de las estrategias

La biblioteca incluye cuatro estrategias, todas las cuales respetan los límites de turno:

EstrategiaLLM requeridoMejor para
SlidingWindowCompactionStrategyNoChats sensibles al costo, contexto reciente
TurnWindowCompactionStrategyNoMantener los últimos N turnos completos
TokenCountCompactionStrategyNoLímites duros de ventana de contexto
RecursiveSummarizationCompactionStrategySesiones largas que necesitan recuerdo

Las tres primeras estrategias descartan los eventos antiguos de inmediato, por lo que son rápidas y gratuitas --- una buena opción predeterminada cuando solo necesitamos limitar el historial de forma económica. La resumación recursiva, por otro lado, conserva el núcleo de lo que elimina, a costa de una llamada adicional al modelo. En resumen, recurrimos a la resumación recursiva solo cuando necesitamos recordar contexto antiguo, de lo contrario, las estrategias de ventana más baratas son suficientes.

6.2. Ejecutar la compactación manualmente

El SessionMemoryAdvisor ejecuta la compactación automáticamente a medida que la sesión crece, pero también podemos activarla manualmente llamando a compact() directamente. Esto es útil en pruebas o trabajos por lotes donde decidimos exactamente cuándo se reduce el historial. Utilicemos la SlidingWindowCompactionStrategy para mantener solo los eventos más recientes, pasando un disparador a compact() , que devuelve un CompactionResult describiendo lo que archivó:

@Test
void givenMultiTurnConversation_whenCompacting_thenOlderEventsAreArchived() {
    Session session = sessionService.create(CreateSessionRequest.builder()
      .userId("alice")
      .build());
    for (int turn = 1; turn <= 4; turn++) {
        sessionService.appendMessage(session.id(), new UserMessage("Question " + turn));
        sessionService.appendMessage(session.id(), new AssistantMessage("Answer " + turn));
    }

    CompactionResult result = sessionService.compact(session.id(),
      new TurnCountTrigger(2),
      SlidingWindowCompactionStrategy.builder()
        .maxEvents(4)
        .build());

    assertThat(result.eventsRemoved()).isPositive();
    assertThat(sessionService.getEvents(session.id())).hasSameSizeAs(result.compactedEvents());
}

Primero construimos cuatro turnos, luego compactamos la sesión. Finalmente, verificamos que se eliminaron algunos eventos y que el historial almacenado ahora coincide con el resultado compactado. Las afirmaciones confirman que los eventos antiguos fueron archivados y que la sesión ahora contiene solo el conjunto compactado.

6.3. Resumen con un LLM

La resumación recursiva es la única estrategia que necesita un ChatClient. Las otras tres simplemente descartan o usan ventanas de eventos, pero esta pregunta un modelo para escribir un resumen de los eventos que elimina, y luego los reemplaza con un único evento de resumen sintético. Esa llamada adicional al modelo es el precio por mantener el contexto antiguo disponible en forma condensada:

@Test
void givenLongConversation_whenSummarizing_thenOlderEventsAreReplacedBySummary() {
    Session session = sessionService.create(CreateSessionRequest.builder()
      .userId("alice")
      .build());
    for (int turn = 1; turn <= 4; turn++) {
        sessionService.appendMessage(session.id(), new UserMessage("Question " + turn));
        sessionService.appendMessage(session.id(), new AssistantMessage("Answer " + turn));
    }

    ChatClient chatClient = ChatClient.builder(chatModel).build();
    CompactionResult result = sessionService.compact(session.id(),
      new TurnCountTrigger(2),
      RecursiveSummarizationCompactionStrategy.builder(chatClient)
        .maxEventsToKeep(4)
        .build());

    SessionEvent summary = result.compactedEvents().stream()
      .filter(SessionEvent::isSynthetic)
      .findFirst()
      .orElseThrow();

    assertThat(result.eventsRemoved()).isPositive();
    assertThat(summary.getMessage().getText()).isNotBlank();
}

El evento sintético en el conjunto compactado es el resumen generado por el modelo, por lo que la sesión mantiene el núcleo de los turnos descartados en forma condensada.

7. Conclusión

En este artículo, exploramos las sesiones de memoria a corto plazo de Spring AI. Vimos cómo un SessionService almacena las conversaciones como eventos conscientes de turnos. El SessionMemoryAdvisor hace que esa memoria sea transparente para el ChatClient. Finalmente, los disparadores y estrategias modulares mantienen el historial dentro de la ventana de contexto del modelo.

A medida que la API sale de la incubación, está posicionada para convertirse en el reemplazo por defecto de ChatMemory, por lo que invertir en ella ahora nos prepara para el largo plazo.

Como siempre, el código fuente completo está disponible en GitHub.

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.

Compartir artículo