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", ...) tuple에 넣은 일반 string에는 이 marker를 지정할 자리가 없기 때문이다. LangChain에서 가장 편한 문법을 사용하면 할인 혜택을 전혀 받지 못한다. 오류도 발생하지 않는다.
TL;DR
- LangChain의
("system", "string")tuple에는cache_control을 넣을 수 없으므로 Claude는 아무것도 캐싱하지 않는다. 동일한 1,800-token system prompt로claude-sonnet-5에서 측정한 결과 cache write와 read가 모두 0이었다. - content block에
cache_control을 지정한SystemMessage를 사용하면 해결된다. system block에 marker 하나만 두어도bind_tools로 바인딩한 tool까지 캐싱된다. langchain-anthropic1.4.8에서는 실제로 cache write가 발생해도input_token_details.cache_creation이 계속 0으로 표시된다. 실제 수치는ephemeral_5m_input_tokens에 있다.- 순서가 잘못된 RAG prompt, 즉 고정 규칙보다 가변 context가 앞에 있는 prompt는 호출할 때마다 약 1.25배의 cache write 할증을 낸다. 캐싱을 아예 하지 않는 것보다 비싸다.
시리즈: 5부 중 5부 · 이전 글: 1부 — 캐싱 원리 · 2부 — Provider 비교와 평가 · 3부 — 실행 가능한 코드 튜토리얼 · 4부 — Use case별 최적의 LLM
캐싱 시리즈의 마지막 5부다. 1부에서는 prefix caching의 동작 원리를 설명하고, 3부에서는 raw SDK 튜토리얼을 다룬다. Provider를 아우르는 전체 내용은 프롬프트 캐싱 완벽 가이드에 정리했다. 이번 글에서는 LangChain이 prompt를 조립할 때 무엇이 달라지는지 살펴본다. 아래 결과는 모두 2026-07-04에 Synthorai gateway를 통해 측정했다. 사용한 버전은 langchain-core 1.4.8, langchain-anthropic 1.4.8, langchain-openai 1.3.3이다.
먼저, 어떤 “캐싱”을 찾고 있는가?
서로 관련 없는 두 기능이 같은 용어를 쓴다. 검색해서 들어간 LangChain 문서 페이지도 대개 찾던 내용이 아니다.
응답 캐싱 (LangChain의 InMemoryCache) | 프롬프트 캐싱 (이 시리즈) | |
|---|---|---|
| 저장 대상 | 애플리케이션에 전체 completion 저장 | Provider에 prompt prefix의 KV state 저장 |
| 비용이 절감되는 조건 | 완전히 동일한 request가 반복됨 | 서로 다른 request가 같은 prefix를 공유함 |
| 설정 위치 | set_llm_cache(InMemoryCache()), SQLite, Redis | cache_control marker 또는 자동 prefix matching |
| Agent loop, RAG, chat | 거의 쓸모없음. request가 매번 달라짐 | system과 tool이 매 turn 반복되므로 핵심적인 최적화 수단 |
여기서 “완전히 동일한 request”는 말 그대로 정확히 같아야 한다. 내장 캐시는 (직렬화된 prompt, model-config string) 쌍을 key로 사용한다. 측정 결과, 완전히 동일하게 반복한 호출은 API call 없이 0 ms 만에 반환됐다. prompt에 공백 하나를 추가하면 miss가 났고, 같은 prompt라도 max_tokens를 1만 바꾸면 miss가 났다. 캐시에서 재생된 응답에는 원래 호출의 usage 수치도 그대로 포함되므로, 단순하게 token을 집계하면 이중으로 계산된다. Semantic cache는 third-party integration으로 제공되지만, 내장 캐시는 exact match만 지원한다.
따라서 set_llm_cache는 테스트에서 동일한 호출을 제거할 때는 유용하다. 하지만 agent의 매 turn마다 다시 보내는 2,000-token system prompt는 프롬프트 캐싱으로 처리해야 한다. 그러려면 prompt를 올바른 형태로 조립해야 한다.
해결책은 string이 아니라 content block이다
cache_control은 content block 안에 들어간다. 따라서 system message는 단순 string이 아니라 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 system prompt를 같은 gateway에서 측정한 결과다.
| 호출 | String-tuple 문법 | 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부에서 설명했다. Marker 사용 방식은 LangChain Anthropic integration 문서와 Anthropic의 프롬프트 캐싱 가이드에 나온 raw SDK 방식과 같다.
Template 변수의 위치가 적중률을 결정한다
LangChain template에서는 어느 위치에든 변수를 쉽게 삽입할 수 있다. 바로 그 편리함이 문제를 만든다. Cache key는 byte 단위로 정확히 일치하는 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) |
캐시가 고장 난 것이 아니다. 해당 변수가 cache key에 포함된 것이다. 날짜처럼 반복되는 값은 값마다 한 번씩 cache write 비용이 들고, 이후부터 hit가 난다. Timestamp나 request ID처럼 호출마다 고유한 값은 모든 호출을 cold write로 만들며, 적중률은 정확히 0이 된다.
실무에서 비용이 크게 발생하는 사례는 RAG다. 많은 chain에서 사용하는 template은 검색한 context를 고정 instruction보다 앞, 즉 system prompt의 맨 위에 둔다. Query마다 바뀌는 800-token 검색 context와 marker가 있는 system block을 사용해 두 순서를 측정했다.
| Prompt 내부 순서 | 호출 1 | 호출 2 (새 query, 새 context) |
|---|---|---|
| Context 먼저, 규칙은 그다음 | write 3,133 | 다시 write 3,133, read 0 |
| 규칙 먼저 (marker 지정), context는 human turn에 배치 | write 1,852 | read 1,852 |
잘못된 첫 번째 방식은 단순히 “할인이 없는” 수준이 아니다. 호출할 때마다 3,133 token 전체에 정상 input 가격의 약 1.25배인 cache write 할증이 붙고, cache read는 한 번도 발생하지 않는다. 순서가 잘못된 RAG prompt에서 캐싱을 켜면 캐싱을 아예 하지 않는 것보다 비싸다. 고정 content가 가변 content 뒤에 있으므로 캐시 관점에서는 없는 것이나 마찬가지다.
측정 결과에서 다음 규칙을 도출할 수 있다.
- 고정 text를 먼저, marker가 지정된 block 안에 둔다. System rule, tool definition, few-shot example이 여기에 해당한다.
- 변하는 내용은 모두 marker 뒤에 둔다. 가능하면 검색 context, 날짜, 사용자 질문을 human turn에 배치한다.
- Block 안의 변수는 cache write 비용을 상쇄할 만큼 자주 반복될 때만 허용한다.
Tool definition도 함께 캐싱된다
Agent는 호출할 때마다 tool schema를 다시 전송한다. Anthropic의 request 구조에서는 tool이 system prompt보다 앞에 위치한다. Marker는 “request 시작부터 이 지점까지 모두 캐싱”하라는 뜻이므로 실무적으로 두 가지 질문이 생긴다. System block의 marker가 앞에 있는 tool도 포함하는가? 그리고 LangChain의 bind_tools는 매 호출에서 tool을 완전히 동일한 byte로 직렬화하는가? 직렬화 결과가 조금이라도 달라지면 prefix가 바뀌어 모든 호출이 miss가 난다.
둘 다 측정했다. Marker를 지정한 같은 system prompt에서 tool이 없을 때 warm cache read는 1,861 token이었다. Tool 두 개를 바인딩하자 2,389 token으로 늘었다. 추가된 528 token은 캐시에서 읽은 tool schema다. 이 2,389라는 수치는 3회 연속 호출에서 정확히 같았다. 즉 bind_tools는 매번 동일하게 직렬화하며, framework가 prefix에 불필요한 변동을 만들지 않는다. 결론적으로 system block에 marker가 있으면 tool 자체에는 cache_control이 필요 없다. Tool 뒤에 있는 marker 하나로 충분하다.
한 가지 형태에서는 반대로 구성할 수도 있다. 가장 큰 고정 데이터가 tool이고 system prompt는 짧거나 없는 경우다. 이때도 request 어딘가에는 marker가 필요하며, tool에 둘 수 있다. 단, @tool로 decorate한 function에는 marker를 넣을 field가 없으므로 raw Anthropic 형식의 dict를 사용해야 한다. 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
}])
Request에 marker가 지정된 system message가 없는 상태에서 측정한 결과, cold write는 3,002, warm read는 3,002였다.
Multi-turn에서는 marker를 마지막 message로 옮긴다
대화도 순서 문제처럼 보이지만 실제로는 반대다. History는 뒤에 추가되기만 하므로 이미 순서가 완벽하다. 전체 transcript가 고정 prefix가 되기 때문이다. 여기서는 캐시 범위가 문제다. System block에 marker를 두면 system block까지만 캐싱되고 이후 내용은 캐싱되지 않는다. History가 길어져도 warm read는 system 크기에 고정되며, 누적된 모든 turn은 일반 input 가격으로 청구된다.
Raw SDK와 같은 방식으로 해결할 수 있다. Marker를 가장 최근 message에 배치한다. Breakpoint가 대화 진행에 맞춰 앞으로 이동하면서 지금까지의 대화 전체가 cached 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)])
두 turn을 측정했다. Turn 1에서는 1,864를 write했다. Turn 2에서는 1,864를 read하고 15-token delta만 write했다. 이 delta는 이전 답변과 새 질문이다. 이전 prefix에는 약 10%인 read 요금이 적용됐다. Agent loop에 필요한 형태이며, LangChain에서는 일반 message list로 구현할 수 있다. Anthropic은 request당 marker를 최대 4개까지 허용하므로, 이동하는 marker를 system block이나 tool의 고정 marker와 함께 사용할 수 있다.
Meter를 확인하고 필드 이름을 정확히 파악한다
LangChain은 usage를 usage_metadata로 표준화한다. 여기에는 주의할 점이 있다. langchain-anthropic 1.4.8로 실행한 모든 응답에서 실제 cache write가 발생해도 표준 필드인 input_token_details.cache_creation은 계속 0이었다. 실제 write 수치는 비표준 key에 들어간다.
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)
Provider는 write를 정확히 보고했다. Raw response에는 cache_creation_input_tokens: 1875가 있었으며, r.response_metadata["usage"]에서 확인할 수 있다. 표준 mapping이 이를 TTL bucket key 아래에 넣었을 뿐이다. cache_creation만 보는 비용 dashboard는 write 할증이 계속 누적되는데도 캐싱 비용이 없다고 보고한다. Raw usage object를 신뢰하거나 bucket key를 정확히 파악해야 한다. Gateway가 cache 필드를 잘못 보고하는 문제와 같은 유형이며, LLM Gateway가 Cache 정보를 거짓으로 보고하는가?에서 점검 방법을 다룬다.
암시적 캐시: 순서가 잘못돼도 조용히 실패하므로 더 철저히 모니터링해야 한다
Claude의 캐시는 명시적이다. GPT와 대부분의 open-weight provider는 prefix가 일치하면 marker 없이 자동으로 캐싱한다. LangChain에서는 constructor만 바꾸면 같은 chain을 사용할 수 있다.
llm = ChatOpenAI(model="glm-5.2", base_url="https://synthorai.io/v1")
Marker 없이 일반 string system prompt를 사용했을 때 GLM 5.2의 두 번째 호출은 약 1,850-token prefix 중 1,088 token을 읽었다. 전체가 아닌 이유는 자동 캐시가 끝까지 byte 단위로 일치시키는 대신 큰 block 단위로 match하기 때문이다. 예를 들어 OpenAI는 128-token 단위라고 명시한다. 여기까지는 별다른 작업 없이 얻는 이익이다. 하지만 앞의 RAG 표에서 본 순서 문제는 여기에도 그대로 적용되며, 실패 방식은 더 까다롭다. 호출마다 새로운 검색 context를 사용해 자동 경로에서도 같은 순서 실험을 반복했다.
| 순서 (marker 없음, 자동 캐시) | 호출 1 | 호출 2 (새 query, 새 context) |
|---|---|---|
| Context 먼저, 규칙은 그다음 | read 0 | read 0 |
| 규칙 먼저, context는 human turn에 배치 | read 0 | read 1,088 |
순서가 잘못되면 무조건 0이다. 변하는 context가 맨 앞에 있으므로 어느 두 호출도 같은 prefix를 공유하지 않고, 할인도 발생하지 않는다. 명시적 방식에서는 같은 실수가 호출마다 cache write 할증으로 청구서에 드러난다. 암시적 방식에서는 할증도, 오류도, 신호도 없다. “자동”이니 작동하고 있다고 생각하는 동안 prompt는 계속 캐시 대상이 되지 않는다. 배치할 marker도 없으므로 암시적 방식에서 조정할 수 있는 유일한 수단은 prompt 순서다.
테스트에서 한 번 확인하는 데 그치지 말고 production meter로 검증해야 한다. LangChain에서는 input_token_details.cache_read, raw 응답에서는 prompt_tokens_details.cached_tokens를 확인한다. OpenAI의 자동 캐싱에는 최소 prefix 길이가 1,024 token이라고 명시돼 있다. Provider별 TTL과 적용 조건은 서로 다르며, 2부에서 자세히 다룬다.
체크리스트
- Claude에서
("system", "...")string tuple에는cache_control을 넣을 자리가 없다. 아무것도 캐싱되지 않으며 경고도 없다. 캐싱할 system prompt는 content block과 marker를 포함한SystemMessage에 넣는다. - Cache key는 byte 단위로 정확히 일치하는 prefix다. 고정 content를 먼저 두고, 변수는 marker 뒤나 human turn에 배치한다. RAG context를 규칙보다 앞에 두면 miss에 그치지 않고 호출마다 write 할증까지 낸다.
- 캐싱된 block 안의 변수는 값마다 별도의 cache entry를 만든다. 반복되는 값은 비용을 상쇄할 수 있지만, timestamp나 request ID처럼 호출마다 고유한 값은 절대 hit가 나지 않는다.
- Prefix에서 tool은 system prompt보다 앞에 있으므로 system marker가 바인딩된 tool까지 캐싱한다.
bind_tools는 결과를 결정적으로 직렬화한다. Tool이 가장 큰 고정 block이면 Anthropic 형식의 tool dict에 marker를 둘 수도 있다. - 대화에서 system block에 marker를 고정하면 계속 늘어나는 history에는 정상 가격이 적용된다. 가장 최근 message에 marker를 배치하면 매 turn마다 이전 prefix를 읽고 delta만 write한다.
input_token_details.cache_creation을 모니터링하면 안 된다. Write가 발생해도 계속 0이므로, dashboard에는 캐싱 비용이 없는 것처럼 보이지만 write 할증은 누적된다. 실제 수치는ephemeral_5m_input_tokens에 있다. 또는 rawresponse_metadata["usage"]를 확인한다.- 자동 캐시 model인 GPT, GLM, DeepSeek에서는 prompt 순서가 유일한 조정 수단이며, 순서가 잘못돼도 조용히 실패한다. 할증도, 오류도 없고, 할인만 영원히 발생하지 않는다. Usage 필드에서 hit를 검증한다.
set_llm_cache는 정확히 같은 prompt와 model config를 key로 전체 응답을 저장한다. 동일한 request가 반복될 때만 효과가 있으며 agent loop에는 도움이 되지 않는다.
필요한 습관은 간단하다. String 대신 content block을 사용하고, 고정 내용을 변수보다 앞에 두며, 대화에 맞춰 marker를 이동시키고, 올바른 usage 필드를 읽으면 된다. 측정 결과, 고정 token 전체에 90% 할인을 받는 경우와 아무 할인도 받지 못하는 경우로 갈렸다. 순서가 잘못된 RAG에서는 오히려 추가 비용을 냈다. LangChain이 프롬프트 캐싱을 방해하는 것은 아니다. 잘못된 prompt 구조도 올바른 구조만큼 쉽게 작성할 수 있게 해줄 뿐이다.
면책 조항
2026-07-04에 https://synthorai.io/를 대상으로 측정했다. 사용한 버전은 langchain-core 1.4.8, langchain-anthropic 1.4.8, langchain-openai 1.3.3이며, model은 claude-sonnet-5와 glm-5.2다. 약 1,800-token의 영어 system prefix와 작은 sample을 사용했고, cache write가 반영될 시간을 확보하기 위해 연속 호출 사이에 1–2초 간격을 뒀다. Cold cache를 보장하려고 실험마다 새로 무작위화한 prefix를 사용했다. 이 때문에 표마다 baseline token 수가 1,852에서 1,875까지 조금씩 다르다. Library의 field mapping과 provider의 cache 동작은 version에 따라 달라질 수 있다. 이 수치를 기준으로 운영하기 전에 자체 stack에서 다시 측정해야 한다.