요청에서 도구 하나를 제거해 보세요: 어떤 GPT-5 버전은 76%를 캐싱에 유지하지만, 다른 버전은 아무것도 유지하지 못합니다
요약
프롬프트 캐싱 효율이 모델 버전에 따라 극명하게 달라짐을 실험을 통해 밝힙니다. 도구 정의 하나를 제거하는 작은 변화만으로도 캐시 유지율이 급격히 떨어질 수 있음을 경고하며, 효율적인 캐싱을 위한 프롬프트 구조 설계의 중요성을 강조합니다.
핵심 포인트
- 프롬프트 캐싱은 의미론적 매칭이 아닌 바이트 단위의 접두사 일치 방식임
- 모델 버전(gpt-5.2 vs 5.5)에 따라 도구 변경 시 캐시 유지 성능 차이가 매우 큼
- 캐싱 효율을 높이려면 정적 콘텐츠를 앞에, 가변 콘텐츠를 뒤에 배치해야 함
- 도구 정의의 위치와 순서가 캐시 유지율에 결정적인 영향을 미침
프롬프트 캐싱 (Prompt caching)은 LLM 기반 애플리케이션 내에서 가장 저렴하게 성능을 높일 수 있는 방법입니다. 애플리케이션이 매 요청마다 긴 시스템 프롬프트 (system prompt)를 보내면, 모델 제공업체는 이전에 보았던 부분(처음부터 시작되는 부분)을 인식하여 이를 다시 처리하는 대신 실제 토큰 비용의 아주 일부만 청구합니다. 시스템 프롬프트와 도구 정의 (tool definitions)가 수천 토큰에 달할 수 있고 턴 (turn) 사이의 변화가 거의 없는 도구를 사용하는 에이전트 (agent)의 경우, 이 할인 혜택은 엄청납니다.
캐싱의 이점을 얻기 위한 조언은 간단합니다. 프롬프트의 앞부분을 안정적으로 유지하세요. 정적 콘텐츠를 앞에 두고, 가변 콘텐츠를 마지막에 배치하며, 캐시된 접두사 (prefix)가 온전하게 유지되도록 도구 정의를 고정하십시오. 이 조언은 옳지만, 이는 또한 아무도 언급하지 않는 방식으로 버전에 따라 달라지며, 그 의존성은 매우 심각합니다.
저는 동일한 모델 제품군의 네 가지 버전에 대해, 요청에서 단 하나의 도구만 제거하고 다른 것은 아무것도 변경하지 않은 채, 이틀 동안 세 번의 배치로 버전당 15회씩 실험을 진행했습니다. gpt-5.2에서는 이 변경으로 인해 캐시의 4분의 1을 잃었습니다. 두 버전 뒤인 gpt-5.5에서는 동일한 변경이 시스템 프롬프트를 포함한 전체 캐시를 버리게 만들었습니다. 그리고 gpt-5.1에서는 동일한 테스트가 하나의 답으로 수렴하지 않았는데, 이는 그 자체로 하나의 발견이었으며 이 포스트가 단일 실행 대신 반복된 실행 결과를 보고하는 이유이기도 합니다.
이 포스트는 실험 내용과 그 결과, 그리고 런타임 (runtime)에 도구 목록을 변경하는 모든 것에 이 결과가 무엇을 의미하는지에 대한 것입니다.
방법론을 설명하기 전에 요약본을 먼저 보여드립니다. 모든 행에 대해 동일한 작업(도구 하나 제거)을 수행했을 때, 요청 중 캐시에서 여전히 제공되는 비율입니다:
| 모델 | 마지막 도구 제거 | 첫 번째 도구 제거 |
|---|---|---|
gpt-5.2 | 76% | 46% |
| ... |
포스트의 나머지 부분은 이 수치들이 어떻게 측정되었는지, 왜 신뢰할 수 있는지, 그리고 이에 대해 어떻게 대처해야 하는지에 대한 내용입니다. 전체 실험은 여러분이 복제할 수 있는 작은 합성 리그 (synthetic rig)에서 실행됩니다.
다음 사항들에 이미 익숙하다면 이 글을 통해 가장 많은 것을 얻을 수 있습니다:
- 채팅 완성 (chat completion) 요청이 어떻게 구성되는지 -
system메시지,tools정의, 그리고user메시지 - 모델이 당신의 요청을 별개의 필드가 아닌 하나의 평평한 토큰 시퀀스 (flat sequence of tokens)로 본다는 개념
- 프롬프트 캐싱 (prompt caching)이 대략적으로 무엇을 하는지 (반복되는 접두사 (prefix)에 대해 비용을 적게 청구함). 만약 이것이 처음이라면, OpenAI와 Azure의 캐싱 문서를 먼저 10분 정도 읽어보는 것이 좋은 입문서가 될 것입니다.
캐싱은 접두사 일치 (prefix match) 입니다
프롬프트 캐싱은 의미론적 매칭 (semantic matching)이 아닙니다. 제공업체는 문맥을 통해 검색하는 것이 아니라, 첫 번째 토큰부터 왼쪽에서 오른쪽으로 토큰 시퀀스를 따라가며 이미 캐시에 있는 것과 바이트 단위로 완전히 동일한 (byte-for-byte identical) 가장 긴 구간을 찾습니다. 그 가장 긴 바이트 단위로 완전히 동일한 시퀀스가 접두사 (prefix)가 되며, 동일한 시퀀스가 일치하지 않거나 끊기는 지점 이후의 모든 것은 새로운 것으로 처리되어 전체 비용이 부과됩니다.
- 위치가 전부입니다 - 프롬프트의 시작 부분 근처에서 변경이 발생하면, 매칭이 첫 번째 차이점에서 중단되기 때문에 앞부분 _및 그 이후의 모든 것_이 무효화됩니다. 뒷부분 근처에서의 변경은 뒷부분만 무효화합니다.
- 동일하지만 도달할 수 없는 콘텐츠는 가치가 없습니다 - 만약 두 요청이 50번째 토큰에서 다르다면, 51번째 위치부터 있는 3,000개의 동일한 토큰은 끊김 지점을 지났기 때문에 아무것도 캐싱하지 못합니다.
- 최소 크기 제한 (Size floor) - OpenAI/Azure 모델의 경우, 제공업체는 접두사가 최소
1,024토큰 길이를 넘어야만 캐싱합니다. 그보다 적은 것은 캐싱도 되지 않고, 에러도 발생하지 않으며, 그저 비용만 청구될 뿐입니다.
독점적인 내용이 없는 합성 에이전트 (Synthetic agent)
이를 깔끔하게 측정하기 위해, 저는 공개할 수 있는 에이전트 형태의 요청이 필요했습니다. 따라서 도메인은 가상의 공공 도서관 카탈로그입니다. 구조적으로는 에이전트를 사용하는 실제 도구를 반영하며, 내장된 세 가지 속성이 있습니다:
- 긴 정적 시스템 프롬프트 (Long static system prompt) (~1.8k 토큰) - 분석 지침, 워크플로우 규칙, 포맷팅 규칙.
- 상단 근처의 작고 가변적인 블록 (Small varying block) - 우리가 어떤 컬렉션을 작업하고 있는지 설명하는 몇 줄의 텍스트. 목록이 아닌 수치를 포함하므로 너비가 거의 일정하며, 컬렉션 간의 차이는 숫자 부분뿐입니다. 이는 사용자별 또는 데이터셋별 블록의 아날로그(analogue)입니다.
- 8개의 도구 정의 (8 tool definitions) -
tools=로 전달되며, 이 블록 자체만으로1,024토큰을 차지하도록 의도적으로 장황하게 작성되었습니다 (~1.5k 토큰).
중요한 측정값 중 하나는 API 자체에서 제공됩니다:
def cached(resp) -> int:
# 이전 SDK 버전에서는 prompt_tokens_details가 일반 dict로 전달될 수 있으므로,
# 속성 접근(attribute access) 대신 dict-safe하게 읽어야 합니다.
...
cached_tokens는 이 요청의 토큰 중 몇 개가 캐시(cache)에서 제공되었는지를 나타냅니다. 이 단일 수치가 실험의 전부입니다.
참고
이전 SDK 버전에서는
prompt_tokens_details가 타입이 지정된 객체(typed object) 대신 가공되지 않은 dict로 반환되므로,getattr(details, "cached_tokens", 0)는 조용히0을 반환하며 모든 요청이 캐시 미스(cache miss)처럼 보이게 됩니다. 만약 모든 곳에서 0이 나타난다면, 미스라고 믿기 전에 먼저response.usage.model_dump()를 출력하여 해당 필드가 실제로 존재하는지 확인하십시오.
측정: 두 가지 함정, 대조군, 그리고 재현
함정 1 - 동일한 요청을 두 번 실행할 때, 첫 번째 실행이 캐시를 쓰고 두 번째 실행이 첫 번째 실행의 캐시를 타게 되어, 실제 미스가 히트(hit)처럼 보이는 경우입니다. 이에 대한 해결책은 **논스 (nonce)**입니다. 즉, 매 실행마다 공유 텍스트 앞에 새로운 고유한 주석을 추가하여 각 실행이 콜드 스타트(cold start) 상태로 시작하게 만드는 것입니다. 이는 공유 블록의 *맨 앞(head)*에 위치하므로, 한 실행 내의 요청들은 그 이후의 모든 내용을 여전히 공유할 수 있습니다.
함정 2 - 쓰기(write) 작업이 캐시 쓰기로 인식되어 읽기 가능해지기까지 약간의 시간이 걸리기 때문에 아직 가시화되지 않는 경우입니다. 두 번째 요청을 너무 빨리 보내면 실제 히트가 미스로 읽힙니다. 이 실험을 위한 해결책은 쓰기와 읽기 사이에 30초간의 sleep을 두는 것입니다.
Positive control (양성 대조군) - 테스트 요청이 끝난 후, 가장 처음 보냈던 요청을 변경 없이 다시 보냅니다. 만약 그 요청조차 히트(hit)로 돌아오지 않는다면, 쓰기(write) 작업이 가시화(visible)되지 않은 것이므로 해당 실행은 무효이며, 테스트 요청에서의 0점은 아무것도 증명하지 못합니다. 무효 처리된 실행은 제외하며, 무효가 몇 건이었는지 보고합니다.
Replication (재현) - 아래의 모든 셀은 15회씩, 세 번의 배치(batch)로 실행되었습니다. 숫자가 시간이 지나도 유지되는지 테스트하기 위해 세 번째 배치는 처음 두 배치로부터 3일 후에 진행했습니다. 이 포스트의 초안을 gpt-5.1의 단 한 번의 깨끗한 실행 결과에 근거하여 작성했기 때문에, 해당 셀은 이봉 분포(bimodal)를 띠는 것으로 나타났습니다. 비결정론적(nondeterministic) 프로세스의 단 한 번의 실행은 당신에게 그 모드(mode) 중 하나를 완전한 확신을 가지고 건네줄 뿐입니다.
질문할 가치가 있는 질문
전체 프롬프트 비용을 다시 지불하지 않고, 요청마다 보내는 도구(tool)의 종류를 바꿀 수 있을까?
이것은 가설적인 이야기가 아니며 에이전트(agent)의 일상적인 모습입니다. 또한 개발자들이 OpenAI 자체 포럼에서 질문했지만 답변을 얻지 못한 문제입니다.
- MCP 서버가 세션 중간에 연결되거나 끊어지면, 사용자의 조작 없이도 도구 목록이 변경됩니다. 서브프로세스(subprocess)가 종료되거나, 세션이 만료되거나, 서버가 재연결되는 경우입니다.
- **Skill loader (기술 로더)**가 문맥(context)에 따라 도구를 필터링하면 매 턴마다 서로 다른 부분 집합을 보냅니다.
- 매 턴마다 도구를 선택하는(per-turn tool selection) 모든 로직은 진행 과정에서 배열(array)을 재구성합니다.
에이전트 워크로드에 대한 프롬프트 캐싱(prompt caching)에 관한 2026년 2월의 평가(Lumer et al., "Don't Break the Cache", arXiv:2601.06007)는 규칙을 명확히 명시하고 있습니다: 도구 정의가 프롬프트에 포함되어 있을 때, "사용 가능한 도구 세트에 가해지는 어떠한 변경도 캐시된 접두사(cached prefix)를 무효화한다"라고 하며, 완화 방법으로 고정된 도구 세트를 권장합니다. (그들의 실험은 삽입된 UUID를 통해 캐시 경계(cache boundaries)를 제어하는데, 이는 제가 사용한 nonce와 동일한 트릭이며 독립적으로 도달한 방식입니다.)
따라서 본 테스트는 시스템 프롬프트를 바이트 단위로 완전히 동일하게(byte-for-byte identical) 유지하면서, 다음의 두 가지 특정 방식으로 도구 목록만을 변경합니다:
a = call(system, TOOLS) # 8개 도구 전체 - cold, 엔트리를 작성함
# ... 30초 대기 ...
keep_17 = call(system, TOOLS[:-1]) # 마지막 도구를 제거 -> TOOLS의 엄격한 접두사 (strict prefix)
...
TOOLS[:-1]은 도구 1번부터 7번까지를 유지하며 전체 목록의 _엄격한 접두사 (strict prefix)_가 됩니다. 만약 도구 블록이 내부적으로 접두사 일치 (prefix-match)를 지원한다면, 이 방식은 대부분 캐싱된 상태로 유지될 것입니다. 반면 TOOLS[1:]은 도구 2번부터 8번까지를 유지하며 도구 블록의 맨 앞부분부터 어긋나게 되는데, 이는 도구 목록의 앞부분에서의 변경이 _뒤쪽_까지 영향을 미쳐 그 위의 시스템 프롬프트 (system prompt)를 파괴하는지 여부를 알려줍니다.
gpt-5.2에 대한 결과
이틀에 걸쳐 세 번의 배치, 총 15회의 실행을 진행했습니다. 모든 실행 결과는 토큰 단위로 다음과 같습니다:
keep 1-8 (대조군) cached = 3,328
keep 1-7 (마지막 제거) cached = 2,560
keep 2-8 (첫 번째 제거) cached = 1,536
이 수치들을 서로 비교해 보십시오. 이야기의 핵심은 전적으로 이 비교에 담겨 있습니다.
마지막 도구를 제거했을 때는 2,560 토큰이 캐싱되었으며, 이는 약 3.4k 토큰 요청의 약 76%에 해당합니다. 단축된 목록이 원본의 바이트 단위로 동일한 접두사 (byte-for-byte prefix)이기 때문에, 시스템 프롬프트 (system prompt)와 도구 블록의 대부분이 살아남았습니다. 매칭 과정이 여덟 번째 도구가 시작될 지점까지 내려가며 진행된 것입니다.
첫 번째 도구를 제거했을 때는 1,536 토큰만 유지되었는데, 이는 대략 시스템 프롬프트 정도의 양이며 그 외에는 아무것도 남지 않았음을 의미합니다. 블록의 상단에서 도구를 제거하면 첫 번째 도구 정의 (tool-definition) 토큰이 변경되므로, 그 지점에서 매칭이 깨지게 되고 나머지 7개의 정의는 전체 비용을 지불하며 다시 처리됩니다.
동일한 작업 — 도구 하나를 제거하는 것 — 을 수행했습니다. 유일한 차이점은 어느 쪽 끝을 잘라내느냐이며, 이는 캐시의 4분의 3을 유지하느냐 아니면 절반만 유지하느냐의 차이를 만듭니다.
따라서 이 버전에서는 다음과 같이 해야 합니다:
도구 정의 (tool definitions)를 변경 빈도에 따라 정렬하십시오. 안정적인 것을 앞에 두고, 변동이 잦은 것을 마지막에 두십시오. 끝 (tail) 부분에서 도구를 추가하거나 제거하는 것은 비용이 저렴하지만, 앞 (head) 부분에서 동일한 변경을 수행하면 그 뒤에 오는 모든 도구에 대한 비용을 지불해야 합니다.
중요 참고 사항
정확한 수치에 있어 한 가지 변수가 있습니다. 부분적 (partial) 일치 시, 캐시가 "공유된 구간 (shared span), 128 단위로 내림"이 예측하는 것보다 한두 블록을 더 포기하는 경우가 있는데, 제 데이터에 따르면 버전마다 1~6개 블록 정도 차이가 납니다. 반면, 완전히 동일한 요청을 재전송할 경우 캐시는 거의 모든 것을 유지합니다. 따라서 저는 공식이 아닌 패턴을 보고하고 있는 것입니다. 확실한 사실은 다음과 같습니다. 이 장비가 생성한 모든
cached_tokens값(300개 이상) 중, 128-블록 경계에 정확히 일치하도록 코드와 함께 원본 CSV로 제공된 300개의 데이터에서, 전달이 안정적이었던 모든 경우에 대해 순서는 예외 없이 유지되었습니다: 전체 일치 (full match) > 마지막 제거 (drop-last) > 첫 번째 제거 (drop-first). 이 순서가 본문의 모든 결론의 근거입니다. 부분적 경계에서 정확히 몇 개의 블록이 살아남는지는 제가 알기로는 문서화되어 있지 않습니다.
gpt-5.5에 대한 결과
이전과 동일한 스크립트를 사용했습니다. 모델 문자열만 변경했습니다.
keep 1-8 (control) cached = 3,200
keep 1-7 (drop last) cached = 0
keep 2-8 (drop first) cached = 0
대조군 (control)이 적중했으므로 실행 결과는 유효합니다. 즉, 캐싱이 작동하고 있으며, 동일한 재전송은 캐시에서 제공됩니다. 하지만 도구를 변경한 두 가지 변형 모두 0으로 떨어졌습니다. 수치가 감소한 것이 아니라, 세 개 배치(batch) 전체의 모든 유효한 실행에서 **0 (Zero)**이 나왔습니다.
여기서 keep 1-7이 무엇을 의미하는지 보십시오. 해당 요청은 원본과 바이트 단위로 완전히 동일한 약 1.9k-토큰의 시스템 프롬프트 (system prompt)를 공유하며, 오직 마지막 도구(맨 끝의 88개 토큰)를 제거했다는 점만 다릅니다. gpt-5.2에서는 이 변경 사항이 2,560개의 토큰을 유지했습니다. 하지만 gpt-5.5에서는 아무것도 유지하지 못했습니다. 목록의 끝에서 도구 하나를 제거하는 것만으로 전체 요청, 즉 시스템 프롬프트와 모든 것에 대한 캐시가 파괴되었습니다.
gpt-5.5에서 도구(tools) 블록은 사실상 원자적(atomic)이며, 이를 건드리는 것은 프롬프트의 맨 앞부분까지 영향을 미칩니다. MCP 서버에 의해 도구 하나가 추가되거나, MCP 서버가 재연결(도구 정의 업데이트와 함께)되거나, 스킬 필터에 의해 도구 하나가 누락되는 것과 같은 도구 목록의 어떠한 변경도 매 턴마다 모든 것을 다시 처리(reprocesses)하게 만듭니다. 이때 이것이 전체 비용을 들여 다시 처리하고 있다는 것을 알려주는 오류 메시지는 전혀 나타나지 않습니다. 이 버전은 "도구 세트의 어떠한 변경도 접두사(prefix)를 무효화한다"라는 문헌의 포괄적인 규칙이 정확히 들어맞는 버전입니다. 반면 5.1과 5.2에서는 동일한 규칙이 측정 가능한 수준에서 불완전합니다.
모델의 문제였을까, 아니면 제공업체의 문제였을까?
초기에 명확히 해야 할 혼란 변수 중 하나는, 저의 첫 번째 실행은 Azure OpenAI를 대상으로 했고, 이후 실행은 OpenAI API를 직접 대상으로 했다는 점입니다. 그래서 저는 gpt-5.1에 대해 두 제공업체를 대상으로 동일한 테스트를 수행했습니다. 그 결과 네 가지 값은 토큰 단위까지 동일하게 나왔으며, 모든 값이 동일한 매칭 경계(match boundaries)에 걸쳐 있었습니다.
두 가지 주의 사항이 있습니다: 해당 테스트는 단일 실행이었으며, gpt-5.1의 캐시 히트(cache hit) 전달(delivery) 방식이 비결정론적(nondeterministic)이라는 사실을 발견하기 전(다음 섹션 참고)에 수행되었습니다. 따라서 "동일하다"는 부분적으로 운이 따른 결과일 수 있으며, 확실한 주장은 두 제공업체 모두 동일한 경계에서 값을 생성한다는 것입니다. 그리고 제공업체가 영향을 미치지 않음을 확인한 후에는, 통제된 비변수(non-variable) 상태를 유지하기 위해 모든 재현 실험을 한 곳의 제공업체(OpenAI 직접 연결)에서 수행했습니다. 저는 이 연구 전반에서 제공업체에 의존적인 동작을 발견하지 못했으나, 심층적인 재현 실험은 단일 제공업체를 대상으로 이루어졌습니다.
버전에 따른 추세
AI 자동 생성 콘텐츠
본 콘텐츠는 Dev.to AI tag의 원문을 AI가 자동으로 요약·번역·분석한 것입니다. 원 저작권은 원저작자에게 있으며, 정확한 내용은 반드시 원문을 확인해 주세요.
원문 바로가기