Kotlin + Spring Boot로 구현하는 실무형 LLM 메시지 설계

궁금하면 500원·2025년 10월 14일

AI 미생지능

목록 보기
71/73

프리픽스 캐싱부터 프롬프트 설계

최근 챗 컴플리션 모델을 서비스에 도입할 때, 단순히 질문을 던지는 것을 넘어 비용 최적화응답 품질 제어가 핵심 과제로 떠오르고 있습니다.
오늘은 모델이 메시지를 인식하는 방식과 비용을 아끼는 기술적 장치인 '프리픽스 캐시', 그리고 효과적인 프롬프트 배치 전략을 정리해 보겠습니다.


1. 메시지와 컨텍스트

LLM은 단순히 텍스트를 받는 것이 아니라, 지시자를 포함한 메시지 리스트를 입력받습니다.

  • 구조적 학습: 모델은 system, user, assistant라는 지시자를 통해 각각의 역할을 구분하도록 학습되었습니다.
  • 순서와 문맥: 모델은 메시지의 순서를 통해 흐름을 파악하며, 이 누적된 순서를 컨텍스트라고 부릅니다. 이전 대화 내용을 기반으로 다음 답변을 생성하는 '멀티턴' 대화의 핵심입니다.

2. 비용 절감의 프리픽스 캐시

긴 대화를 이어갈 때 가장 큰 부담은 토큰 비용입니다.
이를 해결하기 위해 많은 유료 모델이 프리픽스 캐시를 지원합니다.

  • 동작 원리: 두 번째 질문을 보낼 때, 첫 번째 질문과 앞부분이 일치하면 모델은 이전에 계산했던 가중치를 재사용합니다.

  • 작동 조건:

  1. 일치성: 메시지 리스트의 앞부분이 토큰 단위로 정확히 일치해야 합니다.
  2. 임계치: 보통 4,000 토큰 이상의 입력일 때 활성화되는 경우가 많습니다.
  • 효과: 캐시된 토큰에 대해서는 비용을 면제해주거나 대폭 할인해주므로, 긴 컨텍스트를 다룰 때 매우 중요합니다.

3. 시스템 프롬프트 배치 전략

모델의 성격과 규칙을 정하는 system 프롬프트는 어디에 위치해야 할까요?

  • 최상단 배치: 대다수 모델은 학습 시 시스템 프롬프트를 맨 앞에 두고 학습했습니다. 따라서 지시를 가장 정확하게 인식하게 하려면 메시지 리스트의 첫 번째에 두는 것이 국룰입니다.

  • 어텐션 희석 문제: 대화가 길어지면 모델의 주의력이 뒤쪽 토큰에 쏠리면서, 맨 앞의 시스템 지시를 망각하기 시작합니다.

  • 해결책

  1. 리마인드 시스템 메시지: 대화 중간이나 마지막에 "너는 JSON으로 대답해야 한다는 걸 기억해"라는 식의 요약된 시스템 메시지를 다시 삽입합니다.

  2. 유저 프롬프트 강화: 유저의 질문 마지막에 지시 사항을 살짝 덧붙여 어텐션을 다시 끌어올립니다.


4. Zero-shot vs Few-shot

프롬프트에 예시를 주는 기법은 양날의 검입니다.

  • Zero-shot: 예시 없이 지시만 함. 모델의 창발성과 사고력을 가장 잘 활용할 수 있습니다.
  • Few-shot: 여러 예시를 줌. 답변의 형식을 강제하기 좋지만, 모델이 사고를 멈추고 예시를 그대로 복제하려는 경향이 강해집니다.
  • 실무 조언: 지시만으로 해결되는 Zero-shot이 가장 안전합니다. 예시가 너무 강력한 제약이 되어 모델의 유연성을 죽일 수 있기 때문입니다.

5. 실무형 Kotlin 구현 예시

다음은 위 원칙들을 적용하여 시스템 메시지를 최상단에 고정하고, 캐시 효율을 위해 순서를 유지하며, 필요 시 지시 사항을 강화하는 백엔드 서비스 코드입니다.

import org.springframework.stereotype.Service

/**
 * LLM 메시지 역할 정의
 */
enum class ChatRole {
    SYSTEM, USER, ASSISTANT
}

data class ChatMessage(
    val role: ChatRole,
    val content: String
)

@Service
class LlmChatService {

    /**
     * 실무형 메시지 리스트 생성 로직
     * 1. 시스템 프롬프트를 최상단에 배치 (캐시 유지 및 지시 강화)
     * 2. 유저와 어시스턴트의 대화 순서 엄격 준수
     * 3. 대화가 길어질 경우 마지막 유저 메시지에 지시 사항 리마인드 추가
     */
    fun buildMessages(
        systemInstruction: String,
        history: List<ChatMessage>,
        userQuestion: String,
        isJsonMode: Boolean = true
    ): List<ChatMessage> {
        val messages = mutableListOf<ChatMessage>()

        // [Rule 1] 시스템 프롬프트는 무조건 인덱스 0번
        messages.add(ChatMessage(ChatRole.SYSTEM, systemInstruction))

        // [Rule 2] 히스토리 추가 (순서가 바뀌면 프리픽스 캐시가 깨짐 주의)
        messages.addAll(history)

        // [Rule 3] 어텐션 희석 방지를 위한 전략
        val finalUserContent = if (isJsonMode && history.size > 5) {
            // 대화가 길어지면 유저 질문 뒤에 지시사항을 리마인드하여 어텐션 강화
            "$userQuestion\n\n(Reminder: 응답은 반드시 JSON 형식을 유지해 주세요.)"
        } else {
            userQuestion
        }
        
        messages.add(ChatMessage(ChatRole.USER, finalUserContent))

        return messages
    }
}

요약 및 결론

  1. 순서가 곧 돈이다: 메시지 리스트의 앞부분을 고정하여 프리픽스 캐시를 적극 활용하세요.
  2. 시스템 메시지는 최상단에: 모델의 본분을 잊지 않도록 맨 앞에 배치하되, 대화가 길어지면 마지막에 리마인드를 섞어주세요.
  3. 예시는 신중하게: 모델의 사고력을 믿는다면 Zero-shot을 우선하고, Few-shot은 형식이 절대적으로 중요할 때만 사용하세요.

LLM을 대하는 백엔드의 자세

["LLM은 마법이 아니라 인터페이스다"]
수많은 API를 연동하고 비즈니스 로직을 짜왔지만, LLM은 확실히 결이 다르다는것을 배운
시간이였습니다.
예전에는 'Input A를 넣으면 Output B가 나온다'는 결정론적 사고가 지배적이었다면, 이제는 확률론적 사고를 백엔드에 녹여내야 한다 생각합니다.
실무에서 가장 무서운 것은 '불확실성'과 '비용'인데
이번에 정리한 프리픽스 캐싱은 단순히 돈을 아끼는 기술을 넘어, 인프라 비용을 예측 가능하게 만드는 중요한 전략입니다.
또한, 시스템 프롬프트의 위치를 고민하는 과정은 과거 디자인 패턴을 고민하던 시간과 비슷하는것을배웠습니다.
"프롬프트 엔지니어링은 기획자의 영역이다?" 아니라. 시스템 메시지를 어디에 배치하고, 컨텍스트를 어떻게 관리하여 캐시 효율을 높일지 결정하는 것은 철저히 백엔드 엔지니어의 엔지니어링 영역 이라는것을 느꼈습니다.

profile
에러가 나도 괜찮아 — 그건 내가 배우고 있다는 증거야.

0개의 댓글