챕터 03
대규모 언어 모델(LLM)에게 사용자가 원하는 작업을 구체적으로 지시하거나, 질문의 형태로 요구사항을 전달하는 일종의 명령문입니다.
프롬프트는 모델에게 어떤 상황에서, 무엇을, 어떤 형식으로 응답해야 하는지를 알려주는 중요한 역할을 합니다.
Spring AI는 프롬프트를 Prompt 클래스로 표현합니다. 이 클래스는 두 가지 핵심 요소로 이루어집니다.
| 구성 요소 | 설명 |
|---|---|
| Message | 각 메시지는 프롬프트 내에서 고유한 역할을 가진다 |
| ChatOptions | LLM이 응답을 생성할 때 적용할 옵션 정보 — 사용할 모델, 응답의 다양성(temperature), 최대 응답 토큰 수 등이 포함된다 |
| 메시지 타입 | 역할 설명 |
|---|---|
| SystemMessage | LLM의 행동과 응답 스타일을 지시하는 메시지. 주로 LLM이 입력을 해석하는 방법과 답변하는 방식을 지시한다 |
| UserMessage | 사용자의 질문, 명령을 담고 있는 메시지. LLM의 응답을 형성하는 기초가 되므로 가장 중요한 메시지이다 |
| AssistantMessage | LLM의 응답 메시지로 단순한 답변 전달을 넘어, 대화 기억 유지에도 사용되어 일관되고 맥락에 맞는 대화에 도움을 준다 |
프롬프트는 고정된 정적 텍스트일 수도 있지만, 데이터가 바인딩될 자리 표시자({변수명})를 가진 동적 텍스트일 수도 있습니다.
이러한 동적 텍스트를 프롬프트 템플릿(Prompt Template) 이라고 합니다.
프롬프트 템플릿은 실제 데이터로 바인딩되어 완성된 프롬프트로 생성됩니다.
몇 가지 예시(few examples)를 프롬프트에 포함시켜 LLM이 원하는 형식과 패턴을 학습하도록 유도하는 기법입니다.
실무에서 가장 많이 쓰이는 기법으로, 예시를 보여주는 것만으로 출력 형식, 톤, 패턴을 한 번에 제어할 수 있어 범용성이 가장 높습니다.
📄 AiServiceFewShotPrompt.java
public String fewShotPrompt(String order) {
// 프롬프트 생성
String strPrompt = """
고객 주문을 유효한 JSON 형식으로 바꿔주세요.
추가 설명은 포함하지 마세요.
예시1:
작은 피자 하나, 치즈랑 토마토 소스, 페퍼로니 올려서 주세요.
JSON 응답:
{
"size": "small",
"type": "normal",
"ingredients": ["cheese", "tomato sauce", "pepperoni"]
}
예시1:
큰 피자 하나, 토마토 소스랑 바질, 모짜렐라 올려서 주세요.
JSON 응답:
{
"size": "large",
"type": "normal",
"ingredients": ["tomato sauce", "basil", "mozzarella"]
}
고객 주문: %s""".formatted(order);
Prompt prompt = Prompt.builder()
.content(strPrompt)
.build();
// LLM으로 요청하고 응답을 받음
String pizzaOrderJson = chatClient.prompt(prompt)
.options(ChatOptions.builder()
.temperature(0.0)
.maxTokens(300)
.build())
.call()
.content();
return pizzaOrderJson;
}
📄 AiControllerFewShotPrompt.java
public class AiControllerFewShotPrompt {
// ##### 필드 #####
@Autowired
private AiServiceFewShotPrompt aiService;
// ##### 메소드 #####
@PostMapping(
value = "/few-shot-prompt",
consumes = MediaType.APPLICATION_FORM_URLENCODED_VALUE,
produces = MediaType.APPLICATION_JSON_VALUE
)
public String fewShotPrompt(@RequestParam("order") String order) {
//서술식 주문을 JSON으로 변환
String json = aiService.fewShotPrompt(order);
return json;
}
}
LLM은 기본적으로 다음 토큰을 예측하는 방식으로 동작.
즉, 답을 "계산"하는 게 아니라 "그럴듯한 다음 단어"를 이어붙이는 구조.
그래서 복잡한 추론이 필요한 문제에서는 중간 과정을 건너뛰고 바로 답을 내려다가 오류가 생기기 쉬움.
CoT는 LLM에게 답을 바로 내놓으라고 하지 않고, 중간 추론 과정을 먼저 거치도록 유도
Self-Consistency는 동일한 프롬프트를 여러 번 실행해서 가장 일관된 답을 채택, 그런데 여기서 전제는 각각의 실행이 서로 다른 추론 경로를 통해 답에 도달해야 한다는 것.
추론 과정을 유도하면 LLM은 매 실행마다 조금씩 다른 경로로 생각을 전개. 그 여러 경로 중에서 결론이 일치하는 답을 고르는 것이 Self-Consistency의 핵심인데, 추론 경로의 다양성을 만들어주는 것이 바로 CoT의 역할입니다. 결국 Self-Consistency는 CoT 위에서 더 잘 작동하는 기법이고, 두 개념은 따로 존재하지 않음.
CoT를 구현하는 방식에는 크게 두 가지.
Zero-Shot CoT는 별도의 예시 없이, 프롬프트 끝에 "단계적으로 생각해봅시다" 같은 문장 하나를 추가하는 것만으로 LLM의 추론을 유도.
구현이 단순하고 추가 예시를 준비할 필요가 없어서 빠르게 적용할 수 있다는 장점
📄 AiServiceZeroShotCoT.java
@Service
public class AiServiceZeroShotCoT {
private final ChatClient chatClient;
// Zero-Shot CoT의 핵심 — 프롬프트 끝에 "단계적으로 생각해봅시다" 한 줄 추가
private final PromptTemplate promptTemplate = PromptTemplate.builder()
.template("""
다음 문제를 풀어주세요.
문제: {question}
단계적으로 생각해봅시다.
""")
.build();
public AiServiceZeroShotCoT(ChatClient.Builder builder) {
this.chatClient = builder
.defaultOptions(ChatOptions.builder()
.temperature(0.7) // 추론 경로에 다양성을 주기 위해 0.0보다 높게 설정
.maxTokens(500)
.build())
.build();
}
public String zeroShotCoT(String question) {
return chatClient.prompt()
.user(promptTemplate.render(Map.of("question", question)))
.call()
.content();
}
}
📄 AiControllerZeroShotCoT.java
@RestController
@RequestMapping("/ai")
public class AiControllerZeroShotCoT {
@Autowired
private AiServiceZeroShotCoT aiService;
@PostMapping(
value = "/zero-shot-cot",
consumes = "application/x-www-form-urlencoded",
produces = "text/plain;charset=UTF-8"
)
public String zeroShotCoT(@RequestParam("question") String question) {
return aiService.zeroShotCoT(question);
}
}
Few-Shot CoT는 앞서 배운 퓨-샷 프롬프트처럼 예시를 제공하되, 단순히 입력-출력 쌍만 주는 게 아니라 추론 과정까지 포함된 예시를 함께 제공.
복잡한 태스크일수록 Zero-Shot CoT보다 정확도가 높아짐.
📄 AiServiceFewShotCoT.java
@Service
public class AiServiceFewShotCoT {
private final ChatClient chatClient;
// Few-Shot CoT의 핵심 — 추론 과정이 담긴 예시를 프롬프트 안에 포함
private final PromptTemplate promptTemplate = PromptTemplate.builder()
.template("""
다음 예시처럼 단계별로 풀이 과정을 보여주고 최종 답을 알려주세요.
[예시]
문제: 사과 5개 중 3개를 먹었습니다. 남은 사과는 몇 개인가요?
풀이: 처음 사과는 5개입니다. 3개를 먹었으므로 5 - 3 = 2입니다.
답: 2개
[문제]
문제: {question}
풀이:
""")
.build();
public AiServiceFewShotCoT(ChatClient.Builder builder) {
this.chatClient = builder
.defaultOptions(ChatOptions.builder()
.temperature(0.7)
.maxTokens(500)
.build())
.build();
}
public String fewShotCoT(String question) {
return chatClient.prompt()
.user(promptTemplate.render(Map.of("question", question)))
.call()
.content();
}
}
📄 AiControllerFewShotCoT.java
@RestController
@RequestMapping("/ai")
public class AiControllerFewShotCoT {
@Autowired
private AiServiceFewShotCoT aiService;
@PostMapping(
value = "/few-shot-cot",
consumes = "application/x-www-form-urlencoded",
produces = "text/plain;charset=UTF-8"
)
public String fewShotCoT(@RequestParam("question") String question) {
return aiService.fewShotCoT(question);
}
}
두 방식의 선택 기준은 결국 태스크의 복잡도입니다. 추론 구조가 단순하다면 Zero-Shot CoT로 충분하고, 여러 단계를 거쳐야 하는 복잡한 문제라면 Few-Shot CoT 사용이 적절.
CoT는 다단계 추론이 필요한 태스크, 중간 과정을 명시적으로 거치면서 오류가 누적되는 것을 방지할 수 있기 때문입니다.
반면 단순 분류 태스크에는 오히려 역효과
// ❌ 단순 분류에 CoT를 억지로 적용한 경우 — 불필요한 추론이 끼어들 수 있음
영화 리뷰를 [긍정적, 중립적, 부정적] 중 하나로 분류하세요.
단계적으로 생각해봅시다.
리뷰: {review}
// ✅ 단순 분류에는 짧고 직관적인 프롬프트가 더 적합
영화 리뷰를 [긍정적, 중립적, 부정적] 중 하나로 분류하세요.
레이블만 반환하세요.
리뷰: {review}
정리: CoT가 각 실행의 추론 경로 품질을 높여주고, Self-Consistency가 그 여러 경로 중에서 가장 신뢰할 수 있는 결론을 추려내는 역할.
추론의 질을 높이는 것(CoT)과 결과의 신뢰도를 높이는 것(Self-Consistency)이 각자의 역할.
"LLM이 추론을 잘 하게 만들었는데, 추론에 필요한 정보가 LLM 내부에 없다면 어떻게 하지?"
-> LLM은 학습 시점까지의 정보만 알고 있음.
RAG(Retrieval-Augmented Generation)
-> 외부 문서나 데이터베이스에서 관련 정보를 검색해서 프롬프트에 함께 주입하는 방식으로, LLM이 몰라도 검색해서 알려주면 됨.
ReAct 패턴
-> LLM이 스스로 추론하고(Reasoning), 필요한 경우 외부 도구를 호출하고(Acting), 그 결과를 다시 추론에 반영하는 루프를 도는 방식.
지금까지의 흐름 정리
| 단계 | 기법 | 핵심 |
|---|---|---|
| 1 | 제로-샷 / 퓨-샷 | 프롬프트로 패턴을 가르친다 |
| 2 | 역할 부여 / 스텝-백 | 프롬프트로 관점을 조정한다 |
| 3 | CoT | 프롬프트로 추론 과정을 유도한다 |
| 4 | Self-Consistency + CoT | 추론의 질과 결과의 신뢰도를 함께 높인다 |
| 5 | RAG / ReAct | LLM이 모르는 것을 외부에서 채운다 |
예시 없이 지시문만으로 LLM에게 작업을 수행하게 하는 기법.
모델이 사전 학습된 지식만으로 응답을 생성.
📄 AiServiceZeroShotPrompt.java
public class AiServiceZeroShotPrompt {
// ##### 필드 #####
private ChatClient chatClient;
private PromptTemplate promptTemplate = PromptTemplate.builder()
.template("""
영화 리뷰를 [긍정적, 중립적, 부정적] 중에서 하나로 분류하세요.
레이블만 반환하세요.
리뷰: {review}
""")
.build();
// ##### 생성자 #####
public AiServiceZeroShotPrompt(ChatClient.Builder chatClientBuilder) {
chatClient = chatClientBuilder
.defaultOptions(ChatOptions.builder()
.temperature(0.0)
.maxTokens(4)
.build())
.build();
}
// ##### 메소드 #####
public String zeroShotPrompt(String review) {
String sentiment = chatClient.prompt()
.user(promptTemplate.render(Map.of("review", review)))
.call()
.content();
return sentiment;
}
}
📄 AiControllerZeroShotPrompt.java
@RestController
@RequestMapping({"/ai"})
public class AiControllerZeroShotPrompt {
@Generated
private static final Logger log = LoggerFactory.getLogger(AiControllerZeroShotPrompt.class);
@Autowired
private AiServiceZeroShotPrompt aiService;
public AiControllerZeroShotPrompt() {
}
@PostMapping(
value = {"/zero-shot-prompt"},
consumes = {"application/x-www-form-urlencoded"},
produces = {"text/plain"}
)
public String zeroShotPrompt(@RequestParam("review") String review) {
String reviewSentiment = this.aiService.zeroShotPrompt(review);
return reviewSentiment;
}
}
LLM에게 특정 역할(페르소나)을 부여하여 해당 역할에 맞는 관점과 전문성으로 응답하게 하는 기법.
Spring AI에서 SystemMessage를 쓰는 순간부터 자연스럽게 역할 부여를 하게 되어 있어서, 실제 서비스 코드 작성 시 빠질 수 없는 기법.
📄 AiServiceRolePrompt.java
public Flux<String> roleAssignment(String requirements) {
Flux<String> travelSuggestions = chatClient.prompt()
// 시스템 메시지 추가
.system("""
당신이 여행 가이드 역할을 해 주었으면 좋겠습니다.
아래 요청사항에서 위치를 알려주면, 근처에 있는 3곳을 제안해 주고,
이유를 달아주세요. 경우에 따라서 방문하고 싶은 장소 유형을
제공할 수도 있습니다.
""")
// 사용자 메시지 추가
.user("요청사항: %s".formatted(requirements))
// 대화 옵션 설정
.options(ChatOptions.builder()
.temperature(1.0)
.maxTokens(1000)
.build())
// LLM으로 요청하고 응답 얻기
.stream()
.content();
return travelSuggestions;
}
}
📄 AiControllerRolePrompt.java
public class AiControllerRoleAssignmentPrompt {
// ##### 필드 #####
@Autowired
private AiServiceRoleAssignmentPrompt aiService;
//##### 메소드 #####
@PostMapping(
value = "/role-assignment",
consumes = MediaType.APPLICATION_FORM_URLENCODED_VALUE,
produces = MediaType.APPLICATION_NDJSON_VALUE
)
public Flux<String> roleAssignment(@RequestParam("requirements") String requirements) {
Flux<String> travelSuggestions = aiService.roleAssignment(requirements);
return travelSuggestions;
}
}
동일한 프롬프트를 여러 번 실행하여 다양한 추론 경로를 생성하고, 가장 일관성 있는 답변을 최종 답으로 채택하는 기법.
(CoT와 함께 사용할 때 가장 효과적)
📄 AiServiceSelfConsistency.java
package com.example.demo.service;
import java.util.Map;
import org.springframework.ai.chat.client.ChatClient;
import org.springframework.ai.chat.prompt.ChatOptions;
import org.springframework.ai.chat.prompt.PromptTemplate;
import org.springframework.stereotype.Service;
import lombok.extern.slf4j.Slf4j;
@Service
@Slf4j
public class AiServiceSelfConsistency {
// ##### 필드 #####
private ChatClient chatClient;
private PromptTemplate promptTemplate = PromptTemplate.builder()
.template("""
다음 내용을 IMPORTANT, NOT_IMPORTANT 둘 중 하나로 분류해 주세요.
레이블만 반환하세요.
내용: {content}
""")
.build();
// ##### 생성자 #####
public AiServiceSelfConsistency(ChatClient.Builder chatClientBuilder) {
chatClient = chatClientBuilder.build();
}
// ##### 메소드 #####
public String selfConsistency(String content) {
int importantCount = 0;
int notImportantCount = 0;
String userText = promptTemplate.render(Map.of("content", content));
// 다섯번에 걸쳐 응답 받아 보기
for (int i = 0; i < 5; i++) {
// LLM 요청 및 응답 받기
String output = chatClient.prompt()
.user(userText)
.options(ChatOptions.builder()
.temperature(1.0)
.build())
.call()
.content();
log.info("{}: {}", i, output.toString());
// 결과 집계
if (output.equals("IMPORTANT")) {
importantCount++;
} else {
notImportantCount++;
}
}
// 다수결로 최종 분류를 결정
String finalClassification = importantCount > notImportantCount ?
"중요함" : "중요하지 않음";
return finalClassification;
}
}
📄 AiControllerSelfConsistency.java
package com.example.demo.controller;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.http.MediaType;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import com.example.demo.service.AiServiceSelfConsistency;
import lombok.extern.slf4j.Slf4j;
@RestController
@RequestMapping("/ai")
@Slf4j
public class AiControllerSelfConsistency {
// ##### 필드 #####
@Autowired
private AiServiceSelfConsistency aiService;
// ##### 메소드 #####
@PostMapping(
value = "/self-consistency",
consumes = MediaType.APPLICATION_FORM_URLENCODED_VALUE,
produces = MediaType.TEXT_PLAIN_VALUE
)
public String selfConsistency(@RequestParam("content") String content) {
String answer = aiService.selfConsistency(content);
return answer;
}
}
구체적인 질문에 바로 답하기 전에, 더 추상적이거나 일반적인 원칙을 먼저 파악하게 하는 기법.
적용 범위가 좁고 추상적 원칙이 필요한 특수한 상황에서 활용.
📄 AiServiceStepBackPrompt.java
public String stepBackPrompt(String question) throws Exception {
String questions = chatClient.prompt()
.user("""
사용자 질문을 처리할 때 Step-Back 프롬프트 기법을 사용하려고 합니다.
사용자 질문을 단계별 질문들로 재구성해주세요.
맨 마지막 질문은 사용자 질문과 일치해야 합니다.
단계별 질문을 항목으로 하는 JSON 배열로 출력해 주세요.
예시: ["...", "...", "...", "..."]
사용자 질문: %s
""".formatted(question))
.call()
.content();
String json = questions.substring(questions.indexOf("["), questions.indexOf("]")+1);
log.info(json);
ObjectMapper objectMapper = new ObjectMapper();
List<String> listQuestion = objectMapper.readValue(
json,
new TypeReference<List<String>>() {}
);
String[] answerArray = new String[listQuestion.size()];
for(int i=0; i<listQuestion.size(); i++) {
String stepQuestion = listQuestion.get(i);
String stepAnswer = getStepAnswer(stepQuestion, answerArray);
answerArray[i] = stepAnswer;
log.info("단계{} 질문: {}, 답변: {}", i+1, stepQuestion, stepAnswer);
}
return answerArray[answerArray.length-1];
}
public String getStepAnswer(String question, String... prevStepAnswers) {
String context = "";
for (String prevStepAnswer : prevStepAnswers) {
context += Objects.requireNonNullElse(prevStepAnswer, "");
}
String answer = chatClient.prompt()
.user("""
%s
문맥: %s
""".formatted(question, context))
.call()
.content();
return answer;
}
}
📄 AiControllerStepBackPrompt.java
public class AiControllerStepBackPrompt {
// ##### 필드 #####
@Autowired
private AiServiceStepBackPrompt aiService;
@PostMapping(
value = "/step-back-prompt",
consumes = MediaType.APPLICATION_FORM_URLENCODED_VALUE,
produces = MediaType.TEXT_PLAIN_VALUE
)
public String stepBackPrompt(@RequestParam("question") String question) throws Exception {
String answer = aiService.stepBackPrompt(question);
return answer;
}
}
Prompt 클래스(Message + ChatOptions)로 표현한다{변수명} 자리 표시자를 통해 동적으로 바인딩된다CoT가 하나의 추론 경로를 선형으로 따라간다면, Tree of Thoughts는 여러 추론 경로를 동시에 탐색하고 가장 유망한 방향을 선택하며 나아가는 구조.
ToT는 생각(Thought)을 트리 형태로 유지하며, 각 생각은 문제 해결을 향한 중간 단계로서 일관된 언어 시퀀스를 나타냄. 이 접근법은 LM이 의도적인 추론 과정을 통해 중간 생각들로 문제 해결 진행 상황을 스스로 평가할 수 있게 함.
CoT → Self-Consistency → ToT로 이어지는 흐름으로 이해하면 됩니다. CoT가 추론 과정을 만들고, Self-Consistency가 여러 경로 중 다수결로 선택하고, ToT는 탐색 자체를 트리 구조로 설계해서 더 정교하게 가지를 치고 되돌아오는(backtracking) 방식.
(복잡도 순서: CoT < Self-Consistency + CoT < ToT)
프롬프트 체이닝은 CoT의 연장선으로, 복잡한 태스크를 여러 단계의 독립적인 API 호출로 분리한다.
Tree of Thoughts(ToT)는 CoT를 트리 구조로 확장해 여러 추론 경로를 동시에 탐색한다.
LLM은 기본적으로 자유로운 형식의 텍스트를 반환한다. 이 텍스트를 Java 객체로 변환하고, 데이터베이스에 저장하고, 다른 API에 넘기려면 응답 형식이 예측 가능해야 한다.
LLM의 출력은 전통적으로 JSON을 요청해도 단순한 문자열로 도착한다. 올바른 JSON일 수도 있지만, 그것은 JSON 데이터 구조가 아닌 그냥 문자열일 수도 있어서 챕터 4의 구조화된 출력이라는 분야가 나오게 된다.
프롬프트로 형식을 유도하는 것(챕터 3)을 알아야, Spring AI가 그 출력을 어떻게 Java 객체로 안정적으로 변환하는지(챕터 4)를 이해할 수 있다.