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 | |
|---|---|---|
추상화 | 고수준(플루언트) | 저수준 |
호출 |
|
|
메시지 구성 | 메서드 체인으로 간단히 |
|
언제 | 대부분의 경우 | 메시지·옵션을 세밀하게 제어할 때 |
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(...)로 한다.
➡️ 다음 편: 프롬프트 엔지니어링