LLM 비동기 호출 결과, 순서대로 받는 큐 설계하기

LLM 비동기 호출

결론부터 말씀드리면, LLM 비동기 호출을 여러 개 동시에 보낼 때 응답이 뒤섞이는 문제는 asyncio.gather만으로는 해결되지 않는 경우가 있고, 이럴 땐 순번을 매긴 뒤 재정렬 버퍼(reorder buffer)를 두는 큐를 직접 만들어야 합니다. 특히 응답을 모아서 한 번에 처리하는 게 아니라 도착하는 즉시 화면에 순서대로 뿌려야 하는 스트리밍 UI라면 더욱 그렇습니다. 이 글에서는 Python 3.11 기준 asyncioheapq만으로 순서 보장 큐를 만드는 방법과, 이 방식이 오히려 발목을 잡는 상황을 코드와 함께 정리해 드립니다.

비동기 호출인데 왜 응답 순서가 뒤바뀔까요?

LLM 비동기 호출을 5개 동시에 보내면, 짧은 답변을 요구하는 요청이 긴 답변을 요구하는 요청보다 먼저 끝나는 일이 흔합니다. 특히 서빙 엔진이 동적 배치(continuous batching) 방식을 쓰는 경우, 생성해야 할 토큰 수가 요청마다 달라서 먼저 보낸 요청이 나중에 끝나는 일이 자연스럽게 벌어집니다.

여기서 헷갈리는 지점이 하나 있습니다. asyncio.gather(*tasks)는 내부적으로 각 태스크가 완료되는 시점은 제각각이어도, 반환값 리스트는 넘긴 태스크의 순서 그대로 돌려줍니다. 즉 gather 자체는 이미 순서를 보장하고 있어서, “결과를 모아서 한 번에 쓴다”면 별도 큐가 필요 없습니다.

문제는 asyncio.as_completed()를 쓰거나, 결과가 도착하는 대로 즉시 다음 단계(예: 프론트엔드 스트리밍, 파일 순차 저장)로 넘겨야 할 때입니다. as_completed는 이름 그대로 완료된 순서로 반환하기 때문에, 요청 순서와 응답 순서가 다르면 그대로 뒤섞인 채 넘어갑니다.

세 가지 방식의 순서 보장 범위가 다릅니다

먼저 상황별로 어떤 도구를 써야 하는지 표로 정리했습니다.

비동기 요청 결과를 순서대로 정렬하는 큐 파이프라인 개념 이미지

방식 순서 보장 시점 스트리밍 가능 여부 구현 난이도 적합한 상황
asyncio.gather 전체 요청 완료 후 한 번에 불가 (다 끝날 때까지 대기) 낮음 결과를 한꺼번에 모아 후처리할 때
asyncio.as_completed 보장 안 됨 (완료된 순서) 가능하지만 순서 뒤섞임 낮음 순서와 무관하게 빨리 처리해야 할 때
재정렬 버퍼 직접 구현 순번대로 즉시 방출 가능 중간 순서 그대로 실시간으로 내보내야 할 때

정리하면, 순서가 필요 없거나 결과를 다 모은 뒤 처리한다면 앞의 두 가지로 충분합니다. 순서를 지키면서 동시에 “도착하는 대로 바로바로” 내보내야 할 때만 세 번째 방식이 필요합니다.

재정렬 버퍼로 순서 보장 큐 만들기

핵심 아이디어는 단순합니다. 각 요청에 순번(idx)을 매겨서 보내고, 응답이 도착하면 asyncio.Queue에 (순번, 결과)를 집어넣습니다. 그리고 소비자 쪽에서는 다음에 내보내야 할 순번이 준비됐는지 우선순위 큐 구조(파이썬에서는 heapq)로 관리하다가, 순서가 맞을 때만 꺼내 줍니다.

import asyncio
import heapq
import random

class OrderedResultQueue:
    """비동기로 뒤섞여 도착하는 (순번, 결과)를 순번대로 꺼내주는 큐"""

    def __init__(self):
        self._raw = asyncio.Queue()
        self._buffer = []       # heapq로 관리하는 재정렬 버퍼
        self._next_idx = 0

    async def put(self, idx: int, value):
        await self._raw.put((idx, value))

    async def get_in_order(self):
        while not (self._buffer and self._buffer[0][0] == self._next_idx):
            idx, value = await self._raw.get()
            heapq.heappush(self._buffer, (idx, value))
        idx, value = heapq.heappop(self._buffer)
        self._next_idx += 1
        return value


async def call_llm(idx: int, prompt: str, oq: OrderedResultQueue):
    delay = random.uniform(0.1, 1.0)
    await asyncio.sleep(delay)  # 실제로는 LLM API 호출 지연 시간
    await oq.put(idx, f"{prompt} -> 응답 (지연 {delay:.2f}s)")


async def main():
    prompts = [f"질문{i}" for i in range(5)]
    oq = OrderedResultQueue()
    producers = [
        asyncio.create_task(call_llm(i, p, oq)) for i, p in enumerate(prompts)
    ]
    for _ in prompts:
        print(await oq.get_in_order())
    await asyncio.gather(*producers)


asyncio.run(main())

실제 응답이 도착하는 순서는 매 실행마다 무작위로 바뀌지만(예: 질문3, 질문0, 질문4, 질문1, 질문2 순으로 완료), get_in_order()가 반환하는 순서는 항상 아래처럼 고정됩니다.

질문0 -> 응답 (지연 0.42s)
질문1 -> 응답 (지연 0.88s)
질문2 -> 응답 (지연 0.15s)
질문3 -> 응답 (지연 0.71s)
질문4 -> 응답 (지연 0.33s)

버퍼가 다음 순번을 기다리는 동안 이미 도착한 뒤 순번들은 heap 안에서 대기하고 있다가, 앞 순번이 채워지는 즉시 연쇄적으로 방출됩니다. 이 부분이 LLM 비동기 호출 결과를 순서대로 스트리밍할 때 실질적으로 동작하는 지점입니다.

LLM 서버에서 스트리밍 방식으로 응답을 실시간 전송하는 모습

이 방식이 오히려 발목을 잡는 경우

재정렬 버퍼는 공짜가 아닙니다. 가장 큰 위험은 헤드 오브 라인 블로킹(head-of-line blocking)입니다. 위 코드에서 idx=0 요청이 타임아웃이나 네트워크 오류로 영영 끝나지 않으면, 나머지 4개 응답이 모두 _raw 큐 안에 도착해 있어도 get_in_order()는 순번 0을 기다리며 영원히 멈춥니다.

실무에서는 이런 이유로 두 가지를 같이 걸어야 합니다.

  • 프로듀서 쪽에 asyncio.wait_for(call_llm(...), timeout=N)을 씌워서, 타임아웃이 나도 실패를 알리는 값을 순번과 함께 큐에 반드시 넣어줍니다. 그래야 소비자가 무한 대기하지 않습니다.
  • 동시 요청 개수를 asyncio.Semaphore로 제한합니다. 재정렬 버퍼는 앞 순번이 막히면 뒤 순번 결과를 계속 메모리에 쌓아두는 구조라서, 동시 요청 수가 많을수록 버퍼가 무한정 커질 수 있습니다.

또한 요청 개수가 적고(예: 5개 이하) 순서 없이 처리해도 되는 배치 작업이라면, 이런 재정렬 로직을 넣는 게 오히려 코드 복잡도만 높이는 과한 설계입니다. 이럴 땐 asyncio.gather로 끝내는 편이 유지보수에 유리합니다.

asyncio.gather로도 순서는 지켜지는데, 왜 굳이 큐가 필요한가요?

asyncio.gather가 순서를 지켜주는 건 맞지만, 그건 모든 요청이 끝난 뒤 한꺼번에 순서대로 반환한다는 뜻입니다. 5개 중 4개가 0.2초 만에 끝나도, 가장 느린 1개가 3초 걸리면 나머지 4개 결과도 3초 뒤에야 받게 됩니다. 반면 재정렬 버퍼 방식은 순번 0번 결과가 준비되는 즉시 내보내고, 그 다음 순번이 오면 바로 이어서 내보냅니다. 챗봇 UI에서 답변을 순서대로 실시간 표시하거나, 여러 문서를 순서대로 파일에 이어 써야 하는 파이프라인이라면 이 차이가 체감됩니다.

정리하면, LLM 비동기 호출 결과를 다루는 큐를 설계할 때는 “결과를 다 모아야 하는가, 아니면 도착하는 대로 순서대로 흘려보내야 하는가”부터 먼저 정하는 게 순서입니다. 전자라면 asyncio.gather 한 줄로 끝나고, 후자라면 이번 글의 OrderedResultQueue 같은 재정렬 버퍼가 필요하되 타임아웃과 동시성 제한을 반드시 같이 설계해야 실제 서비스에서 안전하게 씁니다. 지금 프로젝트에 순서 보장이 필요한 비동기 호출 구간이 있다면, 우선 요청 개수가 몇 개인지, 응답 지연 편차가 얼마나 큰지부터 확인해 보시는 걸 추천드립니다.

LLM 응답에 링크를 인용하라고 했더니 죽은 URL만 나올 때 — 검증 후처리 파이프라인

Leave a Comment