매번 다시 읽히지 않게 — 프롬프트 캐싱으로 재전송 비용 줄이기
2026-04-14
3편에서 불어나는 대화를 요약해 접어 크기를 줄였습니다. 그런데 매 스텝 다시 보내는 것 중, 요약으로도 안 줄어드는 게 있어요 — 안 변하는 접두부입니다. 시스템 프롬프트, 그리고 특히 도구 선언이요.
1편에서 봤듯 모델은 상태가 없어서, 매 요청 도구 선언 전체(이름·설명·JSON 스키마)를 다시 받아야 합니다. 그런데 이 도구 선언은 스텝마다 한 글자도 안 변해요. 똑같은 걸 매번 다시 보내고, 매번 **다시 처리(입력 토큰 과금)**됩니다. 도구가 몇 개만 돼도 스키마 JSON은 꽤 크고, 10스텝짜리 작업이면 같은 도구 선언을 10번 재과금하는 셈이죠.
규칙 — 안 변하는 접두부를 매 스텝 재과금하지 마라. 캐시 breakpoint를 붙여 캐시에서 읽어라.
이번 편에 프롬프트 캐싱으로 이걸 줄입니다. raw HTTP라, SDK가 감추던 cache_control을 손으로 붙여 볼 수 있어요.
💻 코드는 github.com/kahnco/agent-from-scratch에 이어집니다. 응답 usage로 캐시가 진짜 히트했는지, 그리고 캐시가 요구하는 숨은 전제까지 검증합니다.
cache_control 을 붙인다
프롬프트 캐싱의 뼈대는 단순합니다. 요청의 어느 지점에 breakpoint를 찍으면, 거기까지의 접두부가 캐시에 올라가요. 다음 요청에서 그 접두부가 똑같으면, 재처리 대신 캐시에서 훨씬 싸게 읽힙니다. breakpoint는 cache_control 한 조각이에요.
// type 은 "ephemeral"(기본 5분 TTL). 이게 붙은 블록까지의 접두부가 캐시된다.
type CacheControl struct {
Type string `json:"type"`
}
type Tool struct {
Name string `json:"name"`
Description string `json:"description"`
InputSchema json.RawMessage `json:"input_schema"`
CacheControl *CacheControl `json:"cache_control,omitempty"` // 여기 찍으면 캐시 경계
}
우리 에이전트에서 제일 캐시하기 좋은 건 도구 선언입니다. 매 스텝 동일하고(1편부터 안 바뀌었죠), JSON 스키마라 덩치도 있으니까요. 그래서 도구 목록의 마지막 도구에 breakpoint를 찍습니다 — 그러면 도구 선언 전체가 캐시돼요.
// 캐싱을 켜면 도구 선언의 마지막에 캐시 breakpoint 를 둔다.
// 도구는 매 스텝 같으니 다음 요청부터 캐시 히트.
if c.CacheControl && len(tools) > 0 {
tools[len(tools)-1].CacheControl = &CacheControl{Type: "ephemeral"}
}
정말 히트하는지는 응답이 알려 줍니다. 응답의 usage에 캐시 회계가 오거든요.
type Usage struct {
InputTokens int `json:"input_tokens"`
CacheCreationInputTokens int `json:"cache_creation_input_tokens"` // 처음 캐시에 올릴 때
CacheReadInputTokens int `json:"cache_read_input_tokens"` // 캐시에서 읽었을 때(싸다)
}
첫 요청엔 cache_creation_input_tokens가 잡히고(캐시에 쓰느라 살짝 더 비쌉니다), 그 다음부터는 cache_read_input_tokens가 잡혀요 — 캐시 읽기는 원래 입력 토큰의 극히 일부 값으로 과금됩니다. "캐싱을 켜면 cache_control이 실제로 실려 나가고, 응답의 캐시 읽기 토큰이 파싱되는지"를 mock으로 테스트했습니다.
규칙 회수 — 안 변하는 도구 선언에 캐시 breakpoint를 찍으면, 다음 요청부터 그 접두부가 캐시에서 싸게 읽힌다. 히트 여부는 응답 usage 로 확인한다.
숨어 있던 전제 — 접두부가 매번 같아야 한다
여기서 이 편의 진짜 배움이 나옵니다. 캐시는 접두부가 바이트까지 똑같을 때만 히트해요. 한 글자라도 다르면 새 캐시를 만들죠. 그런데 우리 코드엔, 이 전제를 조용히 깨는 버그가 있었습니다.
2편부터 도구를 map에 담아 왔는데, 도구 선언 목록을 이렇게 만들었어요.
func (a *Agent) toolDecls() []Tool {
decls := make([]Tool, 0, len(a.Tools))
for name, t := range a.Tools { // ← map 순회
decls = append(decls, Tool{Name: name, ...})
}
return decls
}
Go에서 map 순회 순서는 랜덤입니다. 일부러 그렇게 설계됐어요(순서에 의존하는 코드를 막으려고). 그래서 이 함수는 매 호출 도구를 다른 순서로 내놓습니다 — 어떤 요청엔 [calculator, word_count], 다음 요청엔 [word_count, calculator]로요. 직렬화된 JSON이 매번 달라지니, 캐시 접두부가 매 요청 바뀌고, 캐시는 절대 히트하지 못합니다. 캐싱을 켜 놓고도 매번 새 캐시만 만들다 끝나죠(오히려 캐시 쓰기 비용만 더 나옵니다).
해법은 순서를 결정적으로 고정하는 것 — 이름순 정렬입니다.
// map 순회는 순서가 랜덤이라, 정렬하지 않으면 요청 접두부가 매번 달라져
// 캐시가 절대 히트하지 못한다. 캐싱이 요구하는, 눈에 안 보이던 전제다.
names := make([]string, 0, len(a.Tools))
for name := range a.Tools {
names = append(names, name)
}
sort.Strings(names)
// names 순서대로 Tool 을 만든다 → 매 요청 같은 순서 → 접두부 안정 → 캐시 히트
이건 캐싱을 안 켰을 땐 보이지도 않던 버그입니다 — 순서가 랜덤이어도 결과는 같았으니까요. 캐싱이 "접두부 안정성"을 요구하는 순간에야 드러나죠. "도구 선언이 매 호출 같은 순서로 나오는지"를 테스트로 못 박았습니다(30번 뽑아 순서가 안 흔들리는지 + 이름순인지).
규칙 회수 — 캐시는 접두부가 바이트까지 같아야 히트한다. map 순회처럼 순서가 흔들리는 건 캐시를 조용히 무력화하니, 결정적 순서로 고정하라.
정직하게 — 캐싱은 공짜도, 무조건도 아니다
프롬프트 캐싱은 강력하지만, 아무 데나 켠다고 이득이 아닙니다.
- 캐시 계층엔 순서가 있다 — tools → system → messages. 캐시는 이 순서로 이어진 접두부를 캐시하고, breakpoint까지만 캐시합니다. 우리는 도구 선언에만 breakpoint를 찍었는데, 그건 안전한 선택이에요 — 4편에서 시스템 프롬프트에 메모리 키를 주입했더니 시스템이 자주 바뀌게 됐거든요. 도구(맨 앞·안정)만 캐시하면 그 뒤의 불안정한 시스템은 캐시에 안 걸려 무해합니다. 만약 시스템까지 캐시하고 싶었다면, 메모리 주입이 캐시를 깨뜨렸을 거예요. 캐싱과 4편의 메모리 주입은 긴장 관계입니다.
- 요약도 캐시를 깬다. 3편의 compaction은 대화 앞부분을 통째로 새 요약으로 갈아 끼웁니다. 대화(messages)까지 캐시하고 싶다면, 접는 순간 그 캐시가 무효화된다는 걸 알아야 해요. 그래서 접기와 캐싱은 서로를 방해할 수 있고, breakpoint를 "요약보다 앞"이 아니라 "안정된 최근 구간"에 두는 식의 설계가 필요합니다.
- 캐시엔 최소 크기와 수명이 있다. 접두부가 일정 토큰 수 이상이어야 캐시되고(작은 프롬프트는 캐싱해도 소용없어요), 기본 TTL은 5분이라 그 안에 다음 요청이 와야 히트합니다(더 긴 TTL 옵션도 있습니다). 에이전트 루프는 스텝이 초 단위로 이어지니 이 5분 창에 잘 맞아떨어지죠 — 캐싱이 에이전트에 특히 잘 듣는 이유입니다.
- 진짜 큰 이득은 대화 캐싱에 있다 — 하지만 조심스럽다. 도구 선언 캐싱은 안전한 첫걸음이고, 더 큰 절약은 자라는 대화 자체를 캐싱하는 데 있습니다. 다만 그건 breakpoint를 매 스텝 "직전까지의 안정 지점"으로 옮기는 sliding 설계가 필요하고(그리고 breakpoint는 요청당 최대 4개), 위의 요약·주입과의 긴장을 다 고려해야 해요. 그래서 이번 편은 가장 안전하고 확실한 도구 선언 캐싱만 코드로 넣고, 대화 캐싱은 개념으로 남깁니다.
이 넷을 알고 켜면, 캐싱은 긴 에이전트 작업의 입력 비용을 크게 깎는 지렛대가 됩니다. 특히 도구가 많고 스텝이 길수록요.
정리 — 접두부를 캐시에서 읽다
- 문제: 안 변하는 도구 선언·시스템을 매 스텝 재전송하고 매번 재과금한다.
- 캐싱:
cache_controlbreakpoint를 도구 선언에 찍으면, 다음 요청부터 그 접두부가 캐시에서 싸게 읽힌다. 히트는 응답 usage 의cache_read_input_tokens로 확인. - 숨은 전제: 캐시는 접두부가 바이트까지 같아야 히트한다. map 순회의 랜덤 순서가 캐시를 조용히 깨뜨렸고, 이름순 정렬로 고쳤다.
- 정직하게: 캐시 계층은 tools→system→messages 순. 4편 메모리 주입은 system을, 3편 요약은 messages를 흔들어 캐시와 긴장한다. 최소 크기·5분 TTL·breakpoint 4개 제한이 있고, 큰 이득인 대화 캐싱은 더 조심스럽다.
이번 편으로 시리즈가 다룬 축이 하나 더 늘었습니다 — 원리(12편), 오래 버티기(34편), 자리 잡기(5편), 그리고 비용(6편). 여기까지면 "프레임워크 없이 짠 에이전트"가 배움을 넘어 실무 감각까지 담게 됐어요.
핵심 한 줄 — 안 변하는 접두부는 캐시에서 읽어라. 단, 캐시는 접두부가 바이트까지 같아야 히트하니, map 순회처럼 순서가 흔들리는 것부터 잡아라 — 캐싱을 켜기 전엔 보이지도 않던 버그다.