
하나의 DGX Spark에서 두 개의 Qwen3 모델 구동하기: 로컬 LLM 코딩을 위한 공존 수학
요약
DGX Spark 환경에서 vLLM을 사용하여 서로 다른 크기의 Qwen3 모델 두 개를 동시에 구동하는 최적화 과정을 다룹니다. Ollama의 메모리 관리 한계를 극복하고 PagedAttention과 메모리 예산 설정을 통해 효율적인 모델 공존을 구현하는 방법을 설명합니다.
핵심 포인트
- Ollama의 메모리 독점 문제를 vLLM의 gpu_memory_utilization으로 해결
- PagedAttention을 통한 효율적인 KV 캐시 관리
- LiteLLM 프록시를 활용한 단일 엔드포인트 라우팅 구현
- 대형 모델(80B)과 경량 모델(4B)의 하드웨어 자원 배분 최적화
Hermes를 사용하는 저의 에이전트 스택은 워크스테이션에서 실행됩니다. 모델들은 동일한 LAN 상의 DGX Spark에서 실행됩니다. 이러한 분리는 의도적인 것입니다. 워크스테이션은 응답성을 유지하고, Spark는 GPU 작업을 수행하며, 이들은 HTTP 프록시를 통해 통신합니다.
Clawrium을 통해 에이전트 플릿(fleet)을 관리하기 시작한 이후, Hermes의 수는 계속 늘어났습니다. 더 많은 호스트에 더 많은 에이전트가 생겼고, 더 많은 동시 트래픽이 발생하며, 이 모든 것이 동일한 Spark로 몰리고 있습니다. 노트북 한 대와 모델 하나로 구성되었던 설정은 이제 단일 백엔드에 맞서는 작은 플릿이 되었으며, 부하의 형태는 단일 모델 서버가 감당할 수 없는 수준이 되었습니다.
Spark는 몇 달 동안 ollama를 통해 모델을 서비스했습니다. 잘 작동했습니다. 모델 하나를 띄우고, 단일 설정을 사용하며, 종료하기도 쉬웠습니다.
하지만 ollama가 카드를 독점합니다. 프로세스당 메모리 예산(memory budget)이 없고, gpu_memory_utilization 조절 노브도 없으며, 추론을 위한 무거운 모델과 빠른 턴(turn)을 위한 빠른 모델을 공존(coresident)시킬 간단한 방법이 없습니다. KV 캐시(KV cache) 관리는 하위 llama.cpp 백엔드가 제공하는 방식에 의존합니다. PagedAttention은 없습니다.
vLLM은 이 모든 것을 해결합니다.
PagedAttention은 KV 블록을 연속적으로 고정(contiguous-pinning)하는 대신 재사용합니다.
gpu_memory_utilization은 컨테이너당 예산을 제공합니다.
하나의 Spark (GB10, 119.67 GiB 통합 메모리)는 :4000 포트의 LiteLLM 프록시 뒤에서 여러 vLLM 컨테이너를 실행할 수 있으며, Hermes는 하나의 URL을 호출하여 두 모델 중 하나로 라우팅됩니다. 그 약속은 이렇습니다: 무거운 작업을 위해 Qwen3-Next-80B-Instruct-FP8을 제공하고, 빠른 턴을 위해 Qwen3-4B-Instruct-2507을 제공하며, 이 둘을 공존시켜 단일 엔드포인트에서 모두 접근 가능하게 만드는 것입니다.
그것이 이유입니다. 이어지는 내용은 이 약속을 지키기 위해 무엇이 필요했는지에 대한 기록입니다.
숫자만 맞다면 Spark 하드웨어는 기꺼이 두 개의 Qwen3 모델을 수용할 것입니다. 하지만 며칠 동안 숫자가 맞지 않았습니다. 그것이 제 지난 주말이 어떻게 흘러갔는지를 보여줍니다.
첫 번째 시도: 목표를 신뢰하라
첫 번째 80B 설정: gpu_memory_utilization: 0.75, max_model_len: 65536, max_num_seqs: 4. vLLM의 KV 캐시 (KV cache) 초기화가 _"No available memory for the cache blocks."_라는 오류와 함께 충돌했습니다. Qwen3-Next는 대부분 Mamba 구조입니다. 블록당 페이지 정렬 (per-block page alignment) 방식으로 인해, 가중치 (weights) 로드 후 남은 약 14 GiB의 잔여 메모리보다 더 높은 KV 풀 (KV pool) 요구량을 발생시킵니다.
0.85로 올렸습니다. 이번에는 여유 메모리 확인 단계에서 충돌이 발생했습니다: "Free memory on device (98.51/119.67 GiB) is less than desired GPU memory utilization (0.85, 101.72 GiB)." 4B 모델이 이미 약 16 GiB를 점유하고 있었습니다. 80B의 0.85 목표치는 남은 메모리가 아니라 카드 전체 메모리를 기준으로 읽고 있었던 것입니다.
이것이 첫 번째 교훈입니다. gpu_memory_utilization은 여유 메모리 (free memory)가 아니라 전체 GPU 메모리 (total GPU memory)의 비율입니다.
두 개의 vLLM 프로세스가 공존하려면, CUDA 프레임워크 오버헤드 (overhead)를 위한 공간을 남겨두기 위해 각 비율의 합이 약 0.95 미만이 되어야 합니다. 만약 계산할 때 여유 메모리를 기준으로 가정한다면, OOM (Out of Memory)과 조용한 KV 기아 (KV starvation) 상태 사이를 오가게 될 것입니다.
80B 모델에 대해 0.80 / 32k / 2로 설정하여 안착했습니다. 깨끗하게 로드되었습니다. 가중치 로드 후 KV 풀은 약 20.8 GiB입니다.
두 번째 시도: Hermes를 연결하기
그 후 Hermes가 온라인 상태가 되었지만, 도구 호출 (tool calls)이 일반 텍스트로 돌아왔습니다. content 안에 <tool_call> JSON이 들어있고, tool_calls: [], finish_reason: stop 상태였습니다. Hermes는 이를 실행하지 않았습니다.
하루 동안 파서 (parser) 분류 작업을 진행했지만 실질적인 결과는 없었습니다. hermes_tool_parser.py와 qwen3xml_tool_parser.py 모두 단수형인 <tool_call>을 찾습니다. 복수형 태그인 <tools>는 시스템 프롬프트 (system-prompt) 정의이지 출력이 아닙니다. 파서가 틀린 것이 아니었습니다. 모델이 내보내지(emit) 않고 있었던 것입니다.
tool_choice: "required"는 작동했습니다. 하지만 tool_choice: "auto"는 tool_calls: [], content: ""와 같이 빈 값으로 돌아왔습니다. <think> 태그 안에 619자 분량의 추론 (reasoning)이 담겨 있었고, 마지막에 _"Alright, that's it"_라고 결론지었지만 호출을 내보내지는 않았습니다.
Qwen의 자체 모델 카드 (model card)에는 명확하게 명시되어 있습니다: Qwen3-Next-80B-Thinking은 오직 사고 모드 (thinking mode)만 지원합니다. 이 체크포인트 (checkpoint)에서 enable_thinking: false는 구조적으로 아무런 동작도 하지 않는 (no-op) 설정입니다. 프롬프트에 /no_think를 넣어도 무시됩니다. 모델은 <think> 내부에서 추론하고 결정하지만, 결코 (호출을) 내보내지 않습니다.
이는 tool_choice: "auto"를 기본값으로 사용하는 모든 에이전트 SDK(agent SDK)에게 복구 불가능한 실패입니다. 해결책은 파서 플래그(parser flag)를 수정하는 것이 아니었습니다. 80B 백본(backbone) 전체를 Thinking 모델에서 Instruct 모델로 교체하는 것이었습니다.
77 GiB 사전 풀(pre-pull). GPU 비우기. --enable-auto-tool-choice --tool-call-parser hermes 옵션으로 실행하되, --reasoning-parser는 제외합니다. 세 개의 LiteLLM 별칭(aliases) (writer / reviewer / sources) 모두 finish_reason: tool_calls와 함께 `tool_choice:
0.10의 4B 모델은 목표치가 암시하는 12 GiB가 아니라 실제로는 13.8 GiB를 점유하고 있습니다. CUDA 프레임워크 오버헤드 (overhead)는 작은 할당량에서도 사라지지 않습니다.
특히 Qwen3-Next의 경우, max_model_len × max_num_seqs는 어텐션 (attention) KV가 아니라 Mamba 상태 정렬 (state alignment)에 의해 지배됩니다. max_model_len을 절반으로 줄인다고 해서 순수 어텐션 (attention) 모델에서처럼 KV 풀 (pool) 요구량이 절반으로 줄어들지는 않습니다. KV를 계획할 때는 Llama 계열 모델의 직관이 아니라 Mamba 페이지 크기 (page sizes)를 기준으로 삼아야 합니다.
배선 작업이 완료되자, LiteLLM은 Spark에서 실행 중인 동일한 두 모델에 대한 모든 별칭 (aliases)을 보여주었습니다.
통찰 (The insight)
gpu_memory_utilization은 vLLM이 프로세스 시작 시 전체 카드 메모리를 기준으로 찍는 스냅샷 (snapshot)입니다. 이는 여유 메모리 (free memory)를 대상으로 하는 목표치가 아닙니다. 이전의 실패한 시도로 인한 CUDA 컨텍스트 (contexts)는 일시적으로 점유율을 부풀려 잘못된 체크를 유발할 수 있습니다. 공존하는 프로세스들은 협상하지 않습니다 — 그들은 경합합니다.
중요한 유일한 수치는 두 프로세스가 모두 안정화된 후의 실제 점유율이며, 이는 재시작하기 더 어려운 모델이 충돌 후 복구하는 데 필요한 여유 공간 (headroom)을 기준으로 측정되어야 합니다. 목표 할당량은 계획 입력값일 뿐이며, 실제 수치가 실측값 (ground truth)입니다.
두 모델의 Spark 배포를 위한 플레이북 (playbook)은 다음과 같습니다: 더 큰 모델을 먼저 로드하고, 안정화될 때까지 기다린 후, nvidia-smi를 실행하여 실제 점유율을 읽습니다. 그런 다음 작은 모델의 gpu_memory_utilization 크기를 [전체 여유 풀 - 자체 프레임워크 오버헤드용 약 5 GiB]로 설정합니다. 두 모델이 모두 깨끗하게 두 번 재시작된 후 다시 확인하십시오.
24시간 실행 과제 (The 24-hour action)
지금 vLLM 배포를 실행 중이라면, 다음을 실행해 보세요:
nvidia-smi --query-gpu=memory.used --format=csv
실제 수치를 gpu_memory_utilization 목표값이 시사하는 값과 비교해 보세요. 만약 두 값이 10% 이상 차이가 난다면, 당신의 사이징 모델 (sizing model)이 잘못된 것입니다. 에이전트 스택 (agent stacks), 병렬 워커 (parallel workers), 폴백 체인 (fallback chains) 등 공존 (coresidency)에 의존하는 그 어떤 것도 배포하기 전에 이를 수정하십시오. 수학은 열망이 아닌 경험적 (empirical)이어야 합니다.
만약 당신이 이와 유사한 로컬 LLM 스택 — DGX Spark (또는 기타 하드웨어), vLLM, 다중 공존 모델 (multiple coresident models), 또는 단일 추론 백엔드 (inference backend)에 원격 에이전트 함대 (remote agent fleet)를 연결하는 작업 — 을 구축하고 있다면, 함께 의견을 나누고 싶습니다.
AI 자동 생성 콘텐츠
본 콘텐츠는 Dev.to AI tag의 원문을 AI가 자동으로 요약·번역·분석한 것입니다. 원 저작권은 원저작자에게 있으며, 정확한 내용은 반드시 원문을 확인해 주세요.
원문 바로가기