Spring AI

기본 채팅과 모델 비교

LLM에 질문을 보내고 답을 받는 가장 기본적인 동작을 다룬다. Spring AI는 두 가지 결의 API를 준다 — 읽기 쉬운 고수준 ChatClient와, 메시지를 직접 다루는 저수준 ChatModel이다. 여기에 더해 응답을 토큰 단위로 흘려보내는 스트리밍, 그리고 지난 편에서 등록한 모델 2개를 바꿔 호출하는 법까지 본다.

이 편에서 만드는 것

/chat(고수준) · /chat/low-level(저수준) · /chat/stream(스트리밍) · /chat/compare(모델 비교) 네 개의 엔드포인트.


1. ChatClient vs ChatModel

두 API는 같은 일을 하지만 추상화 수준이 다르다.

flowchart TB
    subgraph 고수준
      CC["ChatClient<br/>.prompt().user(...).call().content()"]
    end
    subgraph 저수준
      CM["ChatModel<br/>Prompt(메시지들) → ChatResponse"]
    end
    CC --> CM --> API["OpenAI API"]

ChatClient

ChatModel

추상화

고수준(플루언트)

저수준

호출

.prompt().user(...).call().content()

chatModel.call(new Prompt(...))

메시지 구성

메서드 체인으로 간단히

SystemMessage/UserMessage를 직접 조립

언제

대부분의 경우

메시지·옵션을 세밀하게 제어할 때


2. 고수준 — ChatClient

ChatClient.Builder를 생성자에서 주입받아 build() 한 번 해 두고 재사용한다.

@Service
public class ChatService {

    private final ChatClient chatClient;

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

    public String chat(String message) {
        return chatClient.prompt()   // 프롬프트 시작
                .user(message)       // 사용자 메시지
                .call()              // 동기 호출
                .content();          // 응답 본문(String)만 꺼냄
    }
}

3. 저수준 — ChatModel

시스템 메시지와 사용자 메시지를 직접 만들어 Prompt로 묶는다. 메시지 종류와 순서를 정밀하게 통제할 수 있다.

// 생성자에서 OpenAiChatModel을 함께 주입받는다
public ChatService(ChatClient.Builder builder, OpenAiChatModel chatModel) {
    this.chatClient = builder.build();
    this.chatModel = chatModel;
}

public String chatLowLevel(String message) {
    Prompt prompt = new Prompt(List.of(
            new SystemMessage("너는 친절한 한국어 도우미야. 간결하게 답해."),  // 역할·말투 지정
            new UserMessage(message)                                       // 실제 질문
    ));
    ChatResponse response = chatModel.call(prompt);
    return response.getResult().getOutput().getText();   // 응답 텍스트
}

SystemMessage의 역할

SystemMessage는 모델의 말투·역할·규칙을 정하는 '배경 지시'다. 사용자에게 보이지 않지만 답변 스타일을 좌우한다. 프롬프트 설계는 프롬프트 엔지니어링 편에서 더 다룬다.


4. 스트리밍 — 토큰을 받는 대로

.call() 대신 .stream()을 쓰면 답이 완성되길 기다리지 않고 생성되는 즉시 토큰을 흘려보낸다. 반환 타입은 리액터의 Flux<String>이며, webflux 의존성과 SSE(Server-Sent Events) 미디어 타입이 필요하다.

public Flux<String> stream(String message) {
    return chatClient.prompt()
            .user(message)
            .stream()        // ← 스트리밍 호출
            .content();
}
@GetMapping(value = "/chat/stream", produces = MediaType.TEXT_EVENT_STREAM_VALUE)
public Flux<String> stream(@RequestParam String message) {
    return chatService.stream(message);
}

스트리밍이 주는 체감

ChatGPT처럼 글자가 또르르 나오는 효과다. 긴 답변일수록 첫 글자가 빨리 나와 사용자 체감 속도가 크게 좋아진다.


5. 모델 2개 바꿔 쓰기 — 핵심

설정에 등록한 app.models.mini/app.models.full을 @Value로 읽고, 요청마다 OpenAiChatOptions.model(...)로 모델을 덮어쓴다. 빈을 두 개 만들 필요 없이, 같은 ChatModel에 옵션만 바꿔 넘기면 된다.

@Value("${app.models.mini}") private String miniModel;   // gpt-4o-mini
@Value("${app.models.full}") private String fullModel;   // gpt-4o

/** 같은 질문을 두 모델에 보내 응답을 나란히 받는다. */
public Map<String, String> compareModels(String message) {
    return Map.of(
            miniModel, askModel(miniModel, message),
            fullModel, askModel(fullModel, message)
    );
}

private String askModel(String model, String message) {
    Prompt prompt = new Prompt(message, OpenAiChatOptions.builder()
            .model(model)        // ← 이 요청에만 적용되는 모델
            .build());
    return chatModel.call(prompt).getResult().getOutput().getText();
}
curl -G "http://localhost:8080/chat/compare" \
     --data-urlencode "message=너는 어떤 모델이야? 한 문장으로."
{
  "gpt-4o":      "저는 OpenAI가 개발한 GPT-4입니다.",
  "gpt-4o-mini": "저는 OpenAI의 모델입니다."
}

옵션 우선순위

application.yml의 chat.options.model은 기본값이고, 요청 시 OpenAiChatOptions.model(...)로 주면 그 요청에 한해 덮어쓴다. temperature·maxTokens 등 다른 옵션도 같은 방식으로 요청별 조정이 가능하다.


정리

  • ChatClient(고수준)를 기본으로 쓰고, 세밀한 제어가 필요할 때 ChatModel(저수준)을 쓴다.

  • .call()은 한 번에, .stream()은 토큰 단위로 응답을 받는다.

  • 모델 교체는 새 빈이 아니라 요청별 OpenAiChatOptions.model(...) 로 한다.

➡️ 다음 편: 프롬프트 엔지니어링

마지막 수정 2026년 10월 22일