- Published on
LangChain OpenAI 429 폭주 대응 - 레이트리밋·백오프·배치
- Authors
- Name
- 스타차일드
- https://x.com/ETFBITX
운영 환경에서 LangChain 기반 파이프라인을 돌리다 보면 어느 순간 429 Too Many Requests 가 연쇄적으로 터지며 전체 잡이 무너지는 경험을 하게 됩니다. 특히 문서 요약, RAG 인덱싱, 평가(Eval)처럼 짧은 시간에 많은 호출이 몰리는 워크로드는 레이트리밋에 취약합니다.
이 글은 단순히 “재시도 붙이면 된다” 수준을 넘어서, 왜 429가 폭주하는지, 그리고 레이트리밋·백오프·배치·동시성 제어를 어떻게 조합해야 안정적으로 처리량을 끌어올릴 수 있는지를 실전 관점에서 정리합니다.
또한 추론 전략 자체가 호출량을 키우는 경우도 많습니다. 필요하다면 Chain-of-Thought 없이 추론 - ReAct·RAP 실전처럼 “불필요한 토큰/턴을 줄이는 설계”도 함께 점검하는 것이 좋습니다.
429의 본질: 요청 수(RPM)와 토큰(TPM) 두 축
OpenAI 계열 429는 보통 두 가지 제한을 건드립니다.
- RPM(Requests Per Minute): 분당 요청 수 제한
- TPM(Tokens Per Minute): 분당 토큰 처리량 제한(입력+출력)
여기서 중요한 포인트는 다음입니다.
- 동시성(Concurrency)이 높다고 처리량이 늘지 않습니다. 제한을 넘는 순간 429가 늘고, 재시도가 겹치면서 오히려 더 많은 요청이 발생합니다(폭주).
- TPM이 병목이면 “요청 수를 줄여도” 429가 납니다. 예를 들어 큰 컨텍스트를 넣거나 출력 길이가 길면 TPM이 먼저 터집니다.
- LangChain은 체인/에이전트 구성이 복잡할수록 호출 수가 기하급수로 늘 수 있습니다. 한 번의 사용자 요청이 내부적으로
retriever호출, rerank 호출, LLM 호출 여러 번으로 분해됩니다.
따라서 429 대응은 “재시도” 하나로 끝나지 않고, 아래 3가지를 함께 해야 합니다.
- 레이트리밋을 수학적으로 모델링하고
- 재시도는 지수 백오프 + 지터로 “폭주를 막고”
- 동시성과 배치를 워크로드에 맞게 최적화해야 합니다.
1) 먼저 관측: 무엇이 RPM/TPM을 태우는지 로그로 쪼개기
429를 잡는 첫 단계는 “어떤 호출이 얼마나 자주/크게 나가는지”를 보는 것입니다. 체인 단위로만 보면 감이 안 오고, LLM 호출 단위로 분해해야 합니다.
LangChain 콜백으로 호출량 계측
아래는 LangChain 콜백에서 요청 시작/종료를 기록하고, 토큰 사용량을 함께 남기는 예시입니다.
import time
import logging
from langchain.callbacks.base import BaseCallbackHandler
logger = logging.getLogger("llm")
class MetricsCallback(BaseCallbackHandler):
def on_llm_start(self, serialized, prompts, **kwargs):
self._start = time.time()
logger.info("llm_start model=%s prompts=%d", serialized.get("name"), len(prompts))
def on_llm_end(self, response, **kwargs):
elapsed = time.time() - self._start
llm_output = getattr(response, "llm_output", None) or {}
token_usage = llm_output.get("token_usage") or {}
logger.info(
"llm_end elapsed=%.3fs prompt=%s completion=%s total=%s",
elapsed,
token_usage.get("prompt_tokens"),
token_usage.get("completion_tokens"),
token_usage.get("total_tokens"),
)
이 로그를 기반으로 다음 질문에 답할 수 있어야 합니다.
- 체인 한 번 실행에 LLM 호출이 몇 번 발생하는가
- 평균
total_tokens가 얼마인가 - 피크 시간에 분당 호출 수가 얼마나 되는가
이 데이터 없이 백오프만 붙이면, “느려졌는데도 429가 계속 나는” 상태가 오래 갑니다.
2) 레이트리밋을 모델링: 안전한 동시성의 상한을 계산
RPM 기준 상한
분당 요청 제한이 R 이고, 평균 요청 지연이 L 초라면, 단순 근사로 안전한 동시성 C 는 다음을 넘기기 어렵습니다.
- 초당 허용 요청수는
R / 60 - 평균적으로 동시 처리 가능한 요청 수는
C ≈ (R / 60) * L
예를 들어 R=3,000 RPM, L=0.6s 라면 C≈30 수준이 상한입니다. 여기에 체인의 내부 호출이 k 번이면, 사용자 요청 동시성은 C / k 로 더 줄여야 합니다.
TPM 기준 상한
TPM 제한이 T 이고, 평균 요청당 토큰이 S 라면 분당 안전 요청 수는 대략 T / S 입니다.
예를 들어 T=200,000 TPM, S=4,000 tokens 라면 분당 50 요청이 상한입니다. 이 경우 동시성을 아무리 낮춰도, 토큰 사이즈를 줄이거나 배치를 바꾸지 않으면 429가 계속 납니다.
결론은 하나입니다.
- 동시성은 “감”이 아니라 RPM/TPM 기반으로 산정해야 합니다.
3) 재시도는 필수지만, “폭주 방지” 설계가 핵심
429가 났을 때 즉시 재시도하면, 재시도 트래픽이 기존 트래픽 위에 얹혀서 더 큰 429를 만듭니다. 따라서 아래 3요소가 필요합니다.
- 지수 백오프(exponential backoff)
- 지터(jitter)로 재시도 타이밍 분산
Retry-After헤더가 있으면 우선 존중
Tenacity로 지수 백오프 + 지터 적용
import random
from tenacity import retry, stop_after_attempt, wait_exponential, retry_if_exception_type
class RateLimitError(Exception):
pass
@retry(
reraise=True,
stop=stop_after_attempt(8),
wait=wait_exponential(multiplier=0.5, min=0.5, max=20) # 0.5s, 1s, 2s, ...
)
def call_with_backoff(fn, *args, **kwargs):
try:
return fn(*args, **kwargs)
except RateLimitError as e:
# 간단한 지터: 실제로는 에러 객체에서 retry-after를 파싱하는 편이 좋음
jitter = random.uniform(0, 0.3)
import time
time.sleep(jitter)
raise
실제로 OpenAI SDK 예외 타입을 그대로 쓰는 경우가 많지만, 핵심은 동일합니다.
- 백오프는 빠르게 증가해야 하고
- 각 워커가 동시에 재시도하지 않게 지터가 있어야 합니다.
재시도는 “요청 단위”가 아니라 “작업 단위”로 묶기
문서 1,000개를 임베딩하는 잡에서 429가 나면, 문서 단위로 재시도하는 것보다 배치 단위로 재시도하는 편이 안정적입니다. 그렇지 않으면 “부분 성공/부분 실패”가 누적되며 큐가 요동칩니다.
이 관점은 분산 시스템에서 이중쓰기/재처리 문제를 다룰 때와 유사합니다. 안정적인 재처리 설계를 고민한다면 MSA에서 Outbox·CDC로 이중쓰기 0건 만들기에서 다루는 “재시도와 멱등성” 사고방식도 참고할 만합니다.
4) 동시성 제한: 프로세스 내부에서 “세마포어”로 먼저 막기
재시도가 잘 되어도, 동시성이 무제한이면 결국 429가 납니다. 특히 FastAPI, Celery, Ray, Airflow 등에서 워커 수를 늘리면 LLM 호출이 동시에 몰립니다.
asyncio 세마포어로 LLM 호출 동시성 제한
import asyncio
class LLMClient:
def __init__(self, max_in_flight: int = 10):
self.sem = asyncio.Semaphore(max_in_flight)
async def generate(self, llm, prompt: str):
async with self.sem:
return await llm.ainvoke(prompt)
async def run_many(llm, prompts, max_in_flight=10):
client = LLMClient(max_in_flight=max_in_flight)
tasks = [client.generate(llm, p) for p in prompts]
return await asyncio.gather(*tasks)
여기서 max_in_flight 는 앞서 계산한 RPM/TPM 기반 상한에서 시작해 점진적으로 올립니다.
- 팁: 상한을 고정값으로 박지 말고, 429 비율이 올라가면 자동으로 낮추는 적응형(Adaptive) 동시성도 고려할 수 있습니다.
5) 배치 최적화: “요청 수를 줄여” RPM 병목을 풀기
429가 RPM 병목이라면 배치가 가장 직접적인 해결책입니다.
- 임베딩: 여러 텍스트를 한 번에 넣어도 되는 API가 많음
- 분류/추출: 짧은 텍스트 여러 개를 하나의 프롬프트에 묶어 “멀티 아이템 처리”
임베딩 배치 예시(개념 코드)
def chunked(items, size):
for i in range(0, len(items), size):
yield items[i:i+size]
async def embed_documents(embedder, docs, batch_size=64):
vectors = []
for batch in chunked(docs, batch_size):
# embedder가 리스트 입력을 지원한다는 가정
vec = await embedder.aembed_documents(batch)
vectors.extend(vec)
return vectors
배치 사이즈는 크게 잡을수록 RPM은 줄지만, 대신 다음 리스크가 커집니다.
- 요청당 토큰/페이로드 증가로 TPM 또는 요청 바이트 제한에 걸림
- 실패 시 재시도 비용이 커짐
따라서 배치는 “크게 한 방”이 아니라, 실패 비용과 토큰 상한을 고려한 최적점을 찾아야 합니다.
6) 토큰 최적화: TPM 병목을 푸는 가장 확실한 방법
TPM이 병목이면, 백오프/동시성만으로는 한계가 있습니다. 아래를 순서대로 점검합니다.
- 입력 컨텍스트 줄이기
- RAG에서 top-k를 무작정 키우지 않기
- chunk 크기/overlap 재조정
- 중복 문단 제거(해시 기반 dedupe)
- 출력 길이 제한
max_tokens를 명시- “요약은 5줄” 같은 하드 제약
- 멀티턴을 싱글턴으로
- 에이전트가 도구 호출을 여러 번 반복하면 TPM/RPM이 함께 터짐
추론 설계를 바꿔 호출 수를 줄이는 접근은 Chain-of-Thought 없이 추론 - ReAct·RAP 실전과도 연결됩니다. “정답률”만 보지 말고 “호출 비용/레이트리밋 친화성”도 함께 최적화해야 운영이 됩니다.
7) 큐잉과 배압(Backpressure): 폭주를 시스템적으로 막기
실서비스에서 가장 위험한 패턴은 다음입니다.
- 트래픽 급증
- 워커가 동시에 LLM 호출
- 429 증가
- 재시도 트래픽까지 더해져 더 큰 429
- 지연 증가로 타임아웃/재시도 추가
이 루프를 끊으려면 애플리케이션 레벨에서 배압이 필요합니다.
- 요청을 즉시 처리하지 말고 큐에 적재
- 큐 소비자(consumer)가 레이트리밋에 맞춰 처리
- 큐 길이가 임계치를 넘으면 “빠른 실패(429/503)” 또는 “지연 안내”
웹 서버의 커넥션/스레드가 고갈되며 연쇄 장애로 이어지는 모습은 DB 커넥션 풀 고갈과도 닮았습니다. 병목이 생겼을 때 리소스를 잠식하는 메커니즘을 이해하려면 Spring Boot HikariCP 커넥션 고갈 3분 진단 같은 글의 사고방식(풀 크기, 대기, 타임아웃, 배압)을 LLM 호출에도 그대로 적용할 수 있습니다.
8) 실전 체크리스트: 429를 “가끔”이 아니라 “0에 가깝게”
아래 순서대로 적용하면 대부분의 429 폭주를 안정화할 수 있습니다.
- 관측: LLM 호출 횟수, 평균 토큰, 피크 RPM/TPM을 로그로 확보
- 동시성 상한 산정:
RPM/TPM기반으로max_in_flight결정 - 세마포어로 동시성 강제: 앱 내부에서 먼저 제한
- 지수 백오프+지터: 재시도 폭주 방지
- 배치로 RPM 절감: 임베딩/분류/추출은 멀티 아이템 처리
- 토큰 최적화로 TPM 절감: 컨텍스트/출력/멀티턴 줄이기
- 큐잉+배압: 트래픽 급증 시 시스템이 스스로를 보호
마무리
LangChain에서 OpenAI 429가 “폭주”하는 문제는 단순 예외 처리의 영역이 아니라, **처리량 설계(레이트리밋 모델링) + 안정성 설계(백오프/배압) + 비용 설계(토큰/배치 최적화)**가 동시에 필요한 운영 이슈입니다.
재시도를 붙였는데도 429가 계속된다면, 그건 보통 “재시도 로직이 부족한 것”이 아니라 동시성/토큰/배치가 레이트리밋과 불일치하기 때문입니다. 위의 순서대로 병목을 분해해서 맞춰가면, 같은 크레딧/같은 모델로도 체감 처리량과 안정성이 크게 개선됩니다.