12분

DGX Spark를 6개월 굴리고 알게 된 것: 한계처럼 보이는 숫자는 한계가 아니었다

Mac mini가 애플리케이션을, DGX Spark가 모델을 맡고, 그 너머의 모든 것이 127.5GB 한 풀에 들어 있습니다. 6개월 동안 그 구조가 어디서 어긋났는지 적었습니다. alias가 제 일을 하는 바람에 드러나지 않은 모델 교체, 실제 레이트리밋과 부딪힌 팬아웃, 그리고 결국 메모리가 아니었던 병목까지.

  • vLLM
  • DGX Spark
  • LiteLLM
  • 운영
두 대의 기계와 하나의 사설 링크. 이 분리는 의도한 것이고, 동시에 이 글에 나오는 모든 장애가 나온 자리이기도 합니다.

SHIPPED / EVIDENCE

이 글이 다루는 것

LiteLLM 프록시 뒤에서 vLLM을 DGX Spark로 서빙하며 겪은 운영 장애 네 건과 병목 분석 하나입니다. 모든 주장은 공개 커밋이나 기동 로그에 대응하므로, 근거는 그쪽에 두고 이 글은 그 변경이 실제로 무엇을 위한 것이었는지에 대한 기록으로 읽으시면 됩니다.

vLLM이 볼 수 있는 전체 메모리
121.63 GiB
20.18 GiB에 담긴 KV 캐시 토큰
587,202
단일 스트림 디코드
10.7 tok/s
활성 사용자, 그런데도 프리필은 줄을 선다
2명

01

하나의 풀, 세 개의 서비스

팀의 스택은 두 대에서 돕니다. Mac mini가 애플리케이션을 맡습니다. API와 웹은 PM2 아래에서, Postgres와 Redis와 샌드박스 도구 프로세스는 Docker 안에서 돌아갑니다. 그 옆의 NVIDIA DGX Spark GB10이 LiteLLM 프록시 뒤에서 vLLM을 돌리고, 임베딩용 BGE-m3와 이미지 생성용 FLUX를 함께 띄웁니다. 둘은 사설 Tailscale 링크로 통신합니다.

GB10은 CPU와 GPU가 하나의 물리 메모리 풀을 씁니다. 총 127.5GB이고 그중 vLLM이 디바이스 메모리로 인식하는 것은 121.63GiB입니다. nvidia-smi는 사용량을 N/A로 보고합니다. 따로 잴 것이 없기 때문입니다. 그래서 예산은 도구에서 읽어 오는 GPU 수치가 아니라, 직접 할당하고 기동 로그로 확인하는 값이 됩니다.

  • vllm-chat (qwen3.8-27b-fp8, 262K) — 예산 0.47, 실제 57.17 GiB: 가중치 28.95, CUDA 그래프 1.2, 비토치 오버헤드 약 3. 남은 20.18 GiB가 KV 캐시로 fp8 기준 587,202 토큰. 262K 요청이라면 동시 2.24개.
  • vllm-embed (bge-m3) — 예산 0.05, 약 6.1 GiB: 가중치 1.06, 나머지는 KV.
  • flux (FLUX.2-klein-9B) — 비율 미지정, 로드 후 32.5 GiB 상주.
  • 합계 약 95.8 GiB. OS와 Docker와 프로세스 몫으로 약 26 GiB가 남고, MemAvailable은 15~16 GB 선입니다.

02

그 예산이 실제로 말하는 것

이 숫자에서 두 가지가 드러납니다. FLUX가 가장 비싼 입주자인데 정작 주력 제품이 아닙니다. 가끔 오는 이미지 요청이 콜드 로드를 치르지 않도록 32.5 GiB를 상주시키는 선택입니다. 그리고 실질적인 다이얼은 채팅 모델의 비율 하나뿐인데, 그것을 올린다는 것은 OS에서 메모리를 직접 빼 오는 일입니다. OS와 모델이 같은 풀에 있는 기계니까요.

두 대로 나눈 것은 의도한 구성입니다. 추론과 애플리케이션은 자원 프로파일이 다르고, 한 대에 같이 두면 하필 가장 나쁜 순간에 모델이 Postgres를 페이지 캐시에서 밀어냅니다. 다만 이 분리는 아래 나오는 모든 장애의 출처이기도 합니다. 네 건 모두 형태가 같습니다. 링크 한쪽에 있는 사실을 반대쪽이 가정하고 있었다는 것입니다.

03

alias는 제 일을 하고 있었다

9월 2일 DGX를 점검하다가 env/chat.env와 vLLM /v1/models의 root 필드를 읽었습니다. 실제로 서빙되고 있던 모델은 qwen3.8-27b-fp8이었습니다. 애플리케이션은 8월 31일 교체 이후로 줄곧 그것을 qwen3.6-35b-a3b라고 부르고 있었습니다.

아무것도 실패하지 않았습니다. --served-model-name에 새 이름과 함께 옛 이름이 alias로 붙어 있었고, 이것은 마이그레이션 도중 애플리케이션이 깨지지 않도록 하는 표준적인 조치입니다. 그리고 제대로 작동했습니다. 앱은 옛 이름으로 요청하고, LiteLLM은 그것을 해석하고, vLLM은 응답하고, UI의 모델 목록은 이틀 전에 사라진 모델을 계속 보여 주었습니다. 마이그레이션을 보호하려던 편의가 그 마이그레이션이 끝났다는 사실을 가린 것입니다.

그 기간에 두 개의 신호가 보였고, 둘 다 다른 것으로 읽었습니다. 디코드 처리량이 58 tok/s에서 10.7 tok/s로 떨어졌습니다. 2주 전 저희가 직접 잰 Qwen3.8 수치와 정확히 일치하는 5배 하락이었지만, 당시에는 부하 변동으로 보였습니다. 그리고 reasoning_effort=high가 400을 반환하기 시작했는데 그것은 제공자 쪽 특성으로 보였습니다. 어느 쪽도 모델 교체를 가리키지 않았습니다. 모든 로그 줄에 찍힌 이름이 예상하던 그 이름이었기 때문입니다. 원인이 붙지 않은 채로 이틀이 지났습니다.

그래서 적어 둔 교훈은 이렇습니다. 로컬 모델의 정체는 /v1/models가 root라고 말하는 것이지, LiteLLM alias가 해석해 주는 이름이 아닙니다. alias는 라우팅 편의일 뿐 무엇이 돌고 있는지에 대한 진술이 아닙니다. 그래서 이제 부팅과 주기 프로브가 게이트웨이의 /model/info를 읽어 실제로 거기 있는 것으로 카탈로그를 갱신하고, 정적 목록은 프로브 자체가 실패했을 때의 폴백으로만 남습니다.

04

팬아웃이 실제 레이트리밋과 부딪히다

Discussion과 Deep Research는 여러 전문가 호출을 동시에 펼칩니다. 무료 키나 개발자 키 입장에서 그것은 버스트이고, 제공자도 그렇게 취급합니다. 병렬 호출 다섯 건이 어떤 키에서는 5건 전부 429로, 다른 키에서는 5건 중 3건이 429로 돌아왔습니다.

잘못된 것이 세 가지였고, 세 개의 변경으로 나누어 고쳐야 했습니다.

  • 외부 실행 클라이언트에 제공자별 예산이라는 개념이 없었습니다. 이제 제공자별 세마포어 뒤에서 429에 지수 백오프로 대응하고, 후속 리뷰에서 Retry-After를 존중하도록 하고 우리와 무관한 429를 우리 것으로 오인하지 않게 했습니다.
  • Deep Research는 제공자가 감당할 수 있는 폭을 묻지 않고 팬아웃 폭을 정하고 있었습니다. 이제 동시성과 타임아웃이 제공자 힌트를 따릅니다.
  • SDK가 스스로 재시도를 하고 있었습니다. 요청한 적 없는 그 재시도 때문에 장시간 추론 모델이 360초마다 잘려 나가고 있었습니다. 지금은 SDK 재시도를 0으로 두고 타임아웃에 배수를 적용합니다.

05

가장 자신 없게 옹호할 변경

같은 릴리스에서, 명시적으로 선택된 외부 모델이 실행될 수 없을 때 조용히 로컬로 폴백하는 대신 오류를 드러내도록 바꿨습니다.

조용한 폴백은 요청을 살려 둡니다. 새벽 세 시에 원하는 동작이 바로 그것입니다. 다만 그것은 특정 모델을 고른 사용자가 다른 모델의 답을 받고도 끝내 모른다는 뜻이기도 합니다. 옷만 갈아입은 alias 문제인 셈입니다. 두 번째 비용이 더 크다고 판단했지만, 자명한 판단이라고는 말하지 않겠습니다.

06

오버플로를 숨기지 않고 처리하기

262K 창을 서빙하는데도 그 창을 넘깁니다. 긴 에이전트 실행은 도구 출력을 쌓고, 이미지도 공짜가 아닙니다. 안전망은 3단계이고 그 순서가 곧 무엇을 포기할지에 대한 결정입니다. 먼저 입력을 절삭하되 system 메시지와 최근 턴을 남기고, 그다음 max_tokens를 보장된 하한까지 줄이고, system 메시지만으로도 창을 넘길 때에야 ContextOverflowError가 HTTP 413과 감사 기록과 자동 웹훅 알림으로 이어집니다.

출력을 먼저 보호합니다. 문장 중간에 잘린 답변은 컨텍스트가 부족했던 답변보다 나쁩니다. 후자는 사용자가 그 실패를 보고 대응할 수 있기 때문입니다. 절삭은 첫 user 메시지도 앵커로 지킵니다. 에이전트 작업에서 그 메시지가 곧 목표라, 그것을 잃으면 바쁘기만 하고 무의미한 실행이 됩니다.

임계값도 상수가 아닙니다. env 명시, 부팅 시점 프로브 실측, 카탈로그, 기본값 순으로 해결합니다. 링크 반대편의 모델은 언제든 바뀔 수 있기 때문이고, alias 사건이 그 대가를 치르며 알려 준 사실입니다. 이 항목만 나머지보다 오래됐습니다. vLLM 전환 직후인 5월 25일에 들어갔습니다.

07

한 프로세스에서 강제되고 다른 프로세스에서 가정된 상한

요청당 프롬프트 이미지 수를 이제 vLLM 자체의 --limit-mm-per-prompt 값인 8에 맞춥니다. 그 상한은 서빙 쪽에 이미 있었습니다. 애플리케이션에는 없었고, 그래서 애플리케이션은 아홉 번째 이미지를 태연히 조립해 보내고 서버가 요청 전체를 거절하게 두었습니다.

alias 사건과 같은 형태이고, 이쯤 되니 알아보게 됩니다. 한 프로세스에서 강제되는 제약을 다른 프로세스가 베껴 적었다는 것입니다. 지금까지 찾은 유일한 방어는 그 제약을 복사하는 대신 런타임에 원본에서 읽는 것입니다. 관련된 수정이 다음 릴리스에 들어갔습니다. 로컬 도구 루프에서 assistant reasoning을 다음 턴으로 보존하고 vLLM tool_call id를 유지하는 것인데, 멀티턴 도구 사용이 두 가지 모두에 의존합니다.

08

메모리도 아니고, 사용자 수도 아니었다

이런 기계에서 직관적으로 떠올리는 한계는 KV 캐시입니다. 저희에게는 한 번도 그랬던 적이 없습니다.

  • KV는 여유가 있습니다. 20.18 GiB는 587,202 토큰입니다. 262K를 꽉 채운 요청으로는 2.24개뿐이지만 실제 턴은 12~17K라 30~40 시퀀스를 담습니다. 하루 약 1,100 요청, 활성 사용자 2명 수준에서 KV가 제약이 된 적은 한 번도 없습니다.
  • 프리필은 연산 한계입니다. 대략 800~900 tok/s. 17K 첫 턴은 캐시 미스에서 15~22초, 히트에서 5초가 걸립니다. max-num-batched-tokens를 8192에서 16384로 올렸지만 TTFT는 전혀 움직이지 않았습니다.
  • 디코드는 메모리 대역폭 한계로 단일 스트림 10.7 tok/s입니다. FP8 dense 27B는 토큰마다 29 GiB의 가중치를 읽는다는 뜻이라 GB10의 대역폭이 천장입니다. MTP를 2~3으로 올려 얻은 것은 4~5%였습니다.
  • 동시 사용자는 일차적 문제가 아니라 파생 문제입니다. 사용자가 늘 때 차오르는 것은 KV가 아니라 프리필 큐입니다. 17K 프롬프트 하나가 GPU를 약 20초 점유하므로, N명이 동시에 첫 턴을 보내면 그만큼 줄을 서는 것입니다.

09

레버를 당길 순서

첫째, 프리필 자체를 줄이는 것입니다. prefix cache 적중률과 프롬프트 다이어트이고 이미 27% 줄였습니다. 둘째, 두 번째 추론 노드입니다. LiteLLM 라우팅과 cluster 코드는 이미 있습니다. 셋째, 더 작은 모델이나 MoE로의 회귀이고 디코드에서 약 5배입니다.

GPU 메모리를 늘리는 것은 FLUX를 내려 KV를 키울 때만 의미가 있고, 그것으로 처리량 천장은 전혀 건드리지 못합니다. 돈을 쓰기 전에 알아 두면 쓸모 있는 사실이 이것입니다. 이 기계에서 한계처럼 보이는 숫자는 한계가 아니었습니다.

10

한 대 더 살 것인가

삽니다. 그리고 그 대답의 형태가 6개월에 대한 정직한 요약입니다.

위의 어느 것도 하드웨어에 대한 불평이 아닙니다. 네 건의 장애는 모두 링크의 애플리케이션 쪽에 있던 저희 문제였습니다. 읽는 대신 베껴 적은 이름, 반대편이 감당할 수 있는지 묻지 않고 보낸 버스트, 숨기기를 그만둬야 했던 오버플로 경로, 한 프로세스에서 강제되고 다른 쪽에서 가정된 상한. 이 기계는 저희가 사려고 했던 일을 합니다.

다만 확장한다는 것은 다이얼을 돌리는 일이 아니라 Spark를 한 대 더 사는 일이고, 그것이 셀프호스팅이 실제로 하는 거래입니다. API 청구서는 사용량을 따라가는 운영비입니다. 기계는 용량이 생기기 전에 먼저 치러야 하는 자본지출입니다. 예산이 되면 한 대를 더할 것입니다. 이것은 “필요할 때 확장할 수 있다”와는 다른 문장이고, 참인 쪽입니다.

위의 레버들은 발표하는 로드맵이 아닙니다. 첫 번째는 이미 27% 진행됐고, 두 번째를 위한 라우팅과 cluster 코드는 이미 있으며, 이 글의 측정치는 쓰는 시점 기준으로 사흘 전 것입니다. 저희는 매주 무엇을 배포했고 무엇을 못 했는지를 기록으로 남깁니다. 잘 풀리지 않은 주도 포함해서입니다. 이 글은 그 습관의 6개월치 버전이고, 다음 글에는 다른 숫자가, 아마도 다른 병목이 실릴 것입니다.

11

이 측정을 한 사람에 대하여

분명히 밝혀 둘 것이 하나 있습니다. 위의 모든 것을 어떻게 읽을지가 달라질 수 있기 때문입니다. 이 프로젝트는 비개발자가 이끌고 있습니다. openmake_llm은 요즘 바이브코딩이라 부르는 방식으로 만들어지고 있고, 전문 개발자가 개발을 지원합니다.

기동 로그도 실측치도 진짜이고 커밋도 공개돼 있습니다. 다만 그 측정을 한 사람은 측정하는 법을 그 일을 하면서 배웠습니다. 병목 절에 나오는 프리필과 디코드의 구분은 이 프로젝트에 가져온 지식이 아니라 프로젝트가 피할 수 없게 만든 것입니다. alias 사건이 이 글에 있는 이유의 절반은 그것이 좋은 이야기라서이고, 나머지 절반은 서비스를 실제로 운영하며 배운다는 것이 원래 그런 모습이기 때문입니다.

이 이야기를 꺼내는 이유는, 이제 흥미로운 질문이 모델이 코드를 쓸 수 있는가가 아니게 되었기 때문입니다. 손으로는 이 스택을 짤 수 없었을 사람이 그것을 운영할 수 있는가, 정직하게 측정하고 시스템이 자기를 속이는 지점을 찾아내고 증상이 아니라 원인을 고칠 수 있는가 쪽입니다. 6개월이 지난 지금의 답은 그렇다에 가깝습니다. 아주 많이 재어 보아야 한다는 조건이 붙지만요. 위의 장애 네 건이 그 대가의 모습입니다.

그러니 써 보시다가 문서가 틀린 곳이 있으면 알려 주십시오. 같은 질문에 두 번 답하기보다 문서를 고치는 쪽이 낫고, 바깥의 시선이 이 코드베이스에서 실제로 커밋이 된 적이 이미 여러 번 있습니다.

근거 자료

근거 자료

개발일지로 돌아가기