Java에서 프롬프트 캐싱 및 비용 제어
요약
Java 환경에서 LLM 애플리케이션의 비용을 최적화하기 위한 프롬프트 캐싱과 모델 라우팅 전략을 다룹니다. 공유 접두사(shared prefix)를 활용한 캐싱 기술과 비용 효율적인 모델 선택법을 통해 엔지니어링 관점의 비용 제어 방법을 제시합니다.
핵심 포인트
- 프롬프트 캐싱을 통해 시스템 프롬프트와 RAG 컨텍스트 재사용 비용을 90% 절감 가능
- 캐시 적중을 위해 접두사의 바이트 단위 정확한 일치(Prefix match)가 필수적임
- 모델 라우팅을 통해 저렴한 모델로 분류 후 필요 시에만 고성능 모델로 에스컬레이션
- 장황한 모델 출력은 출력 토큰 비용뿐 아니라 다음 호출의 입력 비용까지 증가시킴
서론
우리는 이미 작업에 맞는 적절한 모델 티어를 선택하는 방법과 https://pg-blogs.netlify.app/posts/11-building-reliable-llm-apps-in-java/에서 대규모 공유 접두사(shared prefix)를 캐싱하는 방법을 다루었습니다. 이 두 가지 내용은 더 큰 원칙의 일부분이었습니다: LLM 비용은 고정된 항목이 아니라, 데이터베이스 쿼리 시간이나 컨테이너 메모리에 적용하는 것과 같은 엄격함으로 측정하고 줄일 수 있는 엔지니어링 변수입니다.
이번 글에서는 더 깊이 파고듭니다. 입력/출력 가격 책정이 실제로 어떻게 작동하는지, 정확한 cache_control 형태와 캐시 적중(cache hit)을 가정하는 대신 _증명_하는 방법, 레이턴시에 민감하지 않은 작업을 위한 Batches API, 그리고 모델 라우팅 — 저렴한 모델로 분류하고 어려운 경우만 더 강력한 모델로 에스컬레이션하는 방법을 다룹니다. 전반적인 솔직한 관점은 다음과 같습니다: 최적화하기 전에 측정하라(measure before you optimize). 여기에 제시된 모든 기술에는 자체 비용이 있습니다. 잘못된 워크로드에 적용하면,
- 길고 긴 시스템 프롬프트 (system prompts), 도구 정의 (tool definitions), 그리고 RAG 컨텍스트는 한 번만 작성되는 것이 아니라, 모든 요청마다 다시 읽힙니다. 10,000번의 요청마다 20K 토큰의 시스템 프롬프트를 보낸다면 총 2억(200M) 개의 입력 토큰이 소모됩니다. Opus 4.8 요율을 적용하면, 단 하나의 출력 토큰이 생성되기도 전에 1,000달러가 소모됩니다. 비용이 발생하는 주범은 사용자의 질문이 아니라, 대개 **공유 접두사 (shared prefix)**입니다.
- 장황한 모델 (verbose model)은 비용을 두 번 낭비합니다. 한 번은 추가적인 출력 토큰 자체에 대해, 또 한 번은 다음 턴의
messages이력이 그 장황함을 그대로 유지한 채 이후의 모든 호출에서 입력값으로 전달되기 때문입니다.max_tokens를 제한하고 간결한 출력을 요구하는 것은 단순한 스타일 선호의 문제가 아니라, 비용을 조절하는 레버 (cost lever)입니다.
이것이 바로 아래의 두 가지 기술 — 안정적인 접두사를 캐싱하는 것, 그리고 저렴한 모델로 처리할 수 있는 요청에 비싼 모델을 다시 실행하지 않는 것 — 이 가장 강력한 비용 절감 레버이며, 그중에서도 캐싱이 우선순위가 높은 이유입니다.
프롬프트 캐싱 (Prompt Caching): 한 번 쓰고, 저렴하게 읽기
프롬프트 캐싱을 사용하면 요청의 안정적인 접두사 (stable prefix) — 즉, 시스템 프롬프트, 도구 목록, 검색된 RAG 컨텍스트 등 — 를 표시할 수 있습니다. 이렇게 하면 동일한 접두사를 가진 후속 요청은 이를 다시 처리하는 대신, 아주 적은 비용으로 다시 읽어올 수 있습니다. 이는 claude-api 스킬의 shared/prompt-caching.md에 근거합니다:
- 접두사 일치 (Prefix match) 방식입니다. 캐시 키는 각
cache_control중단점(breakpoint)까지의 정확한 바이트(bytes)로부터 유도됩니다. 접두사 내의 어느 곳에서든 단 1바이트라도 다르면 — 보간된 타임스탬프(interpolated timestamp), 순서가 바뀐 키, 다른 도구(tool) 목록 등 — 그 이후의 모든 내용은 무효화됩니다. - 렌더링 순서는
tools→system→messages입니다. 마지막system블록에 중단점을 설정하면 도구(tools)와 시스템(system)이 함께 캐싱됩니다. - 캐시 읽기(Cache reads) 비용은 기본 입력 가격의 약 0.1배이며, 캐시 쓰기(Cache writes) 비용은 1.25배(5분 TTL) 또는 2배(1시간 TTL)입니다. 5분 TTL의 경우, 두 번의 요청만으로도 손익분기점에 도달합니다 (1.25× + 0.1× vs. 2×). 1시간 TTL은 약 세 번의 요청이 필요합니다.
- 최소 캐싱 가능 접두사(Minimum cacheable prefix)는 모델에 따라 다릅니다 — Opus 4.8은 최소 4,096 토큰이 필요합니다. 이보다 짧은 접두사는 오류 없이 조용히 캐싱되지 않으며, 단지
cacheCreationInputTokens() == 0으로 나타납니다.
정확한 형태
Java SDK는 TextBlockParam에 cache_control을 포함합니다. .systemOfTextBlockParams(...)를 사용하세요. 일반적인 .system(String) 오버로드(overload)는 캐시 제어(cache control)를 부착할 수 없습니다.
import com.anthropic.client.AnthropicClient;
import com.anthropic.client.okhttp.AnthropicOkHttpClient;
import com.anthropic.models.messages.CacheControlEphemeral;
...
더 오래 지속되는 접두사(5분보다 긴 간격이 있는 간헐적인 트래픽 패턴에서 재사용되는 코퍼스(corpus))를 위해서는 명시적인 TTL을 전달하세요:
TextBlockParam.builder()
.text(STABLE_SYSTEM_PROMPT)
.cacheControl(CacheControlEphemeral.builder()
...
캐시 히트(Cache hit) 확인 — 추측하지 말고 필드를 확인하세요
응답의 usage 객체가 진실(ground truth)입니다. input_tokens는 캐싱되지 않은 나머지 부분만 보고합니다. 전체 프롬프트 크기는 다음 세 필드의 합계입니다:
var usage = response.usage();
System.out.println("cache write: " + usage.cacheCreationInputTokens()); // 약 1.25x 지불
System.out.println("cache read: " + usage.cacheReadInputTokens()); // 약 0.1x 지불
...
새로운 접두사(prefix)에 대한 첫 번째 호출은 cacheCreationInputTokens() > 0 및 cacheReadInputTokens() == 0을 나타내며, 해당 요청은 쓰기 프리미엄(write premium) 비용을 지불합니다. TTL(Time To Live) 기간 내에 동일한 접두사를 사용하는 모든 후속 호출은 cacheReadInputTokens() > 0 및 cacheCreationInputTokens() == 0을 나타내야 합니다. 만약 겉보기에 동일한 요청이 반복됨에도 불구하고 cacheReadInputTokens()가 계속 0으로 유지된다면, 접두사의 무언가가 미세하게 다른 것입니다. 예를 들어, 비결정론적 순서(non-deterministic-order)의 도구 목록, 보간된(interpolated) 요청 ID, 또는 LinkedHashMap 대신 HashMap으로 구축된 시스템 프롬프트 등이 원인일 수 있습니다. 캐싱이
이 엔드포인트에 대한 번들링된 기술의 근거(grounding)(python/claude-api/batches.md)는 Python 및 TypeScript의 형태를 완전히 문서화하고 있습니다: client.messages.batches.create(requests=[...])를 통해 {custom_id, params} 요청 항목 리스트를 제출하고, processing_status == "ended"가 될 때까지 client.messages.batches.retrieve(batch_id)를 폴링(poll)한 다음, client.messages.batches.results(batch_id)를 스트리밍하여 각 결과를 해당 custom_id와 다시 매칭합니다. 프롬프트 캐싱 (prompt caching)을 포함한 모든 Messages API 기능은 배치 (batch) 내에서 작동하므로, 하나의 커다란 시스템 프롬프트를 공유하는 10,000개의 분류 호출 배치는 50% 배치 할인과 공유 접두사(shared prefix)에 대한 캐시 읽기(cache-read) 할인을 모두 받게 됩니다.
Java SDK는 com.anthropic.models.messages.batches 아래에 동일한 엔드포인트를 노출합니다. 각 항목은 customId를 자체 요청 파라미터(request params)와 쌍으로 묶어 하나의 BatchCreateParams로 제출됩니다:
import com.anthropic.client.AnthropicClient;
import com.anthropic.models.messages.Model;
import com.anthropic.models.messages.batches.BatchCreateParams;
...
솔직하게 따져봐야 할 비용 트레이드오프 (cost tradeoff): 배치는 지연 시간(latency)을 대가로 50%의 확정된 할인을 제공합니다. 이는 대량의 비대화형(non-interactive) 작업에는 확실한 이점이지만, 사람이 응답을 기다리는 순간에는 잘못된 도구가 됩니다. 또한 동기식 호출 (synchronous call)에는 없는 운영 복잡성(제출, 폴링 또는 웹훅, custom_id별 결과 조정, errored/expired 항목 처리)을 추가합니다. 실제 사용 데이터를 통해 워크로드가 실제로 대량이며 지연 시간에 민감하지 않음을 확인하기 전까지는 이 방식을 선택하지 마세요.
모델 라우팅 (Model Routing): 저렴한 모델로 분류(Triage)하기
캐싱 및 배치와는 별개인 또 다른 레버(lever)는 모든 요청을 가장 비싼 모델로 보내지 않는 것입니다. 실제 트래픽의 상당 부분—감성 분류 (sentiment classification), 의도 탐지 (intent detection), "이 티켓이 긴급한가", 단순 추출(extraction)—은 저렴한 모델의 역량 범위 내에 있습니다. 이러한 요청은 claude-haiku-4-5로 라우팅하고, claude-opus-4-8은 실제로 그것이 필요한 요청을 위해 남겨두세요.
단순하고 정직한 패턴은 다음과 같습니다: 저렴한 모델을 먼저 실행하되, 모델 스스로 불확실성을 표시하도록 지침을 주고, 모델이 그렇게 말할 때만 상위 모델로 에스컬레이션(Escalation)하는 것입니다.
record Triage(String label, boolean confident) {}
Triage triageWithHaiku(String ticketText) {
...
만약 티켓의 80%가 MTok당 $1/$5인 Haiku에 의해 확신을 가지고 분류(Triage)되고, 나머지 20%만이 MTok당 $5/$25인 Opus로 에스컬레이션된다면, 혼합 비용(Blended cost)은 모든 요청을 Opus로 라우팅하는 비용의 극히 일부에 불과합니다. 에스컬레이션 경로가 정확히 저렴한 모델이 "확신할 수 없습니다"라고 말하는 경우를 위해 존재하기 때문에, 쉬운 대다수의 요청에 대해서는 품질 저하도 발생하지 않습니다. 경계해야 할 실패 모드는 저렴한 모델이 과도하게 확신(Overconfident)하는 경우입니다. 분할 라우팅을 신뢰하기 전에 반드시 레이블이 지정된 샘플을 통해 에스컬레이션 비율을 확인하고, 임계값(여기서는 모델이 직접 보고하는 확신 플래그)을 느낌(Vibes)이 아닌 측정된 정확도에 기반하여 설정하십시오.
최적화하기 전에 측정하십시오
이 포스트에서 소개된 모든 기술에는 그 자체의 비용이 따릅니다. 캐시 쓰기 프리미엄(Cache write premium), 배치(Batch) 운영의 복잡성, "실제" 호출 전의 추가적인 분류(Triage) 호출 등이 그것입니다. 이 중 어느 것도 공짜가 아니며, 맹목적으로 적용할 경우 시스템을 오히려 더 비싸게 만들 수 있습니다.
- 매 요청마다 변경되는 접두사(Prefix)를 캐싱하는 것은 읽기 작업 없이 쓰기 프리미엄만 지불하는 격이며, 이는 캐싱을 하지 않는 것보다 더 나쁩니다.
- 지연 시간(Latency)에 민감한 트래픽을 배치(Batching) 처리하는 것은 제품을 망가뜨립니다. 사용자가 로딩 스피너만 바라보고 있다면 50%의 비용 절감은 의미가 없습니다.
- 실제 데이터 분포에서의 정확도를 측정하지 않고 저렴한 모델로 라우팅하는 것은, 청구서상으로는 비용 절감처럼 보일지라도 품질을 조용히 저하시킬 수 있습니다.
먼저 계측(Instrument)하십시오. 요청당 cacheReadInputTokens() / cacheCreationInputTokens() / inputTokens()를 로그로 남기고, 요청 유형별 비용을 추적하며, 이러한 기술들을 적용하기 전에 실제 지연 시간 요구 사항을 파악하십시오. 추측하는 것이 아니라, 실제로 비용이 많이 발생하는 워크로드를 최적화하십시오.
실무 체크리스트
| 실천 사항 (Practice) | 중요한 이유 (Why it matters) |
|---|---|
| 안정적인 접두사(system prompt, tools, RAG context)를 앞에 두고, 변동성이 큰 콘텐츠를 마지막에 배치하십시오 | 접두사 일치 (Prefix match) — 1바이트라도 변경되면 그 이후의 모든 내용은 캐싱되지 않습니다 |
| ... |
마치며 (Final Thoughts)
LLM 애플리케이션을 위한 비용 제어는 정확성(correctness)과 별개의 영역이 아닙니다. 이는 새롭고 비용이 많이 드는 외부 호출(external call)에 적용되는 동일한 엔지니어링 원칙입니다. 접두사(prefix)는 청구서와 같으며, 캐싱(caching)은 반복적으로 읽히는 접두사를 저렴한 읽기 작업으로 바꾸어 줍니다. 배치(batching)는 급하지 않은 대량 작업을 50% 할인된 작업으로 바꾸어 주며, 라우팅(routing)은 "항상 가장 강력한 모델을 사용한다"는 방식을 "작업에 실제로 필요한 모델을 사용한다"로 바꾸어 줍니다. 이 중 그 어떤 것도 측정(measurement)을 대체할 수는 없습니다. 사용 필드(usage fields)를 계측하고, 지연 시간(latency) 요구 사항을 파악하십시오. 그리고 추측이 아닌 실제 수치를 바탕으로 어떤 레버를 당길지 결정하십시오.
AI 자동 생성 콘텐츠
본 콘텐츠는 Dev.to AI tag의 원문을 AI가 자동으로 요약·번역·분석한 것입니다. 원 저작권은 원저작자에게 있으며, 정확한 내용은 반드시 원문을 확인해 주세요.
원문 바로가기