LLM 프롬프트 캐싱 #5: 실제로 캐시가 먹히는 LangChain 설정
목차
멀쩡해 보이지만 캐시는 하나도 안 되는 LangChain system prompt를 하나 보자:
from langchain_core.prompts import ChatPromptTemplate
prompt = ChatPromptTemplate.from_messages([
("system", BIG_STABLE_SYSTEM_PROMPT), # the syntax every tutorial uses
("human", "{question}"),
])
동일한 1,800 token 짜리 system prompt로 claude-sonnet-5 에 이걸 두 번 돌리고 usage 필드를 확인해 봤다. 두 호출 모두 cache write 0, cache read 0. 일부만 맞은 것도 아니고, 캐시가 조각난 것도 아니다. 아무것도 없다. 이유는 이렇다. Anthropic 은 cache_control 로 표시한 부분만 캐싱하는데, ("system", ...) 튜플 안에 들어간 평범한 문자열에는 이 마커를 붙일 자리가 없다. LangChain 에서 가장 편한 문법이 곧 할인 혜택을 통째로 날려버리는 문법이고, 이걸 알려주는 에러 메시지도 없다.
TL;DR
- LangChain의
("system", "string")튜플은cache_control을 담을 수 없어서 Claude가 아무것도 캐시하지 않습니다: 동일한 1,800 토큰 시스템 프롬프트를claude-sonnet-5에서 측정했더니 캐시 쓰기 0회, 읽기 0회였습니다. - 해결책은 콘텐츠 블록
cache_control을 가진SystemMessage입니다; 시스템 블록에 마커 하나를 두면bind_tools로 바인딩된 도구까지 함께 커버됩니다. langchain-anthropic1.4.8에서는 실제 쓰기가 일어나도input_token_details.cache_creation이 0으로 유지됩니다; 실제 카운트는ephemeral_5m_input_tokens에 있습니다.- 순서가 잘못된 RAG 프롬프트(안정적인 규칙 앞에 변하는 컨텍스트를 두는 경우)는 매 호출마다 약 1.25배의 캐시 쓰기 프리미엄을 지불합니다: 아예 캐싱하지 않는 것보다 더 비쌉니다.
이 글은 캐싱 시리즈의 5편이다. 1편에서는 prefix caching 이 어떻게 동작하는지 다뤘고, 3편에서는 raw SDK 로 하는 튜토리얼을 다뤘다. 이번 편은 LangChain 이 프롬프트를 대신 조립해 줄 때 무엇이 달라지는지에 대한 이야기다. 아래 내용은 전부 2026-07-04 에 Synthorai 게이트웨이를 통해 langchain-core 1.4.8, langchain-anthropic 1.4.8, langchain-openai 1.3.3 으로 실측한 결과다.
먼저, 지금 찾는 “캐싱” 이 어느 쪽인가?
전혀 관련 없는 두 기능이 같은 단어를 쓴다. 그리고 검색해서 들어가게 되는 LangChain 문서 페이지는 대개 엉뚱한 쪽이다.
Response caching (LangChain 의 InMemoryCache) | Prompt caching (이 시리즈) | |
|---|---|---|
| 저장하는 것 | completion 전체를 앱 안에 저장 | 프롬프트 prefix 의 KV 상태를 provider 쪽에 저장 |
| 돈을 아끼는 시점 | 완전히 동일한 요청이 반복될 때 | 서로 다른 요청들이 prefix 를 공유할 때 |
| 위치 | set_llm_cache(InMemoryCache()), SQLite, Redis | cache_control 마커 또는 자동 prefix 매칭 |
| 에이전트 루프, RAG, 채팅 | 거의 쓸모없음 (매 요청이 다름) | 핵심 수단. system + tools 가 매 턴 반복되기 때문 |
여기서 “완전히 동일한 요청” 은 말 그대로 완전히 동일한 것이다. 내장 캐시는 (직렬화된 프롬프트, 모델 설정 문자열) 쌍을 키로 쓴다. 실측 결과: 동일한 요청을 반복하면 API 호출 없이 0 ms 에 반환됐고, 프롬프트에 공백 하나만 추가해도 miss 가 났으며, 같은 프롬프트에서 max_tokens 를 1 만 바꿔도 miss 가 났다. (캐시된 재생 결과는 원래 호출의 usage 수치를 그대로 돌려주기 때문에, 토큰 집계를 단순하게 하면 이중으로 계산된다.) semantic 캐시는 서드파티 연동으로 존재하지만, 내장 캐시는 exact match 만 지원한다.
그러니 set_llm_cache 는 테스트에서 동일한 호출을 중복 제거하는 용도로는 괜찮다. 반면 매 에이전트 턴마다 다시 보내는 2,000 token 짜리 system prompt 는 prompt caching 이 맡을 일이고, 이걸 제대로 하려면 프롬프트를 올바른 방식으로 조립해야 한다.
해결책: 문자열이 아니라 content block
cache_control 은 content block 안에 실려서 전달된다. 그래서 시스템 메시지를 그냥 문자열로 두면 안 되고, block 형태의 content 를 가진 SystemMessage 로 만들어야 한다.
from langchain_anthropic import ChatAnthropic
from langchain_core.messages import SystemMessage
from langchain_core.prompts import ChatPromptTemplate
llm = ChatAnthropic(
model="claude-sonnet-5",
base_url="https://synthorai.io", # any Anthropic-compatible endpoint
)
prompt = ChatPromptTemplate.from_messages([
SystemMessage(content=[{
"type": "text",
"text": BIG_STABLE_SYSTEM_PROMPT,
"cache_control": {"type": "ephemeral"}, # a bare string has nowhere to put this
}]),
("human", "{question}"),
])
chain = prompt | llm
같은 1,800 token 짜리 시스템 프롬프트를 같은 gateway 로 측정한 결과다.
| 호출 | 문자열-튜플 방식 | content-block 방식 |
|---|---|---|
| 1번째 (cold) | write 0 / read 0 | write 1,875 / read 0 |
| 2번째, 다른 질문 | write 0 / read 0 | write 0 / read 1,875 |
warm read 는 input 가격의 약 10% 로 청구된다. 즉 Claude 에서는 이 구조 하나만 바꿔도, 매번 정가를 내느냐 아니면 모든 호출에서 고정된 절반에 대해 90% 할인을 받느냐가 갈린다. 비용 계산은 1부에 있고, 마커 동작 방식은 LangChain Anthropic 통합 문서와 Anthropic 의 prompt caching 가이드에 나오는 raw SDK 사용법과 동일하다.
템플릿 변수를 어디에 두느냐가 hit rate 를 좌우한다
LangChain 템플릿은 변수를 어디에든 손쉽게 끼워 넣게 해주는데, 바로 그게 위험 요소다. cache key 는 바이트 단위로 정확히 일치하는 prefix 다. 캐시되는 block 안에 날짜를 넣고 측정해봤다.
SystemMessage(content=[{
"type": "text",
"text": f"Today is {today}. " + BIG_STABLE_SYSTEM_PROMPT, # variable INSIDE the block
"cache_control": {"type": "ephemeral"},
}])
| 호출 | 결과 |
|---|---|
| A일, 질문 1 | write 1,865 (이 값에 대해 cold) |
| A일, 질문 2 | read 1,865 (같은 값, hit) |
| B일, 질문 1 | write 1,865 (새 값, 다시 cold) |
캐시가 깨진 게 아니다. 변수를 기준으로 key 가 잡힌 것이다. 날짜처럼 반복되는 값은 값마다 캐시 write 가 한 번 발생하고 그 이후로는 hit 한다. timestamp 나 request ID 처럼 호출마다 유일한 값은 매 호출이 cold write 가 되고 hit rate 는 정확히 0 이 된다.
이 실수의 실전판 중 비싼 게 RAG 다. 많은 chain 이 쓰는 템플릿은 검색된 context 를 시스템 프롬프트 맨 위, 정적인 지시문보다 앞에 둔다. 쿼리마다 바뀌는 800 token 짜리 검색 context 와 마킹된 시스템 block 으로 두 순서를 모두 측정했다.
| 프롬프트 내 순서 | 호출 1 | 호출 2 (새 쿼리, 새 context) |
|---|---|---|
| context 먼저, 그다음 규칙 | write 3,133 | 또 write 3,133, read 0 |
| 규칙 먼저 (마킹), context 는 human turn 에 | write 1,852 | read 1,852 |
잘못된 쪽은 단순히 “할인이 없다” 수준이 아니다. 매 호출이 전체 3,133 token 에 대해 캐시-write 프리미엄, 즉 정상 input 가격의 약 1.25배를 물면서도 읽어오는 건 하나도 없다. 캐싱을 켠 채로 순서가 잘못된 RAG 프롬프트는 캐싱을 아예 안 하는 것보다 더 비싸다. 고정된 내용이 변하는 내용 뒤에 있으니, 있으나 마나다.
측정 결과에서 나오는 규칙은 이렇다.
- 정적인 텍스트를 먼저, 마킹된 block 안에 둔다. 시스템 규칙, tool 정의, few-shot 예시.
- 변하는 건 전부 마커 뒤로, 가급적 human turn 에 둔다. 검색된 context, 날짜, 사용자 질문.
- block 안의 변수는 자기 캐시 write 를 상쇄할 만큼 자주 반복될 때만 허용된다.
툴 정의도 캐싱된다
에이전트는 매 호출마다 툴 스키마를 다시 보낸다. Anthropic의 요청 레이아웃에서 툴은 시스템 프롬프트 앞에 위치한다. 마커는 “요청 시작부터 여기까지 전부 캐싱하라”는 의미이므로, 현실적으로 두 가지 의문이 생긴다. 시스템 블록에 붙인 마커가 그 앞에 있는 툴까지 커버하는가? 그리고 LangChain의 bind_tools가 매 호출마다 툴을 완전히 똑같은 바이트로 직렬화하는가? 직렬화가 조금이라도 흔들리면 prefix가 바뀌고 모든 호출이 캐시를 놓치게 된다.
두 질문 모두 실측으로 답이 나온다. 같은 마커가 붙은 시스템 프롬프트에서, warm cache read는 툴이 없을 때 1,861 token, 툴 두 개를 bind했을 때 2,389 token이었다. 늘어난 528 token은 캐시에서 나온 툴 스키마다. 그리고 이 2,389는 연속 세 번의 호출에서 정확히 동일하게 나왔다. 즉 bind_tools는 매번 같은 방식으로 직렬화하며, 프레임워크가 prefix에 잡음을 흘려넣지 않는다. 분명히 하자면, 시스템 블록에 마커가 있는 한 툴 자체에는 cache_control이 필요 없다. 툴 뒤에 붙은 그 마커 하나가 모든 일을 처리한다.
반대 배치가 필요한 경우가 하나 있다. 툴이 보내는 것 중 가장 큰 고정 요소이고 시스템 프롬프트는 얇거나 아예 없는 형태다. 이때도 요청에는 마커가 어딘가 있어야 하는데, 그 마커를 툴에 둘 수 있다. 이건 raw Anthropic 포맷 dict에서만 동작한다. @tool 데코레이터가 붙은 함수에는 마커를 담을 필드가 없기 때문이다. bind_tools는 dict를 그대로 통과시킨다.
# variant: NO marked system block anywhere; the tool carries the request's only marker
llm.bind_tools([{
"name": "get_weather",
"description": LONG_TOOL_DESCRIPTION,
"input_schema": {...},
"cache_control": {"type": "ephemeral"}, # passes through bind_tools verbatim
}])
실측: cold일 때 write 3,002, warm일 때 read 3,002. 요청에 마커가 붙은 시스템 메시지는 하나도 없다.
멀티턴: 마커를 마지막 메시지로 옮긴다
대화도 또 다른 순서 문제처럼 보이지만, 사실은 정반대 경우다. 히스토리는 항상 뒤에만 추가되므로 순서는 이미 완벽하고, 전체 트랜스크립트가 안정적인 prefix가 된다. 여기서의 문제는 커버리지다. 시스템 블록에 붙인 마커는 시스템 블록만 캐싱하고 그 이후는 캐싱하지 않는다. 히스토리가 늘어나도 warm read는 시스템 크기에 고정된 채, 쌓인 모든 턴이 일반 input으로 과금된다.
이를 해결하는 패턴은 raw SDK가 쓰는 것과 같다. 가장 최근 메시지에 마커를 붙여 breakpoint가 앞으로 미끄러지게 하면, 지금까지의 대화 전체가 캐시된 prefix가 된다.
def marked(text):
return HumanMessage(content=[{
"type": "text", "text": text,
"cache_control": {"type": "ephemeral"},
}])
# each turn: history stays plain, only the newest human message carries the marker
llm.invoke([system, *history, marked(new_question)])
두 턴에 걸친 실측: 1번 턴은 1,864를 write했고, 2번 턴은 1,864를 read하고 15 token delta만 write했다(이전 답변과 새 질문). 앞선 prefix는 약 10%의 read 요율로 과금된다. 이게 바로 에이전트 루프가 원하는 형태이며, LangChain은 이를 평범한 메시지 리스트로 표현한다. Anthropic은 요청당 최대 네 개의 마커를 허용하므로, 미끄러지는 마커는 시스템 블록이나 툴에 붙인 고정 마커와 조합할 수 있다.
계측값을 읽고, 그 이름을 알아둬라
LangChain 은 사용량을 usage_metadata 로 표준화한다. 여기서 우리가 겪은 함정이 있다. langchain-anthropic 1.4.8 에서는 우리가 돌린 모든 응답에서, 캐시 쓰기가 실제로 일어났는데도 표준 input_token_details.cache_creation 필드가 0 으로 남아 있었다. 진짜 쓰기 카운트는 비표준 키에 들어간다.
r = chain.invoke({"question": "..."})
det = r.usage_metadata["input_token_details"]
det["cache_read"] # correct on hits (1875 above)
det["cache_creation"] # 0 even on a cold write; do not alert on this
det["ephemeral_5m_input_tokens"] # the actual write count (1875)
프로바이더 쪽은 쓰기를 제대로 보고했다(원본 응답의 cache_creation_input_tokens: 1875, r.response_metadata["usage"] 로 확인 가능). 표준화 매핑이 이 값을 TTL 버킷 키에 넣어버렸을 뿐이다. cache_creation 을 지켜보는 비용 대시보드는 캐싱이 공짜라고 알려주지만, 그 사이 쓰기 프리미엄은 조용히 쌓인다. 원본 usage 객체를 믿거나, 버킷 키를 알고 있어야 한다. 이건 게이트웨이가 캐시 필드를 잘못 보고하는 것과 같은 부류의 문제이며, 당신의 LLM 게이트웨이는 캐시에 대해 거짓말을 하고 있는가?에서 이를 점검한다.
암묵적 캐시: 순서를 잘못 넣으면 조용히 실패한다, 그러니 여기서 가장 주의해라
Claude 의 캐시는 명시적이다. GPT 와 대부분의 오픈 웨이트 프로바이더는 prefix 가 일치하면 마커 없이 자동으로 캐싱한다. LangChain 에서는 생성자 한 줄만 바꾸면 같은 체인이 그대로 동작한다.
llm = ChatOpenAI(model="glm-5.2", base_url="https://synthorai.io/v1")
마커 없는 평범한 문자열 system prompt 로, GLM 5.2 는 두 번째 호출에서 약 1,850 개 token 짜리 prefix 중 1,088 개 token 을 읽었다.(전부는 아니다. 자동 캐시는 끝까지 바이트 단위로 맞추는 게 아니라 거친 블록 단위로 일치시킨다. 예를 들어 OpenAI 는 128 token 단위 granularity 를 문서화하고 있다.) 여기까지는 공짜 돈이다. 하지만 위 RAG 표에서 나온 순서 문제가 여기서도 그대로, 더 고약한 실패 모드로 적용된다. 우리는 자동 경로에서 매 호출마다 새로 검색된 context 를 넣어 같은 순서 실험을 다시 돌렸다.
| 순서(마커 없음, 자동 캐시) | 호출 1 | 호출 2(새 쿼리, 새 context) |
|---|---|---|
| context 먼저, 그다음 rules | read 0 | read 0 |
| rules 먼저, context 는 human turn 에 | read 0 | read 1,088 |
잘못된 순서는 무조건 0 이다. 매번 바뀌는 context 가 맨 앞에 있으니 두 호출이 prefix 를 공유할 일이 없고, 할인은 영영 오지 않는다. 명시적 경로에서는 같은 실수가 최소한 매 호출 캐시 쓰기 프리미엄으로 청구서에 드러나기라도 한다. 암묵적 경로에는 프리미엄도, 에러도, 아무 신호도 없다. prompt 는 그냥 조용히 자격 요건을 못 채울 뿐이고, 당신은 “자동”이 곧 “동작 중”이라고 착각한다. 그리고 배치할 마커가 없기 때문에, 암묵적 경로에서 조절할 수 있는 유일한 손잡이는 prompt 순서뿐이다.
그러니 계측값으로 검증해라. 테스트에서 한 번이 아니라 프로덕션에서: input_token_details.cache_read(LangChain) 또는 prompt_tokens_details.cached_tokens(raw). OpenAI 의 자동 캐싱은 추가로 최소 1,024 token 짜리 prefix 를 요구한다고 문서화하고 있으며, TTL 과 적용 조건은 프로바이더마다 다르다. 이건 2부의 영역이다.
체크리스트
- Claude 에서
("system", "...")형태의 문자열 튜플에는cache_control을 실을 곳이 없다. 캐싱도 안 되고 경고도 없다. 캐시 가능한 system prompt 는 content block 과 마커를 담은SystemMessage로 넣어야 한다. - 캐시 키는 바이트 단위로 정확히 일치하는 prefix 다. 정적 콘텐츠를 먼저 두고, 변수는 마커 뒤나 human turn 에 둔다. RAG 컨텍스트를 규칙 앞에 두면 캐시를 놓칠 뿐 아니라, 호출할 때마다 쓰기 프리미엄을 문다.
- 캐시된 블록 안에 변수가 있으면 값마다 캐시 항목이 하나씩 생긴다. 반복되는 값은 비용이 분산되지만, 호출마다 유일한 값(timestamp, request ID)은 절대 히트하지 않는다.
- tool 은 prefix 에서 system prompt 앞에 위치한다. 그래서 system 마커는 bound tool 까지 함께 캐싱한다(
bind_tools는 결정적으로 직렬화된다). tool 이 가장 큰 안정적 블록이라면, 마커를 Anthropic 형식의 tool dict 에 붙일 수도 있다. - 대화에서는 마커를 system 블록에 고정하면 늘어나는 히스토리는 계속 정가로 지불한다. 마커를 가장 최근 메시지에 붙이면, 매 turn 마다 이전 prefix 를 읽고 delta 만 쓴다.
input_token_details.cache_creation을 모니터링하면 안 된다. 쓰기가 일어나도 0 으로 남기 때문에, 대시보드는 캐싱이 공짜라고 결론 내리지만 실제로는 쓰기 프리미엄이 쌓인다. 진짜 값은ephemeral_5m_input_tokens에 있거나, rawresponse_metadata["usage"]를 직접 읽어야 한다.- 자동 캐시 모델(GPT, GLM, DeepSeek)에서는 prompt 순서가 유일한 조절 수단이고, 순서를 잘못 잡으면 조용히 실패한다. 프리미엄도 없고 에러도 없이, 그저 오지 않는 할인만 남는다. usage 필드로 히트를 확인하라.
set_llm_cache는 정확한 prompt 와 모델 설정을 키로 삼아 응답 전체를 저장한다. 동일한 요청이 반복될 때만 이득이고, agent 루프에서는 절대 이득이 없다.
습관은 사소하다. 문자열 대신 content block 을 쓰고, 정적인 것을 변수보다 앞에 두고, 마커를 대화와 함께 밀어주고, usage 필드 하나를 제대로 읽는 것. 측정한 차이는 모든 안정적 token 에 대해 90% 할인이냐 아무것도 아니냐였고, RAG 순서를 잘못 잡은 경우엔 오히려 추가 비용을 내느냐였다. LangChain 이 prompt 캐싱을 방해하는 건 아니다. 다만 잘못된 prompt 모양을 올바른 것만큼이나 쉽게 쓸 수 있게 만들 뿐이다.
면책 조항
2026-07-04 에 https://synthorai.io/ 를 대상으로 측정했다. langchain-core 1.4.8, langchain-anthropic 1.4.8, langchain-openai 1.3.3, 모델은 claude-sonnet-5 와 glm-5.2, 약 1,800 token 짜리 영어 system prefix, 작은 표본, 그리고 연속 호출 사이에 1~2 초 간격을 두어 캐시 쓰기가 자리 잡을 시간을 줬다. 각 실험은 콜드 캐시를 보장하려고 매번 새로 무작위화한 prefix 를 썼고, 그래서 표마다 기준 token 수가 조금씩 다르다(1,852 ~ 1,875). 라이브러리 필드 매핑과 provider 캐시 동작은 버전마다 바뀐다. 이 수치에 의존하기 전에 자신의 스택에서 다시 측정하라.