Spring AI Advisors API - 메모리, RAG, 가드, 로깅을 프롬프트 밖으로 끌어내기
Spring AI 시리즈 2편. Advisor 추상화의 의미, 순서 설계, advisor-context, ChatMemory.CONVERSATION_ID 같은 런타임 파라미터, CallAdvisor/BaseAdvisor를 직접 구현해 가드·프롬프트 보강·로깅을 다루는 패턴까지
이 글은 Spring AI 시리즈의 2편입니다.
- 1편: Spring AI Basic — Prompt, Template, Structured Output
- 2편: Spring AI Advisors API (현재 글)
- 3편: Spring AI Tool Calling과 MCP
- 4편: Spring AI Multimodal — 이미지, 오디오
- 5편: Spring AI Embedding과 RAG 심화
Spring AI에서 가장 자주 만지게 되는 추상화 중 하나가 Advisors API입니다.
처음에는 그냥 “ChatClient 위에 끼우는 인터셉터”처럼 보이지만, 실제로는 다음과 같은 횡단 관심사를 깔끔하게 분리하는 자리입니다.
- 대화 메모리(
ChatMemory) - RAG 컨텍스트 주입
- 입력 검증 / 가드레일
- 로깅 / 트레이싱 / 메트릭
- 프롬프트 자동 보강
이 글은 Advisor를 단순히 사용하는 수준을 넘어, 순서 설계, advisor-context, 런타임 파라미터, CallAdvisor/BaseAdvisor 직접 구현까지 한 흐름으로 정리한 글입니다.
springboot3 + java sample은 github-sample를 참조해주세요.
1. Advisors API란?
Spring AI에서 정말 중요한 추상화 중 하나가 Advisors API입니다.
이 레이어는 모델 호출 전후를 가로채어 요청과 응답을 보강합니다.
문서 표현을 빌리면 Advisor는 요청/응답을 intercept, modify, enhance하는 역할을 합니다.
예를 들어 이런 작업을 Advisor로 분리할 수 있습니다.
- 대화 메모리 주입
- RAG 컨텍스트 주입
- 안전성 필터링
- 반복적인 프롬프트 보강
- 요청/응답 로깅
메모리 관점에서 보면 더 이해가 쉽다
위키독스의 Memory 활용 대화 에이전트 실습에서는 ChatMessageHistory를 프롬프트에 수동으로 넣어 대화 기록을 유지하는 흐름을 보여줍니다.
핵심 메시지는 단순합니다.
- 기억이 없는 체인은 매번 처음 만나는 안내원처럼 동작합니다.
- 메모리가 있는 체인은 이전 대화를 참고하는 개인 비서처럼 동작합니다.
Spring AI에서는 이 개념이 ChatMemory와 MessageChatMemoryAdvisor로 자연스럽게 연결됩니다.
1
2
3
4
5
6
7
ChatMemory chatMemory = MessageWindowChatMemory.builder()
.maxMessages(20)
.build();
ChatClient chatClient = ChatClient.builder(chatModel)
.defaultAdvisors(MessageChatMemoryAdvisor.builder(chatMemory).build())
.build();
즉, 위키독스에서 수동으로 history를 프롬프트에 주입하던 패턴을, Spring AI에서는 Advisor 계층으로 끌어올려 재사용 가능한 정책으로 만들 수 있습니다.
RAG도 같은 패턴으로 이해할 수 있다
메모리가 “이전 대화”를 주입하는 것이라면, RAG는 “외부 지식”을 주입하는 것입니다.
Spring AI에서는 QuestionAnswerAdvisor 같은 Advisor로 이 흐름을 구성할 수 있습니다.
1
2
3
4
5
6
var chatClient = ChatClient.builder(chatModel)
.defaultAdvisors(
MessageChatMemoryAdvisor.builder(chatMemory).build(),
QuestionAnswerAdvisor.builder(vectorStore).build()
)
.build();
여기서 포인트는 프롬프트 자체를 매번 수작업으로 고치지 않아도 된다는 것입니다.
메모리, 검색 문맥, 안전성 같은 공통 정책을 Advisor로 분리해두면, 애플리케이션이 커져도 구조가 유지됩니다.
2. Advisor 순서가 왜 중요한가?
이 부분은 꼭 한 번 짚고 넘어가야 합니다.
공식 문서 기준으로 Advisor는 getOrder() 값이 낮을수록 요청(request)에서는 먼저 실행되고, 응답(response)에서는 나중에 실행됩니다.
즉 체감상 아래처럼 동작합니다.
- 요청 흐름: 앞에 있는 Advisor -> 뒤에 있는 Advisor -> 모델 호출
- 응답 흐름: 모델 응답 -> 뒤에 있는 Advisor -> 앞에 있는 Advisor
이걸 가장 단순하게 보여주는 예시는 아래와 같습니다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
public class TraceAdvisor implements CallAdvisor {
private final String name;
private final int order;
public TraceAdvisor(String name, int order) {
this.name = name;
this.order = order;
}
@Override
public String getName() {
return this.name;
}
@Override
public int getOrder() {
return this.order;
}
@Override
public ChatClientResponse adviseCall(ChatClientRequest request, CallAdvisorChain chain) {
System.out.println("[" + name + "] before");
ChatClientResponse response = chain.nextCall(request);
System.out.println("[" + name + "] after");
return response;
}
}
그리고 다음처럼 등록합니다.
1
2
3
4
5
6
ChatClient chatClient = ChatClient.builder(chatModel)
.defaultAdvisors(
new TraceAdvisor("A", 0),
new TraceAdvisor("B", 100)
)
.build();
이때 실제 실행 흐름은 아래처럼 됩니다.
A beforeB before- ChatModel 호출
B afterA after
즉 Advisor 체인은 리스트처럼 보이지만, 실제 실행은 스택처럼 감싸는 구조입니다.
이 개념이 중요한 이유는 메모리, RAG, 로깅, 가드레일이 서로 영향을 주기 때문입니다.
실제로 순서가 달라지면 어떤 문제가 생길까?
아래와 같은 대화 상황을 생각해보겠습니다.
- 사용자가 첫 질문에서
"우리 서비스는 PostgreSQL 기준으로 설명해줘."라고 말함 - 다음 질문에서
"인덱스 설계 주의점 정리해줘."라고 말함
이제 우리는 두 번째 질문을 받을 때,
- 이전 대화 맥락도 반영하고 싶고
- 벡터 스토어에서 관련 문서도 찾고 싶습니다
그래서 보통 이런 구성을 하게 됩니다.
1
2
3
4
5
6
7
ChatClient chatClient = ChatClient.builder(chatModel)
.defaultAdvisors(
MessageChatMemoryAdvisor.builder(chatMemory).build(),
QuestionAnswerAdvisor.builder(vectorStore).build(),
new SimpleLoggerAdvisor()
)
.build();
이 구성이 의도하는 실제 흐름은 아래와 같습니다.
MessageChatMemoryAdvisor가 이전 대화를 프롬프트에 추가합니다.QuestionAnswerAdvisor가 현재 질문 + 이전 대화 맥락을 참고해 벡터 검색을 수행합니다.- 검색된 문서가 프롬프트에 포함된 상태로 모델이 호출됩니다.
- 모델 응답이 돌아오면
QuestionAnswerAdvisor가 자신의 컨텍스트를 응답에 반영합니다. - 마지막으로 메모리 Advisor가 이번 대화 내용을 메모리에 반영합니다.
이 순서가 중요한 이유는 QuestionAnswerAdvisor가 검색할 때 참고하는 입력이 달라지기 때문입니다.
- 메모리 먼저 -> 검색 질문이
"PostgreSQL 기준 인덱스 설계 주의점"에 가까워짐 - RAG 먼저 -> 검색 질문이 그냥
"인덱스 설계 주의점"에 머무를 가능성이 커짐
즉, 메모리보다 RAG가 먼저 실행되면 벡터 검색 단계에서 중요한 맥락이 빠질 수 있습니다.
잘못 배치한 경우
예를 들어 이렇게 구성하면 문제가 생길 수 있습니다.
1
2
3
4
5
6
ChatClient chatClient = ChatClient.builder(chatModel)
.defaultAdvisors(
QuestionAnswerAdvisor.builder(vectorStore).build(),
MessageChatMemoryAdvisor.builder(chatMemory).build()
)
.build();
이 경우 의도상 흐름은 다음처럼 흘러갑니다.
- 먼저
QuestionAnswerAdvisor가 검색을 시도합니다. - 아직 메모리가 붙기 전이라, 검색에는 현재 질문만 반영될 수 있습니다.
- 그 뒤에
MessageChatMemoryAdvisor가 대화 이력을 붙입니다. - 모델은 대화 이력이 들어간 프롬프트를 받더라도, 이미 검색은 덜 정확한 상태로 끝난 뒤일 수 있습니다.
이런 상황에서는 모델이 Postgres보다 MySQL이나 일반적인 DB 문서를 섞어서 답할 가능성이 커집니다.
로깅 Advisor는 어디에 두는 게 좋을까?
SimpleLoggerAdvisor 같은 로깅 Advisor도 순서에 따라 보는 정보가 달라집니다.
- 앞쪽에 두면: 거의 원본에 가까운 요청을 먼저 볼 수 있습니다.
- 뒤쪽에 두면: 메모리/RAG가 적용된 뒤의 요청을 볼 수 있습니다.
즉 “무엇을 디버깅하고 싶은가”에 따라 위치가 달라집니다.
- 원본 사용자 입력을 보고 싶다 -> 앞쪽
- 최종적으로 모델에 들어간 완성 프롬프트를 보고 싶다 -> 뒤쪽
그래서 Advisor 순서를 정할 때는 단순히 보기 좋게 나열하는 것이 아니라, 어떤 데이터가 어느 시점에 준비되어 있어야 하는지를 기준으로 생각해야 합니다.
정리하면 실무에서는 보통 아래 순서 감각이 유용합니다.
- 원본 요청 검사 / 입력 보강
- 메모리 주입
- RAG / 검색 문맥 주입
- 로깅 또는 응답 후처리
물론 모든 경우의 정답은 아니지만, 적어도 “검색 전에 필요한 컨텍스트가 다 들어왔는가?”를 기준으로 보면 대부분 방향을 잘 잡을 수 있습니다.
3. ChatClientResponse와 advisor-context
Spring AI 공식 문서에서 ChatClientRequest와 ChatClientResponse는 둘 다 advisor context를 가진다고 설명합니다.
이 context는 advisor 체인 전체에서 공유되는 내부 상태 저장소처럼 생각하면 됩니다.
핵심은 아래 두 가지입니다.
ChatClientRequest.context()는 요청 단계에서 advisor끼리 상태를 공유할 때 사용합니다.ChatClientResponse.context()는 응답 단계에서 앞선 advisor가 남긴 상태를 읽을 때 사용합니다.
그리고 이 context는 기본적으로 immutable하게 다뤄집니다.
즉 직접 수정하는 것이 아니라 mutate().context(...)로 새 요청/응답 객체를 만들어 넘기는 방식입니다.
예를 들어 tenantId, traceId, retrievalCount 같은 내부 정보를 advisor 체인에서만 돌리고 싶다고 해보겠습니다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
public class TenantTraceAdvisor implements CallAdvisor {
@Override
public String getName() {
return "tenant-trace-advisor";
}
@Override
public int getOrder() {
return 10;
}
@Override
public ChatClientResponse adviseCall(ChatClientRequest request, CallAdvisorChain chain) {
ChatClientRequest enrichedRequest = request.mutate()
.context("tenantId", "acme")
.context("traceId", "trace-123")
.build();
ChatClientResponse response = chain.nextCall(enrichedRequest);
Integer retrievalCount = (Integer) response.context().getOrDefault("retrievalCount", 0);
return response.mutate()
.context("handledBy", "TenantTraceAdvisor")
.context("retrievalCount", retrievalCount)
.build();
}
}
이 예시에서 중요한 점은 tenantId나 traceId를 곧바로 LLM 프롬프트에 넣지 않았다는 것입니다.
즉 이 값들은 advisor 체인 내부에서만 공유되는 메타데이터로 쓸 수 있습니다.
다른 Advisor가 이 값을 이어서 쓰는 것도 가능합니다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
public class RetrievalCountAdvisor implements CallAdvisor {
@Override
public String getName() {
return "retrieval-count-advisor";
}
@Override
public int getOrder() {
return 20;
}
@Override
public ChatClientResponse adviseCall(ChatClientRequest request, CallAdvisorChain chain) {
String tenantId = (String) request.context().get("tenantId");
// tenantId를 기반으로 다른 vector store, 필터, index를 선택할 수 있음
ChatClientResponse response = chain.nextCall(request);
return response.mutate()
.context("retrievalCount", 3)
.context("resolvedTenant", tenantId)
.build();
}
}
이렇게 하면 응답을 꺼내는 쪽에서도 ChatClientResponse를 통해 advisor chain 내부 정보를 함께 볼 수 있습니다.
1
2
3
4
5
6
7
8
9
10
11
12
ChatClientResponse response = chatClient.prompt()
.advisors(
new TenantTraceAdvisor(),
new RetrievalCountAdvisor()
)
.user("우리 팀 문서 기준으로 Spring AI MCP 도입 포인트를 요약해줘.")
.call()
.chatClientResponse();
String answer = response.chatResponse().getResult().getOutput().getText();
String tenantId = (String) response.context().get("resolvedTenant");
Integer retrievalCount = (Integer) response.context().get("retrievalCount");
이 패턴은 특히 아래 같은 경우에 유용합니다.
- tenant별 검색 인덱스 선택
- traceId, correlationId 전달
- 내부 감사 로그용 메타데이터 전달
- 응답 후 메트릭 계산
중요한 점은 advise-context와 prompt는 다르다는 것입니다.
advisor context에 저장했다고 해서 자동으로 LLM이 그 값을 보는 것은 아닙니다. 정말 모델에게 보여주고 싶다면 advisor가 직접 prompt를 수정해야 합니다.
즉 아래처럼 구분하면 헷갈림이 줄어듭니다.
| 위치 | 용도 | LLM에 전달되나 |
|---|---|---|
| Prompt 파라미터 | 모델이 읽어야 하는 실제 지시/문맥 | 전달됨 |
| Advisor context | advisor 체인 내부 상태 공유 | 자동 전달되지 않음 |
| ToolContext | tool 실행 시 필요한 내부 정보 | 전달되지 않음 |
4. 런타임 advisor 파라미터: ChatMemory.CONVERSATION_ID
advisor를 Bean으로 등록해두더라도, 실제 호출마다 바뀌는 값은 런타임에 넘겨야 하는 경우가 많습니다.
그 대표적인 예시가 ChatMemory.CONVERSATION_ID입니다.
공식 문서 기준 ChatMemory.CONVERSATION_ID는 advisor context에서 대화 식별자를 꺼내기 위한 키입니다.
즉 같은 MessageChatMemoryAdvisor를 쓰더라도, 어떤 대화방의 메모리를 읽고 쓸지 호출 시점에 정할 수 있습니다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
ChatMemory chatMemory = MessageWindowChatMemory.builder()
.maxMessages(20)
.build();
ChatClient chatClient = ChatClient.builder(chatModel)
.defaultAdvisors(MessageChatMemoryAdvisor.builder(chatMemory).build())
.build();
String conversationId = "room-42";
String answer = chatClient.prompt()
.advisors(a -> a.param(ChatMemory.CONVERSATION_ID, conversationId))
.user("내가 방금 전에 뭐 물어봤는지 기억해?")
.call()
.content();
이 코드는 의미상 아래처럼 동작합니다.
MessageChatMemoryAdvisor는 advisor parameter에서ChatMemory.CONVERSATION_ID를 찾습니다.- 값이
room-42라면 해당 대화방의 메모리만 조회합니다. - 응답이 끝나면 같은
room-42메모리에 현재 대화 내용을 저장합니다.
즉 conversation ID를 제대로 분리해야 사용자 A와 사용자 B의 대화가 섞이지 않습니다.
왜 conversation ID 분리가 중요한가?
예를 들어 같은 서버에서 여러 사용자의 요청을 처리한다고 해보겠습니다.
- 사용자 A: “내 이름은 철수야”
- 사용자 B: “내 이름은 영희야”
이때 conversation ID를 제대로 분리하지 않으면, 다음 질의에서 메모리가 엉킬 수 있습니다.
1
2
3
4
5
String answer = chatClient.prompt()
.advisors(a -> a.param(ChatMemory.CONVERSATION_ID, "user-a"))
.user("내 이름이 뭐였지?")
.call()
.content();
위처럼 사용자별 또는 세션별 conversation ID를 명시해두면, 메모리는 user-a 범위 안에서만 조회됩니다.
실무에서는 보통 아래 기준으로 conversation ID를 잡습니다.
- 웹 서비스 채팅창:
sessionId - 로그인 사용자 단위:
userId - 팀/채널 대화:
workspaceId:channelId - 고객 상담 건별:
ticketId
기본 conversation ID에만 의존하면 왜 위험할까?
ChatMemory에는 DEFAULT_CONVERSATION_ID도 존재합니다.
하지만 운영 환경에서는 기본값에만 의존하기보다, 반드시 호출별 conversation ID를 명시적으로 넣는 편이 안전합니다.
특히 멀티유저 환경에서는 이 값이 빠지면 메모리 분리 전략이 불명확해지고, 대화가 섞일 위험이 생깁니다.
다른 advisor 파라미터와 같이 쓰기
Spring AI에서는 advisor parameter를 여러 개 함께 넣을 수 있습니다.
1
2
3
4
5
6
ActorFilms actorFilms = chatClient.prompt()
.advisors(a -> a.param(ChatMemory.CONVERSATION_ID, "user-100"))
.advisors(AdvisorParams.ENABLE_NATIVE_STRUCTURED_OUTPUT)
.user("Tom Hanks 영화 5개를 actor와 movies 구조로 정리해줘.")
.call()
.entity(ActorFilms.class);
즉 한 호출 안에서
- 메모리 분리용 advisor 파라미터
- structured output 활성화 파라미터
를 함께 조합할 수 있습니다.
5. Custom Advisor 직접 만들어보기
기본 제공되는 MessageChatMemoryAdvisor, QuestionAnswerAdvisor, SimpleLoggerAdvisor 만으로 대부분의 요구는 커버됩니다.
하지만 실제 서비스에서는 곧 다음과 같은 요구가 생깁니다.
- 너무 짧은 질문은 LLM에 보내지 말고 빠르게 차단하고 싶다.
- 모델이 질문을 더 잘 이해하도록 프롬프트 자체를 보강하고 싶다.
- 민감 단어가 포함된 요청은 자동 차단하고 정해진 메시지를 응답하고 싶다.
- 요청과 응답을 통째로 로그로 남기되, 어떤 advisor는 raw payload, 어떤 advisor는 핵심 텍스트만 남기게 분리하고 싶다.
이 요구들은 모두 CallAdvisor, StreamAdvisor, BaseAdvisor를 직접 구현해서 해결할 수 있습니다.
1) 입력 가드: CheckCharSizeAdvisor
CallAdvisor만 구현해서 “조건이 안 맞으면 LLM에 보내기 전 예외를 던지는” 가장 단순한 형태입니다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
// 사용자 질문 길이가 너무 짧으면 LLM 호출 전에 예외를 던지는 advisor
@Slf4j
public class CheckCharSizeAdvisor implements CallAdvisor {
@Override
public ChatClientResponse adviseCall(ChatClientRequest request, CallAdvisorChain chain) {
// 호출 전 길이 검증
String userText = request.prompt().getUserMessage().getText();
if (userText.length() < 2) {
log.warn("prompt size too short. length={}", userText.length());
throw new PromptTooShortException("Char size too short");
}
// 정상이면 체인을 그대로 이어서 LLM 호출
return chain.nextCall(request);
}
@Override
public String getName() {
return this.getClass().getSimpleName();
}
@Override
public int getOrder() {
// 입력 검증은 가장 앞쪽에서 동작해야 하므로 최상위 우선순위
return Ordered.HIGHEST_PRECEDENCE;
}
}
여기서 던진 PromptTooShortException은 일반 Spring MVC와 동일하게 @RestControllerAdvice로 잡아 사용자에게 정형화된 에러를 돌려주면 됩니다.
즉 Advisor는 “비즈니스 가드”를 LLM 호출 앞에 두는 자리로 쓸 수 있다는 게 포인트입니다.
Advisor에서 예외를 던지는 방식은
call()흐름에서는 자연스럽지만,stream()흐름에서는 사용자가 토큰을 받기 시작한 뒤에는 효과가 떨어집니다.
그래서 입력 가드는 보통 stream 시작 전, 즉before시점에 두는 것이 안전합니다.
2) 프롬프트 보강: ReReadingAdvisor (Re2 패턴)
BaseAdvisor를 구현하면 before/after 훅으로 요청·응답을 자연스럽게 변형할 수 있습니다.
다음 예시는 “사용자 질문을 한 번 더 읽도록” 모델에게 강제하는 Re-Reading 프롬프트 패턴을 advisor로 만든 것입니다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
public class ReReadingAdvisor implements BaseAdvisor {
// 사용자 질문을 한 번 더 반복해 LLM이 핵심 의도를 다시 읽도록 유도
private static final String DEFAULT_RE2_ADVISE_TEMPLATE = """
{re2_input_query}
Read the question again: {re2_input_query}
""";
private final String re2AdviseTemplate;
private int order = 0;
public ReReadingAdvisor() {
this(DEFAULT_RE2_ADVISE_TEMPLATE);
}
public ReReadingAdvisor(String re2AdviseTemplate) {
this.re2AdviseTemplate = re2AdviseTemplate;
}
@Override
public ChatClientRequest before(ChatClientRequest request, AdvisorChain chain) {
// PromptTemplate으로 사용자 메시지를 다시 렌더링
String augmentedUserText = PromptTemplate.builder()
.template(this.re2AdviseTemplate)
.variables(Map.of("re2_input_query", request.prompt().getUserMessage().getText()))
.build()
.render();
// augmentUserMessage로 user message만 교체한 새 prompt를 만들어 체인에 흘려보냄
return request.mutate()
.prompt(request.prompt().augmentUserMessage(augmentedUserText))
.build();
}
@Override
public ChatClientResponse after(ChatClientResponse response, AdvisorChain chain) {
return response;
}
@Override
public int getOrder() {
return this.order;
}
}
BaseAdvisor의 장점은 다음과 같습니다.
before/after만 구현하면 되므로 call/stream 두 흐름 모두 자동으로 처리됩니다.- 요청 변형(
mutate()), 응답 변형 둘 다 자연스럽게 표현할 수 있습니다. - “프롬프트를 살짝 보강하는 횡단 정책”에 가장 잘 어울립니다.
3) 정책 객체로 분리하기: SafeGuardPolicy
가드레일을 advisor로 만들 때 흔히 빠지는 함정이 있습니다.
“민감 단어 목록을 advisor 안에 하드코딩” 하는 패턴인데, 이 경우 정책 변경마다 코드를 고치고 배포해야 합니다.
샘플 레포는 정책을 record로 분리해서 advisor가 정책 자체를 주입받게 합니다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
public record SafeGuardPolicy(List<String> sensitiveWords, String blockedMessage) {
public static final String DEFAULT_BLOCKED_MESSAGE = "사용자의 질문에 문제가 있는 단어가 있으면 시스템에 요청 할수 없습니다.";
// 외부 리소스(텍스트 파일)에서 민감 단어 목록을 로드
public static SafeGuardPolicy fromResource(Resource resource) {
try {
String content = resource.getContentAsString(StandardCharsets.UTF_8);
List<String> words = content.lines()
.map(String::trim)
.filter(line -> !line.isEmpty())
.filter(line -> !line.startsWith("#"))
.toList();
if (words.isEmpty()) {
throw new IllegalStateException("SafeGuard 민감 단어 목록이 비어 있습니다.");
}
return new SafeGuardPolicy(words, DEFAULT_BLOCKED_MESSAGE);
}
catch (IOException exception) {
throw new UncheckedIOException("SafeGuard 민감 단어 목록을 읽을 수 없습니다.", exception);
}
}
}
그리고 정책 자체를 @Bean으로 노출합니다.
1
2
3
4
5
6
7
8
9
10
@Configuration
public class AdvisorConfig {
// 민감 단어 목록을 외부 파일에서 로드하여 정책 Bean으로 제공
@Bean
SafeGuardPolicy safeGuardPolicy(
@Value("classpath:advisors/sensitive-words.txt") Resource sensitiveWordsResource) {
return SafeGuardPolicy.fromResource(sensitiveWordsResource);
}
}
이 구조의 장점은 명확합니다.
- 민감 단어 목록 같은 정책은 데이터, advisor는 행동 이라는 책임 분리가 가능합니다.
- 정책 데이터가 코드에서 분리되니, 운영 중 갱신이 쉬워집니다.
- 같은 정책을 여러 advisor가 공유할 수 있습니다.
보안성이 매우 중요한 가드 정책은 단순 단어 매칭만으로는 부족할 수 있으므로, 운영 단계에서는 별도 검출 모델이나 정책 엔진과 조합하는 편이 안전합니다.
4) 같은 역할, 다른 추상화 레벨: SimpleLoggerAdvisorHigh vs SimpleLoggerAdvisorLow
같은 “로깅 advisor”라도, 어디까지 로그로 남길지에 따라 구현이 달라집니다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
// 요청/응답 객체 전체를 통째로 출력 (raw payload 디버깅용)
@Slf4j
public class SimpleLoggerAdvisorHigh implements CallAdvisor, StreamAdvisor {
@Override
public ChatClientResponse adviseCall(ChatClientRequest request, CallAdvisorChain chain) {
log.info("SimpleLoggerAdvisorHigh request: {}", request);
ChatClientResponse response = chain.nextCall(request);
log.info("SimpleLoggerAdvisorHigh response: {}", response);
return response;
}
@Override
public Flux<ChatClientResponse> adviseStream(ChatClientRequest request, StreamAdvisorChain chain) {
log.info("SimpleLoggerAdvisorHigh request: {}", request);
// 스트림 응답은 ChatClientMessageAggregator로 모아서 한 번에 로깅
return new ChatClientMessageAggregator()
.aggregateChatClientResponse(chain.nextStream(request),
res -> log.info("SimpleLoggerAdvisorHigh response: {}", res));
}
@Override
public int getOrder() {
return Ordered.HIGHEST_PRECEDENCE + 1;
}
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
// System / User / Assistant 메시지 텍스트만 깔끔하게 추출해서 출력
@Slf4j
public class SimpleLoggerAdvisorLow implements CallAdvisor, StreamAdvisor {
@Override
public ChatClientResponse adviseCall(ChatClientRequest request, CallAdvisorChain chain) {
String systemMessage = request.prompt().getSystemMessage().getText();
String userMessage = request.prompt().getUserMessage().getText();
log.info("System Message: {}, User Message: {}", systemMessage, userMessage);
ChatClientResponse response = chain.nextCall(request);
String content = Objects.requireNonNull(response.chatResponse())
.getResult().getOutput().getText();
log.info("Response Message: {}", content);
return response;
}
@Override
public int getOrder() {
// High보다 안쪽에 위치 → 메모리/RAG 등이 적용된 최종 프롬프트를 본다
return Ordered.HIGHEST_PRECEDENCE + 3;
}
}
같은 “로깅”이지만 두 advisor는 보는 그림이 다릅니다.
High: 거의 원본에 가까운 요청을 보고 싶을 때 (raw payload 디버깅)Low: 메모리/RAG가 다 붙은 뒤의 실제 텍스트를 보고 싶을 때
즉 advisor는 “무엇을 로깅하는가”보다 “체인의 어느 위치에서 보는가” 가 더 중요합니다.
Call vs Stream vs Base — 어떤 걸 구현해야 할까?
샘플 레포 advisor들을 보면 인터페이스 선택 기준이 보입니다.
| 구현 인터페이스 | 적합한 경우 | 예시 |
|---|---|---|
CallAdvisor | call 흐름만 처리, 가드/예외/단순 로깅 | CheckCharSizeAdvisor |
CallAdvisor + StreamAdvisor | call/stream 둘 다 동일하게 처리, 스트림은 aggregator로 모아 처리 | SimpleLoggerAdvisorHigh/Low |
BaseAdvisor | before/after만 구현해 요청·응답을 자연스럽게 변형 | ReReadingAdvisor |
실무 감각으로는 다음이 잘 맞습니다.
- 입력 검증/차단 →
CallAdvisor단독 - 프롬프트 변형 →
BaseAdvisor - 요청/응답 관찰(로깅/메트릭) →
CallAdvisor+StreamAdvisor+ChatClientMessageAggregator
Custom Advisor를 만들 때 자주 빠지는 함정
Ordered.HIGHEST_PRECEDENCE로 막 도배하면 어떤 advisor가 먼저인지 알 수 없습니다. 가드/로깅/메모리/RAG 순서로 의도적으로 격자를 잡아야 합니다.stream응답을 한 토큰씩 로깅하면 로그가 폭증합니다. 반드시ChatClientMessageAggregator로 모아서 한 번에 로깅하는 편이 좋습니다.- 정책 데이터(민감 단어, 화이트리스트 등)는 advisor 클래스 내부가 아니라 외부 리소스/Bean으로 분리하는 편이 운영에 유리합니다.
- advisor에서 prompt를 직접 갈아끼울 때는 반드시
request.mutate().prompt(...)로 새 객체를 만들어 흘려야 합니다. 원본을 직접 수정하면 immutability를 깨뜨려 옆 advisor가 영향을 받습니다.
정리
Advisor는 “ChatClient 호출을 가로채는 인터셉터”라는 단순한 추상화로 시작하지만, 운영 단계로 갈수록 다음을 결정하는 핵심 자리가 됩니다.
- 어디에 메모리/RAG/가드/로깅을 끼울 것인가
- 순서를 어떻게 잡을 것인가 (
getOrder()) - 어떤 정보를 prompt에 넣고, 어떤 정보를 advisor-context로만 흘릴 것인가
- 어떤 정보는 호출 시점에 advisor 파라미터로 넘길 것인가 (
ChatMemory.CONVERSATION_ID)
샘플 레포의 CheckCharSizeAdvisor, ReReadingAdvisor, SafeGuardPolicy, SimpleLoggerAdvisorHigh/Low는 각각 가드 / 프롬프트 보강 / 정책 객체 분리 / 같은 역할 다른 위치 라는 네 가지 패턴을 보여줍니다. 처음 직접 advisor를 만들 때는 이 네 가지 중 하나에서 시작하는 것이 가장 안전합니다.
다음 글
이전 글이 궁금하다면:
