프롬프트 캐싱을 실제 워크로드에 붙이기 – 핀테크 거래 분류기 설계 일지

지난 글에서 같은 prefix를 60번 호출해 캐싱의 비용 곡선을 그렸다.

그 글 마지막에 이렇게 약속했다.

“다음 글에서는 같은 캐싱 설계를 실제 워크로드에 옮겨본다.

이번 글이 ‘캐싱은 왜 켜는가’였다면

다음 글은 ‘캐싱을 어떻게 붙이는가’다.”

이번 글이 그 약속이다.

먼저 솔직하게 밝힌다.

운영 코드를 그대로 옮길 수는 없어서 핀테크에서 흔한 거래 메시지 분류기를 대표 시나리오로 재구성했다.

수치는 앞 글의 실측을 외삽한 것이고 설계 판단은 실제로 캐시를 붙이며 부딪힌 것들이다.

“왜 켜는가”는 앞의 세 글에서 끝냈으니 여기서는 마커를 어디에 박고

TTL을 어떤 코드 경로에서 가를지를 일지처럼 쓴다.


워크로드부터 정의한다

캐싱 설계는 워크로드 정의에서 시작한다.

여기서 잡은 시나리오는 이렇다.

거래 메시지 한 건을 받아 사전 정의된 라벨로 분류하는 작업이다.

시스템 프롬프트는 세 덩어리로 구성된다.

  • 분류 라벨 정의: 약 800 토큰. 거의 안 바뀐다.
  • few-shot 예시 20개: 약 2,400 토큰. 분기마다 손보지만 평소엔 고정이다.
  • 회사 약관·규정 발췌: 약 1,600 토큰. 규정 개정 때만 바뀐다.

여기에 거래 메시지 한 건(약 50 토큰)이 사용자 입력으로 들어온다.

합치면 호출당 입력이 5천 토큰에 육박하는데 그중 4,800 토큰이 매 호출 똑같다.

캐싱 교과서에 나오는 가장 이상적인 모양이다.

길고, 변하지 않고, 매 호출 재사용되는 prefix.


prefix를 캐시 친화적으로 배치한다

캐시가 먹으려면 prefix가 정확히 일치해야 한다.

그래서 메시지 레이아웃을 “안 변하는 것 → 가끔 변하는 것 → 매번 변하는 것” 순으로 고정한다.

[1] 시스템 프롬프트 (라벨 정의)   ← 거의 불변
[2] 도구 정의                     ← 거의 불변
[3] 약관·규정 발췌                ← 규정 개정 시에만
[4] few-shot 예시 20개            ← 분기마다
---- 여기까지 캐시 ----
[5] 거래 메시지 한 건             ← 매 호출 변함

원칙은 단순하다.

자주 바뀌는 걸 뒤로 보낼수록 앞쪽 캐시가 오래 산다.

여기서 핀테크 특유의 함정이 하나 있다.

거래 시각, 계좌번호, 요청 ID 같은 동적 값을 무심코 시스템 프롬프트 앞쪽에 끼워넣기 쉽다.

“오늘 날짜: 2026-06-03” 한 줄이 prefix에 들어가는 순간 자정마다 캐시가 통째로 깨진다.

동적 값은 반드시 [5]번, 사용자 입력 블록으로 내려야 한다.

여담이지만, 이 함정은 분류기가 아니라 챗봇을 만들며 실제로 밟았다.

시스템 프롬프트 맨 앞에 “현재 시각” 한 줄을 넣어두고 멀티턴을 돌렸더니 턴마다 cache_read가 0으로 찍혔다.

prefix 길이만 한참 의심하다 매 턴 바뀌는 게 그 시각 한 줄뿐이라는 걸 뒤늦게 봤다.

그 줄을 사용자 입력 쪽으로 내리자 두 번째 턴부터 캐시가 붙기 시작했다.

캐싱은 켜는 일보다 무엇이 prefix를 더럽히는지 찾아내는 게 일의 대부분이었다.


cache_control 마커를 어디에 박는가

앞 글에서 캐시 포인트는 최대 4개까지 둘 수 있다고 했다.

이 워크로드는 그 4개를 알뜰하게 다 쓰기 좋은 구조다.

system = [
    {"type": "text", "text": LABEL_DEFINITIONS,
     "cache_control": {"type": "ephemeral"}},        # 캐시 포인트 1
]

tools = [...]  # 도구 정의에도 cache_control 부착 가능

messages = [
    {
        "role": "user",
        "content": [
            {"type": "text", "text": POLICY_EXCERPT,
             "cache_control": {"type": "ephemeral"}},  # 캐시 포인트 2
            {"type": "text", "text": FEW_SHOT_BLOCK,
             "cache_control": {"type": "ephemeral"}},   # 캐시 포인트 3
            {"type": "text", "text": transaction_msg}, # 매번 변하는 입력 (마커 없음)
        ],
    }
]

포인트를 단계별로 쪼개두면 일부만 바뀌어도 앞쪽은 살아남는다.

few-shot을 한 개 추가하면 캐시 포인트 3 이후만 다시 써지고

라벨 정의와 약관 캐시(포인트 1·2)는 그대로 재사용된다.

마커를 맨 끝 한 곳에만 몰아 박으면 이 부분적 생존이 안 된다.

few-shot 한 줄 고친 게 전체 캐시를 날리는 일이 생긴다.


TTL을 코드 경로에서 가른다

앞 글의 실측이 남긴 결론은 한 줄이었다.

“TTL 선택은 비용이 아니라 호출 간격으로 결정해야 한다.”

그래서 TTL을 상수로 박지 않고 워크플로우 성격에 따라 함수로 고른다.

def pick_ttl(workflow: str) -> str:
    # 실시간 분류: 거래가 초 단위로 들어와 호출 간격이 5분 안쪽
    if workflow == "realtime_classify":
        return "5m"
    # 야간 배치·정산: 호출이 띄엄띄엄, 간격이 5분을 자주 넘김
    if workflow == "nightly_batch":
        return "1h"
    return "5m"

실시간 분류는 거래가 쉴 새 없이 들어와 캐시가 5분 안에 계속 갱신된다.

쓰기 단가가 싼 5분 TTL이 맞다.

반대로 야간 배치는 호출이 드물어 5분 캐시가 식어버린다.

캐시 미스로 매번 처음부터 다시 쓰느니 2배 쓰기 비용을 한 번 무는 1시간 TTL이 싸다.

앞 글에서 본 5분 캐시에 1시간 호출이 free-ride하던 함정도 여기서 조심해야 한다.

두 워크플로우가 같은 prefix를 공유하면 TTL이 섞인다.

그래서 실시간과 배치는 prefix 맨 앞에 워크플로우 태그를 한 줄 달아 캐시 키를 분리했다.


캐시 미스를 로깅한다

캐싱은 켜두는 게 아니라 보고 있는 거다.

매 호출의 usage 네 가지를 구조화해 남긴다.

u = response.usage
log.info("cache", extra={
    "workflow": workflow,
    "input": u.input_tokens,
    "cwrite": u.cache_creation_input_tokens,
    "cread": u.cache_read_input_tokens,
    "output": u.output_tokens,
})

이 로그 하나로 운영 중 이상을 바로 잡아낸다.

  • cread가 계속 0이다 → prefix가 매 호출 깨지고 있다. 동적 값이 앞쪽에 샜는지 본다.
  • cwrite가 매 호출 크게 잡힌다 → TTL이 짧거나 호출 간격이 길어 캐시가 자꾸 새로 써진다.
  • 실시간 워크플로우에서 cwrite에 작은 값(20~40)이 꾸준히 붙는다
    → 앞 글에서 본 5분 TTL의 user message 확장 비용이다. 정상이다.

마지막 항목을 모르면 “왜 cache_creation이 0이 아니지?”에서 반나절을 태운다.

앞 글에서 실측으로 한 번 짚었던 패턴이 운영 알람의 임계값을 잡는 데 그대로 쓰인다.


가정한 절감액과 그 한계

앞 글의 호출당 수치를 이 워크로드에 그대로 얹으면 그림은 분명하다.

하루 1만 건, 실시간 5분 TTL 기준으로 캐싱 OFF 대비 월 1만 달러대 절감이 나온다.

다만 이건 가정이다.

실제 운영에서는 세 가지가 그림을 흐린다.

  • 캐시 미스율: few-shot이나 약관을 손대는 빈도가 높으면 절감이 줄어든다.
  • 호출 간격 분포: 거래가 한산한 시간대엔 5분 캐시가 식어 미스가 늘어난다.
  • prefix 오염: 동적 값이 한 군데라도 새면 절감이 통째로 사라진다.

그래서 가정 숫자는 “상한선”으로만 본다.

진짜 숫자는 위 로그를 한 주 쌓아 미스율을 곱해봐야 나온다.


캐싱 4부작을 닫으며

세 글에 걸쳐 캐싱을 한 바퀴 돌았다.

  1. 개념 — 캐싱은 KV 행렬을 재사용하는 표시다. 쓰기는 비싸고 읽기는 거의 공짜.
  2. 실측 — 60번 호출로 손익분기(2~3회)와 16% 비용을 눈으로 확인했다.
  3. 설계 — prefix를 캐시 친화적으로 배치하고, 마커를 단계별로 박고, TTL을 호출 간격으로 가르고, 미스를 로깅한다.

캐싱은 단가를 안 바꾼다.

같은 토큰을 비싼 길로 보낼지 싼 길로 보낼지, 그 흐름만 바꾼다.

그 흐름을 설계하는 감각이 결국 LLM 비용을 다루는 일의 핵심이었다.

댓글 남기기