Spring AI 프롬프트와 템플릿

Spring AI 프롬프트와 템플릿 (Prompts)

AI 모델에 무엇을 어떻게 보내느냐에 따라 응답 품질이 크게 갈려요. 그 출발점이 바로 프롬프트예요. Spring AI에서 프롬프트를 다루는 방식은 Spring MVC의 "View"를 관리하는 것과 꽤 비슷합니다. 동적 콘텐츠가 들어갈 자리에 플레이스홀더를 넣어 텍스트를 만들고, 사용자 요청이나 다른 코드가 그 자리를 채워주는 구조죠. SQL 문장에 표현식 플레이스홀더를 넣는 것과도 같은 원리예요.

Spring AI가 발전하면서 AI 모델과 상호작용하는 고수준 추상화가 계속 생기고 있는데, 이 섹션에서 다루는 기초 클래스들은 JDBC와 비슷한 역할을 해요. ChatModel은 JDK의 핵심 JDBC 라이브러리와 비슷하고, ChatClientChatModel 위에 쌓인 JdbcClient 같은 존재입니다. Advisor를 통해 과거 대화를 반영하고, 프롬프트에 추가 컨텍스트 문서를 붙이고, 에이전트적 행동도 넣을 수 있죠.

프롬프트의 구조도 시간이 지나며 진화했어요. 처음엔 단순 문자열이었고, 그다음엔 "USER:" 같은 특정 입력을 위한 플레이스홀더가 생겼으며, OpenAI가 여러 메시지 문자열을 역할별로 분류하면서 더 구조화됐습니다.

API 개요

Prompt

ChatModelcall() 메서드에 Prompt 인스턴스를 넘기고 ChatResponse를 받는 방식은 아주 흔해요. Prompt 클래스는 정돈된 Message 객체들의 시리즈와 요청 ChatOptions를 담는 컨테이너입니다. 각 Message는 프롬프트 안에서 고유한 역할을 갖는데, 내용과 의도가 서로 달라요. 사용자 질문부터 AI 생성 응답, 관련 배경 정보까지 다양한 요소를 담을 수 있죠. 여러 메시지가 각자 맡은 역할로 대화에 참여하기 때문에 AI 모델과 정교하고 세밀한 상호작용이 가능해요.

Prompt 클래스의 핵심 구조를 간추려 보면 이렇습니다 (생성자와 유틸리티 메서드는 생략):

public class Prompt implements ModelRequest<List<Message>> {

    private final List<Message> messages;

    private ChatOptions chatOptions;
}

편의 메서드 (Convenience Methods)

Prompt 클래스는 역할별로 메시지에 접근하는 편의 메서드를 여럿 제공해요.

단일 메시지 접근:

  • getUserMessage(): 프롬프트의 마지막 사용자 메시지를 반환합니다. 없으면 빈 UserMessage를 돌려줘요.
  • getSystemMessage(): 프롬프트의 첫 번째 시스템 메시지를 반환합니다. 없으면 빈 SystemMessage를 돌려줘요.
  • getLastUserOrToolResponseMessage(): 마지막 사용자 또는 도구 응답 메시지를 반환합니다. 대화 연속성 처리에 유용해요.

여러 메시지 접근:

  • getUserMessages(): 모든 사용자 메시지를 순서대로 담은 리스트를 반환합니다.
  • getSystemMessages(): 모든 시스템 메시지를 순서대로 담은 리스트를 반환합니다.

이 메서드들은 다중 턴 대화를 다루거나 메시지를 역할별로 처리해야 할 때 특히 유용해요.

Message

Message 인터페이스는 Prompt의 텍스트 콘텐츠, 메타데이터 속성 모음, 그리고 MessageType이라는 분류 정보를 캡슐화합니다. 인터페이스 정의는 이렇습니다:

public interface Content {

	String getContent();

	Map<String, Object> getMetadata();
}

public interface Message extends Content {

	MessageType getMessageType();
}

멀티모달 메시지 타입은 추가로 MediaContent 인터페이스를 구현해 Media 콘텐츠 객체의 리스트를 제공합니다.

public interface MediaContent extends Content {

	Collection<Media> getMedia();

}

다양한 Message 구현체들은 AI 모델이 처리할 수 있는 서로 다른 메시지 카테고리에 대응됩니다. 모델은 대화 역할(conversational roles)을 기준으로 메시지 카테고리를 구분해요.

역할 (Roles)

각 메시지에는 특정 역할이 부여됩니다. 역할은 메시지를 분류해 프롬프트의 각 부분이 어떤 맥락과 목적을 갖는지 AI 모델에게 명확히 알려줘요. 주요 역할은 다음과 같습니다:

  • System 역할: AI의 행동과 응답 스타일을 안내합니다. AI가 입력을 어떻게 해석하고 답할지에 대한 규칙을 정해주는 역할로, 대화를 시작하기 전에 지시를 내리는 것과 같아요.
  • User 역할: 사용자의 입력(질문, 명령, 진술)을 나타냅니다. AI 응답의 기반이 되는 가장 근본적인 역할이에요.
  • Assistant 역할: 사용자 입력에 대한 AI의 응답입니다. 단순한 답 이상으로 대화 흐름을 유지하는 데 핵심이에요. AI의 이전 응답(이 "Assistant 역할" 메시지들)을 추적함으로써 시스템은 일관되고 맥락에 맞는 상호작용을 보장합니다. Assistant 메시지는 필요할 때 계산, 데이터 조회 같은 특정 기능을 수행하는 도구 호출(Function Tool Call) 요청 정보도 담을 수 있어요.
  • Tool/Function 역할: Tool Call Assistant 메시지에 응답해 추가 정보를 반환하는 역할을 담당합니다.

역할은 Spring AI에서 다음과 같은 열거형으로 표현됩니다:

public enum MessageType {

	USER("user"),

	ASSISTANT("assistant"),

	SYSTEM("system"),

	TOOL("tool");

    ...
}

PromptTemplate

Spring AI에서 프롬프트 템플릿의 핵심 구성 요소는 PromptTemplate 클래스예요. AI 모델로 보낼 구조화된 프롬프트를 만드는 데 사용됩니다.

public class PromptTemplate implements PromptTemplateActions, PromptTemplateMessageActions {

    // Other methods to be discussed later
}

이 클래스는 TemplateRenderer API로 템플릿을 렌더링해요. 기본적으로 Spring AI는 Terence Parr가 만든 오픈소스 StringTemplate 엔진 기반의 StTemplateRenderer 구현을 사용합니다. 템플릿 변수는 {} 문법으로 식별하지만, 구분자를 다른 문법으로 바꿔서 설정할 수도 있어요.

public interface TemplateRenderer extends BiFunction<String, Map<String, Object>, String> {

	@Override
	String apply(String template, Map<String, Object> variables);

}

Spring AI는 TemplateRenderer 인터페이스로 템플릿 문자열에 변수를 실제로 치환합니다. 기본 구현은 StringTemplate을 사용해요. 커스텀 로직이 필요하면 직접 TemplateRenderer 구현을 제공할 수도 있습니다. 템플릿 렌더링이 필요 없는 경우(예: 템플릿 문자열이 이미 완성된 경우)에는 NoOpTemplateRenderer를 쓰면 돼요.

이 클래스가 구현하는 인터페이스들은 프롬프트 생성의 서로 다른 측면을 담당합니다:

  • PromptTemplateStringActions: 프롬프트 문자열을 만들고 렌더링하는 가장 기본적인 형태에 집중해요.
  • PromptTemplateMessageActions: Message 객체를 생성·조작해 프롬프트를 만드는 데 특화돼 있어요.
  • PromptTemplateActions: ChatModel에 넘겨 응답을 받을 수 있는 Prompt 객체를 반환하도록 설계됐어요.

구현된 인터페이스는 다음과 같습니다:

public interface PromptTemplateStringActions {

	String render();

	String render(Map<String, Object> model);

}
  • String render(): 외부 입력 없이 프롬프트 템플릿을 최종 문자열로 렌더링합니다. 플레이스홀더나 동적 콘텐츠가 없는 템플릿에 적합해요.
  • String render(Map<String, Object> model): 동적 콘텐츠를 포함하도록 렌더링 기능을 확장합니다. Map<String, Object>의 키는 프롬프트 템플릿의 플레이스홀더 이름이고, 값은 삽입될 동적 콘텐츠예요.
public interface PromptTemplateMessageActions {

	Message createMessage();

    Message createMessage(List<Media> mediaList);

	Message createMessage(Map<String, Object> model);

}
  • Message createMessage(): 추가 데이터 없이 Message 객체를 만듭니다. 정적이거나 미리 정의된 메시지 콘텐츠에 쓰여요.
  • Message createMessage(List<Media> mediaList): 정적 텍스트와 미디어 콘텐츠를 가진 Message 객체를 만듭니다.
  • Message createMessage(Map<String, Object> model): 동적 콘텐츠를 통합하도록 확장합니다. Map<String, Object>의 각 항목이 메시지 템플릿의 플레이스홀더와 그에 대응하는 동적 값이에요.
public interface PromptTemplateActions extends PromptTemplateStringActions {

	Prompt create();

	Prompt create(ChatOptions modelOptions);

	Prompt create(Map<String, Object> model);

	Prompt create(Map<String, Object> model, ChatOptions modelOptions);

}
  • Prompt create(): 외부 데이터 입력 없이 Prompt 객체를 생성합니다. 정적이거나 미리 정의된 프롬프트에 적합해요.
  • Prompt create(ChatOptions modelOptions): 외부 데이터 없이, 채팅 요청의 특정 옵션과 함께 Prompt 객체를 생성합니다.
  • Prompt create(Map<String, Object> model): 동적 콘텐츠를 포함하도록 확장합니다. 각 map 항목이 템플릿의 플레이스홀더와 그 값이에요.
  • Prompt create(Map<String, Object> model, ChatOptions modelOptions): 동적 콘텐츠와 채팅 요청 옵션을 모두 포함합니다.

예시 사용법

PromptTemplate을 직접 쓰는 간단한 예시입니다:

PromptTemplate promptTemplate = new PromptTemplate("Tell me a {adjective} joke about {topic}");

Prompt prompt = promptTemplate.create(Map.of("adjective", adjective, "topic", topic));

return chatModel.call(prompt).getResult();

역할을 활용한 예시도 있어요. SystemPromptTemplate으로 시스템 메시지에 플레이스홀더 값을 넣고, user 역할 메시지와 결합해 프롬프트를 만듭니다:

String userText = """
    Tell me about three famous pirates from the Golden Age of Piracy and why they did.
    Write at least a sentence for each pirate.
    """;

Message userMessage = new UserMessage(userText);

String systemText = """
  You are a helpful AI assistant that helps people find information.
  Your name is {name}
  You should reply to the user's request with your name and also in the style of a {voice}.
  """;

SystemPromptTemplate systemPromptTemplate = new SystemPromptTemplate(systemText);
Message systemMessage = systemPromptTemplate.createMessage(Map.of("name", name, "voice", voice));

Prompt prompt = new Prompt(List.of(userMessage, systemMessage));

List<Generation> response = chatModel.call(prompt).getResults();

커스텀 템플릿 렌더러 사용

TemplateRenderer 인터페이스를 구현해 PromptTemplate 생성자에 넘기면 커스텀 렌더러를 쓸 수 있어요. 기본 StTemplateRenderer를 커스텀 설정과 함께 계속 쓰는 방법도 있습니다.

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

PromptTemplate promptTemplate = PromptTemplate.builder()
    .renderer(StTemplateRenderer.builder().startDelimiterToken('<').endDelimiterToken('>').build())
    .template("""
            Tell me the names of 5 movies whose soundtrack was composed by <composer>.
            """)
    .build();

String prompt = promptTemplate.render(Map.of("composer", "John Williams"));

문자열 대신 리소스 사용

Spring AI는 org.springframework.core.io.Resource 추상화를 지원해서, 프롬프트 데이터를 파일에 두고 PromptTemplate에서 직접 사용할 수 있어요. Spring 관리 컴포넌트에 Resource 필드를 정의하면 됩니다:

@Value("classpath:/prompts/system-message.st")
private Resource systemResource;

그리고 그 리소스를 SystemPromptTemplate에 바로 넘깁니다:

SystemPromptTemplate systemPromptTemplate = new SystemPromptTemplate(systemResource);

프롬프트 엔지니어링

생성형 AI에서 프롬프트 작성은 개발자의 핵심 작업이에요. 프롬프트의 품질과 구조는 AI 출력의 효과를 크게 좌우합니다. 좋은 프롬프트를 설계하는 데 시간과 노력을 투자하면 AI의 결과가 크게 개선돼요.

효과적인 프롬프트를 만들 때 포함해야 할 핵심 요소는 다음과 같습니다:

  • 지시 (Instructions): 사람에게 말하듯 명확하고 직접적인 지시를 AI에 전달합니다. AI가 무엇을 기대하는지 "이해"하게 하는 데 필수적이에요.
  • 외부 컨텍스트 (External Context): 필요할 때 관련 배경 정보나 특정 안내를 포함합니다. 이 "외부 컨텍스트"가 프롬프트의 프레임을 잡아주고 AI가 전체 시나리오를 파악하게 도와줘요.
  • 사용자 입력 (User Input): 사용자의 직접적인 요청이나 질문으로 프롬프트의 중심이 됩니다.
  • 출력 지시 (Output Indicator): AI 응답의 원하는 형식(예: JSON)을 지정하는 부분이에요. 다만 AI가 이 형식을 항상 엄격히 지키지는 않을 수 있어요. 예를 들어 실제 JSON 앞에 "here is your JSON" 같은 문구를 붙이거나, 정확하지 않은 JSON 같은 구조를 생성할 수도 있습니다.

프롬프트를 작성할 때 예상 질문·답변 형식의 예시를 AI에게 제공하면 매우 도움이 돼요. 이렇게 하면 AI가 쿼리의 구조와 의도를 "이해"해 더 정확하고 관련성 높은 응답을 만들 수 있어요.

토큰 (Tokens)

토큰은 AI 모델이 텍스트를 처리하는 방식의 핵심으로, 단어를 AI 모델이 처리 가능한 형식으로 바꿔주는 다리 역할을 해요. 단어는 입력 시 토큰으로 변환되고, 출력 시 다시 단어로 변환되죠.

토큰은 단어의 일부 정도로 생각하면 돼요. 보통 한 토큰은 단어 약 3/4에 해당합니다. 예를 들어 셰익스피어 전집(약 90만 단어)은 약 120만 토큰으로 환산됩니다.

토큰은 기술적 역할 외에도 실용적인 의미가 있어요:

  • 과금 (Billing): AI 모델 서비스는 토큰 사용량을 기준으로 과금합니다. 입력(프롬프트)과 출력(응답) 모두 총 토큰 수에 포함되므로, 짧은 프롬프트가 더 비용 효율적이에요.
  • 모델 한도 (Model Limits): 모델마다 토큰 한도가 다릅니다. 이 한도가 "컨텍스트 윈도우"(한 번에 처리할 수 있는 정보의 최대량)를 정의해요. 예를 들어 GPT-3는 4K 토큰, Claude 2와 Meta Llama 2 같은 모델은 100K 토큰, 일부 연구 모델은 최대 100만 토큰까지 처리할 수 있어요.
  • 컨텍스트 윈도우 (Context Window): 모델의 토큰 한도가 컨텍스트 윈도우를 결정합니다. 이 한도를 초과하는 입력은 처리되지 않아요. 최소한의 효과적인 정보만 보내는 게 중요합니다.
  • 응답 메타데이터 (Response Metadata): AI 모델 응답의 메타데이터에는 사용된 토큰 수가 포함돼요. 사용량과 비용을 관리하는 데 중요한 정보입니다.