-
로컬 챗봇 시리즈 #4 — Vision 32B에서 7B로, 그리고 포기까지 — 두 vLLM 동거 시행착오IT 2026. 5. 8. 22:30
들어가며 — "이미지도 보내야지"가 호출하는 산수
로컬 챗봇에 학원 시간표 사진, 에러 스크린샷, 지도 캡처 같은 이미지를 던지고 싶었다. 텍스트 모델(Qwen3.6:35B)은 이미지를 못 보니 vision 모델을 따로 띄워야 한다. 평범한 결정 같지만 — DGX Spark의 통합 메모리 128GB 안에서 텍스트 35B + vision을 어떻게 동거시킬 것인가가 곧바로 산수 문제가 된다.
처음엔 32B로 등록했다가 7B로 다운그레이드했고, 결국 이 챗봇에서 이미지 분석 자체를 포기했다. 이 글은 그 시행착오의 기록이다. 메모리 산수와 GPU 스케줄러 우선순위 디자인까지는 깔끔했지만 실전에서 4가지 결함이 누적되면서 응답 ~10분이라는 수용 불가능한 비용에 도달했다. 다음 시도 — 외부 vision API, 통합 모델, 양자화 다운, 하드웨어 업그레이드 — 가 같은 함정을 안 밟도록 무엇이 어디서 깨졌는지 남긴다.
1. 32B → 7B 다운그레이드 — 마진 26GB와 34GB의 차이
처음 등록한 vision 모델은
qwen2.5vl:32b(약 22GB). 7B 대비 분명 화질이 좋다. 그런데 이게 텍스트 35B와 함께 메모리에 잡히면 어떻게 되는지 표로 그려보자.이 그림이 보여주는 핵심은 잔량 26GB와 34GB의 8GB 차이가 단순한 숫자 차이가 아니라는 것이다. 두 케이스 모두 "현재 떠 있는 두 LLM 외에 다른 프로세스가 쓸 수 있는 여유"를 표시한다. OS 자체는 10GB 정도로 잡아둔 상한이지만, 실제로는 Qdrant 벡터 DB, Immich 사진 백엔드, Docker 데몬, 기타 사용자 프로세스가 같이 쓰니 평소에도 ~15-25GB가 추가로 점유된다. 26GB 잔량 시나리오에서는 평상시 점유가 그 잔량을 넘어설 수 있어 위험하지만, 34GB면 8GB 안전판이 더해진다.
이게 절대로 부족한 숫자는 아니다. 하지만 통합 메모리 시스템의 함정 — "한 번 OOM이 나면 시스템 전체가 마비"된다. GPU 메모리만 따로 떨어진 카드형 시스템이라면 GPU만 죽고 OS는 살아있지만, 통합 메모리는 그렇지 않다. 사용자 메모리에 따로 적어둘 정도로 중요한 정책이라 7B로 양보했다.
한편 vision 모델의 화질 격차가 텍스트 LLM만큼 크지 않다는 점도 결정에 한몫. OCR, 차트 읽기, 사물 묘사, 표 구조 인식 같은 일상적 vision 태스크는 7B로도 거의 충분하다. "사진 속 강아지의 정확한 견종 분류"처럼 매우 미세한 분류가 필요한 작업이 드물다.
곁다리 메모: 32B가 22GB인데 7B가 14GB라는 숫자가 비례 안 맞는 게 의아할 수 있다. 단순 비례라면 7B는 ~5GB여야 한다. 답은 두 모델의 정밀도가 다르기 때문이다. 32B는 AWQ Int4(4-bit)로 양자화한 모델 — 풀 정밀도 가중치 ~64GB를 1/4로 압축해 22GB로 줄어들었다. 반면 7B는 BF16(16-bit) 풀 정밀도로 띄운다 — 7B 파라미터 × 2 bytes = 14GB. 7B도 Int4로 양자화하면 ~5GB까지 줄지만 일부러 안 했다. 작은 모델일수록 양자화 손실이 비율적으로 더 크기 때문이다. 큰 모델은 가중치가 충분히 많아 4-bit로 잘라도 정확도가 잘 유지되지만, 7B 같은 소형 모델은 같은 4-bit에서 OCR 정확도·세밀한 묘사가 눈에 띄게 떨어진다. 메모리를 9GB 더 쓰더라도 정밀도를 지키는 게 7B에서는 결과적으로 더 합리적인 선택이다.
2. 두 vLLM이 동거하는 법 — priority 한 숫자로 정책 표현하기
텍스트 vLLM(
vllm-spark-head)은 priority 30. vision vLLM(vllm-spark-vision)은 priority 25. 숫자가 작을수록 우선순위가 높다 — 이 두 줄이 모든 동거 정책을 표현한다. 시간 순서대로 그림으로 그려보면:이 4-state 그림이 보여주는 흐름 — 사용자가 텍스트 채팅 중인 평상시(상태 1)에 이미지를 던지면 챗봇이
analyze_image도구를 호출한다(상태 2). 그 도구의 첫 동작이 GPU 스케줄러에 "gemma-vision priority 25로 GPU 잡아주세요"라고 요청. 스케줄러는 priority 비교를 하고 — vision(25) < text(30)이라 vision이 우선 — 텍스트 vLLM 컨테이너에게 stop 신호를 보낸다(상태 3). 70GB GPU 메모리가 해제되면 vision 컨테이너를 시작(상태 4). 분석 후 vision도 release하면 GPU가 잠시 비고, 그 다음 사용자가 텍스트 메시지를 보내면 vllm_manager.ensure_ready()가 다시 acquire하면서 text 컨테이너가 콜드 스타트.이 디자인의 진짜 가치는 "정책이 데이터로 표현됐다"는 점에 있다. Python 코드는 acquire/release만 호출한다. 어떤 작업이 어떤 작업을 선점할지, GPU를 얼마나 쓸지는 모두
jobs.conf한 파일의 숫자다. 이 숫자를 바꾸면 정책이 바뀐다 — 코드 변경 없이.# ~/projects/gpu-scheduler/jobs.conf vllm-chat|30|on-demand|~/projects/gpu-scheduler/start_vllm.sh|vllm-spark-head|80 gemma-vision|25|on-demand|~/projects/gpu-scheduler/start_vllm_vision.sh|vllm-spark-vision|14이 두 줄에서 핵심은 두 번째 필드(priority)와 마지막 필드(memory_GB) 두 가지. priority 25 vs 30이 "누가 누구를 선점하나"를 결정한다. memory_GB 80 vs 14는 "이 작업이 GPU에서 얼마나 점유할지"를 알려주는데, 스케줄러는 합산이 GPU_BUDGET을 넘는지 확인해서 동시 실행 여부를 판정한다. 만약 미래에 두 모델을 동시 로드하기로 결정한다면? priority 25를 35로 올려서 "vision은 text를 선점하지 않는다"로 바꾸고, GPU_MEMORY_UTILIZATION을 두 컨테이너에 분할하면 된다. 정책이 코드 곳곳에 박혀있으면 이 변경이 위험한 리팩토링이지만, 한 줄짜리 데이터면 안전한 실험이다.
3. 실전에서 드러난 4가지 결함
위 디자인을 실제 사용자 시나리오로 돌렸을 때 그림이 가정한 흐름이 작동하지 않는 지점이 4개 발견됐다. 각각 별개의 코드 path에서 발생했지만 누적되면서 end-to-end 사이클이 깨졌다.
3-1. vLLM 인자 파싱 —
--limit-mm-per-prompt image=4vLLM 0.19부터
--limit-mm-per-prompt가 JSON 형식을 강제한다. 옛 형식image=4는 시작 즉시 ArgParse 에러로 거절되고 컨테이너가 Exit 2로 죽는다. 첫 시도가 무로 끝났고,docker logs를 안 보면 "도커 컴포즈가 멀쩡히 띄웠는데 즉시 죽는" 미스터리 패턴.수정: env-file에
VISION_VLLM_EXTRA_ARGS=--limit-mm-per-prompt {"image":4}로 JSON. vLLM 메시지가 친절해서 한 번 보면 바로 풀리지만, 옛 문서·블로그 글에 옛 형식이 그대로 남아있어 검색하면 잘못된 형식이 먼저 나온다.3-2. 빈 도구 이름 에러
qwen 3.6이 이미지 첨부 요청을 받으면
analyze_image를 호출해야 하는데, 매 첫 시도에서 도구 이름을 빈 문자열로 보내는 형식 에러가 발생한다. 로그:Agent tool: name= content=Error: is not a valid tool, try one of [...analyze_image...]LangGraph가 이 에러를 LLM에 피드백해 두 번째 호출에서 정상화시키지만, 매 이미지 분석마다 ~2초 지연 + 토큰 낭비. 깊이 들어가면 LLM이 도구 호출 형식을 안정적으로 산출하지 못하는 문제 — 시스템 프롬프트나 vLLM의 tool_choice 옵션 튜닝이 필요한 지점이지만 이 글에서는 미해결.
3-3. vision 컨테이너 orphan — release가 lock만 풀고 컨테이너 stop 안 함
분석 완료 후
vision_manager.release()가 GPU 스케줄러 lock을 푸는데 — Docker 컨테이너 자체는 stop하지 않는다. 결과: vllm-spark-vision이 ~24GB GPU 메모리를 점유한 채 orphan으로 살아남고, 다음 vllm-chat 부팅이 free memory 부족으로 거절된다.ValueError: Free memory on device cuda:0 (17.23/121.63 GiB) on startup is less than desired GPU memory utilization (0.2, 24.33 GiB).수정:
release()에docker stop vllm-spark-vision을 lock release 직전에 추가. vllm-chat의 stop은 별도 스크립트(stop_vllm-chat.sh)로 분리되어 있는데 vision은 그게 없는 비대칭 — 빠뜨리기 쉽다.3-4. agent loop의 ensure_ready 누락
analyze_image이 vllm-chat을 선점한 뒤 agent가 final response를 위해 LLM을 다시 호출한다. 코드는
vllm_manager.ensure_ready()를 호출하지 않아 죽은 컨테이너(:8000)에 직접 connect 시도 →httpx.ConnectError: All connection attempts failed. 사용자에게 응답 못 감.시도 1:
ChatOpenAI서브클래스로_agenerate를 override해서 매 generation 직전 ensure_ready 호출. 깔끔해 보이지만 — LangGraph의 LLM 호출 path가_agenerate를 거치지 않고 우회해서 hook이 fire 안 됨. 시도 무효.시도 2:
create_react_agent(pre_model_hook=...)사용. LangGraph 차원의 공식 hook. 검증은 별도지만 이 글의 핵심 교훈은 "이 hook이 코드 디자인 시점에 누락됐다"는 사실 자체다 — 디자인 그림은 "vision 종료 → 다음 텍스트 호출 시 vllm_manager.ensure_ready()가 다시 acquire"를 가정했지만, 그걸 실제로 부르는 코드가 어디에도 없었다. 그림이 깨끗하면 구현이 따라온다는 보장은 없다.
4. 진짜 비용 — 응답 ~10분
위 결함들을 다 fix한다 가정해도, 사용자가 이미지 한 장을 던진 뒤 다음 응답을 받기까지 누적 시간은 만만치 않다. 실측·추정 분포:
단계 시간 ───────────────────────────────────────── ────── 이미지 업로드 ~즉시 qwen 3.6 도구 호출 (빈 이름 에러 + 재시도) ~4초 GPU acquire + vllm-chat 선점/stop ~10초 vision 컨테이너 부팅 + 모델 로드 (5 shards) ~3-4분 이미지 분석 자체 ~5초 ───────────────────────────────────────── ────── 여기까지 (vision 분석 결과 반환) ~7-8분 docker stop vision ~10초 vllm-chat 재acquire + 콜드스타트 ~3분 토큰 디코드 + 사용자 전송 ~30초 ───────────────────────────────────────── ────── 최종 응답 도달까지 ~10-11분"이미지 한 번 보냈더니 그 다음 응답이 ~10분 늦게 온다"는 UX는 daily 챗봇이 받아들일 수준이 아니다. 일상 대화 중에 이미지를 던지는 건 자연스러운 행위인데 그게 사용자를 ~10분 wait 상태에 가두면, 사용자는 다시는 이미지를 안 보낸다. 기능이 "있긴 한데 안 쓰는" 상태가 되면 코드 부담만 남는다.
5. 결론 — 포기, 그리고 남는 자산
이 글을 시작할 때 전제는 "메모리 산수가 맞으면 디자인이 맞다"였는데 그 가정이 틀렸다. 산수는 맞았지만:
- 의존하는 코드 path 4개(vLLM 인자, LLM 도구 호출, GPU 스케줄러 release, agent loop hook)가 각자 다른 방식으로 가정을 깬다
- 모든 fix를 다 끼워 맞춰도 누적 응답 ~10분이라는 비용은 그대로 — 통합 메모리에서 모델 콜드 스타트 비용은 양보 불가
그래서 이 챗봇에서 이미지 분석은 일단 포기한다. 시리즈 #4의 결론이 "안 한다"가 됐지만 그 자체가 의미 있는 결과다 — 자세한 시행착오 기록 없이 "산수상 가능"만 믿고 다시 시도하면 같은 함정에 빠진다.
남는 자산
- gpu-scheduler의 priority + memory_budget 디자인 — vision은 안 쓰지만 voice·VLM·photo-enhance 등 다른 on-demand 작업에는 그대로 유효
- vllm_manager의 vllm-chat 단일 운영 검증 — idle timeout, prefix cache, FP8 안정성 모두 확인
- fix 두 줄 —
--limit-mm-per-prompt {"image":4}(env 형식),docker stop in release()(orphan 방지). 미래에 vision을 다시 띄울 때 같은 함정 안 밟음
미래 옵션
이번엔 안 하지만, 다음 시도 시 검토할 경로 — 콜드 스타트 비용을 회피하는 순서:
- 외부 vision API — Claude vision, GPT-4o vision을 도구로 추가. 시리즈 #7의
browser_consult패턴과 동일. 로컬 GPU 부담 0, 응답 빠름. 비용/프라이버시 트레이드오프 별도 평가 - 모델 합치기 — 텍스트+vision 통합 모델(Qwen2.5-Omni 같은) 단일 vLLM. 두 모델 동거 자체를 회피. 다만 통합 모델은 텍스트 단독 성능이 떨어지는 경향
- 양자화 다운그레이드 + 항시 적재 — qwen 3.6도 AWQ Int4(~17GB)로 줄여 vision 14GB와 함께 항시 적재(합 ~31GB). 콜드 스타트 비용 0. 텍스트 품질 손실 평가가 핵심 변수
- 하드웨어 업그레이드 대기 — 통합 메모리 256GB+ 시스템에서는 산수 자체가 여유로워짐. 두 모델 BF16/FP8 동거 가능
다음 편(시리즈 #5)은 음성 입출력. 챗봇에 PyTorch를 추가하지 않고 다른 venv를 subprocess로 끌어 쓰는 영리한 회피 패턴이라, 시리즈 #4의 시행착오와는 정반대로 단순하게 풀린다.
이 글은 생성형 AI의 도움을 받아 작성되었습니다. 원본 자료를 기반으로 AI가 초안을 생성하고, 작성자가 검토·편집하였습니다.
'IT' 카테고리의 다른 글
로컬 챗봇 시리즈 #9 — MCP 외부 서버 장애를 graceful하게 흡수하는 디자인: '채팅이 안 막히는 게 우선' (0) 2026.05.09 로컬 챗봇 시리즈 #8 — 봇은 도구 풀을 좁히는 장치다: '지식금고 검색가' 한 도구 봇이 가장 효과적인 이유 (0) 2026.05.09 로컬 챗봇 시리즈 #7 — 도구 11개가 모이면 모델이 헷갈리기 시작한다: 풀 격리와 _safe_tool 안전판 (0) 2026.05.09 로컬 챗봇 시리즈 #6 — LLM을 우회하는 슬래시 커맨드: 작은 모델의 메타 판단 한계를 사용자가 보완하는 채널 (0) 2026.05.08 로컬 챗봇 시리즈 #5 — 다른 프로젝트의 venv를 subprocess로 빌려쓰기: 의존성 추가를 거부하는 영리함 (0) 2026.05.08 로컬 챗봇 시리즈 #3 — 도구에 '지금 누구의 첨부인지' 어떻게 알려주나: ContextVar 패턴 (0) 2026.05.08 로컬 챗봇 시리즈 #2 — Project 시스템 프롬프트는 왜 글로벌 Custom Instructions '다음에' 와야 하나 (0) 2026.05.08 로컬 챗봇 시리즈 #1 — 메시지 편집은 왜 그렇게 단순해야 하나: 컨텍스트 엔지니어링 관점에서 (0) 2026.05.08 Ralph Loop — bash while true + LLM CLI가 만든 어이없게 강력한 에이전트 패턴 (0) 2026.05.07 GPU 스케줄러를 Ollama warmup에서 vLLM 컨테이너로 옮긴 과정 — 시작·종료 시퀀스를 다시 짜다 (0) 2026.05.06