Published on

Ray Serve로 LLM 추론 배포 - 배치·오토스케일 튜닝

Authors

서빙 환경에서 LLM 추론 병목은 대개 모델 자체보다 요청을 GPU에 어떻게 태워 보내느냐(배치), 그리고 부하 변화에 어떻게 따라가느냐(오토스케일) 에서 갈립니다. Ray Serve는 이 두 축을 비교적 단순한 설정으로 제어할 수 있지만, 기본값만으로는 지연(p95)이 튀거나 GPU가 놀기도 합니다.

이 글은 Ray Serve로 LLM 추론 엔드포인트를 만들고, 동적 배치(dynamic batching)오토스케일(autoscaling) 을 함께 튜닝하는 방법을 정리합니다. 특히 “처리량을 올리면 지연이 나빠지고, 지연을 잡으면 GPU가 놀고” 같은 트레이드오프를 측정 가능한 지표로 풀어보는 데 초점을 둡니다.

관련해서 스트리밍/메모리 누수 이슈가 있다면 LangChain 스트리밍 중복토큰·메모리누수 9분 해결도 같이 보면 운영 안정성에 도움이 됩니다.

Ray Serve로 LLM 서빙 아키텍처 빠르게 잡기

Ray Serve는 크게 다음 개념으로 이해하면 됩니다.

  • Deployment: 실제 모델/핸들러가 올라가는 단위(레플리카 수, 리소스, 배치 등 설정)
  • Replica: Deployment의 실행 인스턴스(보통 GPU 1장에 1개 또는 GPU 공유)
  • Handle: 다른 서비스나 라우터가 호출하는 인터페이스
  • Autoscaling: 레플리카 수를 부하에 따라 자동으로 늘리고 줄임

LLM 추론은 보통 아래처럼 구성합니다.

  • Router(CPU): 요청 검증, 프롬프트 템플릿, 라우팅, 캐시 키 생성
  • Model(GPU): 토크나이즈/추론/디토크나이즈
  • (선택) Postprocess(CPU): 포맷팅, 안전 필터, JSON 스키마 검증

핵심은 GPU Deployment에서 배치를 켜고, 오토스케일이 배치를 방해하지 않게 만드는 것입니다.

예제: Ray Serve 기본 배포 코드

아래 예제는 구조를 단순화한 형태입니다. 실제로는 vLLM, TGI, TensorRT-LLM 같은 엔진을 붙이거나, Hugging Face transformers를 직접 호출할 수 있습니다.

# serve_app.py
from ray import serve
import ray
import time

ray.init(address="auto")

@serve.deployment(
    ray_actor_options={"num_gpus": 1, "num_cpus": 2},
    max_concurrent_queries=16,
)
class LLMDeployment:
    def __init__(self):
        # 실제 환경에서는 여기서 모델 로딩
        # 예: self.engine = ...
        pass

    async def __call__(self, request):
        payload = await request.json()
        prompt = payload.get("prompt", "")

        # 데모용: 추론 시간 흉내
        time.sleep(0.05)
        return {"text": f"echo: {prompt}"}

app = LLMDeployment.bind()

실서비스에서는 위 상태로는 “요청이 많아질수록 GPU가 비효율적으로 사용”될 가능성이 큽니다. 이유는 요청을 묶지 않고 한 건씩 처리하기 때문입니다.

배치 튜닝 1: 동적 배치의 목표를 먼저 정의하기

배치 튜닝을 시작하기 전에 목표를 수치로 정합니다.

  • 목표 지표 1: p50/p95 지연(ms)
  • 목표 지표 2: 처리량(tokens/s 또는 req/s)
  • 목표 지표 3: GPU 활용률(SM utilization, memory util)
  • 제약: 최대 지연 상한(예: p95 1.5초 이하), 최대 큐잉 지연(예: 200ms 이하)

LLM은 요청 길이(입력 토큰)와 출력 길이(생성 토큰)에 따라 작업량이 극단적으로 달라서, 단순히 “배치 크기만 키우면” 오히려 지연이 폭발할 수 있습니다. 그래서 Ray Serve에서는 보통 다음 두 파라미터를 함께 봅니다.

  • max_batch_size: 한 번에 묶을 최대 요청 수
  • batch_wait_timeout_s: 배치를 만들기 위해 기다릴 최대 시간

직관은 이렇습니다.

  • max_batch_size를 키우면 처리량이 좋아질 가능성이 크지만, 긴 요청이 섞이면 꼬리가 길어질 수 있음
  • batch_wait_timeout_s를 키우면 배치가 더 잘 만들어져 처리량이 오르지만, 큐 대기 시간이 늘어 지연이 증가

배치 튜닝 2: Ray Serve 배치 핸들러 구현

Ray Serve는 @serve.batch 데코레이터로 배치 처리를 지원합니다. 아래 코드는 요청을 모아 한 번에 처리하는 형태입니다.

from ray import serve
from typing import List, Dict
import time

@serve.deployment(
    ray_actor_options={"num_gpus": 1, "num_cpus": 2},
    max_concurrent_queries=64,
)
class BatchedLLM:
    def __init__(self):
        pass

    @serve.batch(max_batch_size=16, batch_wait_timeout_s=0.02)
    async def infer_batch(self, prompts: List[str]) -> List[Dict]:
        # 실제로는 여기서 엔진에 prompts를 한 번에 넣어 배치 추론
        # 예: outputs = self.engine.generate(prompts, sampling_params=...)
        time.sleep(0.05)
        return [{"text": f"echo: {p}"} for p in prompts]

    async def __call__(self, request):
        payload = await request.json()
        prompt = payload["prompt"]
        return await self.infer_batch(prompt)

app = BatchedLLM.bind()

실전 팁: batch_wait_timeout_s는 “큐잉 예산”으로 잡기

batch_wait_timeout_s는 사실상 “배치를 만들기 위해 허용하는 추가 지연”입니다. 예를 들어 p95 1초를 목표로 하고, 모델 추론이 p95 800ms라면 큐잉/네트워크/직렬화에 200ms 예산이 남습니다. 이 중 배치 대기 시간을 20ms로 잡는 식으로 접근하면, 배치가 지연을 잠식하지 않게 됩니다.

권장 출발점(경험칙):

  • 낮은 지연이 목표(챗봇): max_batch_size 416, batch_wait_timeout_s 0.0050.02
  • 처리량이 목표(오프라인/비동기): max_batch_size 1664, batch_wait_timeout_s 0.020.1

배치 튜닝 3: “길이 불균형”을 다루지 않으면 p95가 무너진다

LLM 배치에서 가장 흔한 함정은 짧은 요청과 긴 요청이 같은 배치에 섞이는 것입니다.

  • 배치의 완료 시간은 보통 “가장 오래 걸리는 샘플”에 의해 결정
  • 결과적으로 짧은 요청도 긴 요청 때문에 같이 기다리게 됨

해결 전략은 크게 3가지입니다.

  1. 길이 기반 버킷팅: 입력 토큰 길이(또는 예상 출력 길이)로 큐를 나눠 비슷한 것끼리 배치
  2. 출력 토큰 상한: max_new_tokens를 강제해 최악 케이스 제한
  3. 스트리밍: 사용자 체감 지연을 낮추되, 서버 측에서는 백프레셔/메모리 관리 필요

Ray Serve 단독으로 “완벽한 버킷팅”을 제공하진 않지만, Router Deployment에서 길이 추정 후 여러 Model Deployment로 라우팅하는 방식으로 구현할 수 있습니다.

@serve.deployment
class Router:
    def __init__(self, small_handle, large_handle):
        self.small = small_handle
        self.large = large_handle

    async def __call__(self, request):
        payload = await request.json()
        prompt = payload["prompt"]
        # 매우 단순한 길이 추정(실제로는 tokenizer로 토큰 수 추정 권장)
        if len(prompt) < 500:
            return await self.small.remote(payload)
        return await self.large.remote(payload)

@serve.deployment(ray_actor_options={"num_gpus": 1})
class SmallModel:
    @serve.batch(max_batch_size=32, batch_wait_timeout_s=0.01)
    async def infer(self, payloads):
        return payloads

@serve.deployment(ray_actor_options={"num_gpus": 1})
class LargeModel:
    @serve.batch(max_batch_size=8, batch_wait_timeout_s=0.02)
    async def infer(self, payloads):
        return payloads

small = SmallModel.bind()
large = LargeModel.bind()
app = Router.bind(small, large)

이렇게 하면 짧은 요청은 큰 배치로 처리량을 확보하고, 긴 요청은 작은 배치로 p95 폭발을 완화하는 식의 튜닝이 가능합니다.

오토스케일 튜닝 1: “배치가 잘 되는 상태”를 먼저 만든다

오토스케일을 먼저 켜면, 부하가 조금만 늘어도 레플리카가 늘어나면서 요청이 분산되어 배치가 깨지는 경우가 많습니다. 그러면 처리량이 기대만큼 늘지 않고, 오히려 GPU가 애매하게 바쁘고 지연도 흔들립니다.

순서는 보통 이렇게 권합니다.

  1. 단일 레플리카(또는 고정 레플리카)에서 배치 파라미터를 먼저 안정화
  2. 그다음 오토스케일을 켜서 부하 구간별 레플리카 수를 튜닝

오토스케일 튜닝 2: Ray Serve autoscaling_config 핵심 파라미터

Ray Serve의 오토스케일은 “현재 처리 중/대기 중인 요청” 같은 신호를 기반으로 레플리카를 조절합니다. 버전에 따라 필드/기본값이 조금씩 다르지만, 실무에서 주로 만지는 핵심은 아래 범주입니다.

  • min_replicas, max_replicas: 최소/최대 레플리카
  • target_ongoing_requests 또는 유사 개념: 레플리카 하나가 감당할 목표 동시 요청 수
  • upscale_delay_s, downscale_delay_s: 스케일 업/다운 판단 지연(진동 방지)
  • metrics_interval_s, look_back_period_s: 관측 주기/윈도우

예시 설정

@serve.deployment(
    ray_actor_options={"num_gpus": 1, "num_cpus": 2},
    max_concurrent_queries=64,
    autoscaling_config={
        "min_replicas": 1,
        "max_replicas": 8,
        "target_ongoing_requests": 24,
        "upscale_delay_s": 10,
        "downscale_delay_s": 120,
        "metrics_interval_s": 5,
        "look_back_period_s": 30,
    },
)
class AutoscaledBatchedLLM:
    @serve.batch(max_batch_size=16, batch_wait_timeout_s=0.02)
    async def infer_batch(self, prompts):
        return [{"text": p} for p in prompts]

    async def __call__(self, request):
        payload = await request.json()
        return await self.infer_batch(payload["prompt"])

target_ongoing_requests를 어떻게 잡나

이 값은 “레플리카 하나에 동시에 걸리는 요청 수 목표”입니다. LLM 배치가 켜져 있으면, 이 값은 단순 동시성이라기보다 배치가 유지될 만큼의 유입량과도 연결됩니다.

  • 너무 낮게 잡으면: 레플리카가 빨리 늘어나 요청이 분산되고 배치가 깨져 효율이 떨어짐
  • 너무 높게 잡으면: 큐가 길어져 지연이 증가

출발점으로는 다음 식의 감각을 추천합니다.

  • target_ongoing_requestsmax_batch_size * 1~2
  • 그리고 부하 테스트로 p95가 목표를 넘지 않는 범위에서 조금씩 올림

오토스케일 튜닝 3: 스케일 다운은 길게, 스케일 업은 짧게

LLM 서빙은 스케일 업이 늦으면 바로 장애 체감으로 이어집니다. 반대로 스케일 다운은 너무 공격적이면 다음 트래픽 파도에서 다시 콜드스타트가 발생합니다.

  • upscale_delay_s: 5~15초로 짧게(급증 대응)
  • downscale_delay_s: 60~300초로 길게(진동 방지)

GPU 모델 로딩이 무겁다면(수십 초 이상), min_replicas를 0으로 두는 것은 비용은 줄여도 체감 품질을 크게 해칠 수 있습니다. 최소 1개는 유지하거나, 트래픽 패턴이 명확하면 시간대별 스케줄링을 고려하세요.

배치와 오토스케일을 함께 볼 때의 운영 체크리스트

1) 지연을 “큐잉 vs 추론”으로 분해해서 본다

p95가 나빠졌을 때 원인이 배치 대기인지, 모델 추론 자체인지 분해해야 튜닝 방향이 나옵니다.

  • 큐잉 지연 증가: batch_wait_timeout_s 과도, target_ongoing_requests 과도, 레플리카 부족
  • 추론 지연 증가: 배치 과대, 긴 요청 혼입, KV 캐시/메모리 압박, GPU 스로틀링

2) GPU OOM은 “배치 크기”보다 “최대 토큰”에서 터진다

많은 팀이 max_batch_size만 줄이다가 성능을 잃습니다. 실제 OOM은 다음 조합에서 잘 납니다.

  • 입력 토큰이 길고
  • 출력 토큰 상한이 높고
  • 동시 요청이 많고(배치/동시성)
  • KV 캐시가 누적되는 엔진 설정

따라서 운영 정책으로 max_new_tokens 상한, 길이 기반 라우팅, 또는 과금/등급별 제한이 필요합니다.

3) 백프레셔(과부하 제어)를 반드시 둔다

오토스케일이 있어도 순간 폭주를 100% 흡수하긴 어렵습니다. 이때 무제한 큐는 지연을 폭발시키고 결국 타임아웃을 양산합니다.

  • 요청 큐 상한
  • 타임아웃
  • 429/503 반환 + 재시도 가이드
  • 비동기 작업 큐로 전환

외부 LLM API를 섞어 쓰는 하이브리드 구조라면 과부하 시 재시도/큐잉 설계가 특히 중요합니다. 패턴은 Claude API 529 과부하 대응 - 재시도·큐잉 설계에서 다룬 방식이 그대로 적용됩니다.

K8s에서 Ray Serve 운영 시 자주 터지는 포인트

Ray Serve를 Kubernetes 위에서 돌리면, 튜닝 이전에 “배포 자체가 흔들리는 문제”가 먼저 나올 수 있습니다.

  • 이미지 풀 실패로 레플리카가 안 뜸
  • 노드 GPU 리소스 스케줄링 실패
  • Readiness/Liveness 설정 부재로 롤링 업데이트 중 요청 유실

특히 ImagePullBackOff는 오토스케일 시점에 갑자기 드러나기도 합니다(새 레플리카가 뜨지 않으니 부하가 기존 레플리카에 몰림). 원인 점검은 K8s ImagePullBackOff·ErrImagePull 원인 12가지를 체크리스트로 삼으면 빠릅니다.

실전 튜닝 플로우(권장 순서)

  1. 단일 레플리카 고정: autoscaling_config 없이 시작
  2. 배치 최소 도입: max_batch_size 816, batch_wait_timeout_s 1020ms
  3. 부하 테스트: 입력/출력 토큰 분포를 현실적으로 구성(짧은 요청만으로 테스트하면 실패)
  4. 길이 불균형 완화: 버킷 라우팅 또는 max_new_tokens 정책
  5. 오토스케일 켜기: min_replicas 1, max_replicas는 예산 내에서
  6. target_ongoing_requests 튜닝: 배치가 깨지지 않으면서 p95 목표를 만족하는 지점 찾기
  7. 스케일 진동 방지: downscale_delay_s를 충분히 길게

마무리

Ray Serve에서 LLM 추론 성능은 “GPU를 더 붙이면 된다”가 아니라, 배치로 GPU 효율을 끌어올리고, 오토스케일이 배치를 깨지 않게 목표 동시성/지연 예산을 맞추는 게임에 가깝습니다.

  • 배치는 max_batch_sizebatch_wait_timeout_s를 지연 예산 관점에서 튜닝
  • 긴/짧은 요청 혼입이 p95를 망치므로 길이 기반 라우팅이나 토큰 상한이 필요
  • 오토스케일은 target_ongoing_requests가 핵심이며, 스케일 다운은 길게 가져가 진동을 막기

다음 단계로는 vLLM 같은 엔진의 continuous batching, KV 캐시 정책, 그리고 스트리밍 백프레셔까지 포함해 “토큰 단위”로 지표를 잡으면 튜닝 정확도가 더 올라갑니다.