Skip to content

필사 모드: vLLM 내부 구조 (5) — 접두사 캐싱, 시스템 프롬프트 설계가 곧 성능이 되는 이유

한국어
0%
정확도 0%
💡 왼쪽 원문을 읽으면서 오른쪽에 따라 써보세요. Tab 키로 힌트를 받을 수 있습니다.

같은 앞부분을 두 번 계산하지 않기

2편에서 블록 단위 관리가 공유를 가능하게 만든다고 했습니다. 이번 편은 그 공유를 요청 사이로 넓힌 기능, 접두사 캐싱입니다.

실제 서비스의 요청들은 생각보다 많이 겹칩니다. 챗봇이라면 모든 요청이 같은 시스템 프롬프트로 시작합니다. 도구를 쓰는 에이전트라면 도구 정의 뭉치가 매번 앞에 붙습니다. 멀티턴 대화라면 두 번째 요청은 첫 번째 요청의 내용을 통째로 포함합니다. 이 겹치는 앞부분의 KV를 매번 다시 계산하는 것은 순수한 낭비입니다.

접두사 캐싱은 이 낭비를 없앱니다. 효과는 처리량보다 첫 토큰까지의 시간에서 먼저 나타납니다. 프리필을 건너뛰는 만큼 사용자가 기다리는 시간이 줄기 때문입니다. main 브랜치 CacheConfig 소스에서 enable_prefix_caching의 선언 기본값은 참입니다. 즉 요즘 버전에서는 따로 켜지 않아도 동작하는 쪽이 기본입니다.

내용은 2026-08-12에 공식 문서·소스에서 확인했습니다. vLLM은 변화가 빠르니 설정값과 동작은 사용 중인 버전의 문서로 다시 확인하세요.

블록 해시가 만들어지는 방식

핵심은 블록을 어떻게 식별하느냐입니다. vLLM 공식 설계 문서의 설명은 명확합니다. 각 KV 캐시 블록을 해시할 때 그 블록 안의 토큰만 쓰는 것이 아니라, 그 블록 앞에 있던 접두사의 토큰까지 함께 반영합니다. 구현상으로는 앞 블록의 해시값, 이번 블록의 토큰들, 그리고 추가 요소가 함께 들어갑니다. 추가 요소에는 LoRA 식별자, 멀티모달 입력의 해시, 캐시 솔트가 포함됩니다.

이 사슬 구조가 전부입니다. 여기서 실무에 필요한 결론이 전부 따라 나옵니다.

블록 크기가 4일 때

프롬프트:  [당신은 친절한] [상담원입니다 오늘] [날짜는 8월] [12일 질문은]
              블록0            블록1             블록2         블록3

해시0 = H(없음,          블록0 토큰, 추가요소)
해시1 = H(해시0,         블록1 토큰, 추가요소)
해시2 = H(해시1,         블록2 토큰, 추가요소)
해시3 = H(해시2,         블록3 토큰, 추가요소)

→ 블록2의 토큰이 하루만 지나도 바뀌면 해시2가 바뀌고,
  해시3은 해시2를 재료로 쓰므로 함께 바뀝니다.
  블록0과 블록1은 그대로 살아남습니다.

한 번 어긋나면 그 뒤는 전부 어긋납니다. 반대로 말하면 어긋나기 전까지는 전부 살아 있습니다. 그래서 접두사 캐싱의 성능은 "얼마나 많이 같으냐"가 아니라 "앞에서부터 몇 토큰까지 같으냐"로 결정됩니다.

왜 꽉 찬 블록만 캐시되는가

설계 문서가 못박는 규칙이 하나 더 있습니다. 꽉 찬 블록만 캐시합니다. 부분적으로 채워진 블록은 다 채워지기 전까지는 캐시에서 꺼내 쓸 수 없습니다.

문서가 든 예시가 이 규칙을 잘 보여 줍니다. 블록 크기가 4일 때 어떤 요청이 앞의 두 블록, 즉 8토큰까지만 캐시에 적중하는 상황이 나옵니다. 세 번째 블록은 4토큰 중 2토큰만 일치하기 때문에 적중으로 치지 않습니다.

여기서 2편에서 예고한 트레이드오프가 실체를 드러냅니다. 블록이 크면 이 반올림 손실이 커집니다. 블록 크기가 32인데 공통 접두사가 40토큰이라면 적중은 32토큰까지이고 나머지 8토큰은 다시 계산합니다. 공통 접두사가 매우 긴 워크로드에서는 이 손실이 무시할 만하지만, 짧은 프롬프트가 많은 서비스에서는 체감이 됩니다.

프롬프트 설계가 곧 성능이 되는 이유

이제 실무 규칙 하나로 정리됩니다. 변하지 않는 것을 앞에, 매번 변하는 것을 뒤에 두세요.

이것이 왜 그렇게 중요한지는 해시 사슬을 보면 자명합니다. 프롬프트 맨 앞줄에 현재 시각을 넣으면, 그 뒤에 아무리 훌륭하고 긴 공통 지침이 있어도 전부 무효가 됩니다. 첫 블록의 해시가 매 요청 달라지고, 뒤따르는 모든 해시가 그것을 재료로 쓰기 때문입니다.

나쁜 배치 — 캐시 적중 0
[현재 시각 09:31:07] [사용자 ID 8823] [긴 시스템 지침 …] [도구 정의 …] [질문]
   매번 다름            매번 다름          항상 같음          항상 같음

좋은 배치 — 앞의 두 덩어리가 통째로 적중
[긴 시스템 지침 …] [도구 정의 …] [사용자 ID 8823] [현재 시각 09:31:07] [질문]
     항상 같음         항상 같음        매번 다름           매번 다름

같은 내용을 순서만 바꿔 넣었을 뿐인데 결과가 완전히 달라집니다. 프롬프트 설계가 성능이 된다는 말은 비유가 아니라 이 구조에 대한 직접적인 서술입니다.

한 가지 더 있습니다. 앞부분은 글자 하나까지 같아야 합니다. 토크나이저가 보는 것은 토큰 열이라서, 공백 하나나 줄바꿈 하나가 달라도 토큰이 달라질 수 있습니다. 시스템 프롬프트를 코드에서 문자열 조합으로 만들고 있다면, 조합 결과가 정말로 매번 동일한 바이트인지 확인해 볼 가치가 있습니다.

캐시가 안 맞는 흔한 이유들

적중률이 기대보다 낮을 때 순서대로 의심해 볼 목록입니다.

첫째, 앞쪽의 가변 요소입니다. 타임스탬프, 세션 식별자, 사용자 이름, 무작위로 섞은 예시 순서. 앞에 있으면 전부 캐시를 죽입니다.

둘째, 공통 접두사가 블록 하나를 채우지 못하는 경우입니다. 시스템 프롬프트가 짧으면 캐시할 꽉 찬 블록 자체가 안 나옵니다. 이때는 캐싱이 고장 난 것이 아니라 캐시할 것이 없는 것입니다.

셋째, 축출입니다. 캐시된 블록은 영원히 남지 않습니다. 새 요청이 블록을 요구하는데 여유가 없으면 오래된 블록부터 회수됩니다. 동시 요청이 많아 KV 캐시가 늘 빠듯한 배포에서는 캐시해 둘 겨를 자체가 없습니다. 이 경우 해법은 프롬프트가 아니라 4편에서 다룬 용량 쪽에 있습니다.

넷째, 요청 그룹이 갈리는 요소입니다. 설계 문서가 밝힌 대로 LoRA 식별자와 멀티모달 입력, 캐시 솔트도 해시 재료입니다. 어댑터가 다르면 같은 텍스트라도 같은 캐시를 쓰지 않습니다.

보안: cache_salt가 있는 이유

접두사 캐싱에는 그림자가 하나 있습니다. 캐시 적중은 응답을 빠르게 만들고, 그 빨라짐은 관측할 수 있습니다. 여러 사용자가 한 엔진을 공유하는 환경에서 공격자가 프롬프트를 바꿔 가며 응답 시간을 재면, 어떤 접두사가 이미 캐시에 있는지를 좁혀 나갈 수 있습니다.

그래서 OpenAI 호환 서버에는 cache_salt 파라미터가 있습니다. 소스의 설명은 분명합니다. 지정하면 접두사 캐시에 주어진 문자열을 섞어서, 다중 사용자 환경에서 공격자가 프롬프트를 추측하지 못하게 합니다. 솔트는 무작위여야 하고, 외부에 노출되지 않아야 하며, 추측할 수 없을 만큼 길어야 합니다. 소스는 예로 256비트에 해당하는 base64 43자를 듭니다.

실무 적용은 간단합니다. 테넌트마다 다른 솔트를 주면 캐시가 테넌트 경계를 넘지 않습니다. 대신 테넌트 사이의 공유 이득은 포기하게 되므로, 같은 조직 안이라면 테넌트 단위, 완전히 분리된 고객이라면 고객 단위가 합리적인 절충점입니다. 참고로 해시 알고리즘 자체도 설정 항목입니다. main 브랜치 CacheConfigprefix_caching_hash_algo 선언 기본값은 sha256이었습니다.

직접 해보기

참고 자료

현재 단락 (1/45)

2편에서 블록 단위 관리가 공유를 가능하게 만든다고 했습니다. 이번 편은 그 공유를 요청 사이로 넓힌 기능, 접두사 캐싱입니다.

작성 글자: 0원문 글자: 3,490작성 단락: 0/45