Skip to content

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

✨ Learn with Quiz
|

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

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

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이었습니다.

직접 해보기

참고 자료

Inside vLLM (5) — Prefix Caching, and Why System Prompt Design Is Performance

Not Computing the Same Prefix Twice

Part 2 explained that block-level management is what makes sharing possible. This part covers the feature that extends that sharing across requests: prefix caching.

Requests in a real service overlap more than you would expect. In a chatbot, every request starts with the same system prompt. In a tool-using agent, the same bundle of tool definitions is prepended every time. In a multi-turn conversation, the second request contains the entire first request as a prefix. Recomputing the KV for this overlapping prefix every single time is pure waste.

Prefix caching eliminates this waste. The effect shows up first in time to first token rather than in throughput, because the user waits less by exactly as much prefill gets skipped. In the CacheConfig source on the main branch, the declared default for enable_prefix_caching is true. In other words, in recent versions the default is to work without turning it on separately.

Everything here was verified against the official documentation and source on 2026-08-12. vLLM moves fast, so re-check settings and behavior against the documentation for the version you are running.

How Block Hashes Are Built

The core question is how a block gets identified. The vLLM design document is explicit about this. When hashing each KV cache block, it does not use only the tokens inside that block — it also folds in the tokens of the prefix that came before it. In the implementation, the hash of the previous block, the tokens of the current block, and a set of extra factors go in together. Those extra factors include the LoRA identifier, the hash of any multimodal input, and the cache salt.

That chain structure is the whole mechanism. Every practical conclusion you need follows from it.

When block size is 4

Prompt:    [You are a helpful]  [assistant. Today]  [the date is Aug]  [12th. The question]
              block0                 block1               block2              block3

hash0 = H(none,        block0 tokens, extras)
hash1 = H(hash0,       block1 tokens, extras)
hash2 = H(hash1,       block2 tokens, extras)
hash3 = H(hash2,       block3 tokens, extras)

→ If block2's tokens change even a day later, hash2 changes,
  and hash3 changes with it, since it uses hash2 as an input.
  block0 and block1 survive untouched.

Once one block diverges, everything after it diverges too. Put the other way, everything up to the point of divergence stays alive. So the performance of prefix caching is decided not by how much of the content is the same, but by how many tokens from the very front are identical.

Why Only Full Blocks Get Cached

The design document nails down one more rule. Only full blocks are cached. A partially filled block cannot be pulled from the cache until it is completely filled.

The example in the document shows this rule clearly. With a block size of 4, one request hits the cache only up through the first two blocks — that is, 8 tokens. The third block matches on only 2 of its 4 tokens, so it does not count as a hit.

This is where the trade-off previewed in part 2 becomes concrete. Larger blocks make this rounding loss bigger. If the block size is 32 and the shared prefix is 40 tokens, the hit only covers 32 tokens and the remaining 8 get recomputed. This loss is negligible for workloads with very long shared prefixes, but it is noticeable in services with a lot of short prompts.

Why Prompt Design Is Performance

This all comes down to one practical rule. Put what never changes at the front, and what changes every time at the back.

Why this matters so much is obvious once you look at the hash chain. If you put the current time on the very first line of the prompt, everything after it is invalidated — no matter how good or how long the shared instructions that follow are. The hash of the first block changes on every request, and every hash after it uses that hash as an input.

Bad layout — 0 cache hits
[Current time 09:31:07] [User ID 8823] [Long system instructions …] [Tool definitions …] [Question]
    different every time     different every time         always the same          always the same

Good layout — the first two chunks hit the cache whole
[Long system instructions …] [Tool definitions …] [User ID 8823] [Current time 09:31:07] [Question]
         always the same            always the same      different every time      different every time

The content is identical — only the order changed — and the outcome is completely different. Saying that prompt design is performance is not a figure of speech. It is a direct description of this mechanism.

One more thing. The shared prefix has to match down to the last character. What the tokenizer sees is a sequence of tokens, so even a single space or a single line break can produce a different token. If you are building the system prompt in code by concatenating strings, it is worth checking whether the concatenated result really is byte-identical every time.

Common Reasons the Cache Does Not Hit

When the hit rate is lower than expected, here is a list to work through in order.

First, a variable element near the front. A timestamp, a session identifier, a user name, a randomly shuffled order of examples — any of these near the front kills the cache.

Second, the shared prefix does not fill even a single block. If the system prompt is short, there is no full block to cache in the first place. In this case caching is not broken — there is simply nothing to cache.

Third, eviction. Cached blocks do not stay around forever. When a new request needs a block and there is no room, the oldest blocks are reclaimed first. In a deployment where concurrent requests are so high that the KV cache is always tight, there is no room to keep anything cached in the first place. Here the fix is not the prompt — it is the capacity question covered in part 4.

Fourth, whatever splits requests into different groups. As the design document states, the LoRA identifier, multimodal input, and cache salt are also inputs to the hash. Different adapters mean the same text does not use the same cache.

Security: Why cache_salt Exists

Prefix caching has a shadow side. A cache hit makes the response faster, and that speedup is observable. In an environment where multiple users share one engine, an attacker who varies the prompt and measures response time can narrow down which prefixes are already in the cache.

This is why the OpenAI-compatible server has a cache_salt parameter. The source is explicit about it. When specified, it mixes the given string into the prefix cache so that, in a multi-user environment, an attacker cannot guess prompts. The salt should be random, never exposed externally, and long enough that it cannot be guessed. The source gives 43 base64 characters, equivalent to 256 bits, as an example.

Applying this in practice is simple. Giving each tenant a different salt keeps the cache from crossing tenant boundaries. The trade-off is that you give up any sharing benefit between tenants, so a reasonable balance is per-tenant within the same organization, or per-customer when customers are fully separate. As a side note, the hash algorithm itself is also a configuration item. The declared default for prefix_caching_hash_algo in CacheConfig on the main branch was sha256.

Try It Yourself

References