Skip to content

[GPU] DeepSeek 자체 배포 Hands-on ​

개요 ​

이전 글에서 CVD의 역할과 GPU가 실제로 필요한 구성이 무엇인지, 그리고 콘솔과 SSH로 GPU 인스턴스에 접속하는 방법까지 다뤘습니다. 이번 글은 그 위에서 실제로 LLM을 하나 띄워보는 단계입니다.

첫 모델로는 DeepSeek-V4-Flash를 골랐습니다. CVD에는 이 모델 전용으로 준비된 이미지가 있고, CLI(ds4)와 REST API가 같은 vLLM 서비스로 연결되는 구조라 배포와 호출을 한 번에 검증하기 좋았습니다.

DeepSeek-V4-Flash 이미지 스펙 ​

전용 이미지에 접속하면 이미 다음 구성이 준비되어 있습니다.

항목설정
GPUPRO 5000 72GB × 4 / 확장 구성은 8 GPU
모델 IDdeepseek-ai/DeepSeek-V4-Flash-0731
API 모델명deepseek-v4-flash
가중치FP4+FP8 혼합, 약 155GB
추론 환경Ubuntu 22.04 / vLLM 0.25.1 / FlashInfer 0.6.14 / PyTorch 2.11.0+cu130 / Python 3.12
기본 병렬 처리TP4 + EP
API / 관리8000 / systemd deepseek-v4-flash
기본 컨텍스트131072 token, 입력과 출력 합계

CLI와 REST API가 결국 같은 8000번 포트의 vLLM 서비스를 호출하는 구조라는 걸 먼저 짚어두면, 뒤에 나오는 여러 호출 방식이 왜 같은 결과를 주는지 헷갈리지 않습니다.

첫 응답 확인 ​

가장 먼저 CLI로 확인했습니다.

bash
ds4 "한 문장으로 자신을 소개해 줘"

같은 걸 REST API로도 직접 호출해봤습니다.

bash
curl -s http://127.0.0.1:8000/v1/chat/completions \
  -H "Content-Type: application/json" \
  -d '{
    "model": "deepseek-v4-flash",
    "messages": [{"role": "user", "content": "안녕"}],
    "max_tokens": 512
  }'

최종 답은 choices[0].message.content에서 읽습니다. 다만 추론(reasoning)이 출력 토큰 예산을 먼저 소진해버리면 content가 비어 있을 수 있어서, finish_reason과 추론 관련 필드를 같이 확인하는 습관을 들였습니다.

서비스와 파일 관리 ​

디렉터리 구조는 이렇게 잡혀 있습니다.

text
/opt/deepseek-v4-flash/
├── models/
│   └── DeepSeek-V4-Flash-0731/   # 가중치 약 155GB, 읽기 전용
├── venv/                        # 추론용 Python 가상환경
├── cache/                       # 다운로드와 컴파일 캐시
├── logs/                        # 실행 로그
└── scripts/
    └── start.sh                 # 서비스 시작 인수

서비스 제어는 systemd 명령으로 합니다.

bash
systemctl status deepseek-v4-flash
systemctl restart deepseek-v4-flash
systemctl stop deepseek-v4-flash
systemctl start deepseek-v4-flash
journalctl -u deepseek-v4-flash -f

이 이미지에는 자동 시작과 Restart=always, 약 10초 간격 재시도가 이미 설정되어 있습니다. 그래서 서비스가 연속으로 실패하는 상황에서는 재시도에 맡기지 않고 바로 로그에서 가중치 경로, GPU 메모리, 커널 오류부터 확인하는 게 맞습니다.

상태와 등록된 모델은 별도 엔드포인트로 조회합니다.

bash
curl -s http://127.0.0.1:8000/health
curl -s http://127.0.0.1:8000/v1/models

헬스 체크는 HTTP 상태 코드가 200인지까지 확인해야 합니다. curl -f나 curl -w "%{http_code}"를 붙여서 코드값을 직접 봤습니다. 8 GPU로 냉간 시작(cold start)하는 경우에는 가중치 로딩과 JIT 컴파일 때문에 20~30분 이상 걸릴 수 있어서, 로그가 계속 진행 중인지를 보고 준비 상태를 판단해야 서비스가 죽었다고 오판하지 않습니다.

컨텍스트 길이 변경과 재시작 ​

컨텍스트 길이는 시작 스크립트를 직접 수정해서 바꿉니다.

bash
vi /opt/deepseek-v4-flash/scripts/start.sh

인수 조각으로는 이런 값이 쓰입니다.

--max-model-len 1048576

이 1M 값은 어디까지나 예시이고, 4 GPU 기본 구성에 그대로 권장하는 값은 아닙니다. 4 GPU에서는 128K로 시작해서 실제 KV cache 사용량과 동시성을 먼저 측정하는 게 맞는 순서였습니다. 4 GPU에서 약 750,000 token짜리 단일 요청을 수용한 사례가 있다고 해도, 이게 모든 요청 길이와 배치 조합에 보장되는 수치는 아닙니다. 8 GPU로 확장한다면 가중치 분할과 TP 설정까지 함께 바꿔야 합니다.

모델 길이 제한 자체를 우회하려면 환경변수를 하나 더 추가합니다.

bash
export VLLM_ALLOW_LONG_MAX_MODEL_LEN=1

이 옵션은 길이 검사만 우회할 뿐, 그 뒤에 따라오는 품질/메모리/처리 시간 문제까지 해결해주지는 않습니다. 지원 가능한 모델 길이를 먼저 확인한 뒤 필요한 경우에만 넣는 걸 원칙으로 했습니다.

변경 후에는 이렇게 검증합니다.

bash
systemctl restart deepseek-v4-flash
journalctl -u deepseek-v4-flash -f
# "Application startup complete." 확인 후 Ctrl+C로 로그 보기 종료
systemctl status deepseek-v4-flash
bash
curl -s http://127.0.0.1:8000/v1/models

응답에 max_model_len 값이 의도한 대로 나오는지, 그리고 시작 로그에 찍힌 설정과 서로 맞는지 대조했습니다. start.sh 인수만 고친 경우와 systemd unit 파일 자체를 편집한 경우는 구분해야 하는데, unit을 건드렸다면 systemctl daemon-reload까지 실행해야 반영됩니다.

기본 대화와 대화 이력 ​

시스템 지시를 포함한 일반 대화 요청입니다.

bash
curl -s http://127.0.0.1:8000/v1/chat/completions \
  -H "Content-Type: application/json" \
  -d '{
    "model": "deepseek-v4-flash",
    "messages": [
      {"role": "system", "content": "질문에 도움을 주는 도우미야"},
      {"role": "user", "content": "Python으로 퀵 정렬을 작성해 줘"}
    ],
    "max_tokens": 1024,
    "temperature": 0.6
  }'

대화 이력을 유지하려면 이전 턴을 요청 본문에 직접 실어 보내야 합니다.

json
{
  "model": "deepseek-v4-flash",
  "messages": [
    {"role": "user", "content": "내 이름은 민수야"},
    {"role": "assistant", "content": "안녕, 민수!"},
    {"role": "user", "content": "내 이름이 뭐야?"}
  ],
  "max_tokens": 512
}

여기서 확인해둘 부분은, 대화 이력은 서버가 아니라 호출하는 애플리케이션이 직접 들고 있다가 다음 요청의 messages에 실어 보내야 한다는 점입니다. 서버 주소가 같다고 해서 이전 질문을 서버가 자동으로 기억하는 구조가 아닙니다.

스트리밍과 추론 모드 ​

SSE 스트리밍은 stream: true만 추가하면 됩니다.

bash
curl -sN http://127.0.0.1:8000/v1/chat/completions \
  -H "Content-Type: application/json" \
  -d '{
    "model": "deepseek-v4-flash",
    "messages": [{"role": "user", "content": "농담 하나 해 줘"}],
    "max_tokens": 512,
    "stream": true
  }'

SSE는 data: {...} 이벤트를 반복해서 내려보내다가 data: [DONE]으로 끝납니다. 네트워크 read 한 번이 JSON 객체 하나와 정확히 일치한다고 가정하면 안 되고, 프록시를 앞에 뒀다면 응답 버퍼링과 idle timeout 설정도 함께 확인해야 합니다.

추론 모드는 요청 본문에 chat_template_kwargs로 켭니다.

json
{
  "model": "deepseek-v4-flash",
  "messages": [{"role": "user", "content": "이 알고리즘의 시간 복잡도를 단계별로 분석해 줘"}],
  "max_tokens": 4096,
  "chat_template_kwargs": {"enable_thinking": true}
}

추론 과정 텍스트는 엔진 버전에 따라 reasoning_content 또는 reasoning 필드로, 최종 답은 여전히 content로 돌아옵니다. content=null이면서 finish_reason="length"라면 출력 예산이 추론 과정에서 이미 소진됐을 가능성부터 봐야 합니다. 다만 간단한 요청에서는 추론 필드 자체가 null로 오는 경우도 있었습니다.

도구 호출 ​

도구 호출도 표준 OpenAI 스타일 스키마를 그대로 받습니다.

json
{
  "model": "deepseek-v4-flash",
  "messages": [
    {"role": "user", "content": "서울의 오늘 날씨는 어때?"}
  ],
  "tools": [
    {
      "type": "function",
      "function": {
        "name": "get_weather",
        "description": "지정한 도시의 날씨 조회",
        "parameters": {
          "type": "object",
          "properties": {
            "city": {"type": "string", "description": "도시 이름"}
          },
          "required": ["city"]
        }
      }
    }
  ],
  "max_tokens": 1024
}

여기서 명확히 해둬야 하는 부분은, 모델이 실제로 함수를 실행하는 게 아니라 tool_calls로 함수명과 JSON 인수, 호출 ID만 제안한다는 점입니다. 실제 실행은 애플리케이션이 맡고, assistant의 tool_calls 메시지와 role="tool"에 해당 tool_call_id를 붙인 결과를 이력에 추가해서 다시 요청을 보내야 다음 턴이 이어집니다. 모델이 제안한 함수명이나 인수를 검증 없이 그대로 셸 명령처럼 실행하는 건 당연히 안 됩니다.

Python과 CLI 연결 ​

Python에서는 OpenAI SDK를 base_url만 바꿔서 그대로 씁니다.

python
from openai import OpenAI

client = OpenAI(
    base_url="http://127.0.0.1:8000/v1",
    api_key="not-needed",  # 인증 없는 사설 테스트 환경에 한함
)

resp = client.chat.completions.create(
    model="deepseek-v4-flash",
    messages=[
        {"role": "system", "content": "너는 코딩 도우미야"},
        {"role": "user", "content": "버블 정렬을 작성해 줘"},
    ],
    max_tokens=1024,
    temperature=0.6,
)

print(resp.choices[0].message.content)

CLI는 이렇게 바로 씁니다.

bash
ds4 "1+1은 얼마야?"
ds4 "이메일을 찾는 정규식을 작성해 줘"
ds4 "영어로 번역해 줘: 오늘 날씨가 좋아"

다른 사설 CVD 인스턴스의 서비스를 호출하고 싶다면 호스트만 바꿔주면 됩니다.

bash
DS4_HOST=127.0.0.1:8000 ds4 "안녕"

DS4_HOST는 호스트:포트 형식이고, 127.0.0.1 대신 대상 CVD의 IP를 넣으면 됩니다. 기본 샘플링 값은 temperature=1.0, top_p=1.0이고 요청에서 얼마든지 덮어쓸 수 있습니다. 다만 temperature를 낮추는 건 응답의 무작위성을 줄이는 것이지, 정답을 보장해주는 옵션은 아니라는 점은 구분해서 써야 합니다.

명령 빠른 모음 ​

이번 배포에서 확인한 환경 버전을 정리하면 이렇습니다.

text
OS           Ubuntu 22.04 LTS
GPU          4 × NVIDIA RTX PRO 5000 72GB Blackwell (SM120)
Endpoint     http://127.0.0.1:8000
vLLM         0.25.1
FlashInfer   0.6.14
PyTorch      2.11.0+cu130
Python       3.12
Model        deepseek-ai/DeepSeek-V4-Flash-0731
Weights      FP4+FP8, ~155 GB
Context      131072 token (128K)

자주 쓰는 명령은 아래처럼 한곳에 모아뒀습니다. 단, 서비스 중단이 섞여 있는 명령 모음이라 위에서부터 순서대로 일괄 실행하면 안 됩니다.

bash
ds4 "질문을 입력해 줘"
curl -s http://127.0.0.1:8000/health
curl -s http://127.0.0.1:8000/v1/models
curl -s http://127.0.0.1:8000/v1/chat/completions \
  -H "Content-Type: application/json" \
  -d '{"model":"deepseek-v4-flash","messages":[{"role":"user","content":"안녕"}],"max_tokens":512}'
systemctl status deepseek-v4-flash
systemctl restart deepseek-v4-flash
journalctl -u deepseek-v4-flash -f

정리 ​

  1. DeepSeek-V4-Flash 전용 이미지는 4 GPU(PRO 5000 72GB) 기준 TP4+EP 병렬 처리로 이미 구성되어 있고, CLI(ds4)와 REST API가 같은 8000번 포트의 vLLM 서비스를 공유합니다.
  2. 서비스는 systemd로 관리되고 자동 재시작이 걸려 있지만, 연속 실패 상황에서는 재시도에 맡기지 말고 journalctl로 가중치 경로/GPU 메모리/커널 오류를 직접 확인해야 합니다.
  3. 컨텍스트 길이는 start.sh의 --max-model-len 인수로 조정하지만, 1M 같은 큰 값은 예시일 뿐 4 GPU 기본 구성에서는 128K부터 시작해서 실제 KV cache와 동시성을 측정하는 게 안전합니다.
  4. 대화 이력은 서버가 아니라 클라이언트가 관리합니다. 이전 턴을 매번 messages 배열에 실어 보내야 합니다.
  5. 스트리밍은 SSE로 data: {...} 이벤트가 반복되다 data: [DONE]으로 끝나고, 추론 모드는 chat_template_kwargs.enable_thinking으로 켜서 reasoning_content/content 두 필드를 분리해서 봐야 합니다.
  6. 도구 호출은 모델이 함수 실행 자체를 대신하지 않습니다. tool_calls 제안을 애플리케이션이 검증하고 실행한 뒤, 결과를 다시 이력에 추가해서 요청해야 다음 턴이 이어집니다.

이어지는 실측은 다음 글에서 다룹니다. 같은 CVD GPU 환경에 GLM을 배포하면서 DeepSeek과 어떤 지점이 같고 어떤 지점이 다른지 비교합니다.

基于 VitePress 构建 · 部署于腾讯云 EdgeOne Pages