본문으로 건너뛰기

© 2026 Molayo

Dev.to헤드라인2026. 06. 07. 11:15

Self-Hosted Claude Code가 예상보다 15배 느렸던 이유

요약

vllm-mlx를 통해 Claude Code를 셀프 호스팅할 때 발생하는 심각한 성능 저하 원인을 분석합니다. 시스템 프롬프트 내 가변 헤더로 인한 캐시 미스와 특정 모델에서의 구조화된 출력 오류 문제를 다룹니다.

핵심 포인트

  • 가변적인 billing 헤더가 접두사 캐싱을 방해하여 성능을 저하시킴
  • 프록시 계층에서 해당 헤더를 제거하여 캐시 효율을 높일 수 있음
  • sparse-MoE 모델 사용 시 엄격한 json_schema 대신 json_mode 권장
  • 멀티 슬롯 KV 캐시 부재 시 에이전트 간 캐시 교체 비용 발생

업데이트 (2026-05-14). Finding #2에서 설명한 SimpleEngine prefix-cache 패치가 이제 vllm-mlx PR #523으로 업스트림(upstream)에 병합되었습니다. 최근 vllm-mlx 빌드를 사용 중이라면 수정 사항이 이미 적용되어 있으므로 로컬 패치가 필요하지 않습니다. 아래의 설명은 해당 패치가 무엇을 하는지, 그리고 왜 필요했는지를 이해하는 데 여전히 유용합니다.
{: .prompt-info }

업데이트 (2026-05-18) — 실제로 이를 실행 중이라면 주의해야 할 두 가지 사항:

  1. sparse-MoE Coder 모델에 대해 엄격한 json_schema response_format을 사용하지 마세요. 만약 동일한 vllm-mlx 인스턴스에서 LangChain(또는 다른 OpenAI 호환 클라이언트)을 구조화된 출력(structured outputs)과 함께 실행한다면, LangChain의 기본값인 "json_schema" 대신 with_structured_output(schema, method="json_mode")를 사용하는 것이 좋습니다. 엄격한(strict) 경로는 문법 제약 디코딩(grammar-constrained decoding)을 트리거하는데, 이것이 Qwen3-Coder-30B-A3B에서 호출당 5분 이상 멈추는 현상이 발생했습니다. 디코더가 멈추면 서버를 재시작할 때까지 Claude Code 세션을 포함한 모든 대기 중인 요청이 차단됩니다. 이 문제는 vllm-mlx#546으로 업스트림에 보고되었습니다.

  2. PR #523은 단일 슬롯 시스템-KV 캐시(single-slot system-KV cache)를 수정합니다. 아마도 멀티 슬롯(multi-slot) 변형도 필요할 것입니다. Claude Code의 서브 에이전트(Explore, Plan, 범용 에이전트)는 서로 다른 도구 세트를 가지고 있으므로, 각 에이전트의 시스템 접두사(system prefix)는 메인 에이전트와 다릅니다. 단일 슬롯 스냅샷을 사용하면, 서브 에이전트가 호출될 때마다 메인 에이전트의 캐시를 밀어내고 그 반대도 마찬가지이며, 매 턴마다 약 28K 토큰의 콜드 프리필(cold prefill) 비용을 온전히 지불해야 합니다. 멀티 슬롯 LRU 후속 작업은 현재 로컬에서만 가능하며, 업스트림 PR을 기다리는 중입니다.
    {: .prompt-warning }

요약 (TL;DR)

요약 (TL;DR)

Mac Studio에서 셀프 호스팅된 vllm-mlx 백엔드를 사용하여 Claude Code를 실행했습니다. 첫 번째 턴(Cold turn)은 약 108초가 소요되었습니다. 시스템 프롬프트(System prompt)가 바이트 단위로 안정적이고, 제대로 된 LLM 엔진이라면 접두사 캐싱(Prefix caching)을 수행해야 함에도 불구하고, 후속 턴(Follow-ups) 역시 _거의 동일한 시간_이 걸렸습니다.

속도 향상을 위해 둘 다 반드시 필요한 두 가지 발견 사항이 있습니다:

  1. Claude Code는 매 턴마다 시스템 블록(System block)에 순환하는 x-anthropic-billing-header 값을 주입합니다. 사용자에게 보이는 시스템 프롬프트는 변하지 않지만, 엔진이 캐시 조회를 위해 해싱(Hashing)하는 바이트는 매 요청마다 변합니다. 이로 인해 접두사 캐시(Prefix cache) 미스가 100% 발생합니다. 프록시 계층(Proxy layer)에서 이 헤더를 제거하면 캐시를 유용하게 사용할 수 있습니다.
  2. vllm-mlx의 SimpleEngine은 요청 간에 KV 상태(KV state)를 유지하지 않습니다. 순환하는 헤더를 제거하더라도, 턴 사이에 시스템 접두사를 실제로 _캐싱(Cache)_하려면 SimpleEngine을 패치해야 합니다. 즉, 히트(Hit) 시 스냅샷을 복구하고 접미사(Suffix)만 프리필(Prefill)하는, 해시 키 기반의 작은 단일 슬롯 캐시가 필요합니다.

결과적으로: 108초가 걸리던 턴이 → 78초의 후속 턴으로 단축되었습니다. 동일한 하드웨어와 동일한 모델에서 1315배의 속도 향상을 달성했습니다.

<table><tbody><tr><td width="33%"><h2>108s → 7-8s</h2>개선 전후의 웜 턴(Warm-turn) 실제 소요 시간</td><td width="33%"><h2>13-15×</h2>동일 하드웨어 + 모델 기준 후속 턴 속도 향상 배수</td><td width="33%"><h2>81 bytes</h2>턴당 100초 이상의 손실을 초래했던 순환 헤더 텍스트 크기</td></tr></tbody></table>

설정 (The setup)

flowchart LR
    CC[Claude Code CLI] -->|/v1/messages<br/>system + tools + msgs<br/>+ rotating cch=...| CCR[claude-code-router]
    CCR --> Shim["Shim<br/><b>(1) x-anthropic-billing-header 제거</b><br/>(2) 도구 호출(tool-call) 스트림 버퍼링"]
...

번호가 매겨진 세 가지 포인트가 속도 향상의 원천입니다. (1)번과 (3)번을 제거하면 다시 100초 이상의 턴 소요 시간으로 돌아가게 됩니다.

  • 백엔드 (Backend): Mac Studio (96 GB)에서 Qwen2.5-Coder-32B-Instruct-8bit를 vllm-mlx로 서빙.
  • 프론트 도어 (Front door): Anthropic의 /v1/messages API를 노출하고 vllm-mlx로 프록시(proxy)하는 작은 FastAPI 심(shim).
  • 라우팅 (Routing): claude-code-router가 Claude Code의 외부 호출을 베어러 토큰(bearer token)과 함께 심(shim)의 URL로 변환.
  • 클라이언트 (Client): CLI인 Claude Code.

엔드 투 엔드(End-to-end)로 아키텍처는 작동했습니다. 도구 호출(Tool calling)도 작동했고, 스트리밍(Streaming)도 작동했습니다. 출력 품질도 괜찮았습니다. 단지 느렸을 뿐입니다. 그리고 이 모든 것들이 원래 작동해야 하는 방식과는 맞지 않는 방식으로 느렸습니다.

배경 설명을 하자면: Claude Code의 프롬프트는 매우 큽니다. 이 설정에서 캡처된 요청들을 측정해 본 결과, 캐싱 가능한 접두사(cacheable prefix) — 즉, Claude Code의 시스템 지침(system instructions)과 도구 정의(tool-definitions) 블록 — 는 약 23,000 토큰에 달합니다 (23개의 도구 세트 기준, 시스템 약 5.6K + 도구 약 17.6K). 접두사 캐시(prefix cache)가 제대로 작동한다면, 매 턴마다 새로운 사용자 메시지와 대화의 뒷부분(conversation tail)만 처리하면 됩니다. 이는 보통 수백 토큰 정도입니다. 하지만 캐시가 없다면, 엔진은 매. 단. 한. 턴. 마다 약 23K 토큰을 다시 프리필(re-prefill)해야 합니다. 32K 컨텍스트 모델에서 이는 대화와 출력을 위해 약 9K의 여유 공간을 남겨두는 것이므로 괜찮지만, 이는 매 턴마다 접두사 작업 내용을 버리지 않을 때만 해당되는 이야기입니다.

예상했던 것 vs 관찰된 것

콜드 턴 (Cold turn)웜 턴 (Warm turn)
순정 vllm-mlx, 심(shim) 없음108 s~100 s
...

콜드 턴 수치는 변하지 않습니다. 첫 번째 요청에서는 맞출 캐시가 없기 때문입니다. 웜 턴의 차이(delta)가 핵심입니다.

발견 사항 #1: 회전하는 빌링 헤더 (the rotating billing header)

첫 번째로 유용한 진단은 Claude Code에서 보낸 두 개의 연속적인 /v1/messages 요청의 로우 바이트(raw bytes)를 비교(diffing)하는 것이었습니다. 시스템 프롬프트, 도구 정의, 대화 기록, 샘플링 파라미터 등 거의 모든 것이 동일했습니다. 하지만 시스템 리스트 중 매 턴마다 바뀌는 블록이 하나 있었습니다:

{"type": "text",
 "text": "x-anthropic-billing-header: cc_version=...; cc_entrypoint=cli; cch=<rotating-hash>"}

Claude Code는 이를 주입합니다. cch= 값은 요청마다 회전(rotate)하며, Anthropic은 이를 과금(billing) 및 대화 추적(conversation tracking) 용도로 사용합니다. Anthropic이 호스팅하는 API에서는 캐시 계층(cache layer)이 이 값을 기준으로 정규화되므로 영향이 없습니다. 하지만 프롬프트를 있는 그대로 해싱하는 셀프 호스팅(self-hosted) 백엔드에서는, 회전하는 값이 매 요청마다 캐시 키(cache key)를 무효화합니다. 모든 턴이 완전히 새로운 턴이기 때문에, 엔진 입장에서도 매 턴이 완전히 새롭게 보이게 됩니다.

심(shim) 단계에서의 해결책은 단 하나의 함수 필터입니다:

def _strip_billing_header(payload: dict) -> None:
    """Claude Code의 `x-anthropic-billing-header` 시스템 블록을 제거합니다.

...```

81바이트의 회전하는 텍스트가 턴당 100초 이상의 비용을 발생시키고 있었습니다. 결코 좋은 거래가 아니었습니다.

> **참고.** vllm-mlx PR #277에서 `/v1/messages` 엔드포인트에 대해 동일한 수정을 조용히 수행했습니다. 만약 최신 빌드의 vllm-mlx를 사용 중이고 자체적인 Anthropic 어댑터(adapter)를 사용하고 있다면, 이미 해결되었을 수도 있습니다. 저는 Coder 에일리어스(alias)의 도구 호출 버퍼링(tool-call buffering)을 위해 직접 만든 심(shim)을 실행하고 있습니다(vllm-mlx의 Hermes 파서(parser)는 도구 JSON을 콘텐츠 델타(content deltas)로 스트리밍하는데, 이는 클라이언트로 깔끔하게 라운드트립(round-trip)되지 않기 때문입니다). 그래서 헤더를 직접 제거해야 했습니다.
> {: .prompt-info }

이 수정 이후, 워밍 턴(warm turns)은 약 100초에서 약 70초로 감소했습니다. 실질적인 승리였지만, 프리픽스 캐시(prefix cache)는 30초가 아니라 95초 이상을 절약했어야 했습니다. 즉, 캐시가 전혀 작동하지 않았거나, 부분적으로만 작동했다는 뜻입니다. 계속 진행하겠습니다.

## 발견 사항 #2: SimpleEngine이 실제로 프리픽스(prefix)를 캐싱하지 않음

vllm-mlx는 두 가지 엔진을 제공합니다. 두 엔진 모두 MLX 네이티브이며, vLLM의 PagedAttention/CUDA 코어(Apple Silicon에서는 전혀 작동하지 않음)를 상류(upstream)에서 가져오지 않습니다. `engine/simple.py`는 _"단일 사용자 처리량(throughput)을 극대화하기 위한 심플 엔진입니다. 한 번에 한 명의 사용자에게 서비스를 제공할 때 최적의 성능을 내기 위해 오버헤드 없이 mlx-lm을 직접 래핑(wrap)합니다."_입니다. `engine/batched.py`는 _"여러 동시 사용자를 위한 연속 배치(continuous batching)용 배치 엔진입니다."_입니다. 단일 사용자 Claude Code 세션의 경우, 스케줄러나 배치 대기 시간이 없고 mlx-lm의 프롬프트 캐시(prompt cache)에 직접 접근할 수 있는 SimpleEngine이 올바른 선택입니다. 여러 사용자가 동시에 동일한 백엔드에 접속할 때는 BatchedEngine이 유리합니다.

제가 사용하고 있었던 것은 SimpleEngine이었습니다. 프로파일링(Profiling) 결과, 결제 헤더(billing header)가 사라진 후에도 매 턴마다 전체 시스템 + 도구 프리픽스(tool prefix)에 대해 프리필(prefill)이 실행되고 있음을 확인했습니다. 캐시 히트율(cache hit rate)은 사실상 0이었습니다.

이유는 다음과 같습니다: SimpleEngine의 요청 핸들러(request handler)는 이전 요청에서 다음 요청으로 KV 상태(KV state)를 전달하지 않습니다. 각 요청은 `make_prompt_cache(model)`를 통해 새로운 프롬프트 캐시를 할당받으며, 전체 프롬프트를 처음부터 다시 프리필합니다. 요청 간에 활용할 수 있는 캐시가 없으며, 프리픽스 캐시(prefix cache)는 단일 요청 내부에서만 존재합니다.

해결책은 작은 패치였습니다: SimpleEngine에 **단일 슬롯, 해시 키 기반의 시스템 프리픽스 KV 캐시(single-slot, hash-keyed system-prefix KV cache)**를 추가하는 것입니다. 프리픽스를 구분하는 ChatML 마커를 사용하여 시스템 프리픽스를 감지하고, 프리픽스 토큰을 해싱한 뒤 다음과 같이 처리합니다:

-   **히트(hit) 시**: 저장된 KV 스냅샷을 복구하고 접미사(suffix)만 프리필합니다.
-   **미스(miss) 시**: 시스템 프리픽스를 청크(chunk) 단위로 프리필하고, 결과로 생성된 KV 상태를 스냅샷으로 찍어 저장합니다(이전 슬롯을 덮어씁니다).

system_kv_cache_for_simple_engine.patch에서 발췌 — 핵심적인 몇 줄입니다.

system_hash = hashlib.sha256(system_prefix_text.encode()).hexdigest()[:16]
...


중요했던 몇 가지 설계 선택 사항은 다음과 같습니다:

- **LRU가 아닌 단일 슬롯 (Single slot).** Claude Code 세션은 한 번에 하나의 대화만 진행하므로, 멀티 슬롯 (multi-slot)은 과합니다. 슬롯은 단순히 `(hash, snapshot, token_count)`를 저장하며, 캐시 미스 (miss) 발생 시 덮어씁니다.
- **전체 프롬프트가 아닌 접두사(prefix)만 해싱.** 이렇게 하면 프롬프트 끝에 붙는 새로운 사용자 메시지가 추가되더라도 캐시가 유지됩니다. 이는 가장 일반적인 경우입니다.
- **ChatML 마커 감지.** 시스템과 사용자의 경계는 렌더링된 프롬프트에서 `<|im_start|>user\n` 또는 `<|im_start|>assistant\n`을 검색하여 찾습니다. 두 마커가 모두 발견되지 않으면, 캐시를 사용하지 않는 경로 (uncached path)로 전환하여 오류가 발생하지 않도록 합니다.
- **모든 예외 상황에 대한 안전한 폴백 (Safe fallback).** 어떤 이유로든 캐시 인식 경로 (cache-aware path)가 실패하면, 경고를 로그로 남기고 원래의 `stream_generate`로 폴백합니다. 성능 최적화 작업이 생성 (generation) 자체를 중단시켜서는 안 됩니다.

전체 패치는 다음과 같이 업스트림 (upstream)에 반영되었습니다:  
[vllm-mlx PR #523](https://github.com/waybarrios/vllm-mlx/pull/523). 리뷰 과정에서 스냅샷 포인터에 대한 TOCTOU 레이스 컨디션 (race condition)을 방지하기 위해 게이트에서 클로저 로컬 캡처 (closure-local capture)를 적용하여 코드를 강화했으며, `RotatingKVCache`가 엔진이 제자리에서 변형하는 버퍼를 별칭 (alias)으로 사용하는 슬라이딩 윈도우 (sliding-window) 모델의 경우 초기화 시점에 캐시를 비활성화하는 프로브 (probe)를 추가했습니다. 캐시 메커니즘에 대해 궁금하다면 병합된 코드가 가장 정확한 참조 자료입니다.

## 수치 (The numbers)

두 가지 수정 사항이 모두 적용된 후, 웜 턴 (warm-turn)의 실제 소요 시간 (wall-clock)은 약 70초 (빌링 헤더 수정만 적용했을 때)에서 **7~8초** (빌링 헤더 수정 + SimpleEngine KV 캐시 적용)로 단축되었습니다. 콜드 턴 (cold turn)은 변함이 없습니다. 첫 번째 요청 시에는 캐싱할 이전 턴이 없기 때문입니다. 하지만 두 번째 턴부터의 캐시 히트율 (cache hit rate)은 사실상 100%이며, 속도 향상이 매우 커서 Claude Code가 매우 느린 상태에서 대화형 (interactive)으로 동작하게 되었습니다.

## 내가 다르게 했을 점 (What I'd do differently)

**엔진을 프로파일링(profiling)하기 전에 입력값의 차이(diff)를 확인했어야 합니다.** 만약 연속된 두 요청 본문(request bodies)을 30초 동안 `diff` 해보았다면, 과금 헤더(billing header)를 바로 찾아냈을 것입니다. 저는 너무 오랫동안 그 `diff`를 실행하지 않았습니다. vllm-mlx 내부 구조를 들여다보고, 프리필(prefill)을 프로파일링하고, mlx-lm 캐시 코드를 읽는 등, 실제로 네트워크를 통해 전송되는 바이트(bytes)가 아닌 다른 것들에만 매달려 있었습니다. 마침내 `diff`를 실행하자, 5분 만에 계속해서 변하는 `cch=` 값이 화면에 나타났습니다.

이 경험은 블랙박스 스택(black-box stack)에서 발생하는 모든 지연 시간(latency) 미스터리에 대한 저만의 개인적인 규칙이 되었습니다. **엔진이 오작동한다고 가정하기 전에, 연속된 두 요청을 캡처하여 `diff`를 수행하고, 무엇이 불안정하게 변하는지 먼저 확인하십시오.** 이번 사례에서 그랬다면 저녁 시간을 통째로 아낄 수 있었을 것이며, 앞으로도 더 많은 시간을 아껴줄 것이라 확신합니다.

두 번째로 바꿀 점은, SimpleEngine 캐시 패치(patch)를 과금 헤더(billing-header) 제거만으로 얻은 이득을 수치화한 *이후*에 적용했어야 한다는 것입니다. 저는 두 가지 수정을 한 세션에 몰아서 진행했고, 이로 인해 속도 향상의 원인을 명확하게 구분하여 파악하기가 더 어려워졌습니다. 이 포스트의 수치들은 후속 측정값을 통해 재구성된 것입니다. 만약 처음에 절제력 있게 행동했다면, 이미 준비된 수치를 가지고 있었을 것입니다.

## 이 문제가 발생하는 경우

다음과 같은 상황이라면 이 문제의 변형된 형태를 겪게 될 것입니다:

- Anthropic 호환 LLM 백엔드(vllm-mlx, llama.cpp의 Anthropic 어댑터, 커스텀 심(shim) 등)를 셀프 호스팅(self-host)하고, Claude Code 또는 다른 Anthropic 프로토콜 클라이언트를 해당 백엔드로 연결하는 경우.
- 시스템 프롬프트(system prompt)가 바이트 단위로 안정적임에도 불구하고, 웜 턴(warm turns)이 콜드 턴(cold turns)보다 빠르지 않다는 것을 인지한 경우.
- 프로파일링 시 엔진의 프리필(prefill) 단계가 매 턴마다 전체 프롬프트에 대해 실행되는 것을 확인한 경우.

만약 Anthropic의 호스팅 API를 사용 중이라면, 이 중 어느 것도 해당되지 않습니다. 플랫폼이 과금 헤더(billing header)와 프리픽스 캐싱(prefix caching)을 투명하게 처리하기 때문입니다.

## 재현 방법

속도 향상을 일으키는 두 가지 요소는 다음과 같습니다:

AI 자동 생성 콘텐츠

본 콘텐츠는 Dev.to AI tag의 원문을 AI가 자동으로 요약·번역·분석한 것입니다. 원 저작권은 원저작자에게 있으며, 정확한 내용은 반드시 원문을 확인해 주세요.

원문 바로가기
0

댓글

0