Published on

LangChain에서 OpenAI 429·타임아웃 재시도 백오프

Authors

서버에서 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_attemptsmax_backoff를 반드시 둔다
  • 지수 백오프 + 지터는 기본, Retry-After는 가능하면 우선
  • 타임아웃은 짧게 실패하도록 두고, 재시도는 천천히
  • 동시성 제한(세마포어/큐)이 백오프만큼 중요
  • 재시도 로그/메트릭 없이는 원인 규명이 불가능

마무리

LangChain에서 OpenAI 호출이 429와 타임아웃으로 흔들릴 때, 핵심은 “재시도를 넣는다”가 아니라 재시도를 시스템의 일부로 설계하는 것입니다. 지터 백오프, Retry-After 존중, 타임아웃 계층 정리, 동시성 제한까지 함께 넣으면 같은 트래픽에서도 실패율이 눈에 띄게 내려갑니다.

추가로 OpenAI API에서 특정 에러를 정확히 해석하고 수정하는 흐름이 필요하다면, 입력/출력 포맷 문제를 다룬 글도 함께 참고해보세요: OpenAI Responses API 400 invalid_output_text 해결 가이드