LLM WikiAccess-protected knowledge portal
← 스터디 홈
12편 · 약 19분

Ray 2.56 Serve LLM: 라우팅 결정과 토큰 스트림을 분리한 direct streaming

LLM 서빙에서 스트림이 우회해야 할 계층

LLM API의 요청 본문은 한 번 들어오지만 응답은 수십~수천 개 토큰 청크로 오래 이어진다. 이 스트림이 일반 애플리케이션 ingress, Ray actor 호출, 응답 generator를 차례로 왕복하면 각 계층의 작은 복사와 스케줄링 비용이 토큰마다 반복된다. 모델 계산이 빨라질수록 Python control plane이 응답 경로에 남아 있는 비용이 더 잘 보인다.

2026년 6월 29일 공개된 Ray 2.56.0은 Ray Serve LLM에서 이 문제를 다루기 위해 요청을 어느 replica로 보낼지 결정하는 경로와 실제 토큰을 전달하는 경로를 분리했다. 릴리스 노트는 이를 direct streaming이라고 부른다.

핵심은 router를 없애는 것이 아니다. router는 여전히 replica를 고른다. 다만 선택이 끝난 뒤 원본 HTTP 요청과 streaming 응답은 router actor를 통과하지 않고 HAProxy와 선택된 LLM replica 사이에서 직접 흐른다.

이 기능은 Ray 2.56.0 태그에서 기본값이 아니다. RAY_SERVE_LLM_ENABLE_DIRECT_STREAMING의 기본값은 0이며, 관련 request router도 experimental namespace에 있다. 새 기본 경로가 아니라 명시적으로 canary해야 하는 선택지로 다루는 편이 안전하다.


구조: control plane은 replica를 고르고 data plane은 직접 흐른다

Ray 2.56 Serve LLM direct streaming Control plane — 요청마다 한 번 실행되는 replica 선택 Client POST /v1/chat/completions HAProxy 원본 요청을 잠시 보유 LLMRouter /internal/route session header·선택 정책 적용 host · port · replica_id 반환 RequestRouter RoundRobin·ConsistentHash Data plane — 원본 요청과 토큰 stream이 직접 흐르는 경로 HAProxy use-server로 replica 고정 선택된 LLMServer replica replica-owned ASGI backend HTTP endpoint vLLM engine 실행 · OpenAI-compatible stream 생성 router actor는 토큰 청크 경로에서 제외 Client SSE token chunks 소비 원본 HTTP 요청 token stream stream은 HAProxy를 거쳐 client로 선택은 짧게, 장시간 지속되는 응답은 선택된 replica와 직접 연결한다.
Ray Serve LLM direct streaming의 control plane과 data plane 분리

실제 순서는 다음과 같다.

  1. HAProxy가 애플리케이션의 path prefix를 보고 ingress request router가 붙은 요청인지 확인한다.
  2. POST /internal/routeLLMRouter를 호출한다. 기본 설정에서는 본문을 보내지 않고 session header만 전달한다.
  3. LLMRouter가 대상 deployment의 choose_replica()를 호출해 replica를 고른다.
  4. 응답으로 받은 replica_id를 HAProxy의 backend server 이름으로 변환한다.
  5. HAProxy가 원본 요청을 해당 replica의 backend HTTP port로 직접 보낸다.
  6. LLM replica가 만든 streaming 응답은 일반 Python ingress deployment를 다시 거치지 않고 HAProxy를 통해 client로 돌아간다.

이 구조를 가능하게 하려고 2.56은 replica가 소유한 backend HTTP endpoint를 controller와 proxy가 볼 수 있도록 metadata를 노출했다. LLM replica의 ASGI 앱도 engine startup 이후 __serve_build_asgi_app__ hook으로 늦게 구성한다. 즉, router가 model server 함수를 actor RPC로 호출하는 대신 이미 떠 있는 HTTP endpoint를 선택하는 구조다.


choose_replicadispatch를 분리한 이유

Ray 2.56은 일반 DeploymentHandle에도 choose_replica()dispatch()를 노출했다. 보통 경로에서는 둘 사이의 race를 막기 위해 선택 시 replica semaphore의 slot을 예약하고 token을 받는다.

async with handle.choose_replica(request_body=body) as selection:
    response = handle.dispatch(selection, body)
    result = await response

예약 lifecycle은 네 단계다.

단계동작보호하는 것
chooserequest router가 replica를 선택정책에 맞는 대상 선택
reservereplica semaphore에서 slot 확보, token 발급선택 후 dispatch 전 capacity race 방지
dispatchtoken을 request metadata에 실어 전송semaphore를 이중 획득하지 않음
release미전송·취소·오류 시 slot 반환capacity leak 방지

하지만 direct streaming의 LLMRouterchoose_replica(..., _reserve=False)를 사용한다. 실제 요청이 DeploymentHandle.dispatch()가 아니라 HAProxy를 통해 나가기 때문이다. slot을 예약해도 HAProxy 요청이 그 token을 소비할 방법이 없다.

따라서 direct streaming의 선택은 capacity 보장 예약이 아니라 현재 replica snapshot을 사용한 pick-only 결정이다. 추가 actor RPC와 rejection retry를 피워 request path를 짧게 만들지만, 선택과 원본 요청 도착 사이에 replica 상태가 변할 수 있다는 대가가 있다. burst traffic에서는 평균 처리량만 보지 말고 rejection, 503, replica별 ongoing request 편차를 함께 봐야 한다.

또한 일반 choose_replica()를 직접 사용할 때는 dispatch()가 반환한 response를 context가 끝나기 전에 await해야 한다. 태그 소스의 docstring은 await하지 않은 응답이 cleanup callback과 완료 callback을 모두 실행해 queue-length cache를 두 번 감소시킬 수 있다고 경고한다.


기본 정책은 RoundRobin, session affinity는 ConsistentHash

Direct streaming builder는 별도 request_router_config가 없으면 RoundRobinRouter를 넣는다. queue length를 세밀하게 비교하기보다 replica를 순환하며 빠르게 선택하는 정책이다.

멀티턴 대화처럼 같은 session을 같은 replica에 보내 KV cache나 in-memory state를 재사용하려면 experimental ConsistentHashRouter를 선택할 수 있다.

from ray.serve.config import RequestRouterConfig

llm_config = LLMConfig(
    model_loading_config=ModelLoadingConfig(
        model_id="my-model",
        model_source="org/my-model",
    ),
    deployment_config={
        "request_router_config": RequestRouterConfig(
            request_router_class=(
                "ray.serve.experimental.consistent_hash_router."
                "ConsistentHashRouter"
            ),
            request_router_kwargs={
                "num_virtual_nodes": 100,
                "num_fallback_replicas": 2,
            },
        ),
    },
)

cluster가 시작되기 전에 session header 이름도 고정한다.

export RAY_SERVE_SESSION_ID_HEADER_KEY=x-session-id

client는 모든 turn에 같은 값을 보낸다.

POST /v1/chat/completions
x-session-id: chat-7f83
content-type: application/json

ConsistentHashRouter의 동작은 다음과 같다.

  • MurmurHash3와 고정 seed로 session ID를 hash한다.
  • replica 하나당 기본 100개 virtual node를 ring에 둔다.
  • primary가 거절하면 시계 방향 successor를 기본 2개까지 시도한다.
  • header가 없으면 매 요청의 internal_request_id를 hash하므로 affinity 없이 분산된다.
  • replica set이 바뀔 때만 ring을 다시 만든다.

이 정책은 queue depth, locality, multiplexed model 신호를 섞지 않는다. 그런 신호를 섞으면 같은 session이 같은 replica로 간다는 결정성이 깨지기 때문이다. hot session이 한 replica를 포화시키면 다른 replica가 비어 있어도 primary는 바뀌지 않는다. fallback을 허용하면 가용성은 좋아지지만 strict affinity와 KV 재사용률은 낮아진다.

운영자는 num_fallback_replicas를 단순 성능 옵션으로 보지 말아야 한다.

설정우선하는 것위험
0strict affinity, cache 재사용assigned replica 포화 시 대기 증가
1~2affinity와 가용성 절충fallback turn의 cache miss
크게 설정요청 수용 가능성사실상 affinity 약화, 여러 replica probe

CapacityQueue는 같은 릴리스의 별도 해법이다

Ray 2.56은 CapacityQueueRouter도 추가했다. 여러 proxy/router가 eventual consistency 상태만 보고 같은 replica를 동시에 선택하는 문제를 줄이기 위해 singleton actor가 replica별 in-flight count를 관리하고 요청마다 capacity token을 발급한다.

ConsistentHashRouter — affinity-first
session ID
hash ring
primary + clockwise fallback
같은 session을 같은 replica에 붙이는 것이 목적이다. queue-aware balancing과 의도적으로 결합하지 않는다.
CapacityQueueRouter — capacity-first
여러 router
singleton CapacityQueue
least-loaded token
공급이 매우 제한된 replica의 충돌 선택을 줄이는 것이 목적이다. 완료·취소 시 token을 반환한다.
Ray 2.56 direct streaming 주의점
LLMRouter의 pick-only 경로는 _reserve=FalseCapacityQueueRouter._choose_replica_for_request()의 token 획득 경로를 우회한다.
따라서 CapacityQueue를 direct streaming의 capacity 보장으로 자동 결합된다고 가정하면 안 된다. 태그 소스 기준으로 두 기능을 별도 실험해야 한다.
ConsistentHash와 CapacityQueue가 해결하는 문제가 다른 이유

CapacityQueue 자체의 fault semantics도 비용이 있다.

  • queue actor가 없거나 죽으면 기본 3회 backoff 뒤 해당 요청만 power-of-two choices로 fallback한다.
  • 모든 capacity가 소진되면 fault로 세지 않고 slot이 생길 때까지 backoff한다.
  • replica가 token을 받았지만 요청을 거절하면 token을 의도적으로 누수시켜 queue가 "이 replica는 실제로 바쁘다"고 학습하게 한다.
  • token_ttl_s를 설정하면 reaper가 누수 token을 나중에 회수한다.
  • queue restart 직후에는 기존 in-flight 상태가 사라져 일시적으로 과할당할 수 있다.

중요한 구분이 있다. CapacityQueueRouter의 중앙 token 획득은 _choose_replica_for_request()에 구현되어 있다. 반면 direct streaming의 pick-only 경로는 choose_replicas()를 직접 호출한다. 2.56 태그에서 CapacityQueueRouter의 choose_replicas()는 중앙 queue가 아니라 power-of-two candidate 선택을 수행한다. 같은 릴리스에 등장했다는 이유만으로 두 기능이 같은 request path에서 함께 작동한다고 해석하면 안 된다.


기능 flag와 현재 경계

공식 direct-streaming release test variant는 다음 환경변수를 함께 사용했다.

export RAY_SERVE_LLM_ENABLE_DIRECT_STREAMING=1
export RAY_SERVE_ENABLE_HA_PROXY=1
export RAY_SERVE_THROUGHPUT_OPTIMIZED=1
export RAY_SERVE_INGRESS_REQUEST_ROUTER_FORWARD_BODY=0

각 flag의 의미는 다르다.

설정2.56 기본값역할
RAY_SERVE_LLM_ENABLE_DIRECT_STREAMING0LLMServer를 ingress로 만들고 LLMRouter를 붙임
RAY_SERVE_ENABLE_HA_PROXY0router 선택 후 backend HTTP endpoint로 직접 전달
RAY_SERVE_THROUGHPUT_OPTIMIZED0별도의 Serve throughput 최적화 묶음
RAY_SERVE_INGRESS_REQUEST_ROUTER_FORWARD_BODYfalse/internal/route에 요청 본문을 전달할지 결정
RAY_SERVE_HAPROXY_INGRESS_REQUEST_ROUTER_TIMEOUT_S5router consultation과 body wait 상한
RAY_SERVE_SESSION_ID_HEADER_KEYx-session-idsession affinity key를 담는 header 이름

본문 전달은 기본적으로 꺼져 있다. RoundRobin이나 ConsistentHash는 body가 필요 없으므로 session header만 보내면 된다. prefix-cache-aware router처럼 prompt를 읽어야 하는 정책은 body forwarding을 켜야 한다. 이때 큰 요청은 HAProxy buffer 한도에서 잘리고 X-Body-Truncated가 붙는다. body-aware 정책은 잘린 JSON도 안전하게 처리해야 하며, 본문 buffering이 TTFT에 미치는 영향을 따로 측정해야 한다.

Ray 2.56.0의 build_openai_app() direct path에는 다음 경계가 있다.

  • 정확히 하나의 LLMConfig만 허용한다. multi-model direct streaming은 아직 지원하지 않는다.
  • custom ingress_deployment_config를 허용하지 않는다.
  • 기본 OpenAiIngress가 아닌 custom ingress class와 extra kwargs를 허용하지 않는다.
  • LLMRouternum_replicas=1로 고정된다. 소스의 TODO는 당시 HAProxy router backend가 단일 endpoint를 기대한다고 설명한다.
  • direct streaming은 OpenAI, data-parallel, prefill/decode builder까지 확장되었지만 조합별 canary가 필요하다.
  • ConsistentHashRouter, CapacityQueueRouter, round-robin router는 experimental API다.

그리고 2.56.0 최종 태그의 HAProxy template은 router가 unreachable이거나 non-200을 반환하거나 알 수 없는 replica_id를 주면 503으로 실패한다. normal backend로 조용히 우회하지 않는다. 이는 routing policy가 적용되지 않은 요청을 성공처럼 보이게 하지 않는 fail-closed 동작이다. canary에서 X-Serve-Reason과 503 비율을 반드시 관찰해야 한다.


채택 검증: 빠른가보다 경로가 맞는가를 먼저 본다

Upstream PR에는 Qwen3 0.6B FP8, H100 replica 8개, 긴 input과 짧은 output으로 direct streaming 결과가 포함되어 있다. 하지만 같은 표에 기존 ingress 경로의 대조군이 없으므로 그 수치를 보편적 speedup으로 인용하면 안 된다. 모델, input/output 길이, 동시성, vLLM 설정, HAProxy topology가 바뀌면 병목도 바뀐다.

1. 경로 확인

  • Ray image와 Python package가 모두 2.56.0인지 기록한다.
  • startup log에서 direct streaming 선택과 LLMServer=ingress, LLMRouter=ingress_request_router를 확인한다.
  • HAProxy backend map에 각 replica의 backend HTTP endpoint가 등록되는지 확인한다.
  • 응답을 처리한 replica ID와 router가 선택한 replica ID가 일치하는지 표본 추적한다.
  • router 실패가 503과 X-Serve-Reason으로 보이는지 fault injection으로 검증한다.

2. API 계약

  • /v1/chat/completions의 streaming·non-streaming 응답을 모두 비교한다.
  • 첫 chunk, 마지막 [DONE], usage, finish reason, tool call JSON의 순서를 확인한다.
  • client disconnect와 timeout 뒤 engine request와 socket이 정리되는지 확인한다.
  • prefill/decode 또는 data parallel builder를 쓰면 각 topology를 별도 배포해 검증한다.

3. 성능 분포

지표의미
TTFT p50/p95/p99routing consultation, body wait, prefill까지의 지연
ITL·TPOT p50/p95/p99token stream이 중간 계층을 우회한 효과와 jitter
E2E latency긴 응답에서 전체 경로 개선 여부
output tokens/s, request/s처리량 변화
ingress/router CPUPython ingress 제거 효과와 LLMRouter 병목
replica별 ongoing requestspick-only 선택의 불균형과 hot replica
router consultation latency·503control plane 안정성

짧은 prompt·긴 decode, 긴 prompt·짧은 decode, mixed length, burst traffic을 나눠 시험한다. direct streaming의 이득은 장시간 token stream에서 더 잘 보일 수 있지만, body-aware routing을 켜면 긴 prompt의 buffering 비용이 반대로 커질 수 있다.

4. routing semantics

  • 같은 x-session-id로 연속 요청을 보내 replica affinity 비율을 측정한다.
  • replica scale-out·scale-in 중 session remapping과 cache hit 변화를 확인한다.
  • num_fallback_replicas=02를 비교해 p99, rejection, KV cache hit의 tradeoff를 본다.
  • session header가 없는 요청이 균등하게 분산되는지 확인한다.
  • body forwarding을 켠 경우 buffer 한도를 넘는 prompt와 잘린 JSON을 시험한다.

5. rollback

  1. 같은 model artifact와 vLLM 설정으로 기존 ingress deployment를 유지한다.
  2. direct streaming deployment에 replay traffic을 먼저 보낸다.
  3. 503, TTFT p99, stream 오류, replica 불균형에 확대 중단 기준을 둔다.
  4. 문제가 생기면 RAY_SERVE_LLM_ENABLE_DIRECT_STREAMING=0인 이전 manifest로 되돌린다.
  5. connection draining 중 client retry가 중복 generation을 만들지 확인한다.

정리

Ray 2.56 Serve LLM의 중요한 변화는 새로운 load-balancing 알고리즘 하나가 아니다. replica 선택은 control plane에 남기고, 원본 HTTP 요청과 token stream은 선택된 model replica의 backend endpoint로 직접 보내는 경계 재설계다.

이 경로는 오래 지속되는 stream에서 Python ingress와 actor 응답 경로의 반복 비용을 줄일 여지가 있다. 대신 HAProxy, singleton LLMRouter, backend endpoint metadata, pick-only selection이라는 새 운영 경계를 만든다. body forwarding은 기본 off이고, router 선택이 실패하면 최종 태그는 503으로 fail closed한다.

ConsistentHashRouter는 session affinity를 위해 queue awareness를 포기한다. CapacityQueueRouter는 여러 router의 stale local view를 중앙 token으로 보정하지만, direct streaming의 _reserve=False 경로와 자동으로 결합되지 않는다. 운영자가 기억할 기준은 간단하다. direct streaming, session affinity, capacity coordination은 서로 다른 문제를 푸는 기능이며, 같은 릴리스에 포함됐어도 조합의 semantics는 태그 소스와 부하 시험으로 확인해야 한다.

References