- Published on
LLM 프롬프트 캐시로 비용 70% 줄이는 법
- Authors
- Name
- 스타차일드
- https://x.com/ETFBITX
서버에서 LLM을 호출하다 보면 비용이 생각보다 빠르게 불어납니다. 특히 매 요청마다 비슷한 시스템 프롬프트, 정책 문구, 도메인 지식, 예시 few-shot를 통째로 붙이는 구조라면 입력 토큰이 폭발합니다.
여기서 가장 효과가 큰 최적화가 프롬프트 캐시(prompt cache) 입니다. 핵심은 간단합니다.
- 자주 반복되는 프롬프트 조각을 고정(prefix) 으로 만들고
- 그 고정 부분을 캐시 가능한 형태로 분리해
- 동일한 고정 부분을 재사용할 때 중복 토큰 과금을 최소화하거나, 최소한 애플리케이션 레벨에서 중복 호출을 줄여 비용을 절감합니다.
이 글은 “비용 70% 절감”이 가능한 전형적인 상황(반복 프롬프트가 길고, 트래픽이 있고, 응답 품질을 위해 항상 긴 컨텍스트를 붙이는 서비스)을 가정하고, 설계 원칙, 키 전략, 무효화, 운영 관측까지 실무 관점에서 정리합니다.
프롬프트 캐시가 먹히는 구조부터 찾기
프롬프트 캐시는 아무 데나 붙인다고 절감이 나지 않습니다. 아래 조건일수록 효과가 큽니다.
1) 입력 토큰 중 “항상 같은 부분”이 길다
예:
- 시스템 프롬프트(역할, 정책, 보안 규칙)
- 회사/도메인 가이드라인
- 포맷 규칙(JSON 스키마, 마크다운 규칙)
- 긴 few-shot 예시
- 고정된 문서/FAQ/제품 설명(변경이 드물거나 버전 관리 가능)
이 고정 부분이 입력의 60% 이상이면, 캐시 최적화로 체감 절감이 크게 납니다.
2) 트래픽이 “유사 요청”을 반복한다
예:
- 고객센터 챗봇: 비슷한 문의 유형 반복
- 코드 리뷰/요약: 동일 레포/가이드라인 반복
- 사내 검색/요약: 같은 문서에 대해 다양한 질문
3) 응답이 꼭 “개인화”일 필요가 없다 (또는 일부만 개인화)
전체 응답을 캐시하는 게 아니라, 고정 프롬프트 prefix를 캐시하고 사용자 질문만 바꾸는 방식이 특히 강력합니다.
캐시의 3가지 레벨: 무엇을 캐시할 것인가
프롬프트 캐시는 흔히 하나로 뭉뚱그리지만, 실무에서는 3가지 레벨로 나뉩니다.
레벨 A: “완성된 응답” 캐시 (Response cache)
- 동일 입력이면 동일 출력이 나오는 경우에만 권장
- 장점: 가장 큰 비용 절감
- 단점: 개인화/시간 의존/비결정성(temperature)에서 캐시 적중률이 낮음
적합한 예:
- 문서 요약(같은 문서, 같은 요약 규칙)
- 고정 템플릿 생성
레벨 B: “프롬프트 prefix” 캐시 (Prompt prefix cache)
- 시스템 프롬프트 + 정책 + 도메인 컨텍스트를 고정 prefix로 분리
- 매 요청마다 변하는 것은 사용자 질문, 일부 변수만
- 장점: 품질 유지하면서도 비용 절감 폭이 큼
이 글의 메인 전략입니다.
레벨 C: “검색 결과/컨텍스트” 캐시 (RAG context cache)
- 벡터 검색 결과(문서 ID 목록), 재랭킹 결과, chunk 텍스트를 캐시
- 장점: 모델 호출 이전 단계 비용과 지연을 줄임
- 단점: 문서 변경/권한/최신성 이슈로 무효화 설계가 중요
70% 절감이 가능한 전형적 계산
예를 들어 매 요청 입력이 아래처럼 구성된다고 가정해봅시다.
- 시스템 + 정책 + 예시: 6,000 tokens (고정)
- RAG로 붙는 문서 조각: 2,000 tokens (부분 고정 또는 자주 반복)
- 사용자 질문: 200 tokens (가변)
총 입력 8,200 tokens.
여기서 고정 prefix 6,000 tokens를 캐시로 재사용하고, RAG 컨텍스트 2,000 tokens 중 절반을 캐시 적중(1,000 tokens 절감)한다고 치면, 매 요청에서 7,000 tokens가 사실상 중복입니다.
- 절감 가능 입력 비중:
7000 / 8200약 85% - 실제 절감률은 캐시 적중률, 캐시 미스, 버전 변경, 사용자별 분기 때문에 낮아지지만
- 트래픽이 충분하고 프롬프트가 잘 고정되면 **50%~70%**는 현실적인 목표가 됩니다.
중요한 포인트는 “토큰을 줄이는 것”과 “모델 호출 횟수를 줄이는 것”을 같이 해야 한다는 점입니다. 프롬프트 캐시를 도입하면 보통 다음이 함께 따라옵니다.
- 동일 요청 중복 호출 방지(요청 병합)
- 재시도/백오프 시 중복 과금 감소
재시도 설계는 별도 글인 OpenAI API 429 Rate Limit 재시도·백오프 설계도 같이 보면 운영 안정성이 좋아집니다.
설계 1: 프롬프트를 “조각”으로 쪼개고 버전 관리하기
캐시를 잘 쓰려면 프롬프트를 한 덩어리 문자열로 다루면 안 됩니다. 최소한 아래처럼 구성 요소를 명시적으로 분리하세요.
system_base: 역할/톤/금지사항policy: 보안/개인정보/저작권/사내 규정format_spec: 출력 포맷(JSON 스키마 등)few_shot: 예시domain_context: 제품/용어/FAQrag_context: 검색 결과로 붙는 chunkuser_query: 사용자 질문
그리고 각 조각에 버전 문자열을 붙입니다.
policy:v3format_spec:v2domain_context:2026-02-01
이 버전이 곧 캐시 무효화 스위치가 됩니다.
설계 2: 캐시 키 전략 (충돌 방지 + 무효화 용이)
캐시 키는 보통 아래 요소를 결합합니다.
- 모델 식별자(예:
gpt-4.1-mini) - 고정 프롬프트 조각들의 버전
- 테넌트/프로젝트 ID(멀티테넌트라면 필수)
- 출력 포맷/언어
- 온도, top_p 등 결정성에 영향을 주는 파라미터
키 예시는 다음처럼 만듭니다(부등호는 MDX 빌드 이슈가 있으니 쓰지 않습니다).
import crypto from "crypto";
type PromptParts = {
model: string;
tenantId: string;
locale: "ko" | "en";
temperature: number;
systemBaseVersion: string;
policyVersion: string;
formatSpecVersion: string;
domainContextVersion: string;
fewShotVersion: string;
};
export function promptPrefixCacheKey(parts: PromptParts) {
const raw = JSON.stringify(parts);
const hash = crypto.createHash("sha256").update(raw).digest("hex");
return `prompt_prefix:${hash}`;
}
이렇게 하면
- 버전만 올려도 자연스럽게 캐시 미스가 나서 안전하게 갱신되고
- 키 길이가 과도해지는 문제도 피할 수 있습니다.
설계 3: “요청 병합”으로 중복 호출 자체를 없애기
실무에서 비용이 새는 대표 케이스는 캐시보다 먼저 동일 요청이 동시에 여러 번 날아가는 것입니다.
- 사용자가 새로고침
- 프론트에서 중복 클릭
- 백엔드에서 타임아웃 후 재시도
- 워커가 동일 잡을 중복 처리
이때는 캐시 적중을 기다리기 전에 in-flight deduplication(진행 중 요청 병합)을 넣으면 즉시 절감됩니다.
아래는 Node.js에서 같은 키 요청이 동시에 들어오면 하나의 Promise를 공유하는 패턴입니다.
const inFlight = new Map<string, Promise<any>>();
export async function dedupeInFlight<T>(key: string, fn: () => Promise<T>): Promise<T> {
const exist = inFlight.get(key);
if (exist) return exist as Promise<T>;
const p = fn().finally(() => {
inFlight.delete(key);
});
inFlight.set(key, p);
return p;
}
이것만으로도 “피크 시간대 비용 급증”이 눈에 띄게 줄어드는 경우가 많습니다.
구현 예시: Redis 기반 프롬프트 prefix 캐시
여기서는 “고정 prefix를 미리 만들어 저장”하는 방식으로 예시를 들겠습니다.
1) prefix 생성 함수
type PrefixInput = {
systemBase: string;
policy: string;
formatSpec: string;
fewShot: string;
domainContext: string;
};
export function buildPrefix(p: PrefixInput) {
return [
`SYSTEM:\n${p.systemBase}`,
`\n\nPOLICY:\n${p.policy}`,
`\n\nFORMAT:\n${p.formatSpec}`,
`\n\nFEW_SHOT:\n${p.fewShot}`,
`\n\nDOMAIN:\n${p.domainContext}`,
].join("\n");
}
2) Redis 캐시 get-or-set
import { createClient } from "redis";
const redis = createClient({ url: process.env.REDIS_URL });
await redis.connect();
export async function getOrSetPrefix(key: string, ttlSeconds: number, build: () => string) {
const cached = await redis.get(key);
if (cached) return cached;
const prefix = build();
await redis.set(key, prefix, { EX: ttlSeconds });
return prefix;
}
3) LLM 호출 시 prefix 재사용
type ChatMessage = { role: "system" | "user" | "assistant"; content: string };
export async function callLLM(params: {
prefixKey: string;
prefixTtlSeconds: number;
prefixBuilder: () => string;
ragContext: string;
userQuery: string;
}) {
const prefix = await getOrSetPrefix(
params.prefixKey,
params.prefixTtlSeconds,
params.prefixBuilder
);
const messages: ChatMessage[] = [
{ role: "system", content: prefix },
{ role: "system", content: `RAG_CONTEXT:\n${params.ragContext}` },
{ role: "user", content: params.userQuery },
];
// 여기서 실제 벤더 SDK 호출
// return openai.chat.completions.create({ model, messages, ... })
return { messages };
}
위 예시는 “문자열 prefix를 Redis에 저장”하는 단순한 형태입니다. 실제로는 벤더가 제공하는 프롬프트 캐시 기능이 있다면(캐시 가능한 prefix를 지정하는 API) 그 메커니즘을 우선 활용하고, 그 위에 애플리케이션 레벨 캐시로 보완하는 전략이 좋습니다.
TTL과 무효화: 가장 흔한 실패 지점
프롬프트 캐시는 잘못 설계하면 품질 사고로 이어집니다. 특히 다음 케이스를 조심해야 합니다.
1) 정책/보안 문구가 바뀌었는데 캐시가 남아있다
해결:
- 정책 조각에 반드시 버전 부여
- 키에
policyVersion포함 - 긴 TTL을 쓰더라도 버전 업으로 즉시 무효화
2) 도메인 컨텍스트가 업데이트됐는데 반영이 늦다
해결:
domain_context를 날짜 기반 버전으로 관리- 배포 파이프라인에서 버전 자동 증가
CI/CD에서 자동화하는 아이디어는 GitHub Actions 병렬·매트릭스로 CI 50% 단축 같은 접근을 응용해, 프롬프트 번들 생성 작업을 병렬화하는 식으로도 확장할 수 있습니다.
3) 사용자 권한이 다른데 같은 캐시를 공유한다
해결:
- 멀티테넌트면
tenantId를 키에 포함 - 권한 레벨(예:
role:adminvsrole:user)도 키에 포함 - RAG 컨텍스트는 “권한 필터 결과” 기준으로 캐시
비용 절감만 보면 안 된다: 지연시간과 관측이 같이 가야 한다
프롬프트 캐시는 보통 비용뿐 아니라 p95 지연시간도 줄입니다. 하지만 캐시 레이어가 추가되면서 오히려 느려지거나, 캐시 미스 폭증으로 장애가 나기도 합니다. 그래서 최소한 아래 지표를 꼭 봐야 합니다.
- 캐시 적중률(hit rate): prefix, RAG 각각
- 캐시 미스 이유: 버전 변경, TTL 만료, 키 분기 증가
- in-flight dedupe 적중률: 병합으로 줄인 호출 수
- 토큰 사용량 분해: prefix, rag, user, output
- 실패율: 429, 타임아웃, 재시도 횟수
프론트 지연도 함께 문제가 된다면, 백엔드 LLM 호출이 Long Task를 유발하는 경로(스트리밍 처리, 렌더링 블로킹)까지 연결해서 보는 것이 좋습니다. 관련해서는 Chrome INP 200ms 이상? Long Task 추적·개선도 참고할 만합니다.
실전 패턴 5가지
1) “긴 고정 prefix + 짧은 질문” 구조로 리팩터링
- 시스템 프롬프트에 변수를 섞지 말고, 변수는 별도 메시지로 분리
- 예: 사용자 이름, 날짜, 플래그를 prefix에 넣지 않기
2) few-shot은 최소화하고, 정말 고정이면 캐시 대상으로
few-shot은 품질에 도움이 되지만 토큰을 많이 먹습니다.
- 자주 쓰는 few-shot은 버전 고정 후 캐시
- 실험용 few-shot은 별도 버전으로 분리해 캐시 오염 방지
3) RAG 컨텍스트는 “문서 ID 리스트”를 먼저 캐시
텍스트 chunk 전체를 캐시하는 것보다
- 검색 결과 문서 ID 리스트
- 재랭킹 결과
를 캐시하면 무효화가 쉬워집니다. 텍스트는 최신 버전을 다시 로딩하면 되기 때문입니다.
4) 온도(temperature)를 낮춰 캐시 친화적으로
응답 캐시(레벨 A)를 노린다면 특히 중요합니다.
temperature를 낮추면 동일 입력에 대한 출력 변동이 줄어 캐시 가치가 올라갑니다.
5) 배치/비동기 작업에서 캐시 효과가 더 크다
온라인 트래픽은 키 분기가 많지만, 배치 작업은 동일 템플릿이 반복되는 경우가 많습니다.
- 보고서 생성
- 로그 요약
- 주간 리포트
이런 곳에서 70% 절감이 가장 빨리 나옵니다.
체크리스트: 도입 순서 추천
- 토큰 사용량을 프롬프트 조각별로 계측(대략이라도)
- 고정 prefix를 분리하고 버전화
- in-flight 요청 병합으로 중복 호출 제거
- Redis 같은 외부 캐시로 prefix 캐시 적용(또는 벤더 기능 우선)
- RAG 검색 결과 캐시로 확장
- 적중률과 비용을 대시보드로 운영
마무리: “캐시는 문자열 저장”이 아니라 “제품 설계”다
LLM 프롬프트 캐시는 단순히 Redis에 프롬프트를 넣는 문제가 아니라,
- 프롬프트를 제품 구성 요소로 분해하고
- 변경을 버전으로 통제하며
- 권한/테넌시/파라미터 분기를 키에 반영하고
- 재시도와 중복 호출까지 포함해 호출량 자체를 줄이는
운영 설계입니다.
이 과정을 제대로 밟으면, 입력 토큰이 큰 서비스에서 비용 70% 절감은 과장이 아닙니다. 특히 “긴 정책/가이드/예시를 매번 붙이는 구조”라면, 오늘 당장 프롬프트를 조각내고 버전부터 붙이는 것만으로도 절감의 첫 단추가 끼워집니다.