메뉴
HN
Hacker News 40일 전

DGX Spark 하나에 두 개의 Qwen3 모델 구동하기

IMP
8/10
핵심 요약

DGX Spark(GB10) 단일 하드웨어에 vLLM과 LiteLLM을 활용해 대형 모델(Qwen3-Next-80B)과 소형 모델(Qwen3-4B)을 동시에 띄우는 고군분투기를 다룹니다. 단순 OOM 문제부터 vLLM의 메모리 할당 방식, 그리고 Qwen3 모델의 도구 호출 에이전트 연동 시 발생하는 치명적인 파싱 및 추론 모드 문제를 해결하는 과정을 담고 있습니다.

번역된 본문

DGX Spark 한 대에 두 개의 Qwen3 모델 구동하기: 로컬 LLM 환경 구축을 위한 메모리 계산법

상주(Residency) 연산, gpu_memory_utilization 함정, 그리고 가장 먼저 확인해야 할 사항들. 로컬 LLM 실험 과정에서 얻은 노하우입니다. Devashish, 2026년 6월 16일

내 에이전트 스택은 Hermes와 함께 워크스테이션에서 돌아갑니다. 모델은 같은 LAN 망에 있는 DGX Spark에서 실행됩니다. 이러한 분리는 의도된 것입니다. 워크스테이션은 가벼운 상태를 유지하고, Spark가 GPU 작업을 처리하며, 둘은 HTTP 프록시를 통해 통신합니다.

Clawrium를 통해 에이전트 플릿(함대)을 관리하기 시작한 이후, Hermes 인스턴스 수가 계속 늘어났습니다. 더 많은 호스트에 더 많은 에이전트가 생기고 동시 트래픽이 증가하며, 모두 같은 Spark로 몰리게 되었습니다. 예전에는 노트북 한 대에서 모델 하나만 구동하던 설정이 이제는 단일 백엔드를 바라보는 소규모 클러스터가 되었습니다. 그리고 이러한 부하의 형태는 기존 단일 모델 서버가 감당할 수 없는 구조입니다.

devashish.me을 읽어주셔서 감사합니다! 새로운 게시물을 받아보고 제 작업을 지원해주세요. 구독하기

수개월 동안 Spark는 ollama를 통해 모델을 서빙했습니다. 잘 작동했습니다. 모델 하나를 띄우고, 단일 설정을 유지하며, 쉽게 내리는 것이 가능했습니다. 하지만 ollama는 GPU 카드를 독점합니다. 프로세스별 메모리 예산도, gpu_memory_utilization 조절 기능도 없으며, 무거운 추론용 모델과 빠른 응답용 모델을 함께 상주시킬(Coresident) 간단한 방법이 없습니다. KV 캐시 관리는 기저의 llama.cpp 백엔드가 제공하는 기본 수준에 불과했습니다. PagedAttention도 지원하지 않았습니다.

vLLM은 이 모든 문제를 해결해 줍니다. PagedAttention은 KV 블록을 연속으로 고정하지 않고 유연하게 회수합니다. gpu_memory_utilization 설정은 컨테이너별로 메모리 예산을 할당해 줍니다. 하나의 Spark(GB10, 119.67 GiB 통합 메모리)는 :4000 포트의 LiteLLM 프록시 뒤에서 여러 vLLM 컨테이너를 실행할 수 있으며, Hermes는 단일 URL을 통해 두 모델 중 하나로 라우팅할 수 있습니다.

여기서 목표는 명확했습니다. 무거운 작업을 위해 Qwen3-Next-80B-Instruct-FP8을, 빠른 응답을 위해 Qwen3-4B-Instruct-2507을 상주시키되, 단일 엔드포인트에서 두 모델 모두에 접근할 수 있게 하는 것입니다. 이것이 '왜' 해야 하는가에 대한 이유입니다. 아래는 이 목표를 실제로 이루기 위해 겪은 과정입니다. 수치만 잘 맞는다면 Spark 하드웨어도 두 개의 Qwen3 모델을 거뜬히 감당할 수 있습니다. 하지만 며칠 동안은 수치가 맞지 않았습니다. 지난 주말이 그렇게 날아갔습니다.

시도 1: 목표치를 맹신하다 처음 80B 모델 설정은 gpu_memory_utilization: 0.75, max_model_len: 65536, max_num_seqs: 4 였습니다. vLLM의 KV 캐시 초기화가 "캐시 블록에 사용할 수 있는 메모리가 없습니다(No available memory for the cache blocks)"라는 에러를 내며 충돌했습니다. Qwen3-Next는 대부분 Mamba 기반이며, 블록별 페이지 정렬로 인해 가중치 로딩 후 남은 약 14 GiB의 메모리보다 더 많은 KV 풀을 요구했습니다.

할당량을 0.85로 올렸습니다. 이번에는 가용 메모리 확인 단계에서 충돌했습니다. "장치의 여유 메모리(98.51/119.67 GiB)가 원하는 GPU 메모리 사용량(0.85, 101.72 GiB)보다 적습니다." 4B 모델이 이미 약 16 GiB를 점유하고 있었습니다. 80B 모델의 0.85 목표량은 '남은 여유 메모리'가 아닌 '카드 전체 메모리'를 기준으로 계산되고 있었습니다.

이것이 첫 번째 교훈입니다. gpu_memory_utilization은 여유 메모리가 아닌 전체 GPU 메모리 대비 비율입니다. 두 개의 vLLM 프로세스를 동시에 상주시키려면 CUDA 프레임워크 오버헤드를 위해 각각의 비율을 합쳐 0.95 미만으로 설정해야 합니다. 만약 남은 여유 메모리를 기준으로 계산한다면 OOM(메모리 부족)과 알 수 없는 KV 부족 현상 사이에서 무한히 고통받게 될 것입니다.

결국 80B 모델은 0.80 / 32k / 2로 설정하니 해결되었습니다. 가중치 로딩 후 KV 풀에 약 20.8 GiB가 할당되어 정상적으로 실행되었습니다.

시도 2: Hermes를 연동하다 문제 없이 모델이 로드되어 Hermes 에이전트를 붙였더니 이번에는 도구 호출(tool calls)이 일반 텍스트로 반환되는 문제가 발생했습니다. JSON 형태가 그냥 텍스트 내용(content)으로 들어가는 것입니다. 실제 파싱된 tool_calls는 비어 있고([]), finish_reason은 'stop'으로 떨어졌습니다. Hermes는 이 텍스트를 도구 호출로 인식하지 못했습니다.

하루 종일 파서(parser) 문제를 추적해 봤지만 해결책이 없었습니다. hermes_tool_parser.py와 qwen3xml_tool_parser.py 모두 단수형인 태그를 찾도록 되어 있었습니다. 복수형인 태그는 시스템 프롬프트 정의에 사용되는 것이지 출력용이 아니었습니다. 파서가 잘못된 게 아니라 모델이 도구 호출 자체를 방출(emit)하지 않는 것이었습니다.

tool_choice를 "required"로 강제하면 작동했습니다. 하지만 "auto"로 설정하면 tool_calls는 비어있고, content도 비어있는 채로, 태그 안에서 619자에 달하는 긴 추론(reasoning) 과정을 거친 뒤 "자, 됐다."라는 결론만 내리고는 도구 호출을 내보내지 않았습니다.

Qwen의 공식 모델 카드는 이에 대해 명확히 설명하고 있습니다. Qwen3-Next-80B-Thinking 모델은 '생각(Thinking) 모드'만 지원합니다. enable_thinking: false 설정은 이 모델 체크포인트에서는 구조적으로 아무런 작동도 하지 않습니다(no-op). 프롬프트에 /no_think를 넣어도 무시됩니다. 모델은 안에서 추론을 한 뒤 결정만 내릴 뿐, 외부로 출력을 하지 않습니다.

기본적으로 tool_choice: "auto"를 사용하는 모든 에이전트 SDK 환경에서 이것은 복구 불가능한(unrecoverable) 치명적인 실패입니다. 결국 이 문제는 파서 플래그 교체만으로는 해결할 수 없는 문제였고, 근본적인 우회 방법이 필요했습니다.

원문 보기
원문 보기 (영어)
Two Qwen3 Models on One DGX Spark: The Residency Math for Local LLM Setup The residency math, the gpu_memory_utilization trap, and what to verify first. Notes from my experiments with local LLMs. Devashish Jun 16, 2026 Share My agent stack with Hermes runs on a workstation. The models run on a DGX Spark on the same LAN. The split is deliberate: the workstation stays responsive, the Spark does the GPU work, and they talk over an HTTP proxy. Since I started managing the agent fleet through Clawrium , the Hermes count has climbed. More agents on more hosts, more concurrent traffic, all hitting the same Spark. What was a one-laptop, one-model setup is now a small fleet against a single backend — and the shape of the load is exactly what a single-model server can’t serve. Thanks for reading devashish.me! Subscribe for free to receive new posts and support my work. Subscribe The Spark served models through ollama for months. It worked. One model up, single config, easy to bring down. But ollama owns the card. There’s no per-process memory budget, no gpu_memory_utilization knob, no straightforward way to coresident a heavy model for reasoning and a fast model for quick turns. KV cache management is whatever the underlying llama.cpp backend gives you. PagedAttention isn’t there. vLLM fixes all of that. PagedAttention reclaims KV blocks instead of contiguous-pinning them. gpu_memory_utilization gives you a per-container budget. One Spark (GB10, 119.67 GiB unified memory) can run multiple vLLM containers behind a LiteLLM proxy on :4000 , and Hermes hits one URL to route to either model. The promise: serve Qwen3-Next-80B-Instruct-FP8 for the heavy work and Qwen3-4B-Instruct-2507 for fast turns, coresident, both reachable from a single endpoint. That’s the why. What follows is what it took to make the promise hold. Spark hardware will happily hold two Qwen3 models if the numbers line up. They didn’t, for several days. That’s where my last weekend went. Attempt one: trust the target First 80B config: gpu_memory_utilization: 0.75 , max_model_len: 65536 , max_num_seqs: 4 . vLLM’s KV cache init crashed with “No available memory for the cache blocks.” Qwen3-Next is mostly Mamba; the per-block page alignment pushes KV pool demand higher than the ~14 GiB residue after weights. Bumped to 0.85. Now the free-memory check crashed: “Free memory on device (98.51/119.67 GiB) is less than desired GPU memory utilization (0.85, 101.72 GiB).” The 4B was already resident at ~16 GiB. The 80B’s 0.85 target was reading the whole card, not what was free. That’s the first lesson. gpu_memory_utilization i s a fraction of total GPU memory, not free memory . Two co-resident vLLM processes need their fractions to sum below ~0.95 to leave room for CUDA framework overhead. If your math assumes free, you’ll oscillate between OOMs and silent KV starvation. Settled at 0.80 / 32k / 2 for the 80B. Loaded clean. KV pool ~20.8 GiB after weights. Attempt two: point Hermes at it Then Hermes came online and tool calls came back as plain text. <tool_call> JSON sitting inside content . tool_calls: [] . finish_reason: stop . Hermes never executed it. A day of parser triage produced nothing actionable. Both hermes_tool_parser.py and qwen3xml_tool_parser.py look for <tool_call> (singular). The <tools> plural tag is the system-prompt definition, not the output. The parser wasn’t wrong. The model wasn’t emitting. tool_choice: "required" worked. tool_choice: "auto" came back empty: tool_calls: [] , content: "" , 619 characters of reasoning inside <think> concluding “Alright, that’s it” without emitting the call. Qwen’s own model card states it plainly: Qwen3-Next-80B-Thinking supports only thinking mode. enable_thinking: false is a structural no-op on this checkpoint. /no_think in the prompt is ignored. The model reasons inside <think> , decides, and never emits. That’s an unrecoverable failure for any agent SDK that defaults to tool_choice: "auto" . The fix wasn’t a parser flag. It was swapping the whole 80B backbone from Thinking to Instruct. 77 GiB pre-pull. Drain GPU. Bring up with --enable-auto-tool-choice --tool-call-parser hermes , no --reasoning-parser . Three LiteLLM aliases (writer / reviewer / sources) all passed tool_choice: "auto" cleanly with finish_reason: tool_calls . Trade accepted: reviewer loses native <think> traces. Reasoning moved into the prompt. Attempt three: the bump that broke coresidency Reviewer agent (running on Hermes) needed 64k context. Bumped the 80B to 0.85 / 65536 / 2 . 80B loaded healthy. The 4B’s restart loop kicked in 19 times: “Free memory on device (12.58/119.67 GiB) is less than desired GPU memory utilization (0.12, 14.36 GiB).” 80B’s actual residency at 0.85 was 101.5 GiB. Plus ~5 GiB CUDA framework overhead. That left ~12.5 GiB free. The 4B needed 14.36 GiB. No room. Toned the 80B back to 0.80, dropped the 4B to 0.10 / 16384 / 8 . Both came up healthy. The 4B’s max_model_len had to drop because the 0.10 allocation leaves only ~3.5 GiB for KV pool — 32k single-seq KV demand (~4.8 GiB) doesn’t fit; 16k (~2.4 GiB) does. The residency math This is the table I wish I’d built on day one: Three observations from the actuals. The 80B’s actual residency at 0.80 ran 8 GiB under allocation. That cushion is the only reason the 4B’s restart variability doesn’t break the deployment. At 0.85, the cushion went negative — same hardware, same models, same vLLM build. The 4B at 0.10 actually resides at 13.8 GiB, not the 12 GiB the target implies. CUDA framework overhead doesn’t disappear at small allocations. On Qwen3-Next specifically, max_model_len × max_num_seqs is dominated by Mamba state alignment, not attention KV. Halving max_model_len doesn’t halve KV pool demand the way it does on a pure attention model. Plan KV against Mamba page sizes, not against intuition from Llama-class models. Once the wiring was complete, LiteLLM showed all the aliases for the same two models running on the spark. The insight gpu_memory_utilization is a snapshot vLLM takes at process start, against total card memory. It is not a target against free memory. CUDA contexts from prior failed attempts can transiently inflate residency and trip the check spuriously. Co-resident processes don’t negotiate — they race. The only number that matters is actual residency after both processes have stabilized, measured against the headroom the harder-to-restart model needs to come back from a crash. Target allocations are a planning input; actuals are the ground truth. For a two-model Spark deployment, the playbook is: load the bigger model first, let it settle, run nvidia-smi to read actual residency, then size the smaller model’s gpu_memory_utilization against the free pool minus ~5 GiB for its own framework overhead. Recheck after both restart cleanly twice. The 24-hour action If you have a vLLM deployment running right now, pull this: nvidia-smi --query-gpu=memory.used --format=csv Compare the actual number to what your gpu_memory_utilization target implies. If the two diverge by more than 10%, your sizing model is wrong. Fix it before you ship anything that depends on coresidency — agent stacks, parallel workers, fallback chains. The math has to be empirical, not aspirational. If you’re standing up a similar local-LLM stack — DGX Spark(or other hardware), vLLM, multiple coresident models, or wiring a remote agent fleet to a single inference backend — I’d love to compare notes. Thanks for reading devashish.me! Subscribe for free to receive new posts and support my work. Subscribe Share