프롬프트 캐시 설계와 운영 가이드¶
이 문서는 mlxcel의 프롬프트 프리픽스 KV 캐시를 설명합니다. 도입 동기, 아키텍처, 캐시 키의 의미, 설정, 관측, 문제 해결, 현재 구현의 제한 사항을 다룹니다.
동기¶
대규모 언어 모델 추론은 두 단계로 진행됩니다. 프리필은 모든 프롬프트 토큰의 KV 활성값을 계산하는 단계이고 디코드는 스텝마다 토큰을 하나씩 샘플링하는 단계입니다. 프리필은 연산이 병목인(compute-bound) 단계라서 비용이 프롬프트 길이에 비례해 늘어납니다. 시스템 프롬프트, 도구 스키마, 대화 기록처럼 많은 요청이 공통 프리픽스를 공유하는 워크로드에서는 서버가 매 요청마다 같은 KV 텐서를 다시 계산하고 있었습니다.
프롬프트 프리픽스 캐시는 완료된 시퀀스의 KV 캐시를 저장해 두었다가 같은 프리픽스로 시작하는 이후 요청에 재사용하는 방식으로 이 문제를 해결합니다. 일치하는 항목을 찾으면 새 시퀀스가 캐시된 텐서를 채택하고 첫 번째 불일치 토큰부터만 프리필을 시작하므로 공통 프리픽스를 다시 처리하는 연산을 아낄 수 있습니다.
아키텍처 개요¶
flowchart TD
HTTP["HTTP 요청"] --> RL["라우트 레이어<br/>(chat_req)"]
RL -->|"PromptCacheRequestContext"| ENQ["프리필 큐에 추가"]
RL -->|"template_sig, session_key, mm_digest"| STORE["PromptCacheStore (Arc<RwLock<Inner>>)<br/>HashMap<digest, entry> + 버킷별 RadixTrie"]
ENQ -->|"키 구성"| ADOPT
subgraph BS["BatchScheduler"]
direction TB
ADOPT["try_adopt_cached_prefix()<br/>1. 컨텍스트 + 토큰으로 PromptCacheKey 구성<br/>2. 스토어에서 lookup_longest_prefix() 조회<br/>3. take_detached() → DetachedCacheSet<br/>4. CachePool::adopt() → 새 SequenceId<br/>(KV 텐서 설치, matched_len 이후부터 프리필)"]
RUN["나머지 토큰 프리필 → 디코드 루프"]
DONATE["donate_finished_sequence_cache()<br/>1. CachePool::detach() → DetachedCacheSet<br/>2. CacheEntry(tokens, detached, bytes) 생성<br/>3. store.insert(key, entry)"]
ADOPT --> RUN --> DONATE
end
STORE -->|"lookup_longest_prefix()"| ADOPT
DONATE -->|"기증"| STORE 주요 컴포넌트¶
| 컴포넌트 | 위치 | 역할 |
|---|---|---|
PromptCacheStore | src/server/prompt_cache/store.rs | 스레드 안전 LRU 스토어. BLAKE3 다이제스트를 키로 Arc<CacheEntry> 값을 소유 |
RadixTrie | src/server/prompt_cache/trie.rs | O(L) 최장 프리픽스 매칭을 위한 버킷별 토큰 인덱스 트라이 |
lookup.rs | src/server/prompt_cache/lookup.rs | 2단계(세션 내 일치 + 세션 간) 후보 선택 |
PromptCacheKey / key.rs | src/server/prompt_cache/key.rs | 캐시 키 구성과 BLAKE3 다이제스트 계산 |
DetachedCacheSet | src/lib/mlxcel-core/src/cache/detach.rs | 시퀀스 사이를 오가는 비활성 레이어별 KV 텐서 스냅샷 |
BatchScheduler | src/server/batch/scheduler.rs | 요청 경계에서 채택/기증 호출 |
캐시 키 구성¶
캐시 항목은 일곱 가지 차원을 해시한 32바이트 BLAKE3 다이제스트로 식별합니다. 어느 한 차원이라도 다르면 다이제스트가 달라지고 콜드 프리필이 발생합니다.
구성 차원¶
digest = BLAKE3(
"mlxcel:prompt-cache:v3" ← 도메인 구분자
model_id ← 길이 접두사가 붙은 UTF-8
lora_id ← 길이 접두사가 붙은 UTF-8 (None이면 빈 값)
template_sig ← 길이 접두사가 붙은 UTF-8
session_key ← 길이 접두사가 붙은 UTF-8
rope_regime ← 존재 바이트 + 태그 바이트
mm_digest ← 원시 32바이트 (MultimodalDigest)
prefix_len ← u64 리틀 엔디언
tokens[..prefix_len] ← 각 토큰을 i32 리틀 엔디언으로
)
model_id¶
AppState::display_model_id가 반환하는 표시용 모델 식별자입니다. HuggingFace 방식의 모델 디렉터리에서는 경로의 마지막 구성 요소입니다. 이 값이 있어 서로 다른 모델에서 만든 캐시가 서로 섞이지 않습니다.
lora_id¶
기본 모델이면 빈 값입니다. LoRA 어댑터를 unfused로 서빙할 때는 유효 어댑터 scale의 digest이므로, POST /lora-adapters로 scale을 바꾼 뒤에도 다른 scale에서 계산한 KV를 재사용하지 않습니다. 어댑터 없이 생성한 캐시 항목은 LoRA 어댑터를 쓰는 요청이 채택할 수 없습니다.
rope_regime¶
프롬프트 길이에 따라 RoPE 주파수 테이블을 고르는 모델(Phi-3와 Phi-4 LongRoPE는 original_max_position_embeddings까지 short_factor, 그 위에서 long_factor를 사용)을 위한 1바이트 태그입니다. 요청의 전체 프롬프트 길이에서 구한 태그가 같은 요청끼리만 KV를 공유합니다. 회전이 시퀀스 길이에 따라 달라지지 않는 모델에는 태그가 없습니다.
template_sig¶
렌더링 파이프라인 입력을 해시한 64자 소문자 16진수 BLAKE3 다이제스트입니다. 입력은 다음과 같습니다.
chat_template_source: 원본 Jinja 템플릿 문자열. 공백 하나만 바뀌어도 캐시가 무효화됩니다.chat_template_kwargs: 요청별 kwargs와 서버 기본 kwargs를 병합한 값. 키를 정렬해 정규화하므로 맵 순서가 바뀌어도 불필요한 미스가 생기지 않습니다.tool_choice: 실제로 적용되는 모드 문자열("auto", "none", "required", "specific"일 때는 해당 함수 이름).tools_digest:tools배열을 순서 그대로 해시한 BLAKE3 다이제스트. 각 도구의 type, 함수 이름, 설명, 매개변수를 포함합니다.
함의: 도구가 없던 시스템 프롬프트에 도구 스키마를 추가하면 template_sig가 바뀌어 캐시 미스가 발생합니다. 도구가 있으면 토큰 스트림 자체가 달라지므로 이는 올바른 동작입니다.
session_key¶
요청의 prompt_cache_key, user, 익명 센티널 __mlxcel_anon__ 중에서 결정됩니다. 우선순위는 다음과 같습니다.
extra_body의prompt_cache_key(비어 있지 않은 첫 값을 사용)- 요청의
user필드 __mlxcel_anon__(공유 폴백 버킷)
조회 알고리즘은 2단계로 동작합니다. 먼저 session_key가 정확히 일치하는 범위에서 최선의 일치를 찾고 그다음 같은 (model, lora, template) 버킷의 세션 간 후보로 폴백합니다. 세션 간 결과는 세션 내 최선의 결과보다 엄밀히 더 길 때만 반환됩니다.
mm_digest¶
요청에 포함된 모든 이미지와 오디오 바이트를 메시지 도착 순서대로 해시한 32바이트 BLAKE3 다이제스트입니다. 텍스트 전용 요청에서는 BLAKE3("mlxcel:prompt-cache:multimodal:v1" || 0u64 || 0u64) 값을 사용합니다. 어떤 실제 페이로드와도 겹치지 않는 고정 상수입니다.
서로 다른 URL이 같은 이미지 바이트를 가리키면 두 요청의 mm_digest는 같습니다(다이제스트는 디코딩이 끝난 바이트를 기준으로 계산합니다). 이미지 두 장의 순서를 바꾸면 다이제스트가 달라집니다.
토큰 프리픽스¶
채팅 템플릿 렌더링 후 완전히 토큰화한 프롬프트의 앞쪽 prefix_len개 토큰 ID입니다. 다이제스트는 정확히 이 토큰들만 포함합니다. 스토어가 최장 프리픽스 매칭을 수행하므로 길이 N으로 저장된 항목은 같은 N개 토큰으로 시작하는 길이 N 이상의 요청에 재사용할 수 있습니다.
프리픽스 안정성 보장¶
캐시 재사용이 안전하려면 같은 프리픽스를 공유하는 요청 사이에서 토큰 스트림이 결정적으로 유지되어야 합니다.
preserve_thinking = true 기본값¶
프롬프트 캐시가 켜져 있으면 멀티턴에 걸쳐 사고(thinking) 내용을 유지할 수 있는 모델(Qwen3.6과 그 파생 모델)에서 preserve_thinking이 기본적으로 true가 됩니다. 이 기본값이 없으면 채팅 템플릿이 턴마다 다르게 렌더링됩니다. 두 번째 턴의 토큰 스트림이 첫 번째 턴과 어긋나 저장된 프리픽스를 쓸 수 없게 됩니다. 운영자는 chat_template_kwargs로 요청마다 이 값을 재정의할 수 있습니다.
template_sig 무효화¶
채팅 템플릿 문자열, 키워드 인자, 도구 스키마 중 무엇이든 바뀌면 template_sig 다이제스트도 달라집니다. 이전 항목은 스토어에 남아 있지만 새 다이제스트와는 더 이상 일치하지 않으며 LRU나 TTL 축출로 결국 사라집니다.
도구 호출 시 주의 사항¶
도구 응답에는 API 호출 결과나 모델이 생성한 어시스턴트 텍스트처럼 비결정적인 내용이 들어가므로 토큰 스트림이 매번 달라집니다. 도구 호출 결과가 나오기 전의 프리픽스만 일치할 가능성이 높습니다. 도구를 많이 쓰는 워크로드에서 캐시를 켜면 시스템 프롬프트만 쓰는 시나리오보다 히트율이 낮게 나올 수 있습니다.
설정¶
모든 설정값은 CLI 플래그와 환경 변수로 지정할 수 있습니다. 둘 다 지정하면 CLI 플래그가 우선합니다.
플래그와 환경 변수¶
| CLI 플래그 | 환경 변수 | 기본값 | 설명 |
|---|---|---|---|
--prompt-cache-enabled[=BOOL] | MLXCEL_PROMPT_CACHE_ENABLED | true | 기능 활성화 여부. CLI 플래그가 없으면 LLAMA_ARG_CACHE_REUSE(llama.cpp 호환 별칭)도 읽습니다. |
--prompt-cache-capacity-bytes BYTES | MLXCEL_PROMPT_CACHE_CAPACITY_BYTES | 모델 기반, 최소 2147483648 (2 GiB) | KV 저장소(dense-KV 계열)에 캐시된 모든 항목에 적용되는 총 바이트 예산. 모델 기반 용량 기본값 참조. |
--prompt-cache-max-entries N | MLXCEL_PROMPT_CACHE_MAX_ENTRIES | 1024 | 살아 있는 캐시 항목 수의 상한. |
--prompt-cache-ttl SECONDS | MLXCEL_PROMPT_CACHE_TTL | 3600 | 마지막 조회 성공 시점부터 적용되는 초 단위 수명(TTL). |
--prompt-cache-min-prefix N | MLXCEL_PROMPT_CACHE_MIN_PREFIX | 32 | 항목이 캐시 대상이 되기 위한 최소 프리픽스 길이(토큰 단위). |
--apc-enabled[=BOOL] | APC_ENABLED | true | 자동 프리픽스 캐싱(블록 단위 해시 체인) 활성화. --prompt-cache-enabled가 켜져 있어야 합니다. 하이브리드 SSM/어텐션 모델에서는 자동으로 비활성화됩니다. |
--apc-block-size N | APC_BLOCK_SIZE | 16 | APC 블록당 토큰 수. 값을 줄이면 재사용 단위가 잘아지지만 블록별 해싱 오버헤드는 늘어납니다. |
--apc-num-blocks N | APC_NUM_BLOCKS | (자동 결정) | 추적할 APC 블록 항목 수의 상한. 기본값은 --prompt-cache-max-entries를 기반으로 한 휴리스틱입니다. |
--apc-hash ALGO | APC_HASH | sha256 | 블록 해시 알고리즘. sha256(기본값, 와이어 호환용) 또는 blake3(더 빠르지만 와이어 비호환). |
--prompt-cache-snapshot-capacity-bytes BYTES | MLXCEL_PROMPT_CACHE_SNAPSHOT_CAPACITY_BYTES | 모델 기반, 최소 536870912 (512 MiB) | 세션 스냅샷 저장소의 바이트 예산. 프리픽스 캐시와 별도로 계산합니다. 모델 기반 용량 기본값 참조. |
--prompt-cache-snapshot-max-entries N | MLXCEL_PROMPT_CACHE_SNAPSHOT_MAX_ENTRIES | 4096 | 살아 있는 세션 스냅샷 수의 상한. |
--prompt-cache-snapshot-ttl SECONDS | MLXCEL_PROMPT_CACHE_SNAPSHOT_TTL | 7200 | 세션 스냅샷의 TTL. |
CLI 플래그가 없을 때는 MLXCEL_PROMPT_CACHE_ENABLED를 LLAMA_ARG_CACHE_REUSE 별칭보다 먼저 확인합니다. 덕분에 llama.cpp 호환 환경 변수를 이미 쓰는 배포 환경에서도 코드 수정 없이 이 기능을 제어할 수 있습니다.
모델 기반 용량 기본값¶
환경 변수와 CLI 플래그 어느 쪽으로도 저장소의 바이트 예산을 정하지 않으면, 시작 시 로드한 모델의 config.json으로 예산을 정합니다. 명시한 값은 절대 바꾸지 않습니다. 이렇게 정한 값은 저장소가 담을 수 있는 상한일 뿐 미리 할당하는 양이 아니어서, 상주 메모리는 항목이 실제로 들어올 때만 늘어납니다.
스냅샷 저장소(--prompt-cache-snapshot-capacity-bytes)는 스냅샷 전용 계열 전부에서 올라갑니다. 재귀 상태 계열(Qwen 3.5/3.8, Jamba, Falcon-H1, LFM2, Bailing linear, Nemotron-H, Mamba, Granite 4 hybrid, PLaMo 2)과 어텐션 캐시 계열(Gemma 3, Gemma 4, Gemma 4 unified, Llama 4, AFMoE, Muse Glimmer, Inkling)이 여기에 속합니다. 기본값은 max(536870912, min(6 * entry, left / 4))이고, 항목 하나가 left / 2 안에 들어가면 최소 entry 하나만큼은 확보합니다. entry는 대표 길이 min(--ctx-size, 8192) 토큰(--ctx-size를 지정하지 않으면 8192)에서의 스냅샷 크기이고, left는 메모리 추정기가 계산한 모델 자체 사용량을 뺀 나머지 메모리입니다. 슬라이딩 윈도우 계열(Gemma 3, Gemma 4)은 히스토리 경계 스냅샷을 슬라이딩 레이어가 순환하기 전에 뜨기 때문에 모든 레이어를 전체 길이로 잡아 계산합니다. 최상위와 text_config의 model_type을 모두 확인하고 끝의 _text는 무시하므로, VLM 형태로 묶인 체크포인트도 텍스트 모델과 같은 기본값을 받습니다.
KV 저장소(--prompt-cache-capacity-bytes)도 dense-KV 계열에서 같은 방식을 씁니다. 같은 대표 길이에서 윈도우 상한을 적용한 항목 여섯 개를 기준으로 하고, 상한과 항목 하나 하한도 같으며, 2 GiB 아래로는 내려가지 않습니다. Llama 3.1 8B에서는 기본값이 6 GiB가 됩니다. 스냅샷 전용 계열은 이 저장소에 기증하지 않으므로 대상에서 빠집니다.
시작 로그에는 INFO 수준으로 Applied model-aware prompt-cache snapshot capacity default 또는 Applied model-aware prompt-cache KV store capacity default가 이전 용량, 권장 용량, 대표 항목 크기, 상한과 함께 찍힙니다. 항목 하나가 저장소 전체보다 크면 그 항목은 보관될 수 없어 매 턴 cached=0이 되는데, 이때 서버는 저장소마다 한 번 WARN 로그로 항목 크기와 용량을 남기고 --prompt-cache-snapshot-capacity-bytes 또는 --prompt-cache-capacity-bytes를 안내합니다.
빠른 시작¶
# 기본값으로 활성화: 모델 기반 예산(최소 2 GiB), 1024개 항목, 1시간 TTL
mlxcel-server -m /path/to/model
# APC도 기본 활성화됩니다. 비교를 위해 별도로 끄려면 다음과 같이 실행합니다.
mlxcel-server -m /path/to/model --apc-enabled=false
# 더 큰 배포: 8 GiB, 4096개 항목, 30분 TTL, blake3 해시를 쓰는 APC
mlxcel-server -m /path/to/model \
--prompt-cache-enabled \
--prompt-cache-capacity-bytes 8589934592 \
--prompt-cache-max-entries 4096 \
--prompt-cache-ttl 1800 \
--apc-enabled \
--apc-hash blake3
튜닝 지침¶
capacity-bytes를 높여야 할 때: Prometheus 게이지 mlxcel_prompt_cache_bytes가 설정한 한도에 가까워지고 mlxcel_prompt_cache_evictions_total{reason="lru"}가 빠르게 늘어날 때입니다. 용량을 키우면 잦은 축출이 줄어듭니다.
max-entries를 높여야 할 때: 짧은 대화가 많은 경우입니다. 항목 하나하나는 바이트 기준으로 작지만 항목 개수가 먼저 한도에 걸립니다. mlxcel_prompt_cache_entries를 설정한 최대값과 비교해 확인하십시오.
ttl을 낮춰야 할 때: 대화 수명이 짧고 메모리가 부족한 워크로드입니다. TTL을 낮추면 오래된 항목이 LRU 압력에 밀려나기 전에 미리 제거됩니다.
min-prefix를 높여야 할 때: 프롬프트 변화가 심해 공유 프리픽스가 짧은 경우입니다. 임계값을 높이면 채택/기증 오버헤드에 비해 연산 절약이 미미한 자잘한 항목으로 캐시가 가득 차는 일을 막을 수 있습니다.
세션 스냅샷¶
블록 단위 프리픽스 캐시로는 아예 복원할 수 없는 계열이 있습니다. 상태가 평범한 KV 테이블이 아니기 때문입니다. Gemma 4, Qwen 3.5, SSM 하이브리드는 캐시 전체 스냅샷만 재현할 수 있는 재귀 상태를 들고 있습니다. 이 계열의 멀티턴 재사용은 세션 키로 관리되는 세션 스냅샷 저장소를 거치고, 두 번째 턴이 히스토리 prefill을 건너뛰게 만드는 것도 이 저장소입니다.
prefill이 히스토리 경계에서 스냅샷을 기증합니다. 요청의 프롬프트가 렌더링된 대화 히스토리와 새 user 메시지로 나뉠 때, prefill은 프롬프트 끝에서만이 아니라 그 경계에서 스냅샷을 뜹니다. 다음 턴의 히스토리는 저장된 것의 프리픽스이므로 다시 계산하지 않고 복원됩니다. 이 기증이 없으면 스냅샷 전용 계열은 두 번째 턴에서 절대 적중하지 못하므로 기본값이 on입니다. 귀속 분석용으로 끄려면 MLXCEL_DISABLE_BOUNDARY_SNAPSHOT=1을 씁니다.
다음 턴의 프리픽스를 백그라운드로 예열합니다. 답변이 끝나면 백그라운드 작업이 세션의 경계 스냅샷을 방금 만든 assistant 답변까지 확장해서, 다음 턴이 답변과 메시지를 합쳐서가 아니라 새 user 메시지만 prefill하게 만듭니다. 경계 기증과 분리돼 있는 이유는 경계 스냅샷은 요청 경로의 전경 작업이고 예열은 아니기 때문입니다. 예열만 끄려면 MLXCEL_DISABLE_CACHE_WARMUP=1을 씁니다.
한 세션의 스냅샷은 체인을 이루고, 새 것이 조상을 대체합니다. 매 턴이 무관한 항목을 추가하는 대신 체인을 연장하므로, 저장소는 턴당 하나가 아니라 세션당 하나의 살아 있는 스냅샷을 유지하고 위의 예산도 세션 단위 예산이 됩니다. 스케줄러는 남은 예산을 노출하므로 운영자가 축출까지 얼마나 남았는지 볼 수 있습니다.
발산한 복원은 거절되고, 거절 사유가 분류됩니다. 요청의 히스토리가 저장된 스냅샷과 맞지 않으면, 거기서 복원하면 존재한 적 없는 상태가 나오므로 거절하고 요청은 정상 prefill합니다. 프롬프트 캐시 통계는 이 거절들을 miss 수에 뭉뚱그리지 않고 원인별로 나눠 보고하므로, 클라이언트가 히스토리를 다시 쓰는 세션과 그냥 차갑게 도착한 세션이 구분됩니다. Gemma 4는 부분 복원이 되는 예외입니다. 항목 전체를 버리는 대신 요청과 스냅샷의 최장 공통 프리픽스에서 재개할 수 있습니다.
결정마다 로그 한 줄씩 보려면 MLXCEL_APC_TRACE=1을 설정하십시오. 요청이 어느 스냅샷에 매칭됐는지, 발산한 스냅샷이 왜 거절됐는지 적힙니다.
Metal에서 요청 메모리 승인 제어를 받는 텍스트 전용 Nemotron-H 요청(서버 참조)은 콜드 prefill로 처리됩니다. 스냅샷을 복원하지도 기증하지도 않고 백그라운드 예열도 건너뛰며, 이미 보관된 스냅샷은 상주 메모리 기준선에 계속 포함됩니다.
qwen4_exp(Qwen3.8-Flash-Next)는 시퀀스별 상태 전체를 스냅샷합니다. GatedDeltaNet 순환 상태, PLE 윈도와 n-gram 문맥, KV 캐시, QSA 인덱스를 저장된 dtype 그대로 담습니다. n-gram 행 테이블은 스냅샷에 들어가지 않으므로 PLE 저장소가 상주형이든 외부형이든 복원 방식은 같습니다. 다른 계열이거나 계층 수와 종류가 다르거나 형상이 맞지 않거나 KV 모드가 다르면 이름이 붙은 오류로 복원이 실패합니다. 이미지나 비디오가 있는 요청은 로그에 사유를 남기고 스냅샷을 거부한 뒤 콜드 prefill하며, MRoPE 상태는 저장하지 않습니다. 어텐션 오프셋이 주장된 토큰 길이와 다른 캡처는 거부하고, 복원 시에는 빈 롤백 로그를 설치하므로 복원 지점보다 앞으로 되감는 요청은 추측하지 않고 거부합니다. 스냅샷 저장소는 늘어나기 전에 macOS 메모리 예약분을 다시 확인하며, 이 동작은 macOS 메모리 가드레일에 설명되어 있습니다.
재현성과 캐시 히트¶
프롬프트 캐시 히트는 prefill을 나눠서 수행합니다. 캐시된 프리픽스는 이전 forward에서 계산했고 지금은 나머지만 prefill하는데, 이렇게 나누면 부동소수점 합산 순서가 달라집니다(양자화 matmul 커널은 행 수에 따라 경로를 고르고, 어텐션이 받는 쿼리 형태도 달라집니다). 그래서 첫 생성 토큰의 logit은 콜드 prefill과 가깝지만 항상 비트 단위로 같지는 않고, greedy 응답이 확률이 거의 같은 토큰에서 콜드 실행과 달라질 수 있습니다.
채택한 프리픽스가 콜드 prefill 계획의 분할 지점(스냅샷 계열의 히스토리 경계 또는 청크 경계)에서 끝나고 채택한 행이 같은 조각으로 기록된 경우에는, 히트가 같은 프롬프트의 콜드 실행을 정확히 재현합니다. 히스토리 경계 분할이 없는 계열에서는 캐시를 끈 실행과 캐시를 켠 상태의 콜드 prefill이 항상 같습니다.
회귀 테스트, 평가 실행, 두 빌드 비교처럼 캐시 상태와 무관한 출력이 필요한 요청에는 cache_prompt: false를 보내십시오. 이 필드는 요청 최상위, 평탄화된 OpenAI SDK extra_body, extra_body 내부 중첩 위치 어디에서나 읽으며, chat 라우트와 네이티브 /completion에서 동작합니다. 이 요청은 콜드 prefill로 처리되고 캐시 항목을 읽지도 기증하지도 않으므로 다른 요청이 쓰는 저장소를 바꾸지 않습니다. 서버 전체에 같은 동작을 적용하려면 --no-cache-prompt(또는 --prompt-cache-enabled=false)를 씁니다.
자동 프리픽스 캐싱 (APC)¶
APC는 전체 프리픽스 캐시 위에 얹는 기능이며 기본으로 켜져 있습니다(--apc-enabled). 토큰 프리픽스 전체가 처음부터 일치하는 항목만 매칭하는 대신 APC는 토큰 시퀀스를 고정 크기 블록으로 나누고 블록마다 연쇄 해시를 만듭니다. 저장된 항목과 앞쪽 K개 블록을 공유하는 이후 요청은 전체 프리픽스 길이가 달라도 그 K개 블록의 KV 상태를 재사용할 수 있습니다.
동작 방식¶
각 항목은 BlockHashChain을 저장합니다. 32바이트 해시의 나열이며 블록 n의 해시는 (블록 n-1의 부모 해시, 블록 n의 토큰들, 멀티모달 다이제스트)에서 유도됩니다. 이런 Merkle-DAG 구조에서는 토큰 하나만 바뀌어도 그 이후 모든 블록 해시가 바뀝니다. 그래서 서버는 새 요청의 앞쪽 몇 개 블록까지가 저장된 후보와 일치하는지 정확히 판별할 수 있습니다.
조회 시점에는 트라이가 후보 항목을 찾은 뒤 서버가 해당 프리픽스 범위에서 요청의 블록 해시를 다시 계산하고 두 체인을 나란히 비교합니다. 두 체인이 일치하는 마지막 블록 인덱스가 새 매칭 프리픽스 길이가 되며 이 값은 온전한 블록 경계에 맞춰 잘립니다. 시스템 프롬프트와 처음 몇 개의 사용자 턴은 공유하지만 갈라지는 지점이 제각각인 요청들처럼, 공유 구간은 길어도 끝까지 같지는 않은 워크로드에서 전체 프리픽스 매칭보다 더 잘게 재사용할 수 있습니다.
하이브리드 SSM 모델 자동 제외¶
순환(recurrent) 레이어를 가진 모델(Mamba, Mamba2, Jamba, Nemotron-H, GatedDeltaNet/Qwen3-Next, KimiLinear)은 KV 캐시 외에 은닉 상태도 함께 유지합니다. KV 텐서만 복원하고 SSM 상태를 0으로 두면 출력이 잘못됩니다. 서버는 시작할 때 config.json에서 이 모델 계열을 감지해 해당 모델의 APC를 조용히 끕니다. 전체 프리픽스 캐시는 영향을 받지 않고 그대로 동작합니다.
해당 model_type 문자열: jamba, mamba, mamba2, nemotron_h, gated_delta, kimi_linear, kimi_k3, qwen3_next, falcon_mamba, longcat_flash, longcat_flash_ngram, rwkv7, recurrent_gemma, baichuan_m1. text_config.model_type과 아키텍처 이름도 함께 검사합니다.
APC 관측 지표¶
GET /v1/cache/stats 응답에는 다음 APC 전용 필드가 포함됩니다.
| 필드 | 설명 |
|---|---|
apc_enabled | 실행 중인 모델에서 APC가 활성 상태인지 여부. |
total_blocks_stored | 모든 항목의 블록 해시 체인 길이 합계. |
unique_block_hashes | 모든 항목에 걸친 서로 다른 블록 해시 수. total_blocks_stored - unique_block_hashes는 항목 간 블록 재사용 기회의 근사값입니다. |
apc_active_entries | 블록 해시 체인이 채워진 항목 수. |
POST /v1/cache/reset은 APC 체인을 포함해 스토어 전체를 비우고 해제된 항목 수와 바이트 수를 보고합니다.
APC를 켜야 할 때¶
블록 경계 여러 개를 넘을 만큼 길지만 끝까지 같지는 않은, 길이가 제각각인 공유 프리픽스를 가진 요청이 많은 워크로드에서 APC가 효과를 냅니다. 공통 시스템 프롬프트 뒤에 길이가 서로 다른 대화가 이어지는 경우가 전형적인 예입니다. APC가 없으면 대화마다 자기 전체 시퀀스를 캐시하고 프리픽스가 정확히 일치해야만 재사용됩니다. APC가 있으면 앞쪽 N개 블록을 공유하는 요청은 그 N개 블록의 KV 상태를 물려받을 수 있습니다.
APC는 삽입과 조회 시점에 블록별 해싱 비용을 약간 더합니다. Apple Silicon에서 텍스트 전용 워크로드라면 무시해도 될 수준입니다. 짧은 요청에서 지연 시간이 늘어난다면 apc_active_entries를 확인하고 --apc-block-size를 높여 요청마다 계산하는 블록 수를 줄이는 방안을 검토하십시오.
관측¶
응답 필드¶
모든 POST /v1/chat/completions 응답의 usage 필드에는 prompt_tokens_details.cached_tokens가 들어갑니다.
{
"usage": {
"prompt_tokens": 512,
"completion_tokens": 64,
"prompt_tokens_details": {
"cached_tokens": 480
}
}
}
cached_tokens는 다시 계산하지 않고 캐시된 항목에서 KV 상태를 물려받은 프롬프트 토큰 수입니다. 값이 0이면 콜드 프리필입니다(캐시 미스 또는 기능 비활성화).
Prometheus 메트릭¶
--metrics 플래그를 설정하면 모든 메트릭이 GET /metrics에서 Prometheus 텍스트 형식으로 노출됩니다.
| 메트릭 | 유형 | 설명 |
|---|---|---|
mlxcel_prompt_cache_hits_total | 카운터 | 캐시 채택에 성공한 누적 횟수 |
mlxcel_prompt_cache_misses_total | 카운터 | 일치 항목을 찾지 못한 누적 조회 횟수 |
mlxcel_prompt_cache_prefix_tokens_reused_total | 카운터 | 전체 히트에서 재사용한 누적 프리픽스 토큰 수 |
mlxcel_prompt_cache_evictions_total{reason="lru"} | 카운터 | 항목 수 또는 바이트 한도 압력에 따른 축출 |
mlxcel_prompt_cache_evictions_total{reason="ttl"} | 카운터 | TTL 만료에 따른 축출 |
mlxcel_prompt_cache_evictions_total{reason="capacity"} | 카운터 | 용량에 따른 축출(현재는 lru와 통합) |
mlxcel_prompt_cache_bytes | 게이지 | 살아 있는 모든 항목이 차지하는 현재 총 바이트 |
mlxcel_prompt_cache_entries | 게이지 | 현재 살아 있는 항목 수 |
히트율 계산식:
hit_rate = mlxcel_prompt_cache_hits_total
/ (mlxcel_prompt_cache_hits_total + mlxcel_prompt_cache_misses_total)
히트당 평균 절약 토큰 수:
로그 출력¶
스케줄러는 캐시 히트가 날 때마다 DEBUG 레벨 로그 한 줄을 남깁니다.
채택에 실패하면 역시 DEBUG 레벨로 다음 줄을 남깁니다.
문제 해결¶
히트를 기대했는데 캐시 미스가 나는 경우¶
-
template_sig변동: 요청 사이에 채팅 템플릿, kwargs, 도구 스키마가 바뀌었습니다.--chat-template,--chat-template-kwargs, 도구 목록이 동일한지 확인하십시오. 템플릿 문자열의 공백 차이 하나도 다이제스트를 바꿉니다. -
session_key불일치: 요청마다user값이나prompt_cache_key힌트가 다르면 서로 다른 세션 버킷에 들어갑니다. 세션 간 폴백은 세션 간 일치가 세션 내 일치보다 엄밀히 더 길 때만 동작합니다. -
min-prefix미달: 공통 프리픽스가--prompt-cache-min-prefix(기본 32 토큰)보다 짧습니다. 공유 프리픽스가 더 길다는 확신이 있을 때만 임계값을 높이고 더 짧은 공통 프리픽스까지 잡고 싶으면 낮추십시오. -
세션의 첫 요청: 아직 항목이 없습니다. 캐시는 시퀀스가 정상적으로 끝난 뒤에만 채워지므로 첫 요청은 항상 콜드 프리필입니다.
-
멀티모달 내용 변경: 텍스트가 같아도 이미지나 오디오 페이로드가 다르면
mm_digest가 달라져 캐시 미스가 납니다.
메모리 압력과 축출¶
mlxcel_prompt_cache_evictions_total{reason="lru"}가 빠르게 늘어난다면 다음을 시도하십시오.
- 여유가 있으면
--prompt-cache-capacity-bytes를 높이십시오. - 항목 크기가 작으면
--prompt-cache-max-entries를 높이십시오. --prompt-cache-ttl을 낮춰 오래된 항목을 더 일찍 제거하십시오.
세션 키 오용과 대화 간 충돌¶
user 필드는 변하지 않는 최종 사용자 식별자를 넣는 자리입니다. 같은 user 값을 전혀 무관한 대화들에 재사용하면 그 요청이 모두 같은 세션 버킷에 들어갑니다. 그러면 세션 간 매처가 초반 토큰 시퀀스(예: 공통 시스템 프롬프트)만 우연히 겹치는 무관한 대화의 프리픽스를 집어 올 수 있습니다. 대개는 해가 없지만 예상 밖의 히트가 날 수 있습니다. 대화 간 충돌을 막으려면 extra_body에 대화별 prompt_cache_key를 설정하십시오.
도구 호출 비결정성¶
도구 호출 인자와 응답은 생성된 토큰이라 실행할 때마다 달라집니다. 캐시 버킷은 마지막 생성이 끝난 시점까지의 토큰 스트림을 저장합니다. 도구 호출이 들어간 멀티턴 대화에서는 매칭 프리픽스에 도구 호출 스키마(안정적)는 포함되지만 생성된 인자나 결과(비결정적)는 포함되지 않습니다. 그래서 실제 매칭 길이가 전체 대화 기록보다 짧을 수 있습니다.
제한 사항¶
현재 구현에는 다음과 같은 제한이 있습니다.
-
Paged 채택/기증은 풀 기반 계열에 한정:
--decode-storage-backend paged에서 채택과 기증은CachePool::adopt_paged와CachePool::detach_paged로 refcount가 있는 풀 블록을 공유합니다. KV 형태를 활성 풀에 설치할 수 없으므로 Paged 백엔드에서는Dense항목을, Dense 백엔드에서는Paged항목을 채택하지 않습니다. 모델 소유 상태를 쓰는 계열은 스냅샷 경로를 사용합니다. -
멀티모달 요청은 항목 전체가 일치해야 채택: 이미지나 오디오가 포함된 요청은 일치 범위가 저장된 항목 전체를 덮을 때만 캐시 항목을 채택합니다. 그래야 프리필하는 접미부가 새로 덧붙은 텍스트가 됩니다. 부분(블록 정렬) 일치는 거부되고 요청은 콜드 프리필을 수행합니다.
-
프로세스 간 공유 없음: 캐시는 서버 프로세스의 힙에 존재합니다. 여러
mlxcel-server프로세스는 항목을 공유하지 않습니다. 수평 확장을 해도 노드 사이에서는 캐시 이득이 없습니다. -
프로세스당
PromptCacheStore하나: 같은 프로세스에 로드된 모든 모델이 스토어 하나를 공유합니다. 실제로는 모델마다model_id차원이 달라 항목이 충돌하지는 않지만 메모리는 함께 씁니다.
함께 보기: 서버 전체 아키텍처와 프롬프트 캐시가 요청 처리 파이프라인에서 차지하는 위치는 docs/architecture.md를 참고하십시오.