- Published on
LangChain에서 OpenAI 429·타임아웃 재시도 백오프
- Authors
- Name
- 스타차일드
- https://x.com/ETFBITX
서버에서 LangChain으로 OpenAI 호출을 붙이면, 개발 초반에는 잘 되다가 트래픽이 조금만 늘어도 429(Rate Limit)이나 타임아웃이 빈번해집니다. 이때 흔히 retry=3 같은 단순 재시도만 넣는데, 실제 운영에서는 재시도 폭주(retry storm) 로 더 큰 장애를 만들 수 있습니다.
이 글에서는 LangChain 기반 호출에서 429와 타임아웃을 예측 가능한 방식으로 다루기 위해, 아래 4가지를 함께 설계하는 방법을 다룹니다.
- 지수 백오프 + 지터(jitter)
- OpenAI가 주는
Retry-After힌트 존중 - 타임아웃을 “짧게 실패”하도록 조정하고, 재시도는 “천천히”
- 동시성 제한(클라이언트 측 큐잉)
유사한 맥락의 “타임아웃이 곧 장애가 되는” 운영 이슈는 인프라에서도 자주 발생합니다. 예를 들어, Ingress 레벨에서 408이 나올 때의 접근법도 참고가 됩니다: EKS에서 ALB Ingress 408 Request Timeout 해결 가이드
1) 429와 타임아웃이 같이 터지는 이유
429는 “요청이 너무 많다”의 두 가지 의미
OpenAI의 429는 단순히 초당 요청 수(RPS)만 의미하지 않습니다. 보통 다음 중 하나입니다.
- RPM 제한: 분당 요청 수 초과
- TPM 제한: 분당 토큰 수 초과(프롬프트/출력 토큰 합)
LangChain은 체인 구성에 따라 한 번의 사용자 요청이 내부적으로 여러 번 호출될 수 있습니다(예: retriever + rerank + final answer). 그래서 “사용자 요청 수는 적은데도” 429가 나올 수 있습니다.
타임아웃은 “느린 모델”만의 문제가 아니다
타임아웃은 모델 응답이 느려서만 발생하지 않습니다.
- 네트워크 레벨 지연(특히 서버리스, NAT, 프록시)
- 연결 풀 고갈
- 동시성 과다로 인한 이벤트 루프 지연
- 재시도 폭주로 인한 자기 유발 부하
즉, 429와 타임아웃은 서로를 강화합니다.
429가 나서 재시도 증가 → 트래픽 더 증가 →429더 증가- 타임아웃으로 재시도 증가 → 실제로는 서버가 처리 중인데 중복 요청 발생 → 토큰/요청 제한 더 빨리 소모
2) 재시도 설계의 기본: “무조건 재시도”는 금지
재시도 정책은 다음 질문에 답해야 합니다.
- 어떤 에러에서 재시도할 것인가
- 최대 몇 번까지 할 것인가
- 각 재시도 간 대기 시간은 어떻게 늘릴 것인가
- 서버가
Retry-After를 주면 그걸 우선할 것인가 - 요청 단위 idempotency(멱등성)를 확보할 것인가
LLM 호출은 일반적으로 “동일 입력이면 동일 출력”이 보장되지 않습니다. 따라서 재시도는 비즈니스적으로 허용 가능한 범위에서만 해야 합니다. 예를 들어 “요약 생성”은 재시도해도 되지만, “결제 승인 문구 생성 후 DB에 저장” 같은 흐름은 멱등 키가 없으면 위험합니다.
3) LangChain에서 429·타임아웃 재시도 구현 (Python)
LangChain 내부에도 재시도 유틸이 있지만, 운영에서 필요한 수준의 제어(지터, Retry-After, 로깅/메트릭, 동시성 제한)를 위해서는 보통 클라이언트 호출 래퍼를 따로 두는 편이 안전합니다.
아래 예시는 tenacity로 지수 백오프 + 지터를 적용하고, 429에서 Retry-After가 있으면 그 값을 우선합니다.
import random
import time
from typing import Optional
import httpx
from tenacity import retry, stop_after_attempt, wait_exponential_jitter, retry_if_exception
from langchain_openai import ChatOpenAI
def _is_retryable_exc(e: Exception) -> bool:
# httpx 계열 네트워크/타임아웃
if isinstance(e, (httpx.TimeoutException, httpx.NetworkError)):
return True
# OpenAI SDK 또는 LangChain 래핑 예외는 버전에 따라 타입이 다를 수 있어
# 메시지/속성 기반으로 429를 감지하는 방식을 함께 둡니다.
msg = str(e).lower()
if "429" in msg or "rate limit" in msg or "too many requests" in msg:
return True
# 5xx도 재시도 후보
if "500" in msg or "502" in msg or "503" in msg or "504" in msg:
return True
return False
def _extract_retry_after_seconds(e: Exception) -> Optional[float]:
# 라이브러리/버전에 따라 헤더 접근 방식이 다릅니다.
# 가능한 경우에만 추출하고, 없으면 None.
retry_after = None
# 예: httpx.HTTPStatusError
if isinstance(e, httpx.HTTPStatusError):
ra = e.response.headers.get("retry-after")
if ra:
try:
retry_after = float(ra)
except ValueError:
pass
# 문자열 기반 힌트가 있는 경우(마지막 수단)
# 운영에서는 로깅으로 실제 예외 포맷을 확인해 맞추는 편이 좋습니다.
return retry_after
def _sleep_with_retry_after_or_backoff(retry_state):
e = retry_state.outcome.exception() if retry_state.outcome else None
if e:
ra = _extract_retry_after_seconds(e)
if ra is not None:
# 약간의 지터를 추가해 동시 재시도 쏠림을 완화
time.sleep(ra + random.uniform(0, 0.25))
return
# tenacity 기본 wait 전략을 사용
retry_state.retry_object.wait(retry_state)
@retry(
retry=retry_if_exception(_is_retryable_exc),
stop=stop_after_attempt(6),
wait=wait_exponential_jitter(initial=0.5, max=20.0),
before_sleep=_sleep_with_retry_after_or_backoff,
reraise=True,
)
def invoke_with_retry(llm: ChatOpenAI, messages):
return llm.invoke(messages)
# 사용 예
llm = ChatOpenAI(
model="gpt-4o-mini",
temperature=0,
# 요청 단위 타임아웃은 짧게(예: 20초) 두고, 재시도로 복구
timeout=20,
)
resp = invoke_with_retry(llm, [
("system", "You are a helpful assistant."),
("user", "LangChain에서 429 재시도를 안전하게 설계하는 법을 요약해줘")
])
print(resp.content)
포인트
stop_after_attempt(6)처럼 최대 재시도 횟수를 명확히 제한합니다.wait_exponential_jitter로 지수 백오프 + 지터를 적용해 “다 같이 재시도”를 피합니다.- 가능하면
Retry-After를 우선합니다.429에서 서버가 “몇 초 뒤에 다시 와라”를 주는 경우가 많습니다. - 타임아웃을 무작정 늘리기보다, 짧은 타임아웃 + 백오프 재시도가 전체 안정성에 유리한 경우가 많습니다.
4) LangChain에서 동시성 제한이 백오프만큼 중요하다
백오프를 잘 짜도, 동시에 200개 요청이 들어오면 결국 429는 납니다. 특히 RAG나 에이전트처럼 “한 사용자 요청이 여러 LLM 호출로 분해”되는 구조라면, 서버 입장에서는 동시성 제한이 사실상 필수입니다.
Python asyncio 환경이라면 세마포어로 간단히 막을 수 있습니다.
import asyncio
from langchain_openai import ChatOpenAI
llm = ChatOpenAI(model="gpt-4o-mini", timeout=20)
sem = asyncio.Semaphore(10) # 동시에 10개만 LLM 호출
async def ainvoke_limited(messages):
async with sem:
return await llm.ainvoke(messages)
async def main():
tasks = [
ainvoke_limited([("user", f"요청 {i} 요약")])
for i in range(50)
]
results = await asyncio.gather(*tasks, return_exceptions=True)
print(len(results))
asyncio.run(main())
이 방식은 “재시도”가 아니라 “애초에 덜 때리기”에 가깝습니다. 운영에서는 대개 이게 더 강력합니다.
5) 타임아웃은 계층별로 분리해서 잡아야 한다
LLM 호출 타임아웃을 한 값으로만 잡으면, 어디서 시간이 새는지 모호해집니다. 보통은 아래처럼 계층을 나눕니다.
- HTTP 클라이언트 타임아웃(연결/읽기/전체)
- LLM 호출 타임아웃(요청 단위)
- 상위 API 타임아웃(예: ALB, API Gateway, Ingress)
- 워커/잡 타임아웃(비동기 처리 시)
특히 상위 타임아웃이 30초인데 LLM 타임아웃을 60초로 두면, 상위에서 먼저 끊기고 백엔드에서는 계속 처리하는 “유령 작업”이 생길 수 있습니다. 이 패턴은 408/504를 같이 유발합니다.
타임아웃을 다룰 때의 실전 접근은 gRPC에서도 동일합니다. 데드라인과 재시도가 만나면 폭주가 생기기 쉽습니다: gRPC MSA에서 데드라인·리트라이 폭주 막는 법
6) 429를 줄이는 토큰 전략: TPM을 먼저 의심하라
429가 계속 나는데 RPS는 낮다면 TPM 초과일 가능성이 큽니다. 다음을 점검하세요.
- 시스템 프롬프트가 과도하게 길지 않은가
- 대화 히스토리를 무제한으로 붙이지 않는가
- RAG에서 문서 chunk를 너무 많이 넣지 않는가
max_tokens를 과도하게 크게 잡지 않았는가
LangChain에서 히스토리를 자르는 가장 단순한 방법은 “최근 N턴만 유지”입니다. 또는 요약 메모리로 전환합니다.
from langchain_core.messages import HumanMessage, AIMessage
def truncate_history(messages, keep_last=8):
# system 메시지는 유지하고, 나머지는 뒤에서 keep_last만 유지
system = [m for m in messages if getattr(m, "type", "") == "system"]
others = [m for m in messages if m not in system]
return system + others[-keep_last:]
history = [
HumanMessage(content="긴 대화 1"),
AIMessage(content="응답 1"),
# ...
]
history = truncate_history(history, keep_last=8)
7) 관측 가능성: 재시도는 로깅이 없으면 디버깅 불가
재시도 로직을 넣었다면, 최소한 아래는 남겨야 합니다.
- 요청 ID(또는 trace ID)
- 시도 횟수(attempt)
- 에러 타입(429, timeout, 5xx)
- 대기 시간(backoff seconds)
- 최종 성공/실패
또한 429가 났을 때 “요청당 토큰 수”를 같이 기록하면 TPM 초과인지 판단이 빨라집니다.
8) 운영 팁: 백오프만으로 해결되지 않을 때
1) 큐 기반 비동기 처리로 전환
사용자 HTTP 요청을 LLM 처리 시간만큼 붙잡지 말고, 작업 큐로 넘긴 뒤 폴링/웹훅/스트리밍으로 결과를 전달하면 상위 타임아웃 문제를 크게 줄일 수 있습니다.
2) 모델/엔드포인트 분리
요약, 분류 같은 저비용 작업은 더 작은 모델로 분리하면 TPM/RPM 압박이 내려갑니다.
3) 회로 차단기(circuit breaker)
429가 일정 비율 이상이면 잠깐 요청을 차단하고 빠르게 실패시키는 전략이 전체 장애를 줄일 때가 많습니다.
9) 체크리스트
429는 RPM이 아니라 TPM일 수 있다- 재시도는
max_attempts와max_backoff를 반드시 둔다 - 지수 백오프 + 지터는 기본,
Retry-After는 가능하면 우선 - 타임아웃은 짧게 실패하도록 두고, 재시도는 천천히
- 동시성 제한(세마포어/큐)이 백오프만큼 중요
- 재시도 로그/메트릭 없이는 원인 규명이 불가능
마무리
LangChain에서 OpenAI 호출이 429와 타임아웃으로 흔들릴 때, 핵심은 “재시도를 넣는다”가 아니라 재시도를 시스템의 일부로 설계하는 것입니다. 지터 백오프, Retry-After 존중, 타임아웃 계층 정리, 동시성 제한까지 함께 넣으면 같은 트래픽에서도 실패율이 눈에 띄게 내려갑니다.
추가로 OpenAI API에서 특정 에러를 정확히 해석하고 수정하는 흐름이 필요하다면, 입력/출력 포맷 문제를 다룬 글도 함께 참고해보세요: OpenAI Responses API 400 invalid_output_text 해결 가이드