- Published on
Ray Serve로 LLM 추론 배포 - 배치·오토스케일 튜닝
- Authors
- Name
- 스타차일드
- https://x.com/ETFBITX
서빙 환경에서 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) - 제약: 최대 지연 상한(예:
p951.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_size416,0.02batch_wait_timeout_s0.005 - 처리량이 목표(오프라인/비동기):
max_batch_size1664,0.1batch_wait_timeout_s0.02
배치 튜닝 3: “길이 불균형”을 다루지 않으면 p95가 무너진다
LLM 배치에서 가장 흔한 함정은 짧은 요청과 긴 요청이 같은 배치에 섞이는 것입니다.
- 배치의 완료 시간은 보통 “가장 오래 걸리는 샘플”에 의해 결정
- 결과적으로 짧은 요청도 긴 요청 때문에 같이 기다리게 됨
해결 전략은 크게 3가지입니다.
- 길이 기반 버킷팅: 입력 토큰 길이(또는 예상 출력 길이)로 큐를 나눠 비슷한 것끼리 배치
- 출력 토큰 상한:
max_new_tokens를 강제해 최악 케이스 제한 - 스트리밍: 사용자 체감 지연을 낮추되, 서버 측에서는 백프레셔/메모리 관리 필요
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가 애매하게 바쁘고 지연도 흔들립니다.
순서는 보통 이렇게 권합니다.
- 단일 레플리카(또는 고정 레플리카)에서 배치 파라미터를 먼저 안정화
- 그다음 오토스케일을 켜서 부하 구간별 레플리카 수를 튜닝
오토스케일 튜닝 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_requests≈max_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가지를 체크리스트로 삼으면 빠릅니다.
실전 튜닝 플로우(권장 순서)
- 단일 레플리카 고정:
autoscaling_config없이 시작 - 배치 최소 도입:
max_batch_size816,20msbatch_wait_timeout_s10 - 부하 테스트: 입력/출력 토큰 분포를 현실적으로 구성(짧은 요청만으로 테스트하면 실패)
- 길이 불균형 완화: 버킷 라우팅 또는
max_new_tokens정책 - 오토스케일 켜기:
min_replicas1,max_replicas는 예산 내에서 target_ongoing_requests튜닝: 배치가 깨지지 않으면서p95목표를 만족하는 지점 찾기- 스케일 진동 방지:
downscale_delay_s를 충분히 길게
마무리
Ray Serve에서 LLM 추론 성능은 “GPU를 더 붙이면 된다”가 아니라, 배치로 GPU 효율을 끌어올리고, 오토스케일이 배치를 깨지 않게 목표 동시성/지연 예산을 맞추는 게임에 가깝습니다.
- 배치는
max_batch_size와batch_wait_timeout_s를 지연 예산 관점에서 튜닝 - 긴/짧은 요청 혼입이
p95를 망치므로 길이 기반 라우팅이나 토큰 상한이 필요 - 오토스케일은
target_ongoing_requests가 핵심이며, 스케일 다운은 길게 가져가 진동을 막기
다음 단계로는 vLLM 같은 엔진의 continuous batching, KV 캐시 정책, 그리고 스트리밍 백프레셔까지 포함해 “토큰 단위”로 지표를 잡으면 튜닝 정확도가 더 올라갑니다.