📚 엔지니어 · 세션 · LLM · MCP · Core (수정중) · L02

L14 — LLM Prompt Caching: 중복 구현을 core 로 끌어올리기 (수정중)

작성일자 2026-06-21  ·  수정일자 2026-06-21  ·  강의차수 L02  ·  예상소요 30분

목표 이 강의가 끝나면, (1) 프롬프트 캐싱(같은 입력을 다시 보낼 때 값을 깎아 주는 기능)이 왜·언제 비용을 줄이는지 설명할 수 있고, (2) 두 프로젝트(popax·knowclaw)에 따로 있던 똑같은 코드를 공용 코드 창고(standarda-core)로 하나로 합치고, 태그를 달아 쓰던 프로젝트들과 새 프로젝트 틀(template)까지 퍼뜨리는 한 바퀴를 직접 따라 할 수 있다.

1. 오늘의 출발점 — "왜 같은 코드가 두 프로젝트에 있었나"

popax(meeting_chat)와 knowclaw(kms)는 둘 다 Anthropic 의 Claude 모델로 돈다. 그것도 "도구를 번갈아 쓰며 한 대답에 여러 번 부르는" 방식(tool-use 루프)이다. 그리고 둘 다 프롬프트 캐싱을 각자 따로 만들어 두고 있었다.

  • popax: meeting_chat/services/llm_router.py 에 cached_system_message(text, model) — 시스템 프롬프트 블록만, 그것도 claude 모델일 때만 표시를 붙인다.
  • knowclaw: kms/services/tool_agent.py 에 _with_cache_control(message) + kms/services/llm.py 에 사용량 누적(UsageAccumulator/track_usage) + kms/services/pricing.py 에 캐시 단가까지.

같은 아이디어인데 코드가 두 벌이다. 한쪽을 고쳐도 다른 쪽은 안 고쳐진다. 이게 바로 standarda-core(팀 공용 코드 창고)로 끌어올려야 한다는 신호다. 오늘은 이 겹치는 코드를 정본 하나(진짜배기 한 벌)로 합치고, 태그를 달아 두 프로젝트 + 새 프로젝트 틀(template)까지 퍼뜨린 과정을 그대로 따라간다.

교육적 포인트 2개를 동시에 잡는다: 캐싱이라는 기술 자체 + "두 프로젝트에 같은 게 있으면 core 로" 라는 우리 팀 작업 방식.


2. Prompt caching 30초 요약 — 무엇이고 왜 싸지나

LLM 은 부를 때마다 입력(프롬프트) 전체를 처음부터 다시 읽고, 그만큼 돈을 다시 매긴다. "이 앞부분은 아까 봤잖아" 하고 봐주는 게 없다.

도구를 번갈아 쓰는 루프는 이게 더 심하다. 한 번 대답하려고 LLM 을 여러 번 부르는데, 부를 때마다 같은 시스템 프롬프트 + 같은 도구 설명 + 지금까지의 대화를 통째로 다시 보낸다. 앞부분이 매번 똑같은데도 매번 새로 과금되는 것이다.

Anthropic 프롬프트 캐싱은 여기에 "이 앞부분(prefix)은 방금 본 거랑 같으니, 새로 읽지 말고 저장해 둔 걸 꺼내 써라" 하고 표시를 달아 주는 기능이다. 여기서 prefix 란 매번 똑같이 맨 앞에 붙는 부분을 말한다. 표시하는 방법은 메시지 안의 텍스트 블록에 cache_control 을 다는 것이다:

{'type': 'text', 'text': '<긴 시스템 프롬프트>', 'cache_control': {'type': 'ephemeral'}}

효과(캐시 안 탄 입력 값을 1 로 놓고 몇 배인지):

상태 단가 의미
캐시 쓰기(첫 호출, 캐시 생성) 1.25× 처음 한 번은 약간 더 비쌈
캐시 읽기(이후 적중) 0.10× 같은 prefix 재전송이 1/10 값
일반 입력 1.0× 캐시 안 탄 부분

즉 같은 앞부분을 반복해서 보내는 호출일수록 이득이 크다. 도구 루프가 정확히 그 모양이다 — 한 번 대답하는 동안 시스템 프롬프트와 도구 설명이 8~12번씩 그대로 반복된다.

이 캐시는 잠깐만 저장된다(코드의 ephemeral 이 그 뜻이다). 저장 유효 시간(TTL)은 5분 — 5분 안에 같은 앞부분이 다시 오면 저장해 둔 걸 꺼내 쓴다(적중).


3. 적용 지점을 어떻게 고르나 — 함정 두 개

표시를 아무 데나 단다고 저장해 둔 게 꺼내지는(적중하는) 건 아니다. 두 가지를 반드시 확인한다.

① 앞부분(prefix)이 호출 사이에 "글자 하나까지 똑같아야" 한다. 캐시는 앞부분이 한 글자라도 다르면 빗나간다. 그래서 매번 바뀌는 본문(회의록 전문·통화 전사문 같은 것)을 시스템 프롬프트에 끼워 매번 보내면 안 된다. popax 는 그런 본문을 read_minutes/read_transcript 처럼 필요할 때만 불러오는 도구로 빼놨다. 덕분에 시스템 프롬프트(= 캐시로 아낄 앞부분)를 한 턴 내내 똑같이 유지한다. 캐싱을 떠나서도 좋은 설계인데, 캐싱이 그 설계에 상을 주는 셈이다.

② 앞부분이 일정 크기는 넘어야 캐시가 저장된다(그 아래면 표시가 있어도 안 걸린다 — 에러는 아니다).

모델 캐시 최소 토큰
Claude Opus / Sonnet 1,024
Claude Haiku 2,048

popax 에서 Haiku 로 도는 작은 분류기들은 프롬프트가 100~160 토큰밖에 안 된다. 최소 크기에 한참 못 미쳐서 표시를 달아도 절대 캐시되지 않는다. 그래서 아예 대상에서 뺐다 — "될 곳에만" 단다.

어디에 표시를 다나 — 시스템 블록 한 곳만 달면 도구 설명까지 같이 캐시된다. Anthropic 이 프롬프트를 읽는 순서는 tools → system → messages(도구 → 시스템 → 대화)다. 시스템 블록에 cache_control 을 달면, 그 앞에 있는 도구 정의까지 한꺼번에 캐시 범위 안으로 들어온다. 그래서 popax 는 시스템 한 곳만 표시한다. knowclaw 는 한 발 더 나가, 시스템 블록 + 직전 대화의 끝 두 곳을 표시해(2-breakpoint, 표시점 두 개), 점점 길어지는 대화 앞부분까지 캐시한다.

# knowclaw kms/services/tool_agent.py — 2-breakpoint
if PROMPT_CACHE_ENABLED:
    system = with_cache_control(system)            # (1) 안정적 시스템 프롬프트
    if history:
        history = history[:-1] + [with_cache_control(history[-1])]  # (2) 직전 대화 끝
    msgs = [system] + history

4. 두 구현을 하나로 — 정본 헬퍼 설계

핵심 질문 하나: popax 와 knowclaw 의 차이를 어떻게 함수 하나로 흡수하나?

차이 popax knowclaw
마킹 대상 system 텍스트로 새 메시지 생성 기존 메시지의 마지막 블록
provider 게이트 있음(claude 아니면 평문 — Gemini 혼용) 없음(Anthropic 전용)

답: knowclaw 쪽 표시 방식(마지막 블록에 달되, 원본은 두고 복사본에만 단다)을 기본으로 삼고, 거기에 popax 의 "어느 회사 모델인지 보고 켜고 끄는 장치(provider-gate)"를 model=None 기본값으로 합친다. model 을 넘기면 이 장치가 켜지고(popax 방식), 안 넘기면 항상 적용된다(knowclaw 방식).

# standarda_core/llm_cache.py
def with_cache_control(message, model=None):
    """마지막 콘텐츠 블록에 ephemeral cache_control 을 붙인 복사본을 반환(원본 불변).
    model 을 주면 claude* 가 아닐 때 no-op → 멀티 provider 안전. None 이면 항상 적용."""
    if not _is_anthropic(model):          # model=None → True(적용), 'gemini-*' → False(no-op)
        return message
    content = getattr(message, 'content', None)
    if isinstance(content, str):
        blocks = [{'type': 'text', 'text': content, 'cache_control': {'type': 'ephemeral'}}]
    elif isinstance(content, list) and content:
        blocks = list(content)
        last = blocks[-1]
        blocks[-1] = {**last, 'cache_control': {'type': 'ephemeral'}} if isinstance(last, dict) \
            else {'type': 'text', 'text': str(last), 'cache_control': {'type': 'ephemeral'}}
    else:
        return message
    return message.model_copy(update={'content': blocks})   # 복사본 — 원본 불변

def cached_system_message(text, model=None):
    """popax 가 쓰던 편의 함수 — system 텍스트로부터 캐시 마킹된 SystemMessage."""
    return with_cache_control(SystemMessage(content=text), model)

왜 복사본을 쓰나: LangGraph 가 들고 다니는 messages(대화 기록)는 다음 턴에도 다시 쓰인다. 원본에 표시를 박아 버리면 그 캐시 표시가 대화 기록에 눌어붙는다. 그래서 이번 호출에 쓸 복사본에만 달고, 원본은 건드리지 않는다.

여기에 knowclaw 가 갖고 있던 사용량·비용 계산도 같이 올렸다(무엇까지 올릴지 범위를 정한 것): UsageAccumulator·track_usage()·record_usage()·estimate_cost_usd(). 단, 너무 자주 불러 잠깐 막혔을 때(429) 다시 시도해 주는 껍데기(_RetryingLLM)는 캐싱과는 다른 일이라 core 에 올리지 않고 knowclaw 안에 그대로 뒀다 — "관련 있어 보여도 하는 일이 다르면 같이 올리지 않는다".


5. core 에 올리고 전파하기 — 기여 라이프사이클 (이 강의의 진짜 핵심)

코드를 짜는 것보다 퍼뜨리는 절차가 더 중요하다. standarda-core 는 master(중심 가지)가 보호돼 있어 바로 밀어 넣지(push) 못한다. 순서는 이렇다:

① feature branch        git checkout -b feature/llm-prompt-caching
② 코드 + version bump    standarda_core/llm_cache.py 추가
                         pyproject.toml  version "0.12.0" → "0.13.0"   ← 태그와 반드시 일치!
③ PR                     gh pr create --base master
④ 승인 → merge           (Chris 본인 PR은) gh pr merge 12 --squash --admin
⑤ 태그                   git tag v0.13.0 && git push origin v0.13.0
⑥ 소비처 핀 갱신          requirements.txt  @v0.12.0 → @v0.13.0  후 pip install

가장 잘 걸리는 함정은 ②다: pyproject.toml 의 version 을 안 올리면, git 이 가리키는 곳(@v0.13.0)만 바뀌고 버전 문자열은 그대로라, pip 가 "이미 깔려 있네" 하고 다시 설치하기를 건너뛴다. 태그 vX.Y.Z 와 version = "X.Y.Z" 는 항상 같이 올린다.

소비처 전환은 불러오는 경로(import)는 그대로 두고 속만 core 로 바꾸면, 부르는 쪽 코드를 안 건드려도 된다:

# popax meeting_chat/services/llm_router.py — 로컬 정의를 지우고 re-export
from standarda_core.llm_cache import cached_system_message  # noqa: F401
#  ↑ 기존 `from meeting_chat.services.llm_router import cached_system_message` 가 그대로 동작

knowclaw 도 같은 방식이다 — pricing.py 는 core 의 이름을 그대로 다시 내보내는 얇은 연결층(shim)으로 바꾸고, llm.py 는 계산 부분을 core 에서 불러오고 _RetryingLLM 만 로컬에 남겼다. 결과적으로 겹치던 코드는 사라지고, 부르는 쪽은 하나도 안 바뀌었다.

마지막으로 새로 만드는 프로젝트도 이 규칙을 받게 프로젝트 틀(template)에 안내를 심는다(standarda-template 도 PR → 승인 → merge):

{{cookiecutter.project_slug}}/src/CLAUDE.md  ## Code Patterns
  → 'LLM Prompt Caching' 절: cached_system_message/with_cache_control/track_usage 사용 규칙

이렇게 해두면, 다음에 cookiecutter 로 찍어낸 프로젝트는 처음부터 캐싱 규칙을 CLAUDE.md 에 들고 시작한다.

퍼뜨리는 4단계를 한 줄로: 중복 발견 → core 로 하나로 합치기(+ version bump + 태그) → 소비처는 re-export 로 전환 → template 에 안내. 이게 우리가 공유 자산을 키우는 표준 모양이다.


6. 캐시가 진짜 먹었는지 — 숫자로 확인

표시만 달아 놓고 끝내면 안 된다. 정말 저장해 둔 걸 꺼내 쓰고 있는지(적중) 숫자로 재 봐야 한다. 두 가지 방법이 있다.

  • 로그 한 줄로 (popax): 응답의 usage_metadata['input_token_details']['cache_read'] 가 0 보다 크면 적중한 것이다. popax 편집 루프는 부를 때마다 cache_read/cache_creation 을 로그로 남긴다. 루프의 두 번째 호출부터 cache_read 가 잡히면 정상이다.
  • 모아서 계산 (knowclaw, 이제 core): 한 작업 동안의 모든 호출을 모아 비용까지 뽑는다.
from standarda_core.llm_cache import track_usage, estimate_cost_usd

with track_usage() as acc:
    ...  # 이 안의 모든 LLM 호출이 record_usage 로 누적됨 (cache_read/write 분리)
print(acc.total_cache_read, acc.total_cache_write)
print('추정 비용 $', estimate_cost_usd(acc.by_model))   # 캐시 단가(읽기 0.1x·쓰기 1.25x) 반영

estimate_cost_usd 는 langchain-anthropic 의 규칙 하나를 안다 — input_tokens 는 캐시로 읽은 토큰까지 포함한 전체 입력 값이라는 것. 그래서 정가 입력 = 총입력 - cache_read - cache_write 로 나눠서 각각 다른 단가를 매긴다. 덕분에 캐싱이 실제로 돈을 얼마나 아꼈는지가 숫자로 보인다.


오늘 정리 + 다음

  • 정리: 프롬프트 캐싱은 같은 앞부분을 반복해서 보내는 호출(도구 루프)에서 입력 재과금을 1/10 로 줄인다. 표시는 시스템 블록 한 곳에 달면 되고(그러면 도구 설명까지 캐시), 앞부분은 글자 하나까지 똑같아야 하고 최소 크기 이상이어야 적중한다. 오늘의 진짜 수확은 기술보다 퍼뜨리는 한 바퀴 — popax·knowclaw 두 벌 중복을 standarda-core/llm_cache.py 로 하나로 합치고(켜고 끄는 장치 병합) → version bump + 태그 v0.13.0 → 소비처는 re-export 로 전환 → template 에 안내까지 한 바퀴.
  • 흔한 함정: pyproject.toml version 을 안 올리고 태그만 달면 pip 가 재설치를 건너뛰어 "분명히 올렸는데 소비처엔 새 함수가 없다"가 된다. 태그 ↔ version 은 늘 같이.
  • 다음 시간: M2 "core 에 기여하기" 를 더 넓혀서 — 새 유틸을 core 에 올릴지 프로젝트에 둘지 가르는 기준(두 군데 이상 겹치나? 특정 도메인과 상관없나?)과, PR 볼 때 짚을 점.
  • 자습 권장: standarda_core/llm_cache.py 전체를 읽고, popax minutes_editor.py·graph.py·pipeline/config.py 세 적용 지점이 각각 어떤 앞부분을 캐시하는지 짚어 볼 것. 백로그 #S-260621143826(standarda done) 에 이 작업 산출물 요약이 있다.
이 강의를 학습하셨나요?