Chat Client API

Chat Client API

ChatClient은 AI 모델과 통신하기 위한 fluent API를 제공해요. 동기(synchronous)와 스트리밍 프로그래밍 모델을 모두 지원해요.

참고: ChatClient에서 명령형(imperative)과 리액티브(reactive) 프로그래밍 모델을 결합해 쓰는 것에 관련된 문서 하단의 구현 메모를 참고하세요.

fluent API는 AI 모델에 입력으로 전달되는 Prompt의 구성 요소를 빌드하는 메서드를 가져요. Prompt는 AI 모델의 출력과 동작을 안내하는 지시 텍스트를 담아요. API 관점에서 프롬프트는 메시지의 컬렉션으로 구성돼요.

AI 모델은 두 가지 주요 메시지 타입을 처리해요: 사용자의 직접 입력인 사용자 메시지, 대화를 안내하기 위해 시스템이 생성하는 시스템 메시지예요.

이 메시지들은 사용자 입력에 따라 런타임에 치환되는 자리 표시자(placeholder)를 자주 포함해서, AI 모델의 응답을 사용자 입력에 맞게 커스터마이즈해요.

또한 사용할 AI 모델 이름, 생성 출력의 무작위성·창의성을 제어하는 temperature 설정 같은 Prompt 옵션도 지정할 수 있어요.

출처: 공식문서

ChatClient 생성하기

ChatClientChatClient.Builder 객체로 생성돼요. 어떤 ChatModel Spring Boot 자동 구성에 대해서든 자동 구성된 ChatClient.Builder 인스턴스를 얻거나, 프로그래매틱하게 만들 수 있어요.

자동 구성된 ChatClient.Builder 사용

가장 단순한 사용 사례에서 Spring AI는 Spring Boot 자동 구성을 제공해, 클래스에 주입할 prototype ChatClient.Builder 빈을 만들어 줘요. 간단한 사용자 요청에 String 응답을 가져오는 예시예요:

@RestController
class MyController {

    private final ChatClient chatClient;

    public MyController(ChatClient.Builder chatClientBuilder) {
        this.chatClient = chatClientBuilder.build();
    }

    @GetMapping("/ai")
    String generation(String userInput) {
        return this.chatClient.prompt()
            .user(userInput)
            .call()
            .content();
    }
}

이 간단한 예시에서 사용자 입력이 사용자 메시지의 내용을 설정해요. call() 메서드가 AI 모델에 요청을 보내고, content() 메서드가 AI 모델의 응답을 String으로 반환해요.

여러 Chat 모델 다루기

단일 애플리케이션에서 여러 채팅 모델로 작업해야 하는 시나리오가 있어요:

  • 작업 유형별로 다른 모델 사용(예: 복잡한 추론엔 강력한 모델, 단순 작업엔 빠르고 저렴한 모델)
  • 한 모델 서비스가 불가능할 때 폴백 메커니즘 구현
  • 다른 모델·구성의 A/B 테스트
  • 사용자 선호에 따른 모델 선택 제공
  • 전문화된 모델 결합(하나는 코드 생성, 다른 하나는 창의적 콘텐츠 등)

기본적으로 Spring AI는 단일 ChatClient.Builder 빈을 자동 구성해요. 하지만 애플리케이션에서 여러 채팅 모델로 작업해야 할 수 있어요. 이 시나리오를 다루는 법이에요.

단일 모델 타입으로 여러 ChatClient

이 섹션은 모두 같은 기본 모델 타입을 쓰지만 구성이 다른 여러 ChatClient 인스턴스를 만드는 흔한 사용 사례를 다뤄요. 자동 구성된 ChatClient.Builder를 prototype 범위로 그대로 쓸 수 있어요 — 주입 지점마다 새 인스턴스가 만들어지니까요:

@Configuration
class ChatClientConfig {

    @Bean
    ChatClient defaultChatClient(ChatClient.Builder builder) {
        return builder.build();
    }

    @Bean
    ChatClient customChatClient(ChatClient.Builder builder) {
        return builder.defaultSystem("You are a helpful assistant.").build();
    }
}

다른 모델 타입용 ChatClient

여러 AI 모델로 작업할 때 ChatClient.create(chatModel)이나 ChatClient.builder(chatModel)로 별도의 ChatClient 빈을 정의하고 싶을 수 있어요. 하지만 그러면 자동 구성된 ChatClient.Builder를 우회해서, 관측성과 ChatClientBuilderCustomizer 빈이 무시돼요.

관측성과 커스터마이저를 유지하려면 커스텀 빌더를 만들기 위해 ChatClientBuilderConfigurer를 주입해야 해요. ChatClientBuilderConfigurer는 등록된 모든 ChatClientBuilderCustomizer 빈을 적용하고 관측성을 연결해서, 자동 구성이 내부적으로 하는 일을 그대로 해요.

애플리케이션 컨텍스트에 여러 ChatModel 빈이 있으면 Spring은 자동 구성된 ChatClient.Builder 빈의 ChatModel 의존성을 모호함 없이 해결할 수 없어요. 이를 해결하려면 ChatClient 빈 중 하나를 @Primary로 표시하세요. ChatModel 빈을 수동 정의한다면 그중 하나를 @Primary로 표시해야 할 수도 있고, 대안으로 자동 구성된 것을 덮어쓰는 자신만의 ChatClient.Builder 빈을 정의해도 돼요.

import io.micrometer.observation.ObservationRegistry;
import org.springframework.ai.anthropic.AnthropicChatModel;
import org.springframework.ai.chat.client.ChatClient;
import org.springframework.ai.chat.client.advisor.ToolCallingAdvisor;
import org.springframework.ai.chat.client.advisor.observation.AdvisorObservationConvention;
import org.springframework.ai.chat.client.observation.ChatClientObservationConvention;
import org.springframework.ai.chat.model.ChatModel;
import org.springframework.ai.model.chat.client.autoconfigure.ChatClientBuilderConfigurer;
import org.springframework.ai.openai.OpenAiChatModel;
import org.springframework.beans.factory.ObjectProvider;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.Primary;

@Configuration
public class ChatClientConfig {

    @Bean
    @Primary
    public ChatClient openAiChatClient(OpenAiChatModel chatModel, ChatClientBuilderConfigurer configurer,
            ObjectProvider<ObservationRegistry> observationRegistry,
            ObjectProvider<ChatClientObservationConvention> chatClientObservationConvention,
            ObjectProvider<AdvisorObservationConvention> advisorObservationConvention,
            ObjectProvider<ToolCallingAdvisor.Builder<?>> toolCallingAdvisorBuilder) {
        return buildChatClient(chatModel, configurer, observationRegistry,
                chatClientObservationConvention, advisorObservationConvention, toolCallingAdvisorBuilder);
    }

    @Bean
    public ChatClient anthropicChatClient(AnthropicChatModel chatModel, ChatClientBuilderConfigurer configurer,
            ObjectProvider<ObservationRegistry> observationRegistry,
            ObjectProvider<ChatClientObservationConvention> chatClientObservationConvention,
            ObjectProvider<AdvisorObservationConvention> advisorObservationConvention,
            ObjectProvider<ToolCallingAdvisor.Builder<?>> toolCallingAdvisorBuilder) {
        return buildChatClient(chatModel, configurer, observationRegistry,
                chatClientObservationConvention, advisorObservationConvention, toolCallingAdvisorBuilder);
    }

    private ChatClient buildChatClient(ChatModel chatModel, ChatClientBuilderConfigurer configurer,
            ObjectProvider<ObservationRegistry> observationRegistry,
            ObjectProvider<ChatClientObservationConvention> chatClientObservationConvention,
            ObjectProvider<AdvisorObservationConvention> advisorObservationConvention,
            ObjectProvider<ToolCallingAdvisor.Builder<?>> toolCallingAdvisorBuilder) {
        ChatClient.Builder builder = ChatClient.builder(chatModel,
                observationRegistry.getIfUnique(() -> ObservationRegistry.NOOP),
                chatClientObservationConvention.getIfUnique(),
                advisorObservationConvention.getIfUnique(),
                toolCallingAdvisorBuilder.getIfAvailable());
        return configurer.configure(builder).build();
    }
}

그런 다음 @Qualifier 어노테이션으로 이 빈들을 애플리케이션 컴포넌트에 주입할 수 있어요:


@Configuration
public class ChatClientExample {
    
    @Bean
    CommandLineRunner cli(
            @Qualifier("openAiChatClient") ChatClient openAiChatClient,
            @Qualifier("anthropicChatClient") ChatClient anthropicChatClient) {
        
        return args -> {
            var scanner = new Scanner(System.in);
            ChatClient chat;
            
            // Model selection
            System.out.println("\nSelect your AI model:");
            System.out.println("1. OpenAI");
            System.out.println("2. Anthropic");
            System.out.print("Enter your choice (1 or 2): ");
            
            String choice = scanner.nextLine().trim();
            
            if (choice.equals("1")) {
                chat = openAiChatClient;
                System.out.println("Using OpenAI model");
            } else {
                chat = anthropicChatClient;
                System.out.println("Using Anthropic model");
            }
            
            // Use the selected chat client
            System.out.print("\nEnter your question: ");
            String input = scanner.nextLine();
            String response = chat.prompt(input).call().content();
            System.out.println("ASSISTANT: " + response);
            
            scanner.close();
        };
    }
}

여러 OpenAI 호환 API 엔드포인트

빌더를 사용해 여러 OpenAiChatModel 인스턴스를 만들어 서로 다른 OpenAI 호환 API에 연결할 수 있어요. 여러 공급자로 작업할 때 특히 유용해요.


@Service
public class MultiModelService {
    
    private static final Logger logger = LoggerFactory.getLogger(MultiModelService.class);
    
    public void multiClientFlow() {
        try {
            // Create a new OpenAiChatModel for Groq (Llama3)
            OpenAiChatModel groqModel = OpenAiChatModel.builder()
                .options(OpenAiChatOptions.builder()
                    .baseUrl("https://api.groq.com/openai/v1")
                    .apiKey(System.getenv("GROQ_API_KEY"))
                    .model("llama3-70b-8192")
                    .temperature(0.5)
                    .build())
                .build();
            
            // Create a new OpenAiChatModel for GPT-4
            OpenAiChatModel gpt4Model = OpenAiChatModel.builder()
                .options(OpenAiChatOptions.builder()
                    .baseUrl("https://api.openai.com")
                    .apiKey(System.getenv("OPENAI_API_KEY"))
                    .model("gpt-4")
                    .temperature(0.7)
                    .build())
                .build();
            
            // Simple prompt for both models
            String prompt = "What is the capital of France?";
            
            String groqResponse = ChatClient.builder(groqModel).build().prompt(prompt).call().content();
            String gpt4Response = ChatClient.builder(gpt4Model).build().prompt(prompt).call().content();
            
            logger.info("Groq (Llama3) response: {}", groqResponse);
            logger.info("OpenAI GPT-4 response: {}", gpt4Response);
        }
        catch (Exception e) {
            logger.error("Error in multi-client flow", e);
        }
    }
}

ChatClient Fluent API

ChatClient fluent API는 오버로드된 prompt 메서드로 fluent API를 시작하는 세 가지 방식으로 프롬프트를 만들 수 있어요:

  • prompt(): 인자 없는 메서드로 fluent API를 시작하며, 사용자·시스템·다른 프롬프트 부분을 빌드할 수 있게 해줘요.
  • prompt(Prompt prompt): Prompt 인자를 받아, Prompt의 비-fluent API로 만든 Prompt 인스턴스를 전달할 수 있게 해줘요.
  • prompt(String content): 이전 오버로드와 유사한 편의 메서드예요. 사용자 텍스트 콘텐츠를 받아요.

ChatClient 응답

ChatClient API는 fluent API를 이용해 AI 모델 응답을 여러 방식으로 포맷할 수 있게 해줘요.

ChatResponse 반환

AI 모델의 응답은 ChatResponse 타입으로 정의된 풍부한 구조예요. 응답이 어떻게 생성됐는지에 대한 메타데이터를 포함하고, 각자 자체 메타데이터를 가진 여러 응답(Generation)을 담을 수도 있어요. 메타데이터에는 응답을 만드는 데 쓴 토큰 수(토큰 하나는 대략 단어의 3/4)가 포함돼요. 호스팅 AI 모델이 요청당 사용 토큰 수로 비용을 청구하므로 이 정보는 중요해요.

call() 메서드 뒤에 chatResponse()를 호출해 메타데이터를 담은 ChatResponse 객체를 반환하는 예시예요:

ChatResponse chatResponse = chatClient.prompt()
    .user("Tell me a joke")
    .call()
    .chatResponse();

Entity 반환

반환된 String에서 매핑한 엔티티 클래스를 반환하고 싶은 경우가 많아요. entity() 메서드가 이 기능을 제공해요.

예를 들어 Java record가 주어졌을 때:

record ActorFilms(String actor, List<String> movies) {}

entity() 메서드로 AI 모델 출력을 이 record로 쉽게 매핑할 수 있어요:

ActorFilms actorFilms = chatClient.prompt()
    .user("Generate the filmography for a random actor.")
    .call()
    .entity(ActorFilms.class);

entity(ParameterizedTypeReference<T> type) 시그니처의 오버로드도 있는데, generic List 같은 타입을 지정할 수 있게 해줘요:

List<ActorFilms> actorFilms = chatClient.prompt()
    .user("Generate the filmography of 5 movies for Tom Hanks and Bill Murray.")
    .call()
    .entity(new ParameterizedTypeReference<List<ActorFilms>>() {});

신뢰성 스위치: EntityParamSpec

entity() 오버로드는 모두 선택적 Consumer<EntityParamSpec>를 받아 두 가지 독립적이고 조합 가능한 동작을 활성화해요:

  • validateSchema() — JSON 응답을 엔티티 스키마로 검증하고, 실패 시 오류 피드백으로 자동 재시도.
  • useProviderStructuredOutput() — 프롬프트 텍스트 대신 API 레벨 제약으로 스키마를 공급자에게 전송.
ActorFilms actorFilms = chatClient.prompt()
    .user("Generate the filmography for a random actor.")
    .call()
    .entity(ActorFilms.class, spec -> spec
        .useProviderStructuredOutput()
        .validateSchema());

이 스위치·지원 공급자·한계·저수준 변환기 API의 전체 내용은 Structured Output 참조를 보세요.

스트리밍 응답

stream() 메서드로 비동기 응답을 받을 수 있어요:


Flux<String> output = chatClient.prompt()
    .user("Tell me a joke")
    .stream()
    .content();

Flux<ChatResponse> chatResponse() 메서드로 ChatResponse를 스트리밍할 수도 있어요.

앞으로 reactive stream() 메서드로 Java 엔티티를 반환하는 편의 메서드를 제공할 예정이에요. 그동안 Structured Output Converter를 사용해 집계된 응답을 명시적으로 변환해야 해요. 아래에 그 방법을 보여드리며, 문서 뒷부분에서 자세히 다룰 fluent API의 파라미터 사용도 함께 보여줘요.

var converter = new BeanOutputConverter<>(new ParameterizedTypeReference<List<ActorsFilms>>() {});

Flux<String> flux = this.chatClient.prompt()
    .user(u -> u.text("""
                        Generate the filmography for a random actor.
                        {format}
                      """)
            .param("format", this.converter.getFormat()))
    .stream()
    .content();

String content = this.flux.collectList().block().stream().collect(Collectors.joining());

List<ActorsFilms> actorFilms = this.converter.convert(this.content);

프롬프트 템플릿

ChatClient fluent API는 런타임에 치환되는 변수가 있는 템플릿으로 사용자·시스템 텍스트를 제공하게 해줘요.

String answer = ChatClient.create(chatModel).prompt()
    .user(u -> u
            .text("Tell me the names of 5 movies whose soundtrack was composed by {composer}")
            .param("composer", "John Williams"))
    .call()
    .content();

내부적으로 ChatClient는 PromptTemplate 클래스를 사용해 사용자·시스템 텍스트를 처리하고, 주어진 TemplateRenderer 구현에 의존해 런타임에 제공된 값으로 변수를 치환해요. 기본적으로 Spring AI는 Terence Parr가 개발한 오픈소스 StringTemplate 엔진 기반의 StTemplateRenderer 구현을 사용해요.

Spring AI는 템플릿 처리가 필요 없는 경우용 NoOpTemplateRenderer도 제공해요.

참고: ChatClient에 직접 구성된(.templateRenderer()로) TemplateRendererChatClient 빌더 체인에서 직접 정의한 프롬프트 콘텐츠(예: .user(), .system())에만 적용돼요. QuestionAnswerAdvisor 같은 Advisors가 내부적으로 쓰는 템플릿에는 영향을 주지 않아요 — 그들은 자체 템플릿 커스터마이즈 메커니즘이 있어요(Custom Advisor Templates 참고).

다른 템플릿 엔진을 쓰고 싶다면 TemplateRenderer 인터페이스의 커스텀 구현을 ChatClient에 직접 제공할 수 있어요. 기본 StTemplateRenderer를 커스텀 구성과 함께 계속 쓸 수도 있어요.

예를 들어 기본적으로 템플릿 변수는 {} 문법으로 식별돼요. 프롬프트에 JSON을 포함할 계획이라면 JSON 문법과 충돌하지 않도록 다른 문법을 쓰고 싶을 수 있어요. 예를 들어 <> 구분자(delimiter)를 쓰면 돼요.

String answer = ChatClient.create(chatModel).prompt()
    .user(u -> u
            .text("Tell me the names of 5 movies whose soundtrack was composed by <composer>")
            .param("composer", "John Williams"))
    .templateRenderer(StTemplateRenderer.builder().startDelimiterToken('<').endDelimiterToken('>').build())
    .call()
    .content();

call() 반환값

ChatClientcall() 메서드를 지정한 뒤 응답 타입에 몇 가지 옵션이 있어요.

  • String content(): 응답의 String 콘텐츠 반환
  • ChatResponse chatResponse(): 여러 생성과 응답 메타데이터(예: 응답 생성에 쓴 토큰 수)를 담은 ChatResponse 객체 반환
  • ChatClientResponse chatClientResponse(): ChatResponse 객체와 ChatClient 실행 컨텍스트를 담은 ChatClientResponse 객체 반환 — 어드바이저 실행 중 사용된 추가 데이터(예: RAG 흐름에서 검색된 관련 문서)에 접근 가능
  • entity(): Java 타입 반환
    • entity(ParameterizedTypeReference<T> type): Collection 엔티티 타입 반환용
    • entity(Class<T> type): 특정 엔티티 타입 반환용
    • entity(StructuredOutputConverter<T> structuredOutputConverter): String을 엔티티 타입으로 변환할 StructuredOutputConverter 인스턴스 지정용
    • entity(ParameterizedTypeReference<T> type, Consumer<EntityParamSpec> spec): 위와 같고 선택적 EntityParamSpec 구성 포함
    • entity(Class<T> type, Consumer<EntityParamSpec> spec): 위와 같고 선택적 EntityParamSpec 구성 포함
    • entity(StructuredOutputConverter<T> converter, Consumer<EntityParamSpec> spec): 위와 같고 선택적 EntityParamSpec 구성 포함
  • responseEntity(): ChatResponse와 Java 타입을 모두 반환. 한 호출에서 완전한 AI 모델 응답(메타데이터·생성 포함)과 구조화 출력 엔티티 둘 다 필요할 때 유용
    • responseEntity(Class<T> type): 완전한 ChatResponse 객체와 특정 엔티티 타입을 담은 ResponseEntity 반환용
    • responseEntity(Class<T> type, Consumer<EntityParamSpec> spec): 위와 같고 선택적 EntityParamSpec 구성 포함
    • responseEntity(ParameterizedTypeReference<T> type): 완전한 ChatResponse 객체와 Collection 엔티티 타입을 담은 ResponseEntity 반환용
    • responseEntity(ParameterizedTypeReference<T> type, Consumer<EntityParamSpec> spec): 위와 같고 선택적 EntityParamSpec 구성 포함
    • responseEntity(StructuredOutputConverter<T> structuredOutputConverter): 완전한 ChatResponse 객체와 지정된 StructuredOutputConverter로 변환된 엔티티를 담은 ResponseEntity 반환용
    • responseEntity(StructuredOutputConverter<T> converter, Consumer<EntityParamSpec> spec): 위와 같고 선택적 EntityParamSpec 구성 포함

call() 대신 stream() 메서드를 호출할 수도 있어요.

참고: call() 메서드를 호출하는 것이 실제 AI 모델 실행을 트리거하지 않아요. 단지 Spring AI에게 동기·스트리밍 중 어느 것을 쓸지 지시할 뿐이에요. 실제 AI 모델 호출은 content(), chatResponse(), responseEntity() 같은 메서드를 호출할 때 발생해요.

stream() 반환값

ChatClientstream() 메서드를 지정한 뒤 응답 타입 옵션이 있어요:

  • Flux<String> content(): AI 모델이 생성하는 문자열의 Flux 반환.
  • Flux<ChatResponse> chatResponse(): 응답에 대한 추가 메타데이터를 담은 ChatResponse 객체의 Flux 반환.
  • Flux<ChatClientResponse> chatClientResponse(): ChatResponse 객체와 ChatClient 실행 컨텍스트를 담은 ChatClientResponse 객체의 Flux 반환 — 어드바이저 실행 중 사용된 추가 데이터(예: RAG 흐름에서 검색된 관련 문서)에 접근 가능.

메시지 메타데이터

ChatClient는 사용자·시스템 메시지 양쪽에 메타데이터 추가를 지원해요. 메타데이터는 AI 모델이나 다운스트림 처리에 사용될 수 있는 메시지에 대한 추가 컨텍스트·정보를 제공해요.

사용자 메시지에 메타데이터 추가

metadata() 메서드로 사용자 메시지에 메타데이터를 추가할 수 있어요:

// Adding individual metadata key-value pairs
String response = chatClient.prompt()
    .user(u -> u.text("What's the weather like?")
        .metadata("messageId", "msg-123")
        .metadata("userId", "user-456")
        .metadata("priority", "high"))
    .call()
    .content();

// Adding multiple metadata entries at once
Map<String, Object> userMetadata = Map.of(
    "messageId", "msg-123",
    "userId", "user-456",
    "timestamp", System.currentTimeMillis()
);

String response = chatClient.prompt()
    .user(u -> u.text("What's the weather like?")
        .metadata(userMetadata))
    .call()
    .content();

시스템 메시지에 메타데이터 추가

마찬가지로 시스템 메시지에 메타데이터를 추가할 수 있어요:

// Adding metadata to system messages
String response = chatClient.prompt()
    .system(s -> s.text("You are a helpful assistant.")
        .metadata("version", "1.0")
        .metadata("model", "gpt-4"))
    .user("Tell me a joke")
    .call()
    .content();

기본 메타데이터 지원

ChatClient 빌더 레벨에서 기본 메타데이터도 구성할 수 있어요:

@Configuration
class Config {
    @Bean
    ChatClient chatClient(ChatClient.Builder builder) {
        return builder
            .defaultSystem(s -> s.text("You are a helpful assistant")
                .metadata("assistantType", "general")
                .metadata("version", "1.0"))
            .defaultUser(u -> u.text("Default user context")
                .metadata("sessionId", "default-session"))
            .build();
    }
}

메타데이터 검증

ChatClient는 데이터 무결성을 보장하기 위해 메타데이터를 검증해요:

  • 메타데이터 키는 null이거나 빈 값이 될 수 없어요
  • 메타데이터 값은 null이 될 수 없어요
  • Map을 전달할 때 키도 값도 null 요소를 포함할 수 없어요
// This will throw an IllegalArgumentException
chatClient.prompt()
    .user(u -> u.text("Hello")
        .metadata(null, "value"))  // Invalid: null key
    .call()
    .content();

// This will also throw an IllegalArgumentException
chatClient.prompt()
    .user(u -> u.text("Hello")
        .metadata("key", null))    // Invalid: null value
    .call()
    .content();

메타데이터 접근

메타데이터는 생성된 UserMessage·SystemMessage 객체에 포함되며 메시지의 getMetadata() 메서드로 접근할 수 있어요. 어드바이저에서 메시지를 처리하거나 대화 히스토리를 검사할 때 특히 유용해요.

기본값 사용하기

@Configuration 클래스에서 기본 시스템 텍스트로 ChatClient를 만들면 런타임 코드가 단순해져요. 기본값을 설정하면 ChatClient를 호출할 때 사용자 텍스트만 지정하면 되므로, 런타임 코드 경로에서 요청마다 시스템 텍스트를 설정할 필요가 없어져요.

기본 시스템 텍스트

다음 예시에서 시스템 텍스트가 항상 해적 목소리로 답하도록 구성할 거예요. 런타임 코드에서 시스템 텍스트를 반복하지 않기 위해 @Configuration 클래스에서 ChatClient 인스턴스를 만들어요.

@Configuration
class Config {

    @Bean
    ChatClient chatClient(ChatClient.Builder builder) {
        return builder.defaultSystem("You are a friendly chat bot that answers question in the voice of a Pirate")
                .build();
    }

}

그리고 호출할 @RestController:

@RestController
class AIController {

	private final ChatClient chatClient;

	AIController(ChatClient chatClient) {
		this.chatClient = chatClient;
	}

	@GetMapping("/ai/simple")
	public Map<String, String> completion(@RequestParam(value = "message", defaultValue = "Tell me a joke") String message) {
		return Map.of("completion", this.chatClient.prompt().user(message).call().content());
	}
}

curl로 애플리케이션 엔드포인트를 호출하면 결과는:

❯ curl localhost:8080/ai/simple
{"completion":"Why did the pirate go to the comedy club? To hear some arrr-rated jokes! Arrr, matey!"}

파라미터 있는 기본 시스템 텍스트

다음 예시에서는 시스템 텍스트의 자리 표시자를 사용해, 설계 시점이 아니라 런타임에 완성 문구의 목소리를 지정할 거예요.

@Configuration
class Config {

    @Bean
    ChatClient chatClient(ChatClient.Builder builder) {
        return builder.defaultSystem("You are a friendly chat bot that answers question in the voice of a {voice}")
                .build();
    }

}
@RestController
class AIController {
	private final ChatClient chatClient;

	AIController(ChatClient chatClient) {
		this.chatClient = chatClient;
	}

	@GetMapping("/ai")
	Map<String, String> completion(@RequestParam(value = "message", defaultValue = "Tell me a joke") String message, String voice) {
		return Map.of("completion",
				this.chatClient.prompt()
						.system(sp -> sp.param("voice", voice))
						.user(message)
						.call()
						.content());
	}

}

httpie로 애플리케이션 엔드포인트를 호출하면 결과는:

http localhost:8080/ai voice=='Robert DeNiro'
{
    "completion": "You talkin' to me? Okay, here's a joke for ya: Why couldn't the bicycle stand up by itself? Because it was two tired! Classic, right?"
}

다른 기본값

ChatClient.Builder 레벨에서 기본 프롬프트 구성을 지정할 수 있어요.

  • defaultOptions(ChatOptions chatOptions): ChatOptions 클래스에 정의된 이식 가능한 옵션이나 OpenAiChatOptions 같은 모델별 옵션을 전달. 모델별 ChatOptions 구현에 대한 자세한 내용은 JavaDocs 참고.
  • defaultTools(Object... tools): 모든 요청에 사용 가능한 하나 이상의 기본 도구 등록. ToolCallback, ToolCallbackProvider, 또는 @Tool 주석 메서드가 있는 POJO의 이질적 혼합을 받아요.
  • defaultToolContext(Map<String, Object> toolContext): 도구 실행의 기본 컨텍스트 설정.
  • defaultSystem(String text), defaultSystem(Resource text), defaultSystem(Consumer<PromptSystemSpec> systemSpecConsumer): 기본 시스템 텍스트를 정의하는 메서드.
  • defaultUser(String text), defaultUser(Resource text), defaultUser(Consumer<UserSpec> userSpecConsumer): 사용자 텍스트 정의 메서드. Consumer<UserSpec>는 람다로 사용자 텍스트와 기본 파라미터를 지정할 수 있게 해줘요.
  • defaultTemplateRenderer(TemplateRenderer templateRenderer): 프롬프트 템플릿용 기본 TemplateRenderer 설정.
  • defaultAdvisors(Advisor... advisor), defaultAdvisors(List<Advisor> advisors): Advisors는 Prompt를 만드는 데 쓰이는 데이터 수정을 허용해요. QuestionAnswerAdvisor 구현은 사용자 텍스트와 관련된 문맥 정보를 프롬프트에 덧붙여 Retrieval Augmented Generation 패턴을 가능하게 해요.
  • defaultAdvisors(Consumer<AdvisorSpec> advisorSpecConsumer): AdvisorSpec으로 여러 어드바이저를 구성하는 Consumer 정의 메서드. Advisors는 최종 Prompt를 만드는 데 쓰이는 데이터를 수정할 수 있어요. Consumer<AdvisorSpec>QuestionAnswerAdvisor 같은 어드바이저를 추가하는 람다를 지정할 수 있게 해줘요 — 사용자 텍스트 기반의 관련 문맥 정보를 프롬프트에 덧붙여 Retrieval Augmented Generation을 지원해요.

런타임에는 default 접두사 없는 대응 메서드로 이 기본값을 덮어쓸 수 있어요.

  • options(ChatOptions.Builder optionsCustomizer)
  • tools(Object... tools)
  • toolContext(Map<String, Object> toolContext)
  • messages(Message... messages), messages(List<Message> messages)
  • system(String text), system(Resource text), system(Consumer<PromptSystemSpec> systemSpecConsumer)
  • user(String text), user(Resource text), user(Consumer<UserSpec> userSpecConsumer)
  • templateRenderer(TemplateRenderer templateRenderer)
  • advisors(Advisor... advisor), advisors(List<Advisor> advisors)
  • advisors(Consumer<AdvisorSpec> advisorSpecConsumer)

ChatClient 변경 (Mutating)

mutate() 메서드로 기존 것의 설정을 복제한 새 ChatClient(또는 ChatClientRequestSpec)를 만들 수 있어요:

  • ChatClient에서: Builder mutate()가 클라이언트 기본 설정으로 초기화된 ChatClient.Builder를 반환해요.
  • ChatClientRequestSpec에서: Builder mutate()가 요청의 현재 설정으로 초기화된 ChatClient.Builder를 반환해요.

모든 옵션을 다시 정의하지 않고 파생 클라이언트·요청을 만들 때 유용해요.

Advisors

Advisors API는 Spring 애플리케이션에서 AI 기반 상호작용을 가로채고·수정하고·강화하는 유연하고 강력한 방법을 제공해요.

사용자 텍스트로 AI 모델을 호출할 때 흔한 패턴은 프롬프트에 문맥 데이터를 덧붙이거나 증강하는 거예요.

이 문맥 데이터는 다양한 타입일 수 있어요. 흔한 타입은:

  • 자신의 데이터: AI 모델이 학습하지 않은 데이터예요. 모델이 비슷한 데이터를 봤더라도, 덧붙여진 문맥 데이터가 응답 생성에서 우선해요.
  • 대화 히스토리: 채팅 모델 API는 무상태(stateless)예요. AI 모델에 이름을 알려줘도 이후 상호작용에서 기억하지 못해요. 이전 상호작용이 응답 생성에 고려되도록 대화 히스토리를 각 요청과 함께 보내야 해요.

ChatClient의 어드바이저 구성

ChatClient fluent API는 어드바이저 구성용 AdvisorSpec 인터페이스를 제공해요. 이 인터페이스는 파라미터 추가, 여러 파라미터 한 번에 설정, 체인에 하나 이상의 어드바이저 추가 메서드를 제공해요.

interface AdvisorSpec {
    AdvisorSpec param(String k, Object v);
    AdvisorSpec params(Map<String, Object> p);
    AdvisorSpec advisors(Advisor... advisors);
    AdvisorSpec advisors(List<Advisor> advisors);
}

중요: 어드바이저가 체인에 추가되는 순서가 실행 순서를 결정하므로 중요해요. 각 어드바이저는 프롬프트나 컨텍스트를 어떤 방식으로 수정하며, 한 어드바이저의 변경은 체인의 다음 어드바이저로 전달돼요.

ChatClient.builder(chatModel)
    .build()
    .prompt()
    .advisors(a -> a
        .advisors(
            MessageChatMemoryAdvisor.builder(chatMemory).build(),
            QuestionAnswerAdvisor.builder(vectorStore).build()
        )
        .param(ChatMemory.CONVERSATION_ID, conversationId))
    .user(userText)
    .call()
    .content();

이 구성에서 MessageChatMemoryAdvisor가 먼저 실행되어 대화 히스토리를 프롬프트에 추가해요. 그런 다음 QuestionAnswerAdvisor가 사용자 질문과 추가된 대화 히스토리에 기반해 검색을 수행해, 더 관련성 높은 결과를 제공할 수 있어요.

참고: ChatMemory.CONVERSATION_ID는 메모리 어드바이저를 쓰는 모든 호출에서 .param()으로 제공해야 해요. 빼먹으면 런타임에 IllegalArgumentException이 발생해요.

Question Answer Advisor 알아보기

Retrieval Augmented Generation

Retrieval Augmented Generation 가이드를 참고하세요.

로깅

SimpleLoggerAdvisorChatClientrequestresponse 데이터를 로깅하는 어드바이저예요. AI 상호작용 디버깅·모니터링에 유용해요.

팁: Spring AI는 LLM과 vector store 상호작용에 대한 관측성을 지원해요. 자세한 내용은 Observability 가이드를 참고하세요.

로깅을 켜려면 ChatClient를 만들 때 어드바이저 체인에 SimpleLoggerAdvisor를 추가하세요. 체인 끝쪽에 추가하는 걸 권장해요:

ChatResponse response = ChatClient.create(chatModel).prompt()
        .advisors(new SimpleLoggerAdvisor())
        .user("Tell me a joke?")
        .call()
        .chatResponse();

로그를 보려면 어드바이저 패키지의 로깅 레벨을 DEBUG로 설정하세요:

logging.level.org.springframework.ai.chat.client.advisor=DEBUG

이걸 application.propertiesapplication.yaml 파일에 추가하세요.

AdvisedRequestChatResponse에서 로깅할 데이터를 다음 생성자로 커스터마이즈할 수 있어요:

SimpleLoggerAdvisor(
    Function<ChatClientRequest, String> requestToString,
    Function<ChatResponse, String> responseToString,
    int order
)

사용 예시:

SimpleLoggerAdvisor customLogger = new SimpleLoggerAdvisor(
    request -> "Custom request: " + request.prompt().getUserMessage(),
    response -> "Custom response: " + response.getResult(),
    0
);

이렇게 하면 로깅 정보를 자신의 필요에 맞게 조정할 수 있어요.

팁: 프로덕션 환경에서 민감한 정보를 로깅하는 데 주의하세요.

도구 호출 (Tool Calling)

ChatClient는 명시적으로 자동 등록을 끄지 않는 한 항상 어드바이저 체인에 ToolCallingAdvisor를 자동 등록해요. 이렇게 하면 호출에 정적 도구가 구성되지 않았더라도 다른 어드바이저가 런타임에 동적 주입한 도구가 올바르게 처리돼요.

String response = ChatClient.builder(chatModel)
    .build()
    .prompt("What day is tomorrow?")
    .tools(new DateTimeTools())   // ToolCallingAdvisor is auto-registered
    .call()
    .content();

ToolCallingAdvisor는 기본적으로 Ordered.HIGHEST_PRECEDENCE + 300에 등록돼요.

자동 등록 끄기

전역 (모든 호출)

spring.ai.chat.client.tool-calling.enabled=false를 설정하면 자동 구성된 ChatClient에서 수행되는 모든 호출의 자동 등록을 끌 수 있어요:

spring.ai.chat.client.tool-calling.enabled=false

이 프로퍼티를 설정하면 도구는 여전히 정의로 AI 모델에 전송되지만, 응답의 도구 호출은 자동 실행되지 않아요. 루프를 직접 구동하려면 user-controlled tool execution을 사용하세요.

호출별

AdvisorParams.toolCallingAdvisorAutoRegister(false)로 단일 호출의 자동 등록을 끌 수 있어요:

chatClient.prompt("What day is tomorrow?")
    .tools(new DateTimeTools())
    .advisors(AdvisorParams.toolCallingAdvisorAutoRegister(false))
    .call()
    .content();

또는 자기만의 ToolCallingAdvisor(또는 ToolAdvisor를 구현한 다른 어드바이저)를 제공할 수 있어요 — 그 경우 체인에 이미 ToolAdvisor가 있으므로 자동 등록이 자동으로 억제돼요.

.advisors()로 커스텀 어드바이저를 전달할 수 있어요:

chatClient.prompt("What day is tomorrow?")
    .tools(new DateTimeTools())
    .advisors(customAdvisor)    // auto-registration suppressed
    .call()
    .content();

기본 ToolCallingAdvisor 커스터마이즈

spring.ai.chat.client.tool-calling.* 프로퍼티가 자동 구성된 ToolCallingAdvisor를 제어해요:

Property Default Description
spring.ai.chat.client.tool-calling.enabled true false로 설정하면 모든 호출의 ToolCallingAdvisor 자동 등록을 끄고, 도구는 모델에 전송되지만 도구 호출은 자동 실행되지 않아요
spring.ai.chat.client.tool-calling.advisor-order Ordered.HIGHEST_PRECEDENCE + 300 어드바이저 체인에서 자동 등록된 ToolCallingAdvisor의 위치
Spring Boot: 어드바이저 순서 프로퍼티

자동 등록된 어드바이저를 튜닝하는 가장 간단한 방법은 spring.ai.chat.client.tool-calling.advisor-order 프로퍼티예요. 어드바이저 체인에서 ToolCallingAdvisor가 삽입되는 위치를 제어해요:

spring.ai.chat.client.tool-calling.advisor-order=0

값은 도구 호출 루프 (모든 반복에서 반복됨)에서 실행해야 하는 어드바이저의 순서보다 낮아야 하고, 루프 (사용자 요청당 한 번만 실행)에서 실행해야 하는 어드바이저의 순서보다는 높아야 해요. 기본값은 ToolCallingAdvisor.DEFAULT_ORDER예요.

Spring Boot: 사용자 제어 도구 실행

도구 호출 루프의 각 반복을 관찰하려면 — 예를 들어 중간 청크를 UI로 전달 — 호출별로 자동 등록 어드바이저를 끄고 AdvisorParams.toolCallingAdvisorAutoRegister(false)로 루프를 직접 구동하세요. 완전한 예시는 User-Controlled Tool Execution — With ChatClient 참고.

Spring Boot: 커스텀 ToolCallingAdvisor.Builder

더 깊은 커스터마이즈(예: 커스텀 ToolCallingManager 제공)를 위해 ToolCallingAdvisor.Builder<?> 빈을 선언하세요. 자동 구성된 빈의 @ConditionalOnMissingBean 덕분에 사용자의 빈이 우선해요:

@Bean
ToolCallingAdvisor.Builder<?> toolCallingAdvisorBuilder(ToolCallingManager myToolCallingManager) {
    return ToolCallingAdvisor.builder()
        .toolCallingManager(myToolCallingManager)
        .advisorOrder(Ordered.LOWEST_PRECEDENCE);
}

ChatClient.Builder 빈은 그런 다음 이 커스텀 빌더로 자동 연결돼요.

Spring Boot 없이

Spring Boot 자동 구성을 쓰지 않을 때는 미리 구성된 ToolCallingAdvisor.BuilderChatClient.builder()에 직접 전달하세요:

ToolCallingManager customManager = ToolCallingManager.builder()
    // custom resolver, exception processor, etc.
    .build();

ChatClient chatClient = ChatClient
    .builder(chatModel, observationRegistry, null, null,
            ToolCallingAdvisor.builder().toolCallingManager(customManager))
    .build();

// Every prompt that registers tools will use customManager in the auto-registered advisor
chatClient.prompt("What day is tomorrow?")
    .tools(new DateTimeTools())
    .call()
    .content();

ToolAdvisor 마커 인터페이스

ToolAdvisorChatClient에 어드바이저 체인이 이미 도구 실행을 처리한다고 알리는 마커 인터페이스예요. 커스텀 어드바이저에 이 인터페이스를 구현하면 두 번째 ToolCallingAdvisor의 자동 등록을 막아요.

자세한 내용은 Extending the Loop: Custom ToolAdvisor 참고.

Chat Memory

ChatMemory 인터페이스는 채팅 대화 메모리의 저장소를 나타내요. 대화에 메시지 추가, 대화에서 메시지 검색, 대화 히스토리 지우기 메서드를 제공해요.

현재 내장 구현은 하나: MessageWindowChatMemory예요.

MessageWindowChatMemory는 지정된 최대 크기(기본: 20개 메시지)까지의 메시지 윈도우를 유지하는 채팅 메모리 구현이에요. 메시지 수가 이 한도를 초과하면 오래된 메시지는 퇴출되지만 시스템 메시지는 보존돼요. 새 시스템 메시지가 추가되면 이전 시스템 메시지는 모두 메모리에서 제거돼요. 이렇게 해서 가장 최근 컨텍스트가 항상 대화에 사용 가능하면서 메모리 사용이 제한적으로 유지돼요.

MessageWindowChatMemory는 채팅 대화 메모리의 저장 구현을 제공하는 ChatMemoryRepository 추상화로 뒷받침돼요. InMemoryChatMemoryRepository, JdbcChatMemoryRepository, CassandraChatMemoryRepository, Neo4jChatMemoryRepository, MongoChatMemoryRepository, RedisChatMemoryRepository를 포함한 여러 구현이 있어요.

중요: 모든 메모리 어드바이저에 ChatMemory.CONVERSATION_ID 파라미터가 필수예요. 모든 호출에서 .advisors(a -> a.param(ChatMemory.CONVERSATION_ID, conversationId))로 제공해야 해요. 빼먹으면 IllegalArgumentException이 발생해요. 기본 대화 ID는 없어요.

자세한 내용과 사용 예시는 Chat Memory 문서를 참고하세요.

MemoryAdvisor 마커 인터페이스

MemoryAdvisorBaseChatMemoryAdvisor가 확장하는 마커 인터페이스예요. DefaultChatClient는 이 마커로 자동 등록 로직에서 다운스트림 메모리 어드바이저를 감지해요.

BaseChatMemoryAdvisor를 확장하지 않지만 이 감지에 참여해야 하는 커스텀 메모리 어드바이저는 MemoryAdvisor를 구현해야 해요.

구현 메모 (Implementation Notes)

ChatClient에서 명령형과 리액티브 프로그래밍 모델을 결합해 쓰는 것은 API의 독특한 측면이에요. 애플리케이션은 흔히 리액티브이거나 명령형이지, 둘 다인 경우는 드물어요.

  • Model 구현의 HTTP 클라이언트 상호작용을 커스터마이즈할 때는 RestClient와 WebClient 둘 다 구성해야 해요.
  • 스트리밍은 리액티브 스택에서만 지원돼요. 명령형 애플리케이션은 이 때문에 리액티브 스택(예: spring-boot-starter-webflux)을 포함해야 해요.
  • 비스트리밍은 Servlet 스택에서만 지원돼요. 리액티브 애플리케이션은 이 때문에 Servlet 스택(예: spring-boot-starter-web)을 포함해야 하고 일부 호출이 블로킹될 것으로 기대해야 해요.
  • 도구 호출은 명령형이라 블로킹 워크플로를 만들어요. 이로 인해 부분적/중단된 Micrometer observation(예: ChatClient span과 도구 호출 span이 연결되지 않고 전자가 그 이유로 미완성으로 남는)도 생겨요.
  • 내장 어드바이저는 표준 호출에 블로킹 연산을, 스트리밍 호출에 비블로킹 연산을 수행해요. 어드바이저 스트리밍 호출에 쓰이는 Reactor Scheduler는 각 Advisor 클래스의 Builder에서 구성할 수 있어요.

더 알아보기