Add chat-memory module: MessageWindowChatMemory, JDBC and Redis repositories, per-user conversation IDs
Real PostgreSQL 16 and Redis Stack 7.4; 24 tests write output/01-24. Covers the 20-message default window with no property, the 36-character conversation_id, tool messages dropped by JdbcChatMemoryRepository, concurrent add() on one conversation, a 1.x table under the 2.0 repository, the Redis repository silently backing off for a custom ChatMemory, and the removal of PromptChatMemoryAdvisor. Co-Authored-By: Claude Sonnet 5 <[email protected]> Claude-Session: https://claude.ai/code/session_01Ja4jkzrbQ4LQZBNrb5mkZE
This commit is contained in:
@@ -0,0 +1,16 @@
|
||||
# MessageWindowChatMemory, maxMessages = 4 and 5
|
||||
|
||||
maxMessages = 4. One system message, then one user+assistant turn at a time:
|
||||
after turn 1: S:SYS-1 | U:u1 | A:a1
|
||||
after turn 2: S:SYS-1 | U:u2 | A:a2
|
||||
after turn 3: S:SYS-1 | U:u3 | A:a3
|
||||
after turn 4: S:SYS-1 | U:u4 | A:a4
|
||||
|
||||
A second, different system message arrives:
|
||||
after SYS-2: U:u4 | A:a4 | S:SYS-2
|
||||
|
||||
maxMessages = 5 (odd), no system message:
|
||||
after turn 1: U:u1 | A:a1
|
||||
after turn 2: U:u1 | A:a1 | U:u2 | A:a2
|
||||
after turn 3: U:u2 | A:a2 | U:u3 | A:a3
|
||||
after turn 4: U:u3 | A:a3 | U:u4 | A:a4
|
||||
@@ -0,0 +1,19 @@
|
||||
# MessageChatMemoryAdvisor: what the model is sent
|
||||
|
||||
conv-A, call 1: "My name is Priya"
|
||||
reply: Nice to meet you, Priya.
|
||||
model was sent: S:You are a terse assistant. | U:My name is Priya
|
||||
|
||||
conv-A, call 2: "What is my name?"
|
||||
reply: Your name is Priya.
|
||||
model was sent: S:You are a terse assistant. | U:My name is Priya | A:Nice to meet you, Priya. | U:What is my name?
|
||||
|
||||
conv-B, call 1: "What is my name?" (different conversation ID)
|
||||
reply: I do not know your name yet.
|
||||
model was sent: S:You are a terse assistant. | U:What is my name?
|
||||
|
||||
Stored for conv-A afterwards:
|
||||
USER My name is Priya
|
||||
ASSISTANT Nice to meet you, Priya.
|
||||
USER What is my name?
|
||||
ASSISTANT Your name is Priya.
|
||||
@@ -0,0 +1,5 @@
|
||||
# No conversation ID on the request
|
||||
|
||||
chat.prompt().user("hello").call().content() // no advisors(...) param
|
||||
threw java.lang.IllegalArgumentException
|
||||
message: conversationId cannot be null
|
||||
@@ -0,0 +1,5 @@
|
||||
# ChatMemoryAutoConfiguration, nothing configured
|
||||
|
||||
ChatMemory bean: MessageWindowChatMemory
|
||||
ChatMemoryRepository bean: InMemoryChatMemoryRepository
|
||||
15 turns (30 messages) added -> 20 stored, oldest kept: u6
|
||||
@@ -0,0 +1,7 @@
|
||||
# Guessing a window-size property
|
||||
|
||||
spring.ai.chat.memory.max-messages = 6 -> 30 messages added, 20 stored
|
||||
spring.ai.chat.memory.window-size = 6 -> 30 messages added, 20 stored
|
||||
spring.ai.chat.memory.repository.max-messages = 6 -> 30 messages added, 20 stored
|
||||
|
||||
Spring Boot ignores unknown keys silently, so none of these did anything.
|
||||
@@ -0,0 +1,18 @@
|
||||
# Input tokens per call, 30 turns, four pruning strategies
|
||||
|
||||
Encoding: o200k_base (JTokkit estimate). Each turn: ~25-token question, ~40-token answer, ~6-token system prompt.
|
||||
|
||||
turn unbounded window=10 window=20 budget=400tok
|
||||
1 26 26 26 26
|
||||
2 79 79 79 79
|
||||
5 238 238 238 238
|
||||
10 503 291 503 397
|
||||
11 556 291 556 397
|
||||
15 768 291 556 397
|
||||
20 1033 291 556 397
|
||||
21 1086 291 556 397
|
||||
25 1298 291 556 397
|
||||
30 1563 291 556 397
|
||||
|
||||
sum 23835 7935 13765 10426
|
||||
sum as % of unbounded: window=10 33%, window=20 58%, budget=400tok 44%
|
||||
@@ -0,0 +1,4 @@
|
||||
# TokenBudgetChatMemory, budget = 120 tokens
|
||||
|
||||
three short turns: S:Be terse. | U:short question 1 | A:short answer 1 | U:short question 2 | A:short answer 2 | U:short question 3 | A:short answer 3
|
||||
then one pasted trace: 7 messages kept: S:Be terse. | U:short question 2 | A:short answer 2 | U:short question 3 | A:short answer 3 | U:<pasted stack trace, 89 tokens> | A:Check the datasource URL.
|
||||
@@ -0,0 +1,16 @@
|
||||
# JdbcChatMemoryRepository on PostgreSQL 16
|
||||
|
||||
Table created by initialize-schema=always:
|
||||
conversation_id character varying (36)
|
||||
content text
|
||||
type character varying (10)
|
||||
timestamp timestamp without time zone
|
||||
sequence_id bigint
|
||||
|
||||
After one call, rows for the conversation:
|
||||
seq=0 USER My name is Priya
|
||||
seq=1 ASSISTANT Nice to meet you, Priya.
|
||||
|
||||
New repository + new memory over the same database, then "What is my name?":
|
||||
reply: Your name is Priya.
|
||||
findConversationIds() returns every conversation in the table: [6f0a1c9e-2b7d-4c55-9a53-0d1f3e7a2b10]
|
||||
@@ -0,0 +1,7 @@
|
||||
# conversation_id is VARCHAR(36)
|
||||
|
||||
conversation ID: "user:[email protected]:thread:2026-09-24" (43 characters)
|
||||
model calls made for this request: 0 (the user message is saved before the model is called)
|
||||
thrown: org.springframework.dao.DataIntegrityViolationException
|
||||
root cause: ERROR: value too long for type character varying(36)
|
||||
rows stored for it afterwards: 0
|
||||
@@ -0,0 +1,13 @@
|
||||
# What JdbcChatMemoryRepository.saveAll keeps
|
||||
|
||||
saveAll() given 4 messages:
|
||||
USER Weather in Mumbai?
|
||||
ASSISTANT (tool call weather)
|
||||
TOOL (tool call weather)
|
||||
ASSISTANT 31C and humid.
|
||||
|
||||
findByConversationId() returns 2:
|
||||
USER Weather in Mumbai?
|
||||
ASSISTANT 31C and humid.
|
||||
|
||||
Logged by the repository: WARN JdbcChatMemoryRepository does not support tool call messages. Some messages were filtered out for conversation: tool-conv
|
||||
@@ -0,0 +1,8 @@
|
||||
# Two concurrent add() calls on one conversation
|
||||
|
||||
seeded with: u0, a0
|
||||
two writers each add one message; both read the conversation before either writes it back
|
||||
first writer to save: adds a 3-message list, saved
|
||||
second writer to save: adds a different 3-message list, replaces the first
|
||||
stored afterwards: [u0, a0, <second writer's message>] (3 messages, 4 were sent)
|
||||
the first writer's message is GONE, the second writer's is kept
|
||||
@@ -0,0 +1,7 @@
|
||||
# Two saveAll() calls released at the same instant
|
||||
|
||||
seeded with u0, a0; two writers add from-A and from-B; both saveAll() calls start together
|
||||
The outcome differs from run to run, so the exact count is not printed here. Across repeated runs on
|
||||
PostgreSQL 16 (READ COMMITTED) it is one of two things, and never the 4 messages that were sent:
|
||||
3 messages -> one writer's message is gone
|
||||
6 messages -> both transactions inserted, history is duplicated, sequence_id values collide
|
||||
@@ -0,0 +1,16 @@
|
||||
# A Spring AI 1.x table under the 2.0 repository
|
||||
|
||||
A 1.1.x-shaped table (no sequence_id column) holds 2 rows for "old-conv".
|
||||
|
||||
2.0.1 repository, findByConversationId("old-conv"):
|
||||
ERROR: column "sequence_id" does not exist
|
||||
|
||||
Migration: add the column, backfill it from the timestamp order, then make it NOT NULL:
|
||||
ALTER TABLE SPRING_AI_CHAT_MEMORY ADD COLUMN sequence_id BIGINT
|
||||
UPDATE SPRING_AI_CHAT_MEMORY m SET sequence_id = s.rn FROM (SELECT ctid AS c, row_number() OVER (PARTITION ...
|
||||
ALTER TABLE SPRING_AI_CHAT_MEMORY ALTER COLUMN sequence_id SET NOT NULL
|
||||
CREATE INDEX IF NOT EXISTS SPRING_AI_CHAT_MEMORY_CONVERSATION_ID_SEQUENCE_ID_IDX ON SPRING_AI_CHAT_MEMORY(c...
|
||||
|
||||
findByConversationId("old-conv") after the migration:
|
||||
USER My name is Priya
|
||||
ASSISTANT Nice to meet you, Priya.
|
||||
@@ -0,0 +1,4 @@
|
||||
# Same two writers, per-conversation lock
|
||||
|
||||
stored afterwards: 4 messages
|
||||
contains from-A: true, from-B: true
|
||||
@@ -0,0 +1,16 @@
|
||||
# RedisChatMemoryRepository on Redis Stack
|
||||
|
||||
ChatMemoryRepository bean: RedisChatMemoryRepository
|
||||
call 1 reply: Nice to meet you, Priya.
|
||||
|
||||
Keys under the default prefix "chat-memory:" (3: one JSON document per message, plus a counter):
|
||||
chat-memory:6f0a1c9e-2b7d-4c55-9a53-0d1f3e7a2b10:<n>
|
||||
chat-memory:6f0a1c9e-2b7d-4c55-9a53-0d1f3e7a2b10:<n>
|
||||
chat-memory:counter:6f0a1c9e-2b7d-4c55-9a53-0d1f3e7a2b10
|
||||
|
||||
Fields of one document: [content, conversation_id, metadata, timestamp, type]
|
||||
TTL on each key (spring.ai.chat.memory.repository.redis.time-to-live=24h): between 86390 and 86400 s -> true
|
||||
|
||||
Same conversation, second call "What is my name?":
|
||||
reply: Your name is Priya.
|
||||
stored: U:My name is Priya | A:Nice to meet you, Priya. | U:What is my name? | A:Your name is Priya.
|
||||
@@ -0,0 +1,4 @@
|
||||
# Two concurrent add() calls, Redis repository
|
||||
|
||||
seeded with u0, a0; two writers add one message each, both read before either writes back
|
||||
stored afterwards: [u0, a0, <second writer's message>] (3 messages, 4 were sent)
|
||||
@@ -0,0 +1,11 @@
|
||||
# Redis starter + your own ChatMemory bean
|
||||
|
||||
application has: spring-ai-starter-model-chat-memory-repository-redis, profile "redis", Redis Stack reachable,
|
||||
and its own @Bean ChatMemory (needed to set a window other than 20)
|
||||
|
||||
ChatMemoryRepository bean in the context: InMemoryChatMemoryRepository
|
||||
Redis keys under "chat-memory:" before / after one chat call: 0 / 0
|
||||
|
||||
Condition report for the autoconfigured Redis repository bean:
|
||||
@ConditionalOnMissingBean (types: org.springframework.ai.chat.memory.repository.redis.RedisChatMemoryRepository,org.springframework.ai.chat.memory.ChatMemory,org.springframework.ai.chat.memory.ChatMemoryRepository; SearchStrategy: all) found beans of type 'org.springframework.ai.chat.memory.ChatMemory' chatMemory
|
||||
WARN/ERROR log lines mentioning Redis or ChatMemory, captured over the whole test class: 0
|
||||
@@ -0,0 +1,8 @@
|
||||
# Redis time-to-live = 2s, max-messages-per-conversation = 4
|
||||
|
||||
saved 2 messages; readable now: U:u1 | A:a1
|
||||
3 seconds later: (gone: Redis expired the keys)
|
||||
|
||||
saved 6 messages with max-messages-per-conversation=4, read back 4:
|
||||
U:u1 | A:a1 | U:u2 | A:a2
|
||||
keys in Redis for it: 7
|
||||
@@ -0,0 +1,6 @@
|
||||
# RedisChatMemoryRepository against plain Redis 7.0 (no modules)
|
||||
|
||||
server: redis_version:7.0.15
|
||||
RedisChatMemoryRepository.builder()...initializeSchema(true).build() threw:
|
||||
java.lang.IllegalStateException
|
||||
root cause: JedisDataException: ERR unknown command 'FT._LIST', with args beginning with:
|
||||
@@ -0,0 +1,7 @@
|
||||
# app.ownership.enforce=true (default)
|
||||
|
||||
alice starts a conversation and says "My name is Priya"
|
||||
|
||||
bob: GET /conversations/<alice's id>/messages -> 403
|
||||
bob: POST /conversations/<alice's id>/messages -> 403
|
||||
alice: GET /conversations/<her id>/messages -> 200, 2 messages
|
||||
@@ -0,0 +1,7 @@
|
||||
# app.ownership.enforce=false
|
||||
|
||||
alice starts a conversation and says "My name is Priya"
|
||||
|
||||
bob: GET /conversations/<alice's id>/messages -> 200
|
||||
body: [{"role":"USER","text":"My name is Priya"},{"role":"ASSISTANT","text":"Nice to meet you, Priya."}]
|
||||
bob: POST "What is my name?" -> 200 {"reply":"Your name is Priya."}
|
||||
@@ -0,0 +1,11 @@
|
||||
# Do the chat-memory property keys bind?
|
||||
|
||||
spring.ai.chat.memory.repository.jdbc.initialize-schema=always -> initializeSchema = ALWAYS
|
||||
|
||||
redis.host / port / time-to-live / max-messages-per-conversation = cache.internal / 6390 / PT24H / 200
|
||||
|
||||
Three guesses, each of which looks right and binds nothing:
|
||||
spring.ai.chat.memory.redis.port=6390 (no "repository" segment)
|
||||
spring.ai.chat.memory.repository.redis.ttl=24h (the key is time-to-live)
|
||||
spring.data.redis.port=6390 (Spring Data Redis's key; this repository builds its own Jedis client)
|
||||
-> port = 6379, time-to-live = null
|
||||
@@ -0,0 +1,21 @@
|
||||
# PromptChatMemoryAdvisor: Spring AI 1.1.8 vs 2.0.1
|
||||
|
||||
$ javac PromptAdvisorFrom1x.java # classpath: spring-ai-client-chat 2.0.1
|
||||
PromptAdvisorFrom1x.java:3: error: cannot find symbol
|
||||
import org.springframework.ai.chat.client.advisor.PromptChatMemoryAdvisor;
|
||||
^
|
||||
symbol: class PromptChatMemoryAdvisor
|
||||
location: package org.springframework.ai.chat.client.advisor
|
||||
PromptAdvisorFrom1x.java:10: error: cannot find symbol
|
||||
return PromptChatMemoryAdvisor.builder(chatMemory).build();
|
||||
^
|
||||
symbol: variable PromptChatMemoryAdvisor
|
||||
location: class PromptAdvisorFrom1x
|
||||
2 errors
|
||||
|
||||
$ javac PromptAdvisorFrom1x.java # classpath: spring-ai-client-chat 1.1.8 first
|
||||
PromptAdvisorFrom1x.java:10: warning: [removal] PromptChatMemoryAdvisor in org.springframework.ai.chat.client.advisor has been deprecated and marked for removal
|
||||
return PromptChatMemoryAdvisor.builder(chatMemory).build();
|
||||
^
|
||||
1 warning
|
||||
compiled: PromptAdvisorFrom1x.class
|
||||
@@ -0,0 +1,15 @@
|
||||
# MessageChatMemoryAdvisor + ToolCallingAdvisor
|
||||
|
||||
Advisor.DEFAULT_CHAT_MEMORY_PRECEDENCE_ORDER = HIGHEST_PRECEDENCE + 200
|
||||
ToolCallingAdvisor.DEFAULT_ORDER = HIGHEST_PRECEDENCE + 300
|
||||
|
||||
answer: It is 31C in Mumbai.
|
||||
model calls: 2
|
||||
|
||||
What the model was sent on each call:
|
||||
call 1: U:Weather in Mumbai?
|
||||
call 2: U:Weather in Mumbai? | A:[tool call currentWeather] | T:[tool result "31C in Mumbai"]
|
||||
|
||||
Stored in memory afterwards:
|
||||
USER Weather in Mumbai?
|
||||
ASSISTANT It is 31C in Mumbai.
|
||||
Reference in New Issue
Block a user