DEV Community

Solon Framework
Solon Framework

Posted on

Building Conversational Memory in AI Agents with Solon's ChatSession API

If you've built any LLM-powered application, you've hit this wall: large language models are stateless by design. Every API call is a fresh conversation. The model doesn't remember what you said five messages ago — unless you tell it.

The standard approach is "multi-message prompting": stuff the entire conversation history into each request. But managing that history — trimming old messages, persisting across sessions, handling concurrent users — gets messy fast.

I've been working with Solon's AI module (solon-ai) for a few months, and its ChatSession abstraction solves this cleanly. Let me walk through how it works.

The Problem: Stateless LLMs

When you call chatModel.prompt("Hello").call(), the model has no context. If you ask "What did I just say?", it won't know. The model only sees the current message.

To maintain context, you need to pass previous messages as part of the prompt. This is where ChatSession comes in.

The Solution: ChatSession

Solon's ChatSession interface manages a sequence of messages for a conversation. It records every input and output, and automatically feeds the history back into subsequent prompts.

import org.noear.solon.ai.chat.ChatSession;
import org.noear.solon.ai.chat.session.InMemoryChatSession;

ChatSession chatSession = InMemoryChatSession.builder()
        .maxMessages(10)
        .sessionId("session-1")
        .build();
Enter fullscreen mode Exit fullscreen mode

Usage with synchronous calls

chatModel.prompt("Hello, I'm looking for a laptop recommendation.")
         .session(chatSession)                     // Auto-records history
         .options(o -> {
            o.systemPrompt("You are a helpful tech support agent.");
         })
         .call();                                   // First response
Enter fullscreen mode Exit fullscreen mode

Usage with streaming

chatModel.prompt("What did I ask you about?")
         .session(chatSession)                     // Includes all previous messages
         .stream()                                 // Streams response token by token
         .filter(resp -> resp.hasContent())
         .forEach(resp -> System.out.print(resp.getContent()));
Enter fullscreen mode Exit fullscreen mode

The .session(chatSession) call is the key. It tells the model: "Use this session's history as context, and record this interaction too."

Three Built-in Implementations

Solon ships with three ChatSession implementations, each suited for different scenarios:

Implementation Storage Best For Notes
InMemoryChatSession Local memory (Map) Development, unit tests, low-frequency demos Ephemeral — lost on restart
FileChatSession Local file system Desktop apps, CLI agents, single-machine tools Persistent to disk — survives restarts
RedisChatSession Redis database Distributed environments, production, high concurrency Shared across nodes

InMemoryChatSession

Best for single-server scenarios where you don't need persistence:

ChatSession session = InMemoryChatSession.builder()
        .maxMessages(20)
        .sessionId("user-123")
        .build();
Enter fullscreen mode Exit fullscreen mode

FileChatSession

For applications that need to survive restarts — like a CLI tool or desktop app:

// FileChatSession persists conversation history to disk
// Automatically reads/writes from a local file path
Enter fullscreen mode Exit fullscreen mode

RedisChatSession

For production deployments with multiple instances:

// RedisChatSession shares conversation state across all nodes
// Perfect for load-balanced web applications
Enter fullscreen mode Exit fullscreen mode

Web Integration: Full Example with SSE

Here's where it gets practical. A complete web endpoint that maintains per-user conversation memory with Server-Sent Events streaming:

import org.noear.solon.ai.chat.ChatModel;
import org.noear.solon.ai.chat.ChatSession;
import org.noear.solon.ai.chat.session.InMemoryChatSession;
import org.noear.solon.annotation.Controller;
import org.noear.solon.annotation.Header;
import org.noear.solon.annotation.Inject;
import org.noear.solon.annotation.Mapping;
import org.noear.solon.web.sse.SseEvent;
import reactor.core.publisher.Flux;

import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;

@Controller
public class ChatController {
    @Inject
    ChatModel chatModel;

    final Map<String, ChatSession> sessionMap = new ConcurrentHashMap<>();

    @Mapping("chat")
    public Flux<SseEvent> chat(@Header("sessionId") String sessionId, String prompt) {
        // Get or create session per user
        ChatSession chatSession = sessionMap.computeIfAbsent(sessionId,
                k -> InMemoryChatSession.builder()
                        .sessionId(k)
                        .maxMessages(20)
                        .build());

        // Stream response with memory
        return chatModel.prompt(prompt)
                    .session(chatSession)
                    .options(o -> {
                        o.systemPrompt("You are a helpful assistant.");
                     })
                    .stream()
                    .filter(resp -> resp.hasContent())
                    .map(resp -> new SseEvent().data(resp.getContent()));
    }
}
Enter fullscreen mode Exit fullscreen mode

The sessionMap.computeIfAbsent pattern ensures each sessionId gets its own conversation context. You could swap InMemoryChatSession for FileChatSession or RedisChatSession without changing anything else.

Serialization: For Custom Persistence

If you need to build your own persistence layer (database, S3, etc.), Solon provides serialization utilities for ChatMessage:

import org.noear.solon.ai.chat.message.ChatMessage;

// Single message: to/from JSON
String json = ChatMessage.toJson(message);
ChatMessage msg = ChatMessage.fromJson(json);

// Batch: to/from NDJSON
String ndjson = ChatMessage.toNdjson(messages);
List<ChatMessage> batch = ChatMessage.fromNdjson(ndjson);
Enter fullscreen mode Exit fullscreen mode

These methods are available since v3.8.0 and make it straightforward to store conversation history in any database.

The ChatSession Interface

For reference, here's the full interface (available in org.noear.solon.ai.chat):

public interface ChatSession {
    String getSessionId();
    List<ChatMessage> getMessages();

    List<ChatMessage> getLatestMessages(int windowSize);
    void removeLatestMessage(int windowSize);

    // Convenience methods
    default void addMessage(String userMessage) {
        addMessage(ChatMessage.ofUser(userMessage));
    }
    default void addMessage(ChatMessage... messages) {
        addMessage(Arrays.asList(messages));
    }
    default void addMessage(Prompt prompt) {
        addMessage(prompt.getMessages());
    }
    void addMessage(Collection<? extends ChatMessage> messages);

    boolean isEmpty();
    void clear();

    // Temporary attributes (not persisted)
    Map<String, Object> attrs();
}
Enter fullscreen mode Exit fullscreen mode

Practical Tips

  1. Set maxMessages wisely — too few breaks context, too many blows up token usage. Start with 10-20 and adjust based on your use case.

  2. Use sessionId for multi-tenancy — map one session per user/thread to keep conversations isolated.

  3. Combine with system prompts — the .options(o -> o.systemPrompt("...")) call sets the model's persona, while .session() handles the conversation history.

  4. Production deployments — swap InMemoryChatSession for RedisChatSession when you scale beyond a single instance.

Summary

Solon's ChatSession API solves the "stateless LLM" problem with a clean, minimal abstraction. You get three implementations out of the box (in-memory, file, Redis), a simple builder pattern, and seamless integration with both synchronous and streaming calls.

The web integration example above is production-ready — just add Redis and you have a horizontally scalable chat service with full conversation memory.


All code examples are based on Solon v4.0.3. Official documentation: solon.noear.org/article/925

Top comments (0)