LLM 호출 캐싱: 거의 절대 적중하지 않는 원시 프롬프트 키 (Raw Prompt Key)
요약
프로덕션 환경에서 LLM 캐시 적중률이 낮은 이유는 실행 ID나 타임스탬프 같은 변동성 있는 데이터가 포함된 원시 프롬프트를 키로 사용하기 때문입니다. 이를 해결하려면 변동 요소를 제거하고 의미 있는 프롬프트 부분만 추출하여 정규화된 키를 생성해야 합니다.
핵심 포인트
- 원시 프롬프트 해싱은 실행 ID, 타임스탬프 등으로 인해 캐시 미스를 유발함
- 비용 절감을 위해 변동성 있는 '봉투(envelope)'를 제거한 정규화된 키 사용 권장
- 문자열 기반 키는 의미가 같은 의역(paraphrase)을 구분하지 못하는 한계가 있음
- 캐싱 전략은 정확도보다는 비용 최적화의 관점에서 접근해야 함
테스트 환경에서는 LLM 캐시가 아주 잘 작동하는 것처럼 보입니다. 하지만 프로덕션(Production) 환경에서는 거의 작동하지 않습니다.
캐시가 고장 났기 때문이 아닙니다. 캐시를 설정할 때 사용한 키(Key) 때문입니다. 당신은 원시 프롬프트 문자열(raw prompt string)을 해싱(Hashing)했지만, 프로덕션에서는 모든 프롬프트에 실행 ID(run id), 타임스탬프(timestamp), 시도 횟수 카운터(attempt counter)가 포함됩니다. 매 호출마다 바뀌는 작은 봉투(envelope)와 같은 것이죠. 따라서 hash(prompt)는 매번 새로운 키를 생성하고, 조회(lookup)는 실패(miss)하며, 당신은 불과 5초 전에 이미 구매한 답변에 대해 제공업체(provider)에게 다시 비용을 지불하게 됩니다.
해결책은 더 나은 캐시를 만드는 것이 아닙니다. 더 나은 키를 만드는 것입니다. 해싱하기 전에 변동성이 큰 봉투(volatile envelope)를 제거하고, 실제로 의미를 담고 있는 프롬프트 부분에 키를 설정하세요.
아래는 동일한 6개의 프롬프트를 단순한(naive) 캐시와 정규화된(normalized) 캐시에 실행하는 stdlib 스크립트입니다. 단순한 방식(Naive): 6번의 호출, 0번의 적중(hits). 정규화된 방식(Normalized): 3번의 호출, 3번의 적중. 그 다음, 정규화된 키로도 여전히 틀리는 단 한 가지 사례를 출력합니다. 이는 당신에게 과장된 성능을 팔기보다는 한계점(ceiling)을 보여주고 싶기 때문입니다.
요약 (TL;DR)
- 원시 프롬프트(raw-prompt) 캐시 키는 프로덕션에서 거의 절대 적중하지 않습니다: 모든 호출에는 해시를 변화시키는 변동성 있는 봉투(실행 ID, 타임스탬프, 시도 횟수)가 포함되어 있어 조회가 영원히 실패합니다.
- 대신 안정적인(stable) 부분에 키를 설정하세요. 아래 데모에서 단순한 키 설정은 6번의 호출 / 0번의 적중을 기록하지만, 정규화된 키 설정은 동일한 6개의 프롬프트에 대해 3번의 호출 / 3번의 적중을 기록합니다.
- 이것은 정확성의 문제가 아니라 비용 절감의 레버(cost lever)입니다. 또한 접두사 기반(prefix-based)이며 수명이 짧은 제공업체 자체의 프롬프트 캐시(prompt cache)와는 다른 개념입니다.
- 스크립트는 자체적인 한계를 출력합니다: 의미는 같지만 다른 단어를 사용하는 의역(paraphrases)은 여전히 비용이 두 배로 청구됩니다. 문자열 키(String keys)는 의미를 파악할 수 없습니다.
- stdlib만 사용하며, 결정론적(deterministic)이고, 고정 요소(fixtures)는 합성되었으며 라벨이 지정되었습니다. 두 번 실행해도 동일한 바이트를 얻습니다.
왜 원시 키(raw key)는 자기 자신과도 일치하지 않는가
저는 Apify에서 32개의 공개 스크래퍼(scrapers)를 운영하고 있습니다. 이들 사이에는 2,190회의 프로덕션 실행(production runs)이 있으며, 그중 하나의 Trustpilot 리뷰 스크래퍼는 단독으로 962회 이상의 실행을 기록했습니다. 이 중 어떤 것도 핫 패스(hot path)에서 LLM을 호출하지 않으므로, 이것은 제가 새벽 3시에 캐시 미스(cache miss)를 목격하며 겪은 고난에 관한 이야기가 아닙니다. 이것은 제가 실행 로그에서 계속해서 목격한 형태이며, 단순한(naive) LLM 캐싱을 조용히 망가뜨리는 형태입니다.
동일한 액터(actor)를 다시 실행하면, 다운스트림(downstream)으로 전달되는 페이로드(payload)는 실행 간에 거의 동일합니다. 동일한 레코드, 동일한 필드, 동일한 순서입니다. 변하는 것은 엔벨로프(envelope, 봉투)입니다: 실행 ID(run id), 시작 시간(started-at timestamp), 시도 횟수 카운터(attempt counter) 등입니다. 이것들은 바로 단순한 hash(prompt)가 키(key)를 생성할 때 사용하는 필드들입니다. 해당 페이로드를 기반으로 LLM 단계를 감싸고, 이를 직렬화(serializing)하여 프롬프트를 구성하면, 여러분의 캐시 키는 매번 고유함이 보장되는 입력값의 단 한 부분에 의존하게 됩니다.
한 줄로 요약하자면 그것이 바로 함정입니다. 직접 프롬프트를 작성하여 테스트를 수행하고 두 번 실행하면, 문자열이 바이트 단위로 일치하므로 캐시가 적중(hit)하고 초록색 체크 표시가 여러분에게 거짓말을 합니다. 하지만 프로덕션(prod)에서는 동일한 논리적 질문이 매 호출마다 다른 엔벨로프를 입고 도착하며, 문자열이 달라지기 때문에 캐시는 자기 자신조차 적중하지 못합니다. CI에서의 적중률은 100%이지만, 프로덕션에서의 적중률은 0%에 가깝습니다. 코드는 동일합니다.
코드에 들어가기 전 솔직하게 말씀드립니다. 스크립트의 피스처(fixtures)는 합성된 것입니다. 실제 프롬프트를 기록하지 않았기 때문에, 실행 페이로드에서 보이는 형태를 재현하기 위해 6개의 JSON 엔벨로프를 수동으로 만들었습니다. 2,190이라는 수치는 이 형태가 왜 반복 실행 워크로드(repeat-run workloads)에서 기본값인지에 대한 맥락을 제공하기 위한 것이지, 측정된 캐시 적중률이 아닙니다. 데모는 특정 퍼센티지를 주장하지 않으며, 저 또한 그러지 않을 것입니다.
해결책: 의미에 기반하여 키를 생성하고, 엔벨로프는 버려라
핵심 아이디어는 이렇습니다. 해싱(hash)하기 전에 프롬프트를 파싱(parse)하여, 순수하게 인프라적인(plumbing) 필드들은 버리고, 남은 것들을 정규화(canonicalize)하는 것입니다.
JSON 형태의 프롬프트라면 다음과 같이 처리합니다: json.loads로 파싱하고, 알려진 휘발성(volatile) 키 세트(run_id, ts, attempt 등 프레임워크가 주입하는 것들)를 제거한 다음, 의미론적 페이로드(semantic payload) 자체를 정규화(normalize)합니다. 소문자로 변환하고, 공백을 제거합니다. {"a":1,"b":2}와 {"b":2,"a":1}이 하나의 키로 합쳐질 수 있도록 키를 정렬합니다. 그리고 이를 해싱(hash)합니다.
휘발성 세트는 여러분이 직접 관리해야 하는 부분입니다. 여러분은 어떤 필드가 의미를 담고 있고 어떤 것이 노이즈인지 의도적으로 선언하는 것입니다. 만약 이 목록을 안전하지 않은 방향으로 잘못 작성하면(실제로 중요한 필드를 제거하면), 서로 다른 두 질문이 하나의 캐시 엔트리로 합쳐져 오래된 잘못된 답변을 제공하게 됩니다. 따라서 이것은 단순히 뿌려두고 잊어버리는 데코레이터(decorator)가 아니라, 의도적으로 작성해야 하는 키입니다. 이러한 실패 모드에 대해서는 증명(proof) 이후에 더 자세히 다루겠습니다.
증명: 6개의 프롬프트, 2개의 캐시 키
이 스크립트는 표준 라이브러리(json)만 사용하며, 완전히 결정론적(deterministic)이고, 네트워크, 시계, 난수(randomness)를 사용하지 않습니다. 6개의 프롬프트는 세 가지 질문을 던지는 하나의 합성 에이전트 세션(synthetic agent session)입니다. 그중 두 개는 서로 다른 엔벨로프(envelope)와 함께 한 번 이상 질문됩니다. 하나는 의역(paraphrase)된 것입니다. 이것은 실행 가능한 로컬(runnable local) 코드입니다: 저장한 뒤, python3 -I prompt_cache_key.py를 실행하면 매번 동일한 출력을 얻을 수 있습니다.
"""왜 프로덕션 환경에서 원시 프롬프트(raw-prompt) 캐시 키가 거의 적중하지 않는가.
결정론적, 표준 라이브러리 전용 (json). 네트워크, 시계, RNG, 환경 변수 없음.
...
"""
실행하면 다음과 같은 출력이 나옵니다:
NAIVE : prompts=6 llm_calls=6 cache_hits=0 (raw key: 모든 요청 ID가 고유함)
NORMALIZED : prompts=6 llm_calls=3 cache_hits=3 (휘발성 엔벨로프를 제거하고 질문에 대해 키 생성)
SAVED : 이 피스처(fixture) 세트에서 3회의 호출 절감 (6 -> 3)
...
출력을 읽어보세요, 잘못된 부분도 포함하여
단순한(naive) 라인은 헤드라인과 같습니다. 6개의 프롬프트, 6개의 호출, 적중률 0회. 모든 프롬프트는 동일한 세 가지 질문을 공유하지만, 각 프롬프트는 고유한 req ID를 가지고 있어 원시 문자열(raw string)이 매번 달라지며, 캐시(cache)는 반복을 전혀 인식하지 못합니다. 이것이 제목이 지적하는 메커니즘입니다. 모든 호출이 고유한 봉투(envelope)를 지니고 있을 때, 원시 프롬프트 키(raw prompt key)는 반복을 결코 인식할 수 없습니다. 피스처(fixtures)는 이 메커니즘을 보여주는 것이지, 측정된 실제 운영 환경의 적중률(hit rate)이 아닙니다.
정규화된(normalized) 라인은 req 봉투를 제거하고 소문자화 및 공백을 제거한 질문을 키(key)로 사용합니다. 이제 a1, a2, a3는 하나의 엔트리로 통합되며(두 번째와 세 번째는 의도적으로 대소문자와 공백을 다르게 설정했음에도 키가 여전히 일치함에 유의하세요), a5/a6는 또 다른 하나의 엔트리로 통합됩니다. 실제 호출 3회, 적중 3회. 이 피스처 세트에서는 6회의 호출이 3회로 줄어듭니다.
다음은 CEILING 라인이며, 이것이 제가 데모를 이런 방식으로 작성한 이유입니다. 프롬프트 4는 "How many days to return an item?"이라고 묻습니다. 이는 일반적인 영어로 "What is the refund window?"와 동일한 질문입니다. 사람은 보자마자 이를 중복 제거(dedupe)합니다. 하지만 정규화된 문자열 키(normalized string key)는 단어가 다르기 때문에 이를 수행하지 못하며, 별도의 호출을 소모합니다. 스크립트는 이 미스(miss)를 숨기지 않고 출력합니다. 이를 잡아내려면 임베딩 유사도(embedding similarity)가 필요한데, 이는 네트워크를 통한 모델 호출을 의미하며, 문자열 키보다 훨씬 다르고 무거운 시스템입니다. 이는 의도적으로 이번 논의의 범위(scope)에서 제외했습니다. 정직한 주장은 좁고 명확합니다: 키를 정규화하는 것은 중복된 봉투로 인한 미스를 제거하지만, 의역(paraphrases)에 대해서는 아무런 효과가 없습니다.
실제 트래픽에서의 이득 규모에 대해서는 퍼센트(%)를 제시하지 않겠습니다. 이는 전적으로 반복 비율(repeat ratio), 즉 동일한 논리적 질문이 실제로 얼마나 자주 재발하는지에 달려 있습니다. 저의 사례와 같은 반복 실행 코퍼스(repeat-run corpora)에서는 재발 빈도가 높아 이득이 실질적입니다. 반면 모든 프롬프트가 진정으로 고유한 워크로드(workload)에서는 키를 정규화해도 아끼는 것이 없으며 파싱(parse) 비용만 추가될 뿐입니다. 이 레버(lever)는 잡아낼 반복이 있을 때만 효력을 발휘합니다.
이것은 멱등성(idempotency) 문제도 아니며, 제공업체의 프롬프트 캐싱(provider prompt caching)도 아닙니다.
이와 매우 유사해 보이지만 전혀 다른 두 가지가 있습니다. 이 둘을 혼동하면 잘못된 도구를 선택하게 되므로 구분할 가치가 있습니다.
첫 번째는 멱등성 (idempotency)입니다. 저는 이 시리즈의 이전 글에서 도구 호출을 위한 최대 1회 실행 원장 (at-most-once ledger for tool calls)에 대해 작성한 적이 있으며, 이 또한 중복 제거 키 (dedup key)를 중심으로 작동하기 때문에 사촌 관계처럼 보일 수 있습니다. 하지만 그렇지 않습니다. 해당 원장은 부수 효과 (side effects)를 방어합니다. 예를 들어 카드를 결제하거나 기록을 게시하는 도구 호출처럼, 두 번 실행했을 때 실제 피해가 발생하는 경우를 위해 결과를 기록하고 이를 재현 (replay)함으로써 동작이 최대 한 번만 발생하도록 보장합니다. 여기서의 주제는 쓰기 (write)이며, 목표는 정확성 (correctness)입니다. 반면 여기서의 주제는 읽기 (read), 즉 부수 효과가 없는 LLM 완성 (completion)이며, 목표는 비용 (cost)입니다. 원장은 동작을 보호하기 위해 기록된 결과를 재현합니다. 캐시는 비용을 다시 지불하지 않기 위해 결과를 메모이제이션 (memoize)합니다. 데이터 구조는 같지만, 존재하는 이유는 정반대입니다.
두 번째는 제공업체 (provider) 자체의 프롬프트 캐시 (prompt cache)로, 이는 진정으로 다른 메커니즘입니다. Anthropic의 프롬프트 캐싱 문서 (prompt caching documentation)는 이를 정확하게 설명합니다: "프롬프트 캐싱은 cache_control로 지정된 블록을 포함하여 tools, system, messages (해당 순서대로)에 이르는 전체 프롬프트를 참조합니다." 이는 긴 공유 컨텍스트 (shared context)를 다시 보내는 비용을 줄이기 위해 요청의 접두사 (prefix)를 캐싱하며, 동일한 페이지에는 "기본적으로 캐시는 5분의 수명을 가집니다"라고 명시되어 있습니다. 이는 짧고 갱신되는 TTL (Time To Live)을 가진 접두사 캐시 (prefix cache)입니다. 이는 공유된 접두사의 입력 비용을 절감하지만, 호출 자체를 피할 수는 없습니다. 캐시 적중 (cache hit)이 발생하더라도 완성 (completion)에 대한 비용은 여전히 지불해야 하기 때문입니다. 또한 프롬프트의 시작 부분부터 일치 여부를 확인하기 때문에, 접두사가 안정적일 때만 도움이 됩니다. 이 포스트에서 다루는 가변적인 필드는 프롬프트의 맨 앞에 위치하므로, 매 호출마다 접두사 일치 (prefix match)를 깨뜨립니다. 클라이언트 측 정규화된 키 (client-side normalized key)는 이를 보완하는 것이지, 대체하는 것이 아닙니다.
내가 실제로 배포할 것
먼저 변동성이 큰 집합(volatile set)을 적어 내려가는 것부터 시작하세요. 실제 프롬프트 페이로드(payload)를 열어보고, 질문의 본질을 바꾸지 않으면서 실행 시마다 변하는 모든 필드를 나열하십시오: 실행 ID(run ids), 타임스탬프(timestamps), 시도 횟수 카운터(attempt counters), 트레이스 ID(trace ids), 세션 토큰(session tokens) 등입니다. 그 목록이 핵심입니다. 목록에 포함된 모든 것은 해싱(hash)하기 전에 제거됩니다. 목록에 포함되지 않은 모든 것은 핵심적인 역할을 하므로, 실수로 이를 제거하면 잘못된 캐시 답변을 제공하게 되며, 이는 캐시 미스(miss)보다 더 나쁩니다.
그다음 살아남은 것들을 정규화(canonicalize)하십시오. 소문자 변환, 공백 제거(strip), 객체 키(object keys) 정렬을 수행하고, 논리적으로 동일한 페이로드가 하나의 키를 생성할 수 있도록 안정적인 직렬화(serialization) 방식을 선택하십시오. 원시 문자열(raw string)이 아닌, 이를 해싱하십시오. 데모에서 생략된 주의 사항이 하나 있습니다: normalized_key는 JSON 형태의 프롬프트를 가정하므로 자유 형식의 텍스트(free text)에서는 오류가 발생할 것입니다. 따라서 실제 코드에서는 파싱 실패에 대한 폴백(fallback, 비-JSON 프롬프트를 그 자체의 안정적인 문자열로 취급)이 필요합니다. 신중을 기하고 싶다면, 하루 동안 호출과 함께 키를 로그에 남기고 실제로 서로 다른 두 질문이 충돌(collide)하는지 직접 확인해 보십시오. 그것이 주의 깊게 살펴봐야 할 실패 모드이며, 고객 지원 티켓(support ticket)을 통해 발견하는 것보다 로그에서 잡아내는 것이 훨씬 저렴합니다.
제가 여전히 확신하지 못하는 부분은 정규화의 경계선을 어디에 그어야 하는가입니다. 너무 적게 제거하면 봉투(envelope) 정보가 다시 유출되어 적중률이 다시 0이 됩니다. 너무 많이 제거하면 서로 다른 질문이 병합됩니다. 다양한 워크로드에 걸쳐 제가 신뢰할 수 있는 명확한 경계 규칙은 없습니다. 그리고 p4 케이스인 의역 중복 제거(paraphrase dedup)는 진정으로 임베딩(embeddings)을 필요로 하며, 이는 확실한 이득을 가져다주지만 동시에 확실한 비용을 발생시킵니다. 소규모 규모에서 그 트레이드오프(trade-off)가 가치가 있는지 저는 측정해 보지 않았습니다. 만약 측정해 보셨다면, 그 결과를 듣고 싶습니다.
만약 반대 방향의 축, 즉 성공적인 작업당 실제로 가장 저렴한 모델 티어(model tier)가 무엇인지 최적화하고 있다면, 저는 성공당 비용에 관한 별도의 포스트에서 그 계산 과정을 다루었습니다. 더 저렴한 호출을 선택하는 것과 동일한 호출에 대해 비용을 두 번 지불하지 않는 것은 동일한 청구서 상의 서로 다른 두 가지 레버(levers)입니다.
실제 운영 중인 스크레이퍼 (scrapers)를 관리하는 엔지니어인 제가 작성하였으며, AI의 도움을 받아 초안을 작성하고 저의 Apify 실행 대시보드 및 연결된 Anthropic 문서와 대조하여 사실 관계를 확인했습니다. 스크립트는 제가 작성한 것이며 출력값은 실제 데이터입니다 (python3 -I prompt_cache_key.py). 여섯 개의 프롬프트는 합성된 고정 데이터 (synthetic fixtures)이며, 실제 프롬프트 로그가 아닙니다.
다음 실행 배치에서 도출된 수치들을 계속해서 확인해 주세요. 그리고 저에게 알려주세요. 여러분의 휘발성 필드 집합 (volatile-field set)에는 무엇이 들어있나요? 그리고 정규화된 키 (normalized key)가 서로 분리되어 있어야 할 두 질문을 충돌 (collide)시키는 것을 본 적이 있나요? 저는 모든 댓글을 읽습니다.
AI 자동 생성 콘텐츠
본 콘텐츠는 Dev.to AI tag의 원문을 AI가 자동으로 요약·번역·분석한 것입니다. 원 저작권은 원저작자에게 있으며, 정확한 내용은 반드시 원문을 확인해 주세요.
원문 바로가기