에이전트·RAG

AGENT / 41번째 글

캐시가 듣는 프롬프트와 듣지 않는 프롬프트

프리픽스 캐싱이 실제로 무엇을 저장하고 어떤 조건에서 히트하는지, 캐시를 깨는 흔한 패턴과 손익분기 계산법, 에이전트 루프에서 캐시를 살려 두는 배치 순서를 정리합니다.

PALDYN Team12 MIN READ

지난 글에서 창을 키운다고 다 읽히는 것이 아니라는 얘기를 했다. 그런데 컨텍스트에는 줄일 수 없는 부분도 있다. 시스템 프롬프트와 도구 스키마는 요청마다 토씨 하나 다르지 않게 똑같이 들어간다. 도구를 스무 개 붙인 에이전트라면 이 고정분만 5,000토큰이 넘어가고, 사용자가 한 줄짜리 질문을 던질 때도 그 5,000토큰을 통째로 다시 계산한 값을 낸다. 프리픽스 캐싱은 정확히 이 반복을 없애는 장치이고, 잘 붙이면 비용이 절반 아래로 떨어지고 첫 응답까지의 시간도 같이 줄어든다. 다만 붙였다고 저절로 듣지는 않는다.

캐시가 저장하는 것은 텍스트가 아니다

모델이 답을 만들 때는 두 단계를 거친다. 입력 전체를 한 번에 읽어 각 토큰의 키·값 벡터를 계산하는 프리필 단계, 그리고 그 값을 참조하며 출력을 한 토큰씩 뽑는 디코드 단계다. 입력이 길어질 때 무거워지는 쪽은 프리필이다.

프리픽스 캐싱은 이 프리필 결과, 즉 앞쪽 토큰들의 키·값 벡터를 저장해 두었다가 다음 요청에서 재사용한다. 그래서 캐시는 "이 문장을 본 적 있다"가 아니라 "이 토큰 열까지의 계산 상태를 들고 있다"에 가깝다. 여기서 두 가지 성질이 따라 나온다.

첫째, 앞에서부터만 재사용된다. 계산 상태는 그 앞의 모든 토큰에 의존하므로, 중간이 바뀌면 그 뒤는 전부 쓸모없어진다. 문서 세 개를 넣었는데 두 번째만 갈아 끼워도 세 번째의 캐시는 무효다.

둘째, 비교는 토큰 단위로 정확히 일치해야 한다. 공백 하나, 줄바꿈 하나가 다르면 토큰 열이 달라져 그 자리에서 끊긴다.

프리픽스 캐시가 갈라지는 지점

캐시를 깨는 것들은 대개 사소해 보인다

실제 시스템에서 캐시 히트율이 낮을 때 원인은 거의 정해져 있다. 아래 표의 왼쪽은 전부 "이 정도는 괜찮겠지" 싶은 한 줄이다.

캐시를 깨는 것 왜 깨지는가 고치는 법
시스템 프롬프트의 현재 시각 요청마다 달라지는 토큰이 맨 앞에 있다 마지막 사용자 메시지 쪽으로 옮긴다
사용자 이름·조직 ID를 앞에 삽입 사용자마다 프리픽스가 갈린다 공통 지시를 앞, 개인화를 뒤로
도구 목록을 딕셔너리 순회로 생성 순서가 실행마다 흔들린다 이름순으로 정렬해 고정한다
JSON 직렬화 옵션이 호출부마다 다름 공백·키 순서가 달라진다 직렬화 함수를 한 곳으로 모은다
검색 문서를 시스템 프롬프트에 합침 요청마다 바뀌는 것이 앞에 온다 문서는 항상 이력 뒤에 둔다
이력이 넘칠 때 앞부터 잘라냄 자를 때마다 프리픽스가 새로 바뀐다 뒤에서 붙이고 앞은 되도록 고정

마지막 줄은 뒤에서 다시 본다. 앞의 다섯은 모두 바뀌는 것을 앞에 두었다는 한 가지 실수의 변형이다.

캐시에는 유효 시간도 붙는다. 제공자마다 다르지만 수 분 단위인 경우가 많고, 같은 프리픽스를 다시 쓰면 시간이 연장되는 방식이 흔하다. 그래서 사용자가 드문드문 오는 서비스는 히트율이 구조적으로 낮다. 이럴 때는 트래픽이 몰리는 경로부터 캐시를 붙이는 편이 낫다.

최소 길이 조건도 있다. 너무 짧은 프리픽스는 캐시 관리 비용이 이득보다 커서 아예 캐시되지 않는다. 고정분이 수백 토큰밖에 안 되는 단순 분류 작업이라면 캐싱을 켜도 아무 일이 안 일어난다.

몇 번 재사용해야 이득인가

캐시는 공짜가 아니다. 대개 캐시에 쓰는 첫 요청은 정가보다 비싸고, 이후 읽는 요청은 크게 싸다. 정가를 1로 두고 쓰기 배수를 rwr_w, 읽기 배수를 rrr_r이라 하면 같은 프리픽스를 NN번 쓸 때의 총비용은 이렇게 된다.

Ccache(N)=rw+rr(N−1),Cplain(N)=NC_{\text{cache}}(N) = r_w + r_r (N - 1), \qquad C_{\text{plain}}(N) = N

두 값이 같아지는 지점이 손익분기다.

N∗=rw−11−rrN^{*} = \frac{r_w - 1}{1 - r_r}

쓰기가 정가의 1.25배, 읽기가 정가의 0.1배인 흔한 조건을 넣으면 N∗≈0.28N^{*} \approx 0.28이 나온다. 두 번째 요청부터 이미 이득이라는 뜻이다. 반대로 읽기 할인이 크지 않은 조건이라면 손익분기가 몇 번씩 올라가고, 그때는 재사용이 확실한 경로에만 붙여야 한다.

여기서 놓치기 쉬운 것은 분모가 아니라 분자에서 손해가 난다는 점이다. 한 번 쓰고 다시 안 오는 프리픽스에 캐시를 켜면 매번 rwr_w를 낸다. 프리픽스가 사용자마다 갈리는 구조라면 캐싱을 켠 쪽이 오히려 비싸질 수 있다.

배치 순서를 코드로 고정한다

규칙이 하나뿐이니 코드도 단순하다. 메시지를 만드는 자리를 한 함수로 모으고, 그 안에서 순서를 못 박는다.

def build_messages(user_input, history, docs, now):
    system = [
        {"role": "system", "content": STATIC_INSTRUCTIONS, "cache": True},
        {"role": "system", "content": render_tools(sorted(TOOLS)), "cache": True},
    ]

    # 바뀌는 것은 전부 이 아래로. 시각도 예외가 아니다.
    tail = list(history)
    if docs:
        tail.append({"role": "user", "content": render_docs(docs)})
    tail.append({"role": "user", "content": f"[{now}]\n{user_input}"})

    return system + tail

sorted(TOOLS)가 한 줄짜리 방어다. 도구를 딕셔너리나 집합에서 꺼내 쓰는 코드는 실행마다 순서가 달라질 수 있고, 그러면 스키마 블록의 첫 글자부터 프리픽스가 갈린다. 렌더링 함수를 한 곳에 두는 것도 같은 이유다 — 호출부마다 json.dumps의 옵션이 조금씩 다르면 눈에 안 보이는 공백 차이로 캐시가 안 듣는다.

에이전트 루프에서 생기는 역설

에이전트는 한 과제를 푸는 동안 같은 대화에 도구 호출과 결과를 계속 덧붙인다. 이 모양은 캐시에 아주 유리하다. 앞부분이 그대로 남고 뒤에만 붙으므로, 매 턴 직전 턴까지가 통째로 히트한다.

문제는 이력이 창을 넘칠 때 생긴다. 오래된 턴을 앞에서부터 잘라내는 것이 가장 자연스러운 대응인데, 이렇게 하면 자를 때마다 프리픽스의 시작점이 바뀌어 그 시점 이후 전부가 미스가 된다. 컨텍스트를 아끼려던 조치가 비용을 올리는 셈이다.

이 충돌을 줄이는 방법이 몇 가지 있다.

자르는 빈도를 낮춘다. 한 턴 넘칠 때마다 조금씩 자르는 대신, 임계에 닿으면 넉넉히 한 번에 잘라 다음 무효화까지의 간격을 벌린다. 열 턴마다 한 번 깨지는 편이 매 턴 깨지는 것보다 낫다.

자른 자리를 요약으로 고정한다. 잘라낸 구간을 요약 한 덩어리로 바꿔 시스템 블록 바로 뒤에 두면, 그 요약이 갱신되기 전까지는 다시 안정적인 프리픽스가 된다.

긴 도구 결과는 처음부터 짧게 받는다. 애초에 넘치지 않으면 자를 일도 없다. 이 방향이 가장 확실하고, 다음 글의 주제이기도 하다.

켰다고 믿지 말고 세어 본다

캐싱은 켜 두고도 안 듣는 일이 흔한데, 결과가 정상적으로 나오기 때문에 아무도 모른 채 몇 달이 지나가기 쉽다. 응답 메타데이터에 캐시 읽기·쓰기 토큰 수가 실려 오므로 이것을 로그로 남기면 된다.

usage = response.usage
log.info(
    "cache",
    read=usage.cache_read_input_tokens,
    write=usage.cache_creation_input_tokens,
    fresh=usage.input_tokens,
    route=route_name,
)

보는 값은 하나다 — 전체 입력 토큰 중 캐시에서 읽힌 비율이다.

H=TreadTread+Twrite+TfreshH = \frac{T_{\text{read}}}{T_{\text{read}} + T_{\text{write}} + T_{\text{fresh}}}

경로별로 이 값을 찍어 두면 어디가 새는지 바로 보인다. 고정분이 큰 에이전트 경로인데 HH가 0.2 언저리에 머물면 십중팔구 위 표의 어느 한 줄에 걸려 있다. 반대로 값이 0.8을 넘는 경로는 이미 잘 붙은 것이므로, 거기서 더 짜내기보다 다른 항목을 손보는 편이 이득이 크다.

측정 없이 캐싱을 논하면 대개 프롬프트 순서를 두고 취향 다툼이 된다. 히트율 하나만 로그에 있으면 그 논쟁이 사실 확인으로 바뀐다.


읽어주셔서 감사합니다. 😊

LATEST

에이전트·RAG의 최신 글

에이전트·RAG2026.08.23

에이전트는 어디서 어긋나기 시작하는가

에이전트가 이상한 답을 낼 때 증상은 마지막에 보이지만 어긋난 자리는 그보다 앞입니다. 한 걸음이 무너지는 여섯 자리를 나누고, 증상에서 원인을 거슬러 찾는 방법과 자리별 처방을 정리합니다.

11 MIN
에이전트·RAG2026.08.23

에이전트가 무엇을 했는지 나중에 알 수 있게 만들기

에이전트는 같은 입력에도 다른 경로로 갑니다. 그래서 로그 몇 줄로는 왜 그렇게 됐는지 복원이 안 됩니다. 트레이스를 어떻게 나누고 구간마다 무엇을 붙이며 어떤 지표를 볼지 정리합니다.

11 MIN
에이전트·RAG2026.08.23

에이전트 비용은 걸음 수보다 빨리 늘어난다

걸음이 두 배면 비용은 두 배가 아니라 서너 배입니다. 왜 그렇게 되는지, 어디서 새는지, 상한을 몇 겹으로 어떻게 거는지와 실제로 효과가 큰 순서대로의 대응을 정리합니다.

13 MIN