LLM 앱의 프롬프트 캐싱: 적중률보다 검수된 답변의 비용과 시간

공개 설명서와 계정별 정보를 쓰는 고객지원 도우미로 캐시 접두부를 설계하고, 가상의 두 요청에 대해 쓰기·읽기 비용과 종단 간 지연을 계산합니다. 권한 확인, 품질 검수, 제공업체별 보존 조건도 함께 점검합니다.

Repeated prompts sharing one reusable prefix

캐시 적중 자체가 목표는 아닙니다. 반복되는 LLM 업무에서 따져야 할 것은 접근 통제를 유지하면서 정답으로 인정된 작업 하나를 더 적은 비용이나 시간으로 끝내는가입니다. 버전이 정해진 공개 제품 설명서를 참고해 답하는 고객지원 도우미를 생각해 봅시다. 답변 정책과 설명서는 요청마다 같지만, 질문과 계정 조회 결과는 매번 새롭습니다. 프롬프트 캐싱은 변하지 않은 앞부분의 처리를 재사용할 수 있을 뿐, 계정 정보를 기억하거나 답변을 재사용하거나 정확성을 보증하지 않습니다. OpenAI는 정확히 일치하는 프롬프트 접두부의 계산된 KV 상태를 재사용하는 방식으로 설명하며, 새 입력과 출력에는 여전히 처리가 필요합니다. OpenAI 프롬프트 캐싱 문서

요청을 보내기 전에 공유 경계를 정하기

기존 도우미가 요청을 [타임스탬프 + 계정 ID + 방금 조회한 계정 정보] → [답변 정책] → [설명서 v17] → [도구 정의] → [질문] 순서로 직렬화했다고 합시다. 두 질문은 시작부터 달라서 값비싼 공통 자료가 동일한 접두부를 이루지 못합니다. 두 요청 모두 다음처럼 배치할 수 있습니다.

요청 1: [답변 정책 v3 | 고정된 도구 스키마 | 공개 설명서 v17]
        <캐시 경계> [이번에 권한 확인 후 조회한 계정 정보 A | 질문: 반품 기한은?]
요청 2: [답변 정책 v3 | 고정된 도구 스키마 | 공개 설명서 v17]
        <캐시 경계> [이번에 권한 확인 후 조회한 계정 정보 B | 질문: 교체 수수료는?]

대괄호 안의 공통 접두부는 바이트 수준까지 같은 순서로 직렬화해야 합니다. 타임스탬프, 임의 ID, 요청별 예시, 비결정적인 도구 순서, 알리지 않은 설명서 수정은 빼세요. 정책과 설명서에는 명시적인 버전을 붙이고 내용이 바뀌면 버전도 함께 바꿉니다. 조회 결과, 사용자 질문, 계정 권한 확인 결과는 경계 뒤에 둡니다. 한 사용자의 정보가 자주 반복된다고 공개 공통 접두부에 넣어서는 안 됩니다. 이 경계는 앱의 설계 개념이지 공통 API 문법이 아닙니다. Anthropic에는 콘텐츠 블록의 명시적 cache_control과 마지막 캐시 가능 블록을 이용한 자동 캐싱이 있고, OpenAI의 경계 지정 옵션은 모델 계열에 따라 다릅니다. Anthropic 프롬프트 캐싱 문서; OpenAI 프롬프트 캐싱 문서

최소 길이를 맞추겠다고 짧은 정책에 불필요한 문장을 덧붙이지 마세요. 먼저 선택한 모델과 요청 설정을 확인해야 합니다. OpenAI 문서에서 GPT-5.6 이후 모델의 최소 길이는 눈에 보이는 입력 토큰 1,024개이지만 이전 모델은 설정에 따라 다르며, Gemini의 최소 길이도 모델별로 다릅니다. 대상 조건에 미치지 못하면 짧은 프롬프트를 그대로 두는 편이 나을 수 있습니다. OpenAI 프롬프트 캐싱 문서; Gemini 컨텍스트 캐싱 문서

첫 쓰기 비용까지 포함해 두 답변 계산하기

다음은 제공업체의 요금표나 벤치마크가 아닌 가상의 두 요청 계산입니다. 각 요청의 고정 접두부는 2,400토큰, 달라지는 입력은 300토큰, 출력은 400토큰입니다. 가정한 백만 토큰당 달러 요금은 비캐시 입력 $2, 캐시 쓰기 $2.50, 캐시 읽기 $0.20, 출력 $8입니다. 캐시 없이 두 번 처리하면 비캐시 입력 5,400 + 쓰기 0 + 읽기 0 + 출력 800으로 $0.01720입니다. 첫 요청이 캐시를 쓰고 두 번째가 읽는다고 가정하면 비캐시 입력 600 + 쓰기 2,400 + 읽기 2,400 + 출력 800으로 $0.01408입니다. 네 항목은 서로 배타적인 토큰 과금 구간입니다. 쓰기 요금을 동일한 토큰의 일반 입력 요금 위에 다시 더하지 않습니다. 두 답변 모두 같은 심사를 통과한다면 성공한 작업당 각각 $0.00860과 $0.00704입니다.

시간도 별도의 가정으로 계산해 봅시다. 요청마다 고정 오버헤드 100ms, 출력 생성 400ms, 계정 조회 도구 500ms를 두고, 입력 처리에 쓰기 또는 미스 시 900ms, 읽기 시 180ms가 든다고 가정합니다. 캐시 없는 순차 두 요청은 총 3,800ms, 성공 작업당 1,900ms이고, 쓰기 후 읽기는 총 3,080ms, 성공 작업당 1,540ms입니다. 이는 두 요청의 순차 처리 결과를 그린 모형이지 실제 서비스 지연을 측정하거나 예측한 값이 아닙니다. 출력 생성과 도구 호출 시간은 사라지지 않습니다.

API 자격 증명 없이 이 계산을 재현하려면 아래 코드를 synthetic-cache-check.py로 저장하고 python synthetic-cache-check.py를 실행하세요. 임계값, TTL, 요금, 소요 시간은 모두 가정입니다. 캐시 키도 교육용 단순화이며 제공업체 구현을 나타내지 않습니다. 이 모형이 적중을 예측하더라도 실제 서비스는 라우팅이나 적용 조건 때문에 미스가 날 수 있습니다.

"""Offline illustration only: no SDK, API or provider pricing claims."""
from dataclasses import dataclass

# Entirely invented USD per million tokens and milliseconds per request component.
RATES = {'uncached': 2.0, 'write': 2.5, 'read': 0.2, 'output': 8.0}
MIN_PREFIX = 1000  # Illustrative eligibility threshold, NOT a provider rule.
TTL = 300         # Illustrative five-minute policy, NOT a universal TTL.

@dataclass(frozen=True)
class Request:
    tenant: str
    version: str
    at: int
    prefix: int
    suffix: int = 300
    output: int = 400
    success: bool = True
    authorized: bool = True
    quality: bool = True


def simulate(requests, caching):
    cache = {}
    records = []
    for r in requests:
        # Authorization runs independently of cache lookup. Reject before model call.
        if not r.authorized:
            records.append((r, 'denied', 0, 0, 0, 0, 0))
            continue
        key = (r.tenant, r.version)  # Model/region/config also belong in a production key.
        hit = caching and r.prefix >= MIN_PREFIX and key in cache and r.at - cache[key] < TTL
        write = caching and not hit and r.prefix >= MIN_PREFIX
        if hit or write:
            cache[key] = r.at
        u = r.suffix + (0 if hit or write else r.prefix)
        w = r.prefix if write else 0
        read = r.prefix if hit else 0
        # Fixed overhead + input processing + output generation + downstream tool time.
        latency = 100 + (180 if hit else 900) + 400 + 500
        records.append((r, 'hit' if hit else 'write' if write else 'miss', u, w, read, r.output, latency))
    return records


def report(name, rows):
    good = sum(r.success and r.quality and r.authorized for r, *_ in rows)
    u, w, rd, o = (sum(row[i] for row in rows) for i in (2, 3, 4, 5))
    dollars = (u*RATES['uncached'] + w*RATES['write'] + rd*RATES['read'] + o*RATES['output']) / 1_000_000
    ms = sum(row[6] for row in rows)
    print(f'{name}: statuses={[row[1] for row in rows]}, uncached={u}, write={w}, read={rd}, output={o}, cost=${dollars:.5f}, successful={good}, cost/success=${dollars/good:.5f}, total_ms={ms}, ms/success={ms/good:.0f}')
    return dollars, ms, good


if __name__ == '__main__':
    pair = [Request('A', 'policy-v1', 0, 2400), Request('A', 'policy-v1', 60, 2400)]
    before = report('before', simulate(pair, False))
    after = report('after', simulate(pair, True))
    assert after[0] < before[0] and after[1] < before[1] and after[2] == before[2] == 2
    probes = [
        ('version mismatch', [pair[0], Request('A', 'policy-v2', 60, 2400)], ['write', 'write']),
        ('below minimum', [Request('A', 'short', 0, 800), Request('A', 'short', 60, 800)], ['miss', 'miss']),
        ('expired', [pair[0], Request('A', 'policy-v1', 301, 2400)], ['write', 'write']),
        ('cross tenant', [pair[0], Request('B', 'policy-v1', 60, 2400)], ['write', 'write']),
        ('cold miss then reuse', pair, ['write', 'hit']),
        ('unauthorized', [pair[0], Request('A', 'policy-v1', 60, 2400, authorized=False)], ['write', 'denied']),
    ]
    for label, requests, expected in probes:
        rows = simulate(requests, True)
        actual = [row[1] for row in rows]
        assert actual == expected, (label, actual)
        print(f'{label}: {actual}')
    # Quality flags are external evaluations, not outputs from a synthetic cache.
    quality_fail = simulate([pair[0], Request('A', 'policy-v1', 60, 2400, quality=False)], True)
    assert quality_fail[1][1] == 'hit' and sum(r.success and r.quality for r, *_ in quality_fail) == 1
    print('quality regression: hit, but only 1 of 2 passes; cache hit is not task success')

적중률 대신 검수된 작업을 세기

실제 파일럿에서는 모델, 도구 정책, 설명서 버전, 인가 규칙을 맞춘 질문을 비교해야 합니다. 제공업체가 제공하는 범위에서 비캐시 입력·캐시 쓰기·캐시 읽기·출력 토큰을 구분해 기록하고, 해당 모델과 보존 방식의 실제 요율을 적용합니다. 요청 시작부터 도구 호출과 최종 승인 답변까지의 시간, 필요하면 첫 토큰까지의 시간, 검수 통과 여부도 남깁니다. 실패한 시도와 재시도까지 포함한 총 청구 비용과 총 소요 시간을 승인된 작업 수로 나눕니다. 요청 수나 캐시 적중 수로 나누면 안 됩니다. 경로, 접두부 버전, 테넌트, 요청 간 시간 간격별로 살펴보세요. 잘못된 답변의 캐시 적중은 비용과 시간을 쓰지만 성공 작업을 늘리지 않습니다.

배포 전에는 여섯 경로를 시험합니다. 첫 미스 뒤 재사용, 정책·설명서 버전 변경, 선택한 모델의 최소 길이에 못 미치는 접두부, 설정한 만료 시점을 지난 요청, 같은 공개 자료를 사용하는 다른 테넌트, 권한이 없는 계정 조회입니다. 첫 요청·버전 변경·짧은 접두부·만료 후 요청에서는 읽기가 없어야 합니다. 제공업체가 조직 수준에서 재사용을 허용하더라도 앱에서는 테넌트를 분리하고, 계정 맥락을 만들기 전에 권한을 확인해야 합니다. 캐시 읽기 사실을 접근 권한의 증거로 삼지 마세요. 적중과 미스 모두에서 설명서 근거 표시, 계정 정보가 부족할 때의 적절한 거절, 타 계정 정보 유출 여부를 다시 확인해야 합니다. 위 모의 코드는 이 경로들의 상태를 검사할 뿐, 제공업체의 실제 동작이나 생성 답변의 품질을 입증하지는 않습니다.

제공업체별 동작과 보존 조건 확인하기

OpenAI의 현행 문서는 최신 모델의 명시적·암묵적 경계 및 쓰기 과금과 이전 모델의 암묵적 방식 사이를 구별합니다. 수명, 최소 길이, 사용량 보고 방식도 모델마다 다릅니다. 캐시 상태는 머신 로컬이므로 라우팅과 만료로 미스가 생길 수 있고, 조직이나 지역 처리 경계를 넘어 공유되지 않습니다. 데이터 보존 선택지도 모델과 조직 설정에 따라 달라집니다. 메모리 캐시라는 이유로 보존 문제가 없다고 단정하지 말고 적용되는 정책을 확인하세요. OpenAI 프롬프트 캐싱 문서

Anthropic은 캐시 쓰기·읽기 사용량을 구분하며 5분과 요금이 더 높은 1시간 수명 옵션을 제공합니다. 캐시 범위는 조직 및 일부 플랫폼의 워크스페이스 단위로 분리됩니다. 문서는 원문 토큰 텍스트 대신 메모리 내 KV 표현을 설명하지만 최소 수명 뒤 삭제가 반드시 즉각적이지 않다고도 밝힙니다. 해당 계정의 별도 데이터 보존 조건도 확인해야 합니다. Gemini의 Interactions API 문서는 지원 모델에서 기본적으로 적용되는 암묵적 캐싱과 usage.total_cached_tokens를 설명하며, 명시적 캐시 객체는 다른 API를 사용합니다. 익숙한 업체 이름이나 홍보용 적중률만으로 모델별 제한과 현재 가격을 대신할 수 없습니다. Anthropic 프롬프트 캐싱 문서; Gemini 컨텍스트 캐싱 문서

실제 비교 트래픽에서 검수된 답변당 비용 또는 시간이 개선되고, 거절 판단·정확성·계정 격리가 유지될 때만 접두부 변경을 적용하세요. 설명서가 자주 바뀌거나 재사용할 만큼 요청이 모이지 않거나 500ms짜리 계정 조회가 주된 병목이라면 그 병목부터 다루는 편이 낫습니다. 모델 입력 외 단계까지 시간을 재야 한다는 점은 음성 AI 파이프라인을 다룬 Meydo Journal 글에서도 중요합니다.