[CloudNeta] Hands-On LLM Serving 2주차 - 추적: generate의 대기 루프 (배치 경로)

이 문서는 2주차 part 1 - 모델 서빙 시스템 설계의 /generate 경로를 코드 순서대로 따라간 추적 기록입니다. 스트리밍 경로는 request_id의 여정에서 다룹니다.

/generate_stream이 비켜주며 기다리는 경로라면, /generate는 붙잡고 기다리는 경로입니다. 워커 스레드도, 코루틴도, 큐 브릿지도 쓰지 않습니다. 요청을 받은 그 자리에서 다 될 때까지 돌립니다.

세 갈래: 같은 워커, 다른 길

엔드포인트 세 개가 같은 모델·같은 워커 프로세스를 쓰지만 거기 도달하는 경로가 전부 다릅니다.

가장 큰 갈림은 실행 주체 행입니다. 앞의 둘은 요청을 받은 그 자리에서 끝까지 돌리고, 스트리밍만 일을 데몬 스레드에 넘기고 자기는 잠듭니다.

basic_generate, generate, generate_stream 세 엔드포인트를 입력·실행 주체·대기열·워커 API·반환·동시성 기준으로 비교한 표

시퀀스: 프롬프트 4개를 보내고 응답 하나를 받기까지

스트리밍 쪽과 나란히 놓고 보면 차이가 분명합니다. 여기엔 asyncio.Queue도, run_coroutine_threadsafe도, 깨어나는 코루틴도 없습니다. 루프 하나가 전부입니다.

generate 엔드포인트가 요청 핸들러 스레드에서 배치 루프를 돌며 워커와 왕복하는 시퀀스 다이어그램

워커와의 왕복은 배치가 나뉜 횟수만큼만 일어납니다. 프롬프트 4개면 대개 한 번, 5개면 두 번입니다. 스트리밍의 21회 왕복과 대비됩니다.

배치는 요청 단위로 끊기지 않는다

llm.py에 이런 주석이 달려 있습니다.

Execute the next batch in one go, it may not be the same prompts as the prompts in the request.

배치를 채우는 건 WorkloadManager의 대기열이지 내 요청이 아닙니다.

프롬프트 5개짜리 요청이 4칸 배치 두 번으로 나뉘고 두 번째 배치의 빈 칸에 다른 요청이 들어오는 그림

내 요청은 5개지만 배치는 4칸이라 두 번에 나뉩니다. 두 번째 배치의 남은 칸은 대기열에 있던 누구의 프롬프트든 채울 수 있습니다.

그래서 결과를 request_id로 되찾아야 합니다. 배치에 뭐가 같이 실렸는지는 호출한 쪽이 알 수 없습니다.

_is_batch_finished(request_ids)   # 내 id 들이 전부 finished 인지만 확인한다

배치 경계와 요청 경계는 별개입니다. 루프가 도는 조건은 "배치가 끝났는가"가 아니라 "내 request_id들이 전부 끝났는가"이고, 그 판정은 sequence_map을 통해 이뤄집니다.

두 경로가 큐 한 쌍을 나눠 쓴다

ModelExecutor는 하나뿐이고, task_queue와 result_queue도 한 쌍뿐입니다. /generate를 처리하는 요청 스레드와 requests_processing_loop 데몬 스레드가 같은 큐에 넣고 같은 큐에서 꺼냅니다.

요청 스레드와 데몬 스레드가 하나의 task_queue와 result_queue를 공유하며 단일 워커와 통신하는 구조

큐는 누가 넣었는지 기억하지 않습니다. 먼저 get()을 부른 스레드가 맨 앞의 항목을 가져갑니다. 튜플 앞의 태그가 붙어 있는 이유입니다.

두 경로의 확인 강도는 다릅니다.

# execute_forward_batch 는 태그를 검사한다
result_type, results = self._wait_for_result()
if result_type == 'stream':
    return results
else:
    raise UnexpectedResultTypeError(...)

# execute_batch 는 튜플째 돌려주고, 호출부가 인덱싱한다
results = self._wait_for_result()
return results
...
return results[1][0]['generated_text']

두 경로를 동시에 쓰지 않으면 이 구조는 드러나지 않습니다. 스트리밍만 쓰거나 일반 요청만 쓰면 큐에는 한 종류만 오갑니다. 저장소의 repro_lockstep.py가 겨냥한 지점이 여기입니다.

루프 본체

세 줄로 압축하면 이렇습니다.

# llm.py 의 generate()
request_ids = [add_request(p) for p in prompts]        # ① 대기열에 담고 id 를 챙긴다

while not _is_batch_finished(request_ids):             # ② 내 id 들이 다 끝날 때까지
    sequences = get_next_batch()                       #    대기열에서 최대 4개
    if not sequences:
        time.sleep(0.1); continue                      #    없으면 잠깐 쉬고 다시
    results = execute_batch(sequences)                 # ③ 워커 왕복. 여기서 막힌다
    for r in results[1]:
        update_sequence_output(r['request_id'], ...)   #    finished=True 로 표시

return [get_sequence(i).output[0] for i in request_ids]  # ④ 내 id 순서대로 꺼낸다
스트리밍과 갈리는 한 줄

execute_batch(sequences)에는 Sequence 객체가 그대로 넘어갑니다. 스트리밍 쪽은 {'prompt': ..., 'request_id': ...} dict로 바꿔 보내는데 말이죠. 그래서 워커의 generate()는 p.prompt·p.id로 속성 접근을 하고, generate_forward_batch()는 p['prompt']로 키 접근을 합니다.

형태가 갈린 이유는 스트리밍 Sequence가 client_stream과 loop를 들고 있기 때문입니다. 이 둘은 이벤트 루프에 매인 물건이라 프로세스 경계를 건널 수 없어서, 넘길 것만 추려 dict로 만듭니다. 일반 경로의 Sequence는 그 자리가 None이라 통째로 보내도 됩니다.

말로 풀면

단계 무슨 일
01 접수 프롬프트 4개면 Sequence 4개가 만들어져 incoming_queue에 들어가고 sequence_map에도 등록된다. 이 시퀀스들의 client_stream과 loop는 둘 다 None이다. 결과를 돌려줄 큐가 애초에 필요 없기 때문이다
02 루프 _is_batch_finished(request_ids)가 True가 될 때까지 반복한다. 대기열이 비어 있으면 time.sleep(0.1)로 잠깐 쉬는데, 이 sleep이 이벤트 루프 전체를 함께 재운다
03 왕복 model.generate(max_new_tokens=50)이 KV 캐시까지 써서 끝까지 생성한다. 스트리밍처럼 토큰마다 돌아오지 않고 완성본을 한 번에 돌려준다. 반환된 문자열에는 프롬프트가 그대로 들어 있는데, 워커가 생성분만이 아니라 전체 시퀀스를 디코드하기 때문이다
04 표시 update_sequence_output(..., is_finished=True)로 output에 결과를 넣고 완료 표시를 한다. 다음 루프에서 _is_batch_finished가 이걸 본다
05 수거 배치에 뭐가 같이 실렸든 상관없이 request_ids 순서대로 sequence_map에서 꺼내 리스트로 만든다. 요청 순서와 응답 순서가 여기서 맞춰진다. 꺼낸 뒤엔 맵에서 제거한다
한 줄 요약

/generate_stream은 await로 비켜주고, /generate는 while로 붙잡습니다. 같은 모델·같은 워커를 쓰지만 대기하는 방식이 반대라, 둘을 동시에 때리면 붙잡는 쪽이 비켜주는 쪽의 자리를 가져갑니다.