최근 27B 클래스 멀티모달 모델은 로컬에서도 구동할 수 있게 되었습니다.
다만, 일반적인 VLM(Vision-Language Model)과는 다른 방식으로 사용 가능한 모델도 있습니다.
'답변을 생성하는 것'이 아니라, **'선택지별 확률을 1 포워드 패스(forward pass)로 반환'**하는 판별 모델입니다.
이번에는 autotrust/JEV-27B-VL을 직접 보유한 4×RTX 4090 (PCIe 전용, WSL2) 환경에서 vLLM을 사용하여 배포하고, 배포된 서비스 전체를 테스트해 보겠습니다.
- 모델: autotrust/JEV-27B-VL (Qwen3.8-27B 기반, bf16 51.75 GiB)
- 하드웨어: 4× NVIDIA GeForce RTX 4090 (24GB, PCIe 전용, WSL2)
- 엔진: vLLM v0.31.0 (공식 Docker 이미지)
...
대상 독자는 컨슈머 GPU로 멀티모달 판별 모델을 셀프 호스팅하고 싶은 기술자입니다.
스크립트 작성 방법, 플래그의 이유, 구동 로그, 실제 측정 테스트 결과까지 모두 기록했습니다.
먼저 결론부터 말씀드리자면, 4×RTX 4090 박스에서는 다음이 가능했습니다.
- bf16 가중치 (51.75 GiB)는 TP=4로 여유 있게 탑재됨
- 256K 컨텍스트 (--max-model-len 262144)가 그대로 구동됨
- System 1 (POST /v1/decide)의 텍스트/이미지 판별이 모두 정답이었음
...
공식 serve.sh 스크립트는 '80GB 이상의 GPU 1개'를 전제로 하고 있습니다.
본 기사는 이를 컨슈머 GPU 4개를 사용하여 구동한 기록입니다.
먼저 모델에 대해 간략히 소개하겠습니다.
autotrust/JEV-27B-VL은 JEV-27B(텍스트 판별 모델)의 멀티모달 버전이며, 백본(backbone)은 Qwen3.8-27B (Apache-2.0)입니다.
특징은 하나의 엔진 안에 두 가지 사용 방식이 들어 있다는 점입니다.
| 방식 | 입력 | 출력 | 지연 시간 (latency) |
|---|
System 1 (POST /v1/decide) | 텍스트 또는 이미지 (256K 토큰까지) | 선택지별 보정된 확률 | 1 포워드 패스 |
System 2 (/v1/chat/completions) | 텍스트 또는 이미지 | 텍스트 / 추론 | 생성 시간에 의존 |
System 1의 kind는 3가지 종류가 있습니다.
| kind | 의미 | |
|---|---|
noul | yes/no. `[
(= vLLM의 OpenAI 서버 + POST /v1/decide
경로)가, vLLM 내부 구현을 2가지 사용하고 있기 때문입니다.
1. logprob_token_ids (요청으로 토큰 ID를 지정하여 logprob를 가져올 수 있음)
2. vllm.entrypoints.launchers.api_server (당시의 서버 레이아웃)
이 모델은 2026년 9월 개발 빌드에서 테스트되는 것이므로,
시작 전에 이미지 내에서 두 가지 모두 사용 가능한지 프로브(probe)하는 것이 스크립트의 첫 번째 핵심입니다.
docker run --rm --entrypoint python3 "${IMAGE}" -c '
import vllm, importlib
importlib.import_module("vllm.entrypoints.launchers.api_server")
...
문제가 있는 이미지라면 시작 후 몇 분이 지나서야 다운되는 것보다 이 5초의 프로브에서 멈추는 것이 낫습니다.
스크립트는 3개의 블록으로 구성됩니다.
1. Preflight (시작 전 체크)
2. Launch (docker run)
3. Smoke tests (다른 shell에서 실행)
시작 전에 체크하는 항목들입니다.
- 모델 디렉토리가 존재하는지
- 필수 파일들이 갖춰져 있는지
(config.json, calibration.json, serve_decide.py,
...
어중간하게 다운로드된 잔여물은 '모델이 시작되지 않는' 원인의 전형적인 것이므로, 여기서 구분해 두면 나중에 편합니다.
mapfile -t SHARDS < <(grep -o 'model-[0-9]*-of-[0-9]*\.safetensors'
"${MODEL_DIR}/model.safetensors.index.json" | sort -u)
for s in "${SHARDS[@]}"; do
...
실제 인자들입니다.
VLLM_ARGS=(
--model "${MODEL_MOUNT}"
--served-model-name "${SERVED_MODEL_NAME}"
...
플래그별로 이유를 적겠습니다.
| 플래그 | 이유 |
|---|
--tensor-parallel-size 4 | bf16 51.75 GiB는 1장의 24GB에 올라가지 않으므로, 4장으로 분할 |
--enable-lora --max-lora-rank 32 --lora-modules jev-decision=... | System 1의 어댑터. jev-decision은 OpenAI 루트에서도 모델명으로 불릴 수 있음 |
--logprobs-mode processed_logprobs | generation config 적용 후 logprob를 반환하며, decision head가 요구하는 모든 옵션의 확률을 만듦 |
--max-model-len 262144 | 최대 256K. KV는 토큰당 약 65KB이므로, 262K라면 1 요청에 약 17GB. 병렬도를 높이고 싶다면 131072를 고려 |
--enable-prefix-caching --mamba-cache-mode align | prefix caching. /v1/decide의 정해진 prompt 시작 부분이 유효함 (테스트 중 60%대의 히트율) |
--limit-mm-per-prompt '{"image": 8, "video": 0}' | 1 요청당 이미지 최대 8장까지, 비디오는 무효 |
--max-num-seqs 8 | 필수. 이유는 3.3절 |
--max-num-batched-tokens 8192 | 배치 상한. 긴 context에서도 안정적임 |
--gpu-memory-utilization 0.92 | 24GB × 0.92 ≈ 22.6GB/장 |
--trust-request-chat-template | System 1은 자신의 raw template을 클라이언트로부터 전달하여 렌더링함 |
--disable-custom-all-reduce | 이 PCIe / WSL2 박스는 GPU 간 P2P가 없음 |
--reasoning-parser qwen3 --enable-auto-tool-choice --tool-call-parser qwen3_coder | System 2용. thinking과 tool call을 활성화함 (ENABLE_TOOL_CALL=false로 비활성화 가능) |
이것이 이번의 가장 큰 함정입니다.
README의 serving notes에 다음과 같이 적혀 있습니다.
With more than 8 sequences in one batch, vLLM's LoRA path for this multimodal model class returns wrong System 1 probabilities
즉, 8을 초과하면 빨라지지 않을 뿐만 아니라, System 1의 확률이 깨진다는 것입니다.
상한을 설정해 두면, 클라이언트의 병렬도가 어떻든 결과는 공식과 동일하게 되며,
8개를 초과하는 요청은 단순히 큐에 들어가는 동작으로 유지됩니다.
docker run에 포함된 내용은 다음과 같습니다.
docker run --rm \
--name "${CONTAINER_NAME}" \
--gpus all \
...
WSL2 / PCIe 고유의 부분입니다.
-e VLLM_WSL2_ENABLE_PIN_MEMORY=1 # WSL2의 pinned memory 경로를 활성화
-e NCCL_P2P_DISABLE=1 # PCIe만 사용하므로 P2P를 해제하지 않으면 NCCL이 막히기 쉬움
-e NCCL_IB_DISABLE=1
...
--gpus all
로 GPU 선택을 환경 변수에 의존하는 것이,
같은 머신에서 다른 모델(같은 4장을 사용하는 Qwen3.8 박스 등)을 전환할 때 도움이 됩니다.
bash /root/myvllm/jev27b-vl-vllm.sh
로그 발췌입니다.
체크포인트 크기: 51.75 GiB. 사용 가능 RAM: 115.22 GiB.
안전한 텐서(safetensors) 체크포인트 샤드 로딩: 100%| 18/18
사용 가능 KV 캐시 메모리: 7.02 GiB
...
읽어보니 다음과 같습니다.
- 개당: 가중치 ~13GB + KV 7.02 GiB + 활성화/버퍼로 23GB / 24.5GB
- 총 KV: 450,487 토큰
-> 전체 262K에서 1.72배의 동시 실행
...
nvidia-smi
상황입니다.
0, NVIDIA GeForce RTX 4090, 23243 MiB, 24564 MiB
1, NVIDIA GeForce RTX 4090, 22775 MiB, 24564 MiB
2, NVIDIA GeForce RTX 4090, 22752 MiB, 24564 MiB
...
/v1/models
에는 서비스된 이름(served name)과 LoRA 이름의 두 항목이 나옵니다.
curl -s localhost:8000/v1/models -H 'Authorization: Bearer sk-dummy'
{"id": "claude-opus-5", "root": "/models/JEV-27B-VL", "max_model_len": 262144, ...}
{"id": "jev-decision", ...}
주의: 스크립트의 기본 SERVED_MODEL_NAME
은 플레이스홀더입니다.
운영할 때는 실제 모델 이름으로 덮어쓰십시오.
SERVED_MODEL_NAME=autotrust/JEV-27B-VL bash jev27b-vl-vllm.sh
아래 내용은 모두 위의 실기에서 2026-10-12에 실행한 것입니다.
curl -s localhost:8000/v1/decide -H 'Authorization: Bearer sk-dummy' \
-H 'Content-Type: application/json' -d '{
"kind": "choice",
...
결과(wall 0.71초):
{"options": ["billing", "shipping", "tech support"],
"probabilities": [0.9969, 0.0000, 0.0031],
"choice": "billing",
...}
billing 0.9969는 README에 기재된 0.997와 일치합니다.
adaptation: native
은 decision head가 학습 시 확인한 16개 옵션 이내의 레이블(A~P)로 평가되었다는 의미입니다.
GET /v1/decide/info
에서 최대값과 교정 온도도 볼 수 있습니다.
{"max_options": 256, "native_choice_options": 16,
"temperatures": {"noul": 1.0143, "choice": 1.0161, "score": 1.0036}, ...}
테스트 이미지는 PIL로 만듭니다.
from PIL import Image, ImageDraw
# 녹색 사각형
img = Image.new("RGB", (512, 512), "white")
...
호출은 README와 같이 state
에 이미지 파트를 섞습니다.
import base64, requests
def image(path):
return {"image": "data:image/png;base64,"
...
noul (예/아니오)
decide("noul", ["Photo: ", image("green_square.png")],
"Is this scenario one where: the image shows a green square?")
결과(0.38초):
{'false': 0.0016, 'true': 0.9984}
choice (4지선다)
decide("choice", ["Image: ", image("red_triangle.png")],
"주요 객체의 모양과 색깔은 무엇인가요?",
["빨간 삼각형", "파란 원", "초록 사각형", "노랑 직사각형"])
결과(0.29초):
{'red triangle': 1.0, 'blue circle': 0.0,
'green square': 0.0, 'yellow rectangle': 0.0}
실제 이미지 (README에 포함된 차트 이미지)
모델 리포지토리에는 벤치마크 차트 이미지가 포함되어 있으므로, 그것도 테스트합니다.
decide("choice", ["Image: ", image("multimodal_judge.png")],
"이것은 어떤 종류의 이미지인가요?",
["벤치마크 점수의 막대 차트", "사람 사진",
...
결과(0.78초):
{'a bar chart of benchmark scores': 1.0, 'a photograph of a person': 0.0,
'a map of a city': 0.0, 'a photo of food': 0.0}
합성 이미지와 실제 차트 모두 zero-shot으로 정답이었습니다.
{"kind": "score",
"state": "Review: The delivery took 3 weeks and the item arrived slightly
dented, but it works.",
...
결과(0.31초):
{'3': 0.771, '2': 0.221, '1': 0.006, '0': 0.0, '4': 0.001, '5': 0.0}
"괜찮지만 불만 있음"으로 3점, 차점 2점. 적절한 분포입니다.
curl -s localhost:8000/v1/chat/completions -H 'Authorization: Bearer sk-dummy' \
-H 'Content-Type: application/json' -d '{
"model": "claude-opus-5",
...
결과(0.37초): JEV is up and running.
같은 차트 이미지를 멀티모달 메시지로 전달합니다.
requests.post("http://localhost:8000/v1/chat/completions", json={
"model": "claude-opus-5",
"messages": [{"role": "user", "content": [
...
결과(7.96초, thinking 유효):
이 차트는 멀티모달 심사(보상 모델)로서 각종 모델의 VL-RewardBench에서의 종합 정확도(%)를 비교한 것입니다. 그 결과, JEV-27B-VL System 1이 78.3%로 최고점을 기록했으며, Skywork-VL-Reward-7B의 73.3%에 이어 높은 점수를 기록했습니다.
자신의 README에 실린 차트 요약도 값을 정확하게 출력합니다.
초록색 사각형 이미지에 "이미지의 도형 모양과 색깔을 답하시오" (thinking 유효, 3.66초):
도형의 모양: 사각형
색상: 초록색
System 1 요청을 16개 병렬로 보냅니다.
16 concurrent System 1 requests: total 0.7s
per-req: [0.24, 0.47, 0.47, 0.47, 0.47, 0.47, 0.47, 0.46,
0.46, 0.69, 0.69, 0.69, 0.69, 0.69, 0.69, 0.69]
8개씩 2 웨이브로 나누어 보냈는데, 두 번째 웨이브가 약 0.2초 정도 늘어졌습니다.
README에 나와 있는 "8개를 초과하면 큐에 들어갈 뿐"이라는 동작 그 자체입니다.
GPU KV cache size: 450,487 tokens,
Maximum concurrency for 262,144 tokens per request: 1.72x
...
...
prefix cache의 히트율이 60%대에 도달하는 것은,
/v1/decide 판정 prompt가 시작 부분이 고정된 포맷이기 때문에, 같은 형식의 연속 요청에서 효과를 보고 있기 때문입니다.
| 테스트 | 결과 | latency |
|---|
| System 1 choice (텍스트, README와 동일한 예) | billing 0.9969 (README 값과 일치) | 0.71 초 |
| ... | | |
-
|-
: 높여도 빨라지지 않으며, System 1의 확률이 깨집니다. 이 모델에서는 상한을 8로 고정해 두면 됩니다--max-num-seqs 8
-
chat template: 공식 Qwen3.8 템플릿 그대로 사용하면, System 2의 enable_thinking: false에서 충돌이 발생합니다. 스크립트는 템플릿을 확인하여 경고를 출력하고 있습니다 (수정된 템플릿으로 대체용) -
raw logprobs를 통해 System 1을 직접 구현하는 경우: OpenAI 루트를 직접 호출한다면 top_k: 0, top_p: 1.0
을 반드시 전달해야 합니다. generation config의 기본값 (top_k=20, top_p=0.95)이 확률을 잘라내기 때문입니다. serve_decide.py를 통해 사용한다면 이는 자동으로 처리해 줍니다 -
이미지 사전 축소: Qwen3.8 processor가 리사이즈하지만, 큰 이미지는 미리 작게 (예: 448px까지) 해두는 것이 vision 토큰 수와 latency 측면에서 이득입니다 -
speculative decoding은 사용하지 않음: spec + LoRA + System 1 logprobs의 조합은 미검증이므로, 스크립트에서는 비활성화했습니다 -
256K vs 128K: KV는 토큰당 약 65KB입니다. 262144로 할 경우 1.72배, 131072로 할 경우 3배에 가까운 동시 실행이 기대됩니다. 전체 길이의 판정이 필요한지, 병렬 판정이 필요한지에 따라 MAX_MODEL_LEN을 선택할 수 있습니다.
4×RTX 4090 컨슈머 GPU 박스에서, 256K 컨텍스트의 멀티모달 판정 모델을 구동했습니다.
- bf16 51.75 GiB는 TP=4로 여유롭게 탑재 가능 (개당 23GB/24.5GB)
- System 1의 1 판정이 0.3~0.8초 (텍스트 / 이미지 / 256K 공통)
- 이미지 판정은 zero-shot 방식이지만, 합성 이미지와 실제 차트 모두 정답이었습니다.
...```
개인적으로는,