20230709

Django + Celery 사내 LLM 파이프라인

LLM 호출은 latency가 길다. 평균 3~8초, 최악 30초. 이걸 request-response 사이클 안에 넣으면 서비스가 죽는다. 당연하지만 async queue 필요.

Celery 5 + Redis 브로커로 구성. 기존 Django 서비스에 얹었는데 막상 운영하다 보니 꽤 까다롭다.

rate limit 대응

OpenAI 429가 제일 골치. Celery 기본 재시도로는 token bucket이 안 맞음. 그래서 task별 rate limit 걸고, 실패하면 exponential backoff + jitter.

@shared_task(
    bind=True,
    autoretry_for=(openai.error.RateLimitError, openai.error.APIError),
    retry_backoff=True,
    retry_backoff_max=60,
    retry_jitter=True,
    max_retries=6,
    rate_limit="40/m",
)
def generate_summary(self, doc_id: int):
    doc = Doc.objects.get(pk=doc_id)
    ...

중복 방지

같은 문서 요약 요청이 동시에 여러 번 들어오면 API 낭비. Redis SETNX로 idempotency key 걸어서 in-flight task 있으면 skip. 키는 llm:summary:{doc_id}:{hash(prompt)}.

streaming

사용자 체감을 위해 결과를 stream으로 흘려주고 싶은데, Celery task 내부에서 스트리밍 받아 프론트로 전달하는 게 어색하다. 중간 버퍼를 Redis pub/sub으로 하고, SSE 엔드포인트가 subscribe해서 내려주는 방식으로 일단 해결. WebSocket까진 안 갔다.

결과 저장

LLM 응답은 거의 다 LLMCall 테이블에 원문 prompt + response + token usage + cost + latency 기록. 나중에 디버깅할 때 이 로그가 천금. JSON 필드로 저장하고 GIN 인덱스 걸었다 (Postgres).

Celery는 강력한데, prefork worker가 OpenAI SSE 스트리밍이랑 궁합이 안 좋다. gevent pool로 바꾸고 나니 훨씬 안정적. I/O bound니까 당연한 얘기긴 함.