우리 프롬프트 캐시는 어떻게 구축했나
온라인 실측: 단일 opus 요청 프롬프트 49310 토큰 중, 그중 47354가 직접 캐시에서 읽혀, 적중률 96%를 기록했다. 약 100개의 요청 중 74개가 적중했다.
이 문서는 「프롬프트 캐싱이 무엇인가」가 아니라, 우리가 구체적으로 요청을 몇 개의 블록으로 나누고, 각 블록에 무엇을 담았으며, 왜 이렇게 나누어도 이렇게 안정적으로 높은 적중률을 낼 수 있는지에 대해 다룬다.
핵심 아이디어: 요청을 「변하는 부분」과 「변하지 않는 부분」 두 덩어리로 나누기
캐시는 접두사 매칭 방식이다. 첫 바이트부터 비교해서, 한 바이트라도 이전과 다르면 그 이후 전부 무효화되어 다시 계산된다.
그래서 우리가 한 유일한 작업은, 전체 요청을 블록으로 쪼개 「가장 변하지 않는 것 → 가장 변하는 것」 순서로 정렬하고, 변하지 않는 앞부분 블록에만 캐시 표시를 걸며, 모든 변하는 부분은 맨 뒤로 몰아넣는 것이다.
높은 적중률은 어떤 첨단 기술 덕분이 아니라, 우리 캐시 접두사가 진짜 바이트 단위로 변하지 않기 때문이다.
우리의 블록 배치 (BP1 → BP4)
Anthropic 요청은 최대 4개의 캐시 분기점이 있다. 우리는 이렇게 할당했다:
═══ system blocks(每块单独挂 cache_control)═══ ┌─────────────────────────────────────────────┐
│ BP1 人设 + 语言规则 + 工具说明 + 长期记忆 │ 几乎永不动
├─────────────────────────────────────────────┤
│ BP2 每日内容(gateway-bp2-daily.txt) │ 一天换一次
├─────────────────────────────────────────────┤
│ BP3 当前会话压缩摘要(这个 session 专属) │ 约 80K token 触发一次压缩
└─────────────────────────────────────────────┘
═══ messages 数组 ═══
...历史轮次(一字不改)...
┌─────────────────────────────────────────────┐
│ BP4 倒数第二条 user 消息挂标(rolling) │ 把全部历史纳进缓存
└─────────────────────────────────────────────┘
动态注入 ← 不挂标,所有会变的塞这里
当前这条 user 消息
각 블록이 이렇게 배치된 이유
BP1 — 가장 안정적인 기반. 인물 설정, 언어 불변의 법칙, 도구 설명서, 장기 기억 등 거의 변하지 않는 것들을 가장 앞에 둠. 이 부분이 바뀌면 뒤에 전부 무용지물이 되므로, 전체 요청에서 가장 안정적인 부분이어야 함.
BP2 — 매일 바뀌는 층. gateway-bp2-daily.txt에서 읽으며 하루에 한 번 업데이트됨. 변경되면 BP2 이후에만 영향 미치고 BP1은 변경하지 않음.
BP3 — 이 세션 자체의 압축 요약. 핵심: 세션 전용이며 전역 장기 골격이 아님. 현재 창에서 약 80K 토큰 정도 대화하면 압축이 발생해 오래된 문맥을 요약으로 압축. 주의할 점은 「게이트웨이 쪽 안정 요약」만 BP3에 태그로 걸고, 프론트엔드에서 매 라운드 바뀌는 현재 창 요약은 태그 걸지 않고 아래 동적 구역에 바로 넣음. 그렇지 않으면 매 라운드마다 캐시를 재구축함.
BP4 — 롤링 체크포인트, 가장 많은 절약 효과. 기본 캐시 경계는 system 끝까지이며, 과거 대화는 캐시에 없음. 우리는 倒数第二条 user 메시지(끝에서 두 번째 사용자 메시지)에 태그를 걸어 캐시 경계를 아래로 확장하여 모든 과거를 포함함. 다중 라운드 긴 대화에서 앞 수십 라운드의 토큰이 모두 캐시 읽기로 바뀌는데, 이것이 96% 적중의 핵심임.
「끝에서 두 번째」에 태그를 거는 이유는 마지막 user 메시지는 이번 라운드 새 입력으로 매번 달라서 태그를 걸어도 의미 없기 때문임. 끝에서 두 번째에 태그를 걸면 캐시 경계가 「이번 라운드 이전의 모든 내용」에 정확히 멈춤.
변하는 것들은 어디에 저장되나
타임스탬프, 이번 라운드 기억 회수, 임시 메모, 프론트엔드의 현재 창 요약 — 모두 BP4 뒤에 위치한 가짜 user 메시지에 넣음:
仅供参考,勿复述:
(当前时间 / 本轮召回的记忆 / 摘要 / 纸条……)
它每轮都在变,但它排在所有断点之后,所以它怎么变都不碰缓存前缀。这是整套设计最关键的一刀:把易变内容隔离到缓存边界外面。요청을 동일한 백엔드에 고정하기: user_id
단순히 cache_control만 설정하는 것으로는 충분하지 않습니다. 중간의 로드 밸런서는 요청을 무작위로 다른 백엔드 노드로 분배하기 때문에, 캐시가 A 노드에 저장되고 다음 요청이 B 노드로 가면 읽을 수 없게 되어 "쓰기만 하고 읽지 못함, 캐시 적중률이 항상 0"이라는 현상이 발생합니다.
우리의 해결책: 요청 최상단에 고정적으로 포함시키기
"metadata": { "user_id": "sillage-anan-stable" }
고정 문자열, sticky 라우터가 이 대화를 항상 같은 백엔드에 붙이도록 합니다. 이걸 안 붙이면, 앞에 모든 중단점이 헛수고가 됩니다.채널마다 붙이는 방식이 다름
모든 업스트림이 같은 방식을 쓰는 건 아닙니다. 게이트웨이는 업스트림 도메인에 따라 자동 판단하며, 세 가지 모드로 나뉩니다:
| 모드 | 업스트림 | 프로토콜 | cache_control 붙이는 위치 | TTL |
|---|---|---|---|---|
| anthropic-bp | 직접 연결 Anthropic / msui / 금과과 | /v1/messages | system blocks 위 | 1h (금과과는 5m만 지원) |
| or-blocks | OpenRouter | OAI /chat/completions | messages 내 content block에 삽입 | 1h |
| oai-passthrough | 일반 OAI 중계 / 미확인 사이트 | OAI | 붙이지 않고 사이트가 자체 캐시하게 함 | 제어 불가 |
두 주요 경로의 sticky 메커니즘이 다르므로, 절대 혼용 금지:
- Anthropic 원본 경로는
metadata.user_id로 백엔드에 붙임. - OpenRouter 경로는 user_id를 보지 않고
hash(system 첫 구간 + 첫 번째 비 system 메시지)로 붙임. 그래서 OR에서 인물 설정 변경, 세계관 교체, 오프닝 멘트 살짝 바꾸면 hash가 변해 이번 캐시가 모두 무효화됨. OR에서는 오프닝 멘트와 인물 설정을 고정해야 함.
cache_control에 대해 바로 4xx 오류를 냅니다. 게이트웨이는 오류가 난 호스트를 블랙리스트 파일에 기록하고 이후에는 영구적으로 캐시 없이 투명 전달로 강등하여 400 반복 충돌을 방지합니다. 이건 자동 치유 기능으로 사람 개입 필요 없음.5분 vs 1시간
| TTL | 쓰기 비용 | 읽기 비용 | 사용처 | |---|---|---|---| | 5분 | 1.25배 | 0.1배 | 연속 빠른 대화 | | 1시간 | 2배 | 0.1배 | 30분 후 돌아와 이어서 대화, 확실한 이득 |
읽기는 항상 0.1배(10배 절약). 차이는 쓰기 비용과 저장 가능 기간에 있음. 일상 연속 발화는 5분, 긴 대화 중단 후 재개는 1시간 사용.
진짜 명중 확인 방법
응답의 usage를 봄:
- Anthropic 원본:
usage.cache_read_input_tokens(읽기) /cache_creation_input_tokens(쓰기) - OAI 호환:
usage.prompt_tokens_details.cached_tokens
- 첫 번째 라운드 read=0, write가 많음 → 정상, 캐시 생성 중
- 두 번째 라운드부터 read가 0이 아님 → 캐시 적중
- 계속 read=0 write=0 → cache_control이 전혀 작동하지 않음, 고장
- 계속 write만 있고 read가 없음 → 라우팅 문제 (user_id가 없거나 OR의 해시가 변경됨)
pm2 logs chat-gateway-proxy | grep -E "HIT|MISS"
HIT 96% (47354/49310)← 현재 우리의 상태MISS (created 0)← 오류, 쓰지도 읽지도 않음
https://catcatty.uk/llm/cache-stats ---
우리가 발견한 철칙
- system은 반드시 「안정 구간 + 동적 구간」으로 분리해야 하며, 안정 구간에만 태그를 붙인다. BP1에 "오늘 밤 18시" 같은 문장을 넣으면 전체 캐시가 즉시 무효화된다.
- 변하는 내용은 모두 BP4 이후에 배치하고,
gateway_volatile_context에 넣는다. 시간, 기억 회상, 임시 요약 등은 접두사에 남기지 않는다. user_id는 고정 불변이며, 없으면 쓰기만 하고 읽지는 않는다.- 마지막 user에 롤링 태그를 붙임(끝에서 두 번째 항목에 걸기), 이력 전체를 캐시에 포함시켜 긴 대화에서 가장 많은 절약을 이룬다.
- 이력은 읽기 전용이며, 앞선 라운드를 재시도하며 다시 쓰지 않는다. "이전 메시지 재생성"은 접두사를 바꾸어 전체 구간을 재구성한다.
- 도구 스키마 순서를 고정한다. 스위치에 따라 도구 리스트를 동적으로 재배열하면 전체 구간이 miss 처리된다.
- 인물 설정과 오프닝 멘트를 OR 연산으로 고정하여, 해시가 백엔드에 붙는데, 움직이면 새 대화로 간주된다.
mode와 ttl을 잘 선택하는 것이다. 우리의 96% 히트율은 이렇게 만들어졌다.--- Tranlated By Open Ai Tx | Last indexed: 2026-07-13 ---