개요
Spring AI Advisors API는 Spring AI에서 가로채고 수정하고 확장할 수 있는 강력한 메커니즘을 제공합니다.
본문
Core Components
출처: https://docs.spring.io/spring-ai/reference/api/advisors.html#_core_components
- advisorCall(), advisorStream(): Advisor의 핵심 메서드로 프롬프트를 보내기 전에 검사, 정의, 확장하고 응답을 받은 후 응답 검사, 처리 오류 처리 등을 수행합니다.
- getOrder(): Advisor의 실행 순서를 정의합니다. 낮은 값이 먼저 실행됩니다.
- getName(): Advisor의 이름을 반환합니다.
Advisors Flow
출처: https://docs.spring.io/spring-ai/reference/api/advisors.html#_core_components
Default Advisors
Spring AI는 몇 가지 기본 Advisors를 제공합니다:
- SimpleLoggerAdvisor: 요청 및 응답을 로깅합니다.
@Override
public ChatClientResponse adviseCall(ChatClientRequest chatClientRequest, CallAdvisorChain callAdvisorChain) {
// 1. 요청 로깅
// AI 모델 호출 전, 사용자 요청 내용을 로그에 기록
// (디버깅, 모니터링, 감사 목적)
logRequest(chatClientRequest);
// 2. 다음 Advisor 또는 실제 AI 모델 호출
// Chain of Responsibility 패턴: 다음 체인으로 요청 전달
// 여러 Advisor가 있다면 순차적으로 실행되고, 마지막에는 실제 AI 모델이 호출됨
ChatClientResponse chatClientResponse = callAdvisorChain.nextCall(chatClientRequest);
// 3. 응답 로깅
// AI 모델 호출 후, 생성된 응답 내용을 로그에 기록
// (성능 측정, 응답 품질 분석, 문제 추적 목적)
logResponse(chatClientResponse);
// 4. 응답 반환
// 로깅 완료 후 원본 응답을 그대로 반환 (응답 수정 없음)
return chatClientResponse;
}
- VectorStoreChatMemoryAdvisor: 벡터 DB에 대화를 저장하고 의미 유사도 검색으로 관련 과거 대화만 선택적으로 가져와 프롬프트에 추가합니다.
- before(): 프롬프트를 보내기 전에 벡터 저장소에서 관련 문서를 검색하고 시스템 메시지에 추가합니다.
- after(): 모델의 응답을 받은 후, 새로운 사용자 메시지를 벡터 저장소에 저장합니다.
@Override
public ChatClientRequest before(ChatClientRequest request, AdvisorChain advisorChain) {
// 1. 대화 컨텍스트 정보 추출
// 현재 대화 세션의 고유 ID 획득 (없으면 기본값 사용)
String conversationId = getConversationId(request.context(), this.defaultConversationId);
// 사용자 메시지 텍스트 추출 (null인 경우 빈 문자열 사용)
String query = request.prompt().getUserMessage() != null ?
request.prompt().getUserMessage().getText() : "";
// 검색할 메모리 개수(topK) 결정 (컨텍스트에서 가져오거나 기본값 사용)
int topK = getChatMemoryTopK(request.context());
// 2. 벡터 저장소에서 관련 대화 검색
// 같은 대화 세션의 메모리만 검색하도록 필터 설정
String filter = DOCUMENT_METADATA_CONVERSATION_ID + "=='" + conversationId + "'";
// 검색 요청 객체 생성: 사용자 질문과 의미적으로 유사한 과거 대화 검색
var searchRequest = org.springframework.ai.vectorstore.SearchRequest.builder()
.query(query) // 검색 쿼리 (현재 사용자 메시지)
.topK(topK) // 상위 K개 결과만 반환
.filterExpression(filter) // 대화 ID 필터 적용
.build();
// 벡터 유사도 검색 실행 - 관련성 높은 과거 대화 가져오기
java.util.List<org.springframework.ai.document.Document> documents = this.vectorStore
.similaritySearch(searchRequest);
// 3. Long-term Memory 문자열 생성
// 검색된 과거 대화들을 하나의 문자열로 결합 (각 대화는 줄바꿈으로 구분)
String longTermMemory = documents == null ? ""
: documents.stream()
.map(org.springframework.ai.document.Document::getText) // 각 문서의 텍스트 추출
.collect(java.util.stream.Collectors.joining(System.lineSeparator())); // 줄바꿈으로 연결
// 4. 시스템 프롬프트에 메모리 추가 (RAG 패턴의 "증강" 단계)
// 기존 시스템 메시지 가져오기
org.springframework.ai.chat.messages.SystemMessage systemMessage = request.prompt().getSystemMessage();
// 템플릿을 사용해 시스템 메시지와 long-term memory를 결합
// 예: "지시사항: {instructions}\n과거 대화: {long_term_memory}"
String augmentedSystemText = this.systemPromptTemplate
.render(java.util.Map.of("instructions", systemMessage.getText(),
"long_term_memory", longTermMemory));
// 5. 증강된 시스템 메시지로 새로운 요청 객체 생성
// mutate()를 사용해 기존 요청을 복사하고 프롬프트만 수정
ChatClientRequest processedChatClientRequest = request.mutate()
.prompt(request.prompt().augmentSystemMessage(augmentedSystemText))
.build();
// 6. 현재 사용자 메시지를 벡터 저장소에 저장 (향후 검색을 위해)
org.springframework.ai.chat.messages.UserMessage userMessage = processedChatClientRequest.prompt()
.getUserMessage();
if (userMessage != null) {
// 사용자 메시지를 Document로 변환하여 벡터 DB에 저장
// 나중에 이 대화를 검색할 수 있도록 인덱싱
this.vectorStore.write(toDocuments(java.util.List.of(userMessage), conversationId));
}
// 7. 메모리가 증강된 요청 객체 반환
// 이제 AI는 과거 대화 컨텍스트를 포함한 프롬프트로 응답을 생성함
return processedChatClientRequest;
}
@Override
public ChatClientResponse after(ChatClientResponse chatClientResponse, AdvisorChain advisorChain) {
// 1. AI 어시스턴트 응답 메시지 추출
// 빈 리스트로 초기화 (응답이 없는 경우 대비)
List<Message> assistantMessages = new ArrayList<>();
// chatResponse가 null이 아닌 경우에만 처리 (null 안전성 체크)
if (chatClientResponse.chatResponse() != null) {
// 2. 응답 결과에서 모든 어시스턴트 메시지 추출
assistantMessages = chatClientResponse.chatResponse()
.getResults() // 모든 생성 결과 가져오기 (스트리밍의 경우 여러 개일 수 있음)
.stream() // 스트림으로 변환
.map(g -> (Message) g.getOutput()) // 각 결과에서 실제 메시지 객체 추출
.toList(); // List로 수집
}
// 3. AI 응답을 벡터 저장소에 저장 (Long-term Memory 구축)
// 현재 대화의 conversationId와 함께 저장하여 나중에 검색 가능하도록 함
this.vectorStore.write(
toDocuments(assistantMessages, // 메시지들을 Document 형식으로 변환
this.getConversationId(chatClientResponse.context(), this.defaultConversationId)) // 대화 ID 추출
);
// 4. 원본 응답 객체를 그대로 반환
// after() 메서드는 응답을 수정하지 않고, 저장만 수행함
return chatClientResponse;
}
- PromptChatMemoryAdvisor: 이는 VectorStoreChatMemoryAdvisor와 유사하지만, 벡터 저장소 대신 메모리 내에서 대화 기록을 관리합니다.
- before(): 프롬프트를 보내기 전에 메모리에서 관련 대화를 검색하고 시스템 메시지에 추가합니다.
- after(): 모델의 응답을 받은 후, 새로운 사용자 메시지를 메모리에 저장합니다.
@Override
public ChatClientRequest before(ChatClientRequest chatClientRequest, AdvisorChain advisorChain) {
// 현재 대화 세션의 고유 ID 획득 (없으면 기본값 사용)
String conversationId = getConversationId(chatClientRequest.context(), this.defaultConversationId);
// 1. 현재 대화의 메모리(대화 히스토리) 조회
// ChatMemory에서 이 대화 세션의 모든 과거 메시지 가져오기
List<Message> memoryMessages = this.chatMemory.get(conversationId);
// 디버그 로그: 메모리 처리 전 상태 기록
logger.debug("[PromptChatMemoryAdvisor.before] Memory before processing for conversationId={}: {}",
conversationId, memoryMessages);
// 2. 메모리 메시지를 문자열 형식으로 변환
// USER와 ASSISTANT 메시지만 필터링하여 "역할:내용" 형식으로 변환
String memory = memoryMessages.stream()
.filter(m -> m.getMessageType() == MessageType.USER ||
m.getMessageType() == MessageType.ASSISTANT) // 시스템 메시지 등 제외
.map(m -> m.getMessageType() + ":" + m.getText()) // "USER:안녕하세요" 형식으로 변환
.collect(Collectors.joining(System.lineSeparator())); // 각 메시지를 줄바꿈으로 구분
// 3. 시스템 메시지에 대화 메모리 추가 (컨텍스트 증강)
// 기존 시스템 메시지 가져오기
SystemMessage systemMessage = chatClientRequest.prompt().getSystemMessage();
// 템플릿을 사용해 시스템 지시사항과 대화 히스토리 결합
// 예: "지시사항: {instructions}\n대화 기록: {memory}"
String augmentedSystemText = this.systemPromptTemplate
.render(Map.of("instructions", systemMessage.getText(),
"memory", memory));
// 4. 증강된 시스템 메시지로 새로운 요청 객체 생성
// mutate()를 사용해 기존 요청을 복사하고 프롬프트만 수정
ChatClientRequest processedChatClientRequest = chatClientRequest.mutate()
.prompt(chatClientRequest.prompt().augmentSystemMessage(augmentedSystemText))
.build();
// 5. 현재 사용자 메시지를 대화 메모리에 추가
// (시스템 메시지 생성 후에 추가 - 현재 메시지는 다음 요청에서 사용됨)
UserMessage userMessage = processedChatClientRequest.prompt().getUserMessage();
// ChatMemory에 현재 사용자 메시지 저장 (다음 대화에서 컨텍스트로 활용)
this.chatMemory.add(conversationId, userMessage);
// 6. 메모리가 증강된 요청 객체 반환
// AI는 이제 전체 대화 히스토리를 포함한 컨텍스트로 응답 생성
return processedChatClientRequest;
}
@Override
public ChatClientResponse after(ChatClientResponse chatClientResponse, AdvisorChain advisorChain) {
// 1. AI 어시스턴트 응답 메시지를 담을 리스트 초기화
List<Message> assistantMessages = new ArrayList<>();
// 2. 스트리밍 응답 처리 (단일 결과가 있는 경우)
// 스트리밍 모드에서는 하나의 결과만 반환되므로 별도 처리
if (chatClientResponse.chatResponse() != null &&
chatClientResponse.chatResponse().getResult() != null &&
chatClientResponse.chatResponse().getResult().getOutput() != null) {
// 단일 결과를 리스트로 래핑
assistantMessages = List.of((Message) chatClientResponse.chatResponse().getResult().getOutput());
}
// 3. 일반(비스트리밍) 응답 처리 (여러 결과가 있을 수 있는 경우)
else if (chatClientResponse.chatResponse() != null) {
// 모든 생성 결과에서 어시스턴트 메시지 추출
assistantMessages = chatClientResponse.chatResponse()
.getResults() // 모든 결과 가져오기
.stream() // 스트림 변환
.map(g -> (Message) g.getOutput()) // 각 결과에서 메시지 추출
.toList(); // List로 수집
}
// 4. 추출된 어시스턴트 메시지가 있으면 메모리에 추가
if (!assistantMessages.isEmpty()) {
// 현재 대화 세션의 메모리에 AI 응답 저장
this.chatMemory.add(
this.getConversationId(chatClientResponse.context(), this.defaultConversationId),
assistantMessages
);
// 5. 디버그 로깅 (디버그 레벨이 활성화된 경우에만)
if (logger.isDebugEnabled()) {
// 메모리에 추가된 ASSISTANT 메시지 로그
logger.debug(
"[PromptChatMemoryAdvisor.after] Added ASSISTANT messages to memory for conversationId={}: {}",
this.getConversationId(chatClientResponse.context(), this.defaultConversationId),
assistantMessages
);
// 메모리에 추가 후 전체 대화 히스토리 조회
List<Message> memoryMessages = this.chatMemory
.get(this.getConversationId(chatClientResponse.context(), this.defaultConversationId));
// 현재 메모리 상태 전체 로그 (누적된 모든 대화)
logger.debug(
"[PromptChatMemoryAdvisor.after] Memory after ASSISTANT add for conversationId={}: {}",
this.getConversationId(chatClientResponse.context(), this.defaultConversationId),
memoryMessages
);
}
}
// 6. 원본 응답 객체를 그대로 반환
// after() 메서드는 응답을 수정하지 않고, 메모리 저장과 로깅만 수행
return chatClientResponse;
}
- MessageChatMemoryAdvisor: 과거 대화를 Message 객체 배열 형태로 유지하며 현재 요청의 메시지 리스트에 직접 병합하여 전체 대화 히스토리를 AI에게 전달합니다.
- Before: 과거 대화를 메모리에서 조회하여 현재 요청의 메시지 리스트에 추가합니다.
- After: 모델의 응답을 받은 후, 새로운 어시스턴트 메시지를 메모리에 저장합니다.
@Override
public ChatClientRequest before(ChatClientRequest chatClientRequest, AdvisorChain advisorChain) {
// 현재 대화 세션의 고유 ID 획득 (없으면 기본값 사용)
String conversationId = getConversationId(chatClientRequest.context(), this.defaultConversationId);
// 1. 현재 대화의 메모리(대화 히스토리) 조회
// ChatMemory에서 이 대화 세션의 모든 과거 메시지 가져오기
// (USER, ASSISTANT, SYSTEM 등 모든 타입의 메시지 포함)
List<Message> memoryMessages = this.chatMemory.get(conversationId);
// 2. 메시지 리스트 재구성 (메모리 + 현재 요청)
// 과거 대화 히스토리를 포함하는 새로운 메시지 리스트 생성
List<Message> processedMessages = new ArrayList<>(memoryMessages); // 기존 메모리 복사
// 현재 요청의 지시사항(instructions) 메시지들을 추가
// instructions에는 SystemMessage, UserMessage 등이 포함될 수 있음
processedMessages.addAll(chatClientRequest.prompt().getInstructions());
// 3. 재구성된 메시지 리스트로 새로운 요청 객체 생성
// mutate()를 중첩 사용하여 요청과 프롬프트를 모두 수정
ChatClientRequest processedChatClientRequest = chatClientRequest.mutate()
.prompt(
chatClientRequest.prompt()
.mutate()
.messages(processedMessages) // 과거 대화 + 현재 요청으로 메시지 교체
.build()
)
.build();
// 4. 현재 사용자 메시지를 대화 메모리에 추가
// 다음 대화 턴에서 컨텍스트로 활용하기 위해 저장
UserMessage userMessage = processedChatClientRequest.prompt().getUserMessage();
this.chatMemory.add(conversationId, userMessage);
// 5. 메모리가 포함된 요청 객체 반환
// AI는 이제 전체 대화 히스토리가 포함된 메시지 리스트로 응답 생성
return processedChatClientRequest;
}
@Override
public ChatClientResponse after(ChatClientResponse chatClientResponse, AdvisorChain advisorChain) {
// 1. AI 어시스턴트 응답 메시지를 담을 리스트 초기화
List<Message> assistantMessages = new ArrayList<>();
// 2. 응답에서 모든 어시스턴트 메시지 추출 (null 안전성 체크)
if (chatClientResponse.chatResponse() != null) {
// 모든 생성 결과에서 어시스턴트 메시지 추출
assistantMessages = chatClientResponse.chatResponse()
.getResults() // 모든 결과 가져오기 (여러 개일 수 있음)
.stream() // 스트림으로 변환
.map(g -> (Message) g.getOutput()) // 각 결과에서 실제 메시지 객체 추출
.toList(); // List로 수집
}
// 3. AI 응답을 대화 메모리에 추가
// 현재 대화 세션(conversationId)의 메모리에 어시스턴트 메시지 저장
// 다음 대화 턴에서 컨텍스트로 활용하기 위함
this.chatMemory.add(
this.getConversationId(chatClientResponse.context(), this.defaultConversationId),
assistantMessages
);
// 4. 원본 응답 객체를 그대로 반환
// after() 메서드는 응답을 수정하지 않고, 메모리 저장만 수행 (Pass-through)
return chatClientResponse;
}
- ChatModelCallAdvisor: 응답 형식 지시사항(JSON, XML 등)을 프롬프트에 추가하고 Advisor 체인을 우회하여 ChatModel을 직접 호출합니다.
@Override
public ChatClientResponse adviseCall(ChatClientRequest chatClientRequest, CallAdvisorChain callAdvisorChain) {
// 1. 입력 유효성 검증
// chatClientRequest가 null이면 IllegalArgumentException 발생
Assert.notNull(chatClientRequest, "the chatClientRequest cannot be null");
// 2. 포맷 지시사항 추가 (응답 구조화)
// 요청에 출력 형식(JSON, XML 등)에 대한 지시사항을 프롬프트에 추가
// 예: "다음 JSON 형식으로 응답하세요: {...}"
ChatClientRequest formattedChatClientRequest = augmentWithFormatInstructions(chatClientRequest);
// 3. ChatModel 직접 호출
// Advisor 체인을 우회하고 ChatModel을 직접 호출하여 응답 생성
// (callAdvisorChain.nextCall()을 사용하지 않음에 주목!)
ChatResponse chatResponse = this.chatModel.call(formattedChatClientRequest.prompt());
// 4. ChatClientResponse 생성 및 반환
// AI 응답과 요청 컨텍스트를 포함한 응답 객체 구성
return ChatClientResponse.builder()
.chatResponse(chatResponse) // AI 모델의 응답
.context(Map.copyOf(formattedChatClientRequest.context())) // 요청 컨텍스트 복사 (불변)
.build();
}
- QuestionAnswerAdvisor: 사용자 질문과 관련된 문서를 벡터 검색으로 찾아 프롬프트에 추가하고(RAG), 검색된 문서를 응답 메타데이터로 제공합니다.
- Before: 벡터 검색으로 관련 문서를 찾아 사용자 질문에 컨텍스트로 추가하는 RAG의 Retrieval + Augmentation 단계 수행합니다.
- After: 검색된 문서들을 응답 메타데이터에 포함시켜 AI 답변의 출처를 추적 가능하게 합니다.
@Override
public ChatClientRequest before(ChatClientRequest chatClientRequest, AdvisorChain advisorChain) {
// 1. 벡터 저장소에서 유사한 문서 검색 (RAG의 "Retrieval" 단계)
// 기본 검색 설정을 복사하고 현재 사용자 질문으로 쿼리 설정
var searchRequestToUse = SearchRequest.from(this.searchRequest)
.query(chatClientRequest.prompt().getUserMessage().getText()) // 사용자 질문을 검색 쿼리로 사용
.filterExpression(doGetFilterExpression(chatClientRequest.context())) // 컨텍스트 기반 필터 적용
.build();
// 벡터 유사도 검색 실행 - 질문과 관련된 문서들 찾기
List<Document> documents = this.vectorStore.similaritySearch(searchRequestToUse);
// 2. 검색된 문서들을 컨텍스트에 추가
// 요청 컨텍스트 복사 후 검색된 문서 저장 (나중에 메타데이터로 활용)
Map<String, Object> context = new HashMap<>(chatClientRequest.context());
context.put(RETRIEVED_DOCUMENTS, documents);
// 검색된 문서들의 텍스트를 하나의 문자열로 결합
// 각 문서는 줄바꿈으로 구분
String documentContext = documents == null ? ""
: documents.stream()
.map(Document::getText) // 각 문서의 텍스트 추출
.collect(Collectors.joining(System.lineSeparator())); // 줄바꿈으로 연결
// 3. 사용자 프롬프트를 문서 컨텍스트로 증강 (RAG의 "Augmentation" 단계)
UserMessage userMessage = chatClientRequest.prompt().getUserMessage();
// 템플릿을 사용해 원본 질문과 검색된 문서 컨텍스트를 결합
// 예: "다음 문서를 참고하여 '{query}'에 답변하세요:\n{question_answer_context}"
String augmentedUserText = this.promptTemplate
.render(Map.of("query", userMessage.getText(),
"question_answer_context", documentContext));
// 4. 증강된 프롬프트로 새로운 요청 생성
// 원본 사용자 메시지를 문서 컨텍스트가 포함된 버전으로 교체
return chatClientRequest.mutate()
.prompt(chatClientRequest.prompt().augmentUserMessage(augmentedUserText)) // 증강된 메시지로 교체
.context(context) // 검색된 문서를 컨텍스트에 포함
.build();
}
@Override
public ChatClientResponse after(ChatClientResponse chatClientResponse, AdvisorChain advisorChain) {
// 1. ChatResponse 빌더 준비
ChatResponse.Builder chatResponseBuilder;
// 기존 응답이 없으면 새로운 빌더 생성, 있으면 기존 응답에서 복사
if (chatClientResponse.chatResponse() == null) {
chatResponseBuilder = ChatResponse.builder();
}
else {
chatResponseBuilder = ChatResponse.builder().from(chatClientResponse.chatResponse());
}
// 2. 검색된 문서를 응답 메타데이터에 추가
// before()에서 검색한 문서들을 응답의 메타데이터로 포함
// 사용자가 AI 답변의 출처(source documents)를 확인할 수 있도록 함
chatResponseBuilder.metadata(RETRIEVED_DOCUMENTS,
chatClientResponse.context().get(RETRIEVED_DOCUMENTS));
// 3. 메타데이터가 추가된 응답 객체 반환
return ChatClientResponse.builder()
.chatResponse(chatResponseBuilder.build()) // 메타데이터 포함된 응답
.context(chatClientResponse.context()) // 원본 컨텍스트 유지
.build();
}
- SafeGuardAdvisor: 민감한 단어가 포함된 요청을 사전에 차단하여 부적절한 콘텐츠가 AI 모델에 전달되는 것을 방지하는 필터링합니다.
@Override
public ChatClientResponse adviseCall(ChatClientRequest chatClientRequest, CallAdvisorChain callAdvisorChain) {
// 1. 민감한 단어 검사 및 차단
// 민감한 단어 목록이 존재하고, 프롬프트에 민감한 단어가 포함되어 있는지 확인
if (!CollectionUtils.isEmpty(this.sensitiveWords) // 민감 단어 목록이 비어있지 않은 경우
&& this.sensitiveWords.stream() // 스트림으로 변환
.anyMatch(w -> chatClientRequest.prompt() // 하나라도 매치되면 true
.getContents() // 프롬프트의 전체 내용 가져오기
.contains(w))) { // 민감 단어 포함 여부 확인
// 민감한 단어가 발견되면 AI 호출 없이 실패 응답 반환
// (비용 절감 + 보안 강화 + 빠른 응답)
return createFailureResponse(chatClientRequest);
}
// 2. 민감한 단어가 없으면 다음 Advisor 또는 AI 모델 호출
// 정상적인 요청은 체인을 따라 계속 진행
return callAdvisorChain.nextCall(chatClientRequest);
}
마무리
Advisors API는 Prompt 요청과 응답을 가로채고 수정할 수 있는 유연한 메커니즘을 제공합니다. 이를 통해 로깅, 메모리 관리, RAG, 포맷 지시사항 추가, 민감한 단어 필터링 등 다양한 기능을 구현할 수 있습니다.
Reference Documentation
'Spring' 카테고리의 다른 글
Vector Databases - Spring AI Practice (0) | 2025.10.02 |
---|---|
Embeddings Model API - Spring AI Practice (0) | 2025.10.02 |
Image Model API - Spring AI Practice (0) | 2025.09.18 |
Audio Models - Spring AI Practice (1) | 2025.09.17 |
Structured Output Converter - Spring AI Practice (0) | 2025.09.17 |
댓글