Spring AI · Söhbət yaddaşı və kontekst · Dərs 3
Əvvəlki mesajlarla dialoqun davam etdirilməsi
Əvvəlki mesajları söhbətin kontekstində saxla və növbəti sorğularda modelə ötürərək ardıcıl dialoq qur.
Əvvəlki mesajlarla dialoqu davam etdirmək
Artıq ölçüsü məhdudlaşdırılmış və sahiblik yoxlaması olan tarixçə yaddaşımız var. Növbəti sual əvvəlki dialoqu daşısın deyə onu MessageChatMemoryAdvisor ilə birləşdirək. Əvvəlki modullardakı StudyPromptFactory, AnswerChecks və layihə versiyalarını saxlayın.
1. Advisor-un iki yazma addımını anlayın
Spring AI 1.1.8-də advisor saxlanmış mesajları oxuyur, cari prompt ilə birləşdirir və sistem mesajını əvvələ keçirir. Sonra modeli çağırmazdan əvvəl cari istifadəçi mesajını saxlayır. Cavab gəldikdən sonra assistentin generasiyalarını saxlayır. Cari sistem mesajımızı avtomatik yaddaşa yazmır. Mesaj yaddaşı advisor-unun mənbə kodu
Buna görə advisor tək istifadə edilərsə, uğursuz provayder çağırışı cavabsız istifadəçi mesajı saxlaya bilər. ConversationHistory callback-i əvvəlki vəziyyətin surətini götürür və icra zamanı xəta yaranarsa lokal tarixçəni bərpa edir. AnswerChecks tərəfindən rədd edilmiş cavab da geri qaytarılır. Bu, advisor-un tranzaksiya zəmanəti deyil, onun ətrafında qurduğumuz tətbiq davranışıdır.
2. Hər çağırışda icazəsi yoxlanmış söhbət ID-sini verin
src/main/java/academy/study/MemoryStudyService.java faylını yaradın:
package academy.study;
import java.util.Objects;
import org.springframework.ai.chat.client.ChatClient;
import org.springframework.ai.chat.client.advisor.MessageChatMemoryAdvisor;
import org.springframework.ai.chat.memory.ChatMemory;
import org.springframework.ai.chat.model.ChatModel;
public final class MemoryStudyService {
private final ConversationHistory history;
private final ChatModel model;
public MemoryStudyService(ConversationHistory history, ChatModel model) {
this.history = Objects.requireNonNull(history);
this.model = Objects.requireNonNull(model);
}
public String answer(String owner, String id, String question, StudyPromptFactory.Language language) {
var prompt = StudyPromptFactory.create(question, language);
return history.withConversation(owner, id, memory -> {
var client = ChatClient.builder(model)
.defaultAdvisors(MessageChatMemoryAdvisor.builder(memory).build()).build();
var response = client.prompt(prompt)
.advisors(advisors -> advisors.param(ChatMemory.CONVERSATION_ID, id))
.call().chatResponse();
if (response == null || response.getResults().size() != 1) {
throw new IllegalStateException("Expected one model answer");
}
String text = response.getResult().getOutput().getText();
if (AnswerChecks.check(text) != AnswerChecks.Result.PASS) {
throw new IllegalStateException("Invalid answer length");
}
return text;
});
}
}Servis sahibin sessiyasından yalnız withConversation yoxlamasından sonra istifadə edir. Cari sistem təlimatı StudyPromptFactory-dən gəlir; saxlanan istifadəçi və assistent mesajlarının rolları qorunur. İstənilən dili dəyişmək cari təlimatı dəyişir, amma əvvəlki mesajları tərcümə etmir və modelin təlimata əməl edəcəyinə zəmanət vermir.
Sabitlənmiş BaseChatMemoryAdvisor advisor kontekstində ChatMemory.CONVERSATION_ID tələb edir. Bu parametr verilmədikdə arqument xətası yaranır; həmin versiya səssizcə ortaq standart söhbət seçmir. Düzgün və icazəli ID-ni seçmək bizim məsuliyyətimiz olduğuna görə onu hər dəfə açıq şəkildə veririk. Söhbət ID-si müqaviləsi
3. Tarixçəni məhdudlaşdırın, provayder çağırışı üçün artıq vəd verməyin
Yaddaşımız altı dialoq mesajı saxlayır. Sonrakı model sorğusunda həmin altı mesajla yanaşı cari sistem və istifadəçi mesajı da ola bilər: test edilən axında cəmi səkkiz mesaj. Advisor cari istifadəçi mesajını saxlanan pəncərəyə əlavə etməzdən əvvəl sorğunu qurur. maxMessages(6) bütün prompt üçün altı mesaj limiti deyil.
Sual çağırışdan əvvəl yoxlanır. Uğur üçün boş olmayan və ən çox 4,000 UTF-16 kod vahidindən ibarət bir cavab tələb olunur. Tək generasiya yoxlaması bir neçə namizəd cavabın qəbul edilmiş bir dialoq addımı kimi saxlanmasının qarşısını alır. Etibarsız cavab advisor-un son emalı zamanı qısa müddətə daxili yaddaşa düşə bilər, amma kilid başqa servis əməliyyatının onu görməsinə imkan vermir və callback əvvəlki vəziyyəti bərpa edir.
Bu yoxlamalar token, gecikmə və ya xərc limiti qoymur. Əvvəlki retry ayarı provayder inteqrasiya qatında bir cəhd olaraq qalır. Servis tətbiq səviyyəsində təkrar cəhd, streaming və ya RAG advisor-u əlavə etmir. Şəbəkədə cavab itdikdən sonra çağırışı təkrarlayan tərəf uğurlu dialoq addımını təkrarlaya bilər; sorğunun idempotentliyi bu laboratoriyanın əhatəsində deyil.
4. Təkrar göndərməni, izolyasiyanı və xəta davranışını sübut edin
src/test/java/academy/study/MemoryStudyServiceTest.java faylını yaradın:
package academy.study;
import java.time.Clock;
import java.time.Instant;
import java.time.ZoneOffset;
import java.util.List;
import org.junit.jupiter.api.Test;
import org.mockito.ArgumentCaptor;
import org.springframework.ai.chat.client.ChatClient;
import org.springframework.ai.chat.client.advisor.MessageChatMemoryAdvisor;
import org.springframework.ai.chat.memory.MessageWindowChatMemory;
import org.springframework.ai.chat.messages.AssistantMessage;
import org.springframework.ai.chat.messages.MessageType;
import org.springframework.ai.chat.model.ChatModel;
import org.springframework.ai.chat.model.ChatResponse;
import org.springframework.ai.chat.model.Generation;
import org.springframework.ai.chat.prompt.Prompt;
import static academy.study.StudyPromptFactory.Language.ENGLISH;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.*;
class MemoryStudyServiceTest {
private final ConversationHistory history = new ConversationHistory(
Clock.fixed(Instant.parse("2026-01-01T00:00:00Z"), ZoneOffset.UTC));
private final ChatModel model = mock(ChatModel.class);
private final MemoryStudyService service = new MemoryStudyService(history, model);
private ChatResponse response(String text) {
return new ChatResponse(List.of(new Generation(new AssistantMessage(text))));
}
@Test
void secondTurnIncludesEarlierMessagesWithTheirRoles() {
when(model.call(any(Prompt.class))).thenReturn(response("Use an interface."));
String id = history.create("learner-a");
service.answer("learner-a", id, "Explain interfaces", ENGLISH);
service.answer("learner-a", id, "Show a small example", ENGLISH);
var prompts = ArgumentCaptor.forClass(Prompt.class);
verify(model, times(2)).call(prompts.capture());
var messages = prompts.getAllValues().get(1).getInstructions();
assertEquals(List.of(MessageType.SYSTEM, MessageType.USER, MessageType.ASSISTANT, MessageType.USER),
messages.stream().map(message -> message.getMessageType()).toList());
assertEquals("Explain interfaces", messages.get(1).getText());
assertEquals("Use an interface.", messages.get(2).getText());
assertEquals(4, history.messages("learner-a", id).size());
}
@Test
void separatesConversationsAndRejectsForeignIdBeforeModelCall() {
when(model.call(any(Prompt.class))).thenReturn(response("A local answer"));
String first = history.create("learner-a");
String second = history.create("learner-b");
service.answer("learner-a", first, "Explain interfaces", ENGLISH);
assertThrows(IllegalArgumentException.class,
() -> service.answer("learner-b", first, "Repeat history", ENGLISH));
service.answer("learner-b", second, "Explain resources", ENGLISH);
var prompts = ArgumentCaptor.forClass(Prompt.class);
verify(model, times(2)).call(prompts.capture());
assertEquals(2, prompts.getAllValues().get(1).getInstructions().size());
assertFalse(prompts.getAllValues().get(1).getContents().contains("Explain interfaces"));
}
@Test
void boundsStoredWindowButPromptAlsoContainsCurrentMessages() {
when(model.call(any(Prompt.class))).thenReturn(response("Short answer"));
String id = history.create("learner-a");
for (int i = 0; i < 5; i++) {
service.answer("learner-a", id, "Question " + i, ENGLISH);
}
assertEquals(6, history.messages("learner-a", id).size());
var prompts = ArgumentCaptor.forClass(Prompt.class);
verify(model, times(5)).call(prompts.capture());
assertEquals(8, prompts.getAllValues().get(4).getInstructions().size());
assertFalse(prompts.getAllValues().get(4).getContents().contains("Question 0"));
}
@Test
void providerFailureRestoresPreviousSuccessfulTurn() {
when(model.call(any(Prompt.class))).thenReturn(response("First answer"))
.thenThrow(new IllegalStateException("Provider unavailable"));
String id = history.create("learner-a");
service.answer("learner-a", id, "First question", ENGLISH);
var before = history.messages("learner-a", id);
assertThrows(IllegalStateException.class,
() -> service.answer("learner-a", id, "Failed question", ENGLISH));
assertEquals(before, history.messages("learner-a", id));
}
@Test
void invalidAnswerAndInvalidInputDoNotBecomeHistory() {
when(model.call(any(Prompt.class))).thenReturn(response(" "));
String id = history.create("learner-a");
assertThrows(IllegalStateException.class,
() -> service.answer("learner-a", id, "Valid question", ENGLISH));
assertTrue(history.messages("learner-a", id).isEmpty());
assertThrows(IllegalArgumentException.class,
() -> service.answer("learner-a", id, " ", ENGLISH));
verify(model).call(any(Prompt.class));
}
@Test
void deletedIdCannotStartAnotherModelCall() {
String id = history.create("learner-a");
history.delete("learner-a", id);
assertThrows(IllegalArgumentException.class,
() -> service.answer("learner-a", id, "Hello", ENGLISH));
verifyNoInteractions(model);
}
@Test
void pinnedAdvisorRequiresExplicitConversationId() {
var memory = MessageWindowChatMemory.builder().build();
var client = ChatClient.builder(model)
.defaultAdvisors(MessageChatMemoryAdvisor.builder(memory).build()).build();
assertThrows(IllegalArgumentException.class, () -> client.prompt().user("Hello").call().content());
verify(model, never()).call(any(Prompt.class));
}
}Testlər real advisor-u mock ChatModel ilə işlədir. Modelə çatan mesajları yoxlayır, çağırışları sayır və saxlanan tarixçəni müqayisə edir. Saxta cavab prompt-dan asılı deyil. Buna görə keçən test modelin dialoqu anladığını deyil, təkrar göndərmə və izolyasiya mexanizmlərini sübut edir.
5. Autentifikasiyanı generasiya edilmiş mətndən kənarda saxlayın
Sinifləri etibarlı tətbiq sərhədində birləşdirəndə ConversationHistory obyektini Clock.systemUTC() ilə yaradın və konfiqurasiya edilmiş ChatModel ilə birlikdə MemoryStudyService-ə verin. İdarə etdiyi söhbətlər üçün eyni tarixçə instansiyasını təkrar istifadə edin. Hər sualda yeni instansiya yaratmaq reyestri itirər və əvvəlki ID-ləri əlçatmaz edər.
Bu dərslərdə autentifikasiyasız çat endpoint-i əlavə etmək əvəzinə davranışı testlərdən yoxlayırıq. Sonrakı autentifikasiyalı adapter sahibin kimliyini serverin təhlükəsizlik kontekstindən almalı, yaratma, oxuma, cavablama və silmə əməliyyatlarında eyni sahiblik qaydasını tətbiq etməlidir. Modelə sahib seçməyə, sahiblik yoxlamasını keçməyə və ya öz mətnindən başqa öyrənənin söhbətini seçməyə icazə verməyin.
Etibarlı sessiya yalnız öz tarixçəsinə girişi icazələndirir. Sənədlər, alətlər və ya xarici sistemlər üçün icazə vermir. Yaddaş sonradan RAG ilə birləşdirilərsə, cari sorğu üçün sənəd icazələrini yenidən yoxlayın. İcazə dəyişdikdə əvvəllər əlçatan olan fraqmenti yenidən göndərmək ayrıca məlumat açıqlama qərarıdır.
6. Yoxlayın və davam sualını araşdırın
İcra edin:
env -u RUN_LIVE_EVALUATION mvn clean verifyYığılmış layihədə altmış bir oflayn metod keçməli, bir canlı qiymətləndirmə metodu isə ötürülməlidir. Xarici model çağırışı lazım deyil. Real provayderin rollarla davranışı, cavab keyfiyyəti, kontekst limitləri, xərc və saxlama siyasəti bu test dəsti ilə yoxlanmır.
Tapşırıq: birinci testə üçüncü davam sualını əlavə edin, sonra söhbəti silin və yenidən cavab almağa çalışın. Həm tutulan prompt-ları, həm də model çağırışlarının sayını araşdırın. Ayrıca eyni sahib üçün ikinci söhbəti sınayın.
Cavab yoxlaması: üçüncü addım mövcud əvvəlki dialoqu ilkin rollarla daşıyır. Silmə növbəti model çağırışından əvvəl köhnə ID-ni əlçatmaz edir. Yeni söhbət silinmiş tarixçə olmadan başlayır. Nə ortaq sahiblik, nə də inandırıcı prompt iki söhbəti birləşdirmək üçün icazədir.