LLM 호출 비용 최적화: 프롬프트 캐싱, 소형 모델 및 배치
1. 개요
LLM을 활용한 기능이 데모에서 프로덕션으로 옮겨갈 때, 비용이 일반적으로 해결해야 할 첫 번째 문제가 됩니다. 청구액은 토큰 수에 따라 증가하며, 토큰 수는 사용자 수, 프롬프트 길이, 에이전트 호출 횟수에 따라 증가합니다.
이 글은 LLM 호출 비용을 줄이는 세 가지 일반적인 기법을 소개합니다: 프롬프트 캐싱, 소형 모델로의 라우팅, 배치 처리. 각 기법은 Java 21/Spring Boot 3.x 예제와 함께 제시됩니다. 이 글을 읽고 나면, 캐시를 활용하기 위해 프롬프트를 구조화하는 방법, 작업 유형에 따라 모델을 선택하는 방법, 그리고 배치를 어디에 사용해야 하는지 알 수 있습니다. 또한 각 기법을 적용하지 말아야 할 경우도 설명합니다.
2. LLM 비용의 출처
대부분의 제공자는 토큰을 기준으로 요금을 청구하며 세 가지 범주로 나뉩니다:
- 입력 토큰: 시스템 프롬프트, 대화 기록, RAG 문서, 도구 정의.
- 캐시된 입력 토큰: 캐시에서 다시 읽은 입력 부분으로, 일반 입력보다 보통 저렴합니다.
- 출력 토큰: 모델이 생성한 콘텐츠로, 입력보다 보통 비쌉니다.
많은 애플리케이션에서 입력이 비용의 대부분을 차지합니다. 각 요청마다 동일한 긴 시스템 프롬프트, 동일한 도구 집합, 동일한 문서 섹션을 보내기 때문입니다. 비용을 다음과 같이 모델화할 수 있습니다:
public record TokenUsage(long inputTokens, long cachedInputTokens, long outputTokens) {}
public record ModelPricing(double inputPerMillion, double cachedInputPerMillion, double outputPerMillion) {
public double cost(TokenUsage u) {
long uncached = u.inputTokens() - u.cachedInputTokens();
return (uncached * inputPerMillion
+ u.cachedInputTokens() * cachedInputPerMillion
+ u.outputTokens() * outputPerMillion) / 1_000_000.0;
}
public static void main(String[] args) {
// 설명용 가격, 어떤 제공자의 실제 가격이 아닙니다
var pricing = new ModelPricing(10, 1, 30);
System.out.printf("캐시 없음: %.3f%n", pricing.cost(new TokenUsage(20_000, 0, 500)));
System.out.printf("캐시 있음: %.3f%n", pricing.cost(new TokenUsage(20_000, 18_000, 500)));
}
}
TokenUsage는 각 응답에서 제공자가 반환한 토큰 수를 포함합니다. cost()는 일반 입력 부분과 캐시에서 가져온 입력 부분을 분리한 후 각각의 단가를 곱합니다. 위의 가정된 가격 집합으로 결과는 다음과 같습니다:
캐시 없음: 0.215
캐시 있음: 0.053
여기의 숫자는 설명 목적일 뿐입니다. 중요한 점은 구조입니다: 프롬프트의 대부분이 요청 간에 반복될 때, 캐싱은 가장 큰 비용 항목에 직접 영향을 미칩니다. 제공자의 실제 가격을 구성에 입력하고 로그의 각 요청에 대한 비용을 계산해야 합니다.
3. 프롬프트 캐싱
3.1. 개념
프롬프트 캐싱은 제공자 측 메커니즘입니다. 처음 프롬프트를 처리할 때, 제공자는 프롬프트의 **시작 부분(접두사)**에 대해 이미 계산된 상태를 저장합니다. 그 이후의 요청에서 접두사가 정확히 같은 문자를 가지면 이 상태를 다시 읽으므로 더 저렴하고 보통 더 빠릅니다.
기억해야 할 세 가지 특성이 있습니다:
- 캐시는 접두사에 따라 일치합니다. 프롬프트 시작 부분의 한 글자만 달라도 뒤의 모든 부분은 캐시를 사용할 수 없습니다.
- 캐시는 짧은 수명을 가지며 보통 충분히 긴 접두사가 필요합니다. 구체적인 임계값은 제공자에 따라 다릅니다.
- 자동 캐싱을 하는 제공자도 있고, 요청에서 캐시 지점을 표시하도록 하는 제공자도 있습니다.
3.2. 프롬프트 배열: 정적 부분 먼저, 동적 부분 나중에
가장 중요한 원칙은 안정적인 콘텐츠를 맨 앞에, 변경되는 콘텐츠를 맨 뒤에 배치하는 것입니다:
import java.time.LocalDate;
import java.util.List;
public record ChatMessage(String role, String content) {}
public final class SupportPromptBuilder {
private final String instructions;
private final String productCatalog;
public SupportPromptBuilder(String instructions, String productCatalog) {
this.instructions = instructions;
this.productCatalog = productCatalog;
}
public List<ChatMessage> build(String customerQuestion, LocalDate today) {
return List.of(
new ChatMessage("system", instructions), // 정적
new ChatMessage("system", "상품 카탈로그:\n" + productCatalog), // 정적
new ChatMessage("user", "오늘: " + today + "\n질문: " + customerQuestion)); // 동적
}
}
지침과 상품 카탈로그는 고객 간에 변하지 않으므로 메시지 목록의 맨 앞에 있습니다. 현재 날짜와 질문은 각 요청에 따라 변하므로 맨 마지막 메시지에 있습니다.
흔한 실수는 시스템 프롬프트의 첫 줄에 "당신은 어시스턴트입니다. 지금은 " + LocalDateTime.now()를 작성하는 것입니다. 타임스탬프는 매 요청마다 변경되므로 캐시가 절대 히트하지 않습니다. 유사한 더 찾기 어려운 실수들은 다음과 같습니다:
- 도구 정의를
HashMap에서 직렬화하여 키 순서가 불안정해집니다. - 사용자 이름이나 요청 ID를 시스템 프롬프트에 삽입합니다.
- 각 호출 간에 RAG 문서를 임의의 순서로 정렬합니다.
코드가 변경될 때 접두사가 안정적으로 유지되도록 테스트를 작성할 수 있습니다:
import static org.assertj.core.api.Assertions.assertThat;
import java.time.LocalDate;
import org.junit.jupiter.api.Test;
class SupportPromptBuilderTest {
private final SupportPromptBuilder builder =
new SupportPromptBuilder("당신은 가게의 지원 직원입니다.", "티셔츠, 청바지, 신발");
@Test
void prefixIsIdenticalAcrossDifferentRequests() {
var first = builder.build("S 사이즈 있나요?", LocalDate.of(2024, 1, 1));
var second = builder.build("배송비 얼마인가요?", LocalDate.of(2024, 1, 2));
assertThat(first.subList(0, 2)).isEqualTo(second.subList(0, 2));
assertThat(first.get(2)).isNotEqualTo(second.get(2));
}
}
테스트는 두 요청의 처음 두 메시지를 비교합니다. 이후에 누군가 동적 데이터를 정적 부분에 삽입하면 테스트가 즉시 오류를 보고합니다.
3.3. 명시적 캐시 지점 표시
캐시 지점을 명시적으로 표시하도록 하는 제공자의 경우, 요청은 보통 “여기까지 캐시한다”는 필드를 포함합니다. 예를 들어, JSON 본문은 다음과 같이 보일 수 있습니다. 필드 이름과 위치는 각 제공자의 API에 따라 다릅니다:
{
"model": "your-model",
"system": [
{ "type": "text", "text": "당신은 지원 직원입니다..." },
{ "type": "text", "text": "상품 카탈로그: ...", "cache_control": { "type": "ephemeral" } }
],
"messages": [ { "role": "user", "content": "S 사이즈 있나요?" } ]
}
표시 지점은 마지막 정적 블록의 끝에 배치됩니다. 그 지점 이전의 모든 콘텐츠가 캐시 후보입니다.
3.4. 캐시 히트율 측정
응답은 보통 캐시에서 읽은 토큰 수를 반환합니다. 캐시가 실제로 작동하는지 알 수 없으므로 이 숫자를 기록해야 합니다:
public static double cacheHitRatio(TokenUsage u) {
return u.inputTokens() == 0 ? 0 : (double) u.cachedInputTokens() / u.inputTokens();
}
이 비율을 Micrometer에 푸시하거나 메트릭으로 기록하세요. 배포 후 비율이 갑자기 0이 되면, 누군가 접두사를 변경했을 가능성이 높습니다.
4. 애플리케이션 측 응답 캐싱
프롬프트 캐싱은 입력 비용만 줄입니다. 동일한 질문을 반복해서 받으면, LLM 호출 전체를 건너뛸 수 있습니다. 다음 의존성을 추가하세요:
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-cache</artifactId>
</dependency>
<dependency>
<groupId>com.github.ben-manes.caffeine</groupId>
<artifactId>caffeine</artifactId>
</dependency>
spring.cache.cache-names=faq-answers
spring.cache.caffeine.spec=maximumSize=10000,expireAfterWrite=6h
public interface LlmClient {
String complete(String model, String prompt);
}
@Service
public class FaqAnswerService {
private final LlmClient llmClient;
public FaqAnswerService(LlmClient llmClient) {
this.llmClient = llmClient;
}
@Cacheable(cacheNames = "faq-answers", key = "#root.target.normalize(#question)")
public String answer(String question) {
return llmClient.complete("small-model", "FAQ 질문에 간단히 답하기: " + question);
}
public String normalize(String question) {
return question.strip().toLowerCase(Locale.ROOT).replaceAll("\\s+", " ");
}
}
@Configuration 클래스에 @EnableCaching을 추가해야 합니다. 캐시의 키는 정규화된 질문입니다: 불필요한 공백을 제거하고 소문자로 변환합니다. 이 덕분에 “배송비?” 및 “ 배송 비용? “은 동일한 결과를 사용합니다.
주의:
@Cacheable은 프록시를 통해 작동합니다. 같은 클래스의 다른 메서드가answer()를 직접 호출하면 캐시가 무시됩니다. 캐시 키에 사용자 식별자를 포함하지 않으면, 개인화된 데이터(예: 특정 고객의 주문 상태)를 응답으로 캐시하지 마세요.
5. 소형 모델로의 라우팅
많은 작업에는 가장 강력한 모델이 필요하지 않습니다: 티켓 분류, 이메일에서 필드 추출, 짧은 요약, 형식 검증. 소형 모델은 보통 훨씬 저렴하고 빠릅니다. 이것이 많은 팀이 “모델 라우팅”에 관심을 갖는 이유입니다.
실제 방법은 cascade입니다: 먼저 소형 모델을 호출하고, 출력을 확인하며, 출력이 유효하지 않을 때만 대형 모델로 업그레이드합니다.
import java.util.Set;
public class TicketClassifier {
private static final Set<String> LABELS = Set.of("BILLING", "SHIPPING", "TECHNICAL", "OTHER");
private final LlmClient llm;
private final String smallModel;
private final String largeModel;
public TicketClassifier(LlmClient llm, String smallModel, String largeModel) {
this.llm = llm;
this.smallModel = smallModel;
this.largeModel = largeModel;
}
public String classify(String ticket) {
String prompt = "티켓을 다음 레이블 중 하나로 분류하세요: " + LABELS + ". 레이블만 반환하세요.\n티켓: " + ticket;
String label = llm.complete(smallModel, prompt).strip();
if (LABELS.contains(label)) {
return label;
}
return llm.complete(largeModel, prompt).strip();
}
}
소형 모델이 먼저 처리합니다. 허용된 레이블 집합에서 정확히 하나의 레이블을 반환하면, 해당 결과를 바로 사용합니다. 그렇지 않으면, 예를 들어 모델이 장황하게 답하면, 대형 모델을 사용하여 다시 호출합니다. 검증 단계는 확정적이어야 합니다: JSON 파싱, enum 매칭, 스키마 검증. 소형 모델에게 “자신감”이 있는지 묻지 마세요.
가짜 LlmClient로 테스트하세요:
import static org.assertj.core.api.Assertions.assertThat;
import java.util.ArrayList;
import java.util.List;
import org.junit.jupiter.api.Test;
class TicketClassifierTest {
@Test
void escalatesToLargeModelWhenSmallModelReturnsInvalidLabel() {
List<String> calledModels = new ArrayList<>();
LlmClient fake = (model, prompt) -> {
calledModels.add(model);
return model.equals("small-model") ? "청구와 관련된 건가?" : "BILLING";
};
var classifier = new TicketClassifier(fake, "small-model", "large-model");
assertThat(classifier.classify("두 번 청구되었습니다")).isEqualTo("BILLING");
assertThat(calledModels).containsExactly("small-model", "large-model");
}
@Test
void staysOnSmallModelWhenOutputIsValid() {
List<String> calledModels = new ArrayList<>();
LlmClient fake = (model, prompt) -> {
calledModels.add(model);
return "SHIPPING";
};
new TicketClassifier(fake, "small-model", "large-model").classify("상품이 도착하지 않았습니다");
assertThat(calledModels).containsExactly("small-model");
}
}
모델 이름을 하드코딩하는 대신 구성에 넣어야 합니다. 이렇게 하면 재빌드하지 않고 모델을 변경할 수 있습니다:
llm:
models:
small: small-model
large: large-model
또한 escalate 비율을 추적해야 합니다. 대부분의 요청이 여전히 대형 모델로 올라가면, cascade는 각 요청을 두 번 호출하므로 돈을 더 낭비하고 있습니다.
6. 배치
6.1. 여러 항목을 하나의 요청으로 그룹화
50개의 제품 리뷰 같은 짧은 항목을 많이 분석해야 할 때, 이들을 하나의 프롬프트로 그룹화할 수 있습니다. 지침 부분은 50번이 아닌 한 번만 전송됩니다:
public record Review(String id, String text) {}
public static String buildSentimentPrompt(List<Review> reviews) {
var sb = new StringBuilder("""
각 리뷰에 대해 한 줄의 JSON을 반환하세요: {"id": "...", "sentiment": "POSITIVE|NEGATIVE|NEUTRAL"}
""");
reviews.forEach(r -> sb.append(r.id()).append(": ").append(r.text()).append('\n'));
return sb.toString();
}
각 그룹의 크기는 적당해야 합니다. 그룹이 너무 크면 모델이 항목을 놓치기 쉽습니다. 또한 하나의 파싱 오류가 전체 그룹을 망칠 것입니다. 모든 입력 id가 출력에 있는지 항상 확인하고, 누락된 항목은 별도로 재시도하세요.
6.2. 비동기 배치 API
많은 제공자가 배치 API를 제공합니다. 여러 요청이 포함된 파일을 보내면, 제공자는 더 오랜 시간에 걸쳐 처리하는 대신 보통 동기 호출보다 낮은 가격을 제공합니다. 일반적인 형식은 JSONL이며, 각 줄은 하나의 요청이고 결과를 일치시키기 위해 custom_id가 있습니다. 구체적인 구조는 제공자에 따라 다릅니다:
import com.fasterxml.jackson.databind.ObjectMapper;
import java.util.List;
import java.util.Map;
import java.util.stream.Collectors;
public static String toJsonl(List<Review> reviews, String model, ObjectMapper mapper) {
return reviews.stream()
.map(r -> Map.of(
"custom_id", "review-" + r.id(),
"body", Map.of(
"model", model,
"messages", List.of(Map.of("role", "user",
"content", "감정 분류: " + r.text())))))
.map(line -> {
try {
return mapper.writeValueAsString(line);
} catch (Exception e) {
throw new IllegalStateException(e);
}
})
.collect(Collectors.joining("\n"));
}
각 리뷰가 독립적인 JSON 라인이 됩니다. custom_id는 어느 결과가 어느 리뷰에 속하는지 알 수 있게 해줍니다. 반환 순서는 보장되지 않기 때문입니다. 출력은 다음과 같습니다:
{"custom_id":"review-1","body":{"model":"small-model","messages":[{"role":"user","content":"감정 분류: 빠른 배송"}]}}
{"custom_id":"review-2","body":{"model":"small-model","messages":[{"role":"user","content":"감정 분류: 셔츠가 찢어져 있었습니다"}]}}
JSON의 키 순서는 Map.of가 순서를 유지하지 않기 때문에 다를 수 있습니다. 이것은 배치 API에 영향을 미치지 않습니다. 일반적인 프로세스는 네 단계로 구성됩니다: 밤에 실행되는 @Scheduled 작업이 파일을 생성하고, 업로드하고, 배치를 생성하며, 배치 ID를 데이터베이스에 저장합니다. 그 다음 다른 작업이 정기적으로 상태를 확인하고 결과를 다운로드합니다.
7. 사용하면 안 되는 경우
- 프롬프트 캐싱은 프롬프트가 짧을 때, 접두사가 사용자별로 변경될 때, 또는 트래픽이 너무 드물어서 캐시가 다음 요청 전에 만료될 때 거의 도움이 되지 않습니다. 캐시 쓰기 비용이 있는 제공자의 경우, 절대 다시 읽지 않을 콘텐츠에 캐시를 표시하면 비용이 증가합니다.
- 응답 캐싱은 응답이 재고나 가격처럼 자주 변하는 데이터에 따라 달라질 때, 개인화가 필요할 때, 또는 사용자가 다양한 응답을 기대할 때 적합하지 않습니다.
- 소형 모델은 다단계 추론이 필요한 작업, 복잡한 코드 작성이나 검토, 또는 법적, 의료, 재무 결정처럼 결과가 큰 영향을 미칠 때 출력을 결정론적으로 검증할 수 없다면 적합하지 않습니다. 느낌만으로 모델을 바꾸지 마세요. 먼저 평가 데이터 세트에서 실행하세요.
- 배치는 채팅이나 자동완성 같은 즉시 응답이 필요한 모든 워크플로우에 사용할 수 없습니다. 각 항목이 긴 개별 컨텍스트를 필요로 하거나, 한 항목의 오류가 다른 항목에 영향을 미치면 안 될 때도 여러 항목을 그룹화하기에 부적합합니다.
참고: 최적화하기 전에 측정하세요. 각 요청의
TokenUsage, 사용된 모델, 호출된 기능을 기록하세요. 보통 몇 개의 엔드포인트만 대부분의 비용을 차지하며, 그곳에서 시작해야 합니다.
8. 결론
LLM 비용은 주로 반복되는 토큰과 필요 이상으로 강력한 모델 사용에서 비롯됩니다. 이 글의 각 기법은 낭비의 한 원인을 목표로 합니다:
- 프롬프트 캐싱: 정적 부분을 맨 앞에 놓고, 접두사를 안정적으로 유지하며, 캐시 히트율을 측정합니다.
- 응답 캐싱: 반복되는 질문에 대한 LLM 호출을 건너뜁니다.
- Cascade 소형 → 대형 모델: 결정론적으로 출력을 확인하고 필요할 때만 escalate합니다.
- 배치: 항목을 그룹화하여 지침 부분을 공유하고, 즉각적인 결과가 필요 없는 작업에는 배치 API를 사용합니다.
이 기법들은 서로 결합할 수 있습니다. 예를 들어, 밤 분류 작업은 소형 모델을 사용하고, 배치 API를 통해 보내며, 안정적인 접두사가 있는 프롬프트를 사용할 수 있습니다. 가장 중요한 것은 각 요청의 비용을 측정할 수 있다는 것입니다. 데이터가 있으면, 어떤 변경이 실제로 효과적인지 알 수 있습니다.