AI 서비스의 LLM 호출을 중앙화하기 위해 LiteLLM Proxy를 운영했다. 초기에는 모델 4개를 캐스케이드로 연결했는데, 운영하면서 구조를 2단으로 단순화했다.
Table of contents
Open Table of contents
초기 구조: 4단 캐스케이드
요청 → gemini-3-flash-preview
↓ (실패 시)
→ gemini-2.5-flash
↓ (실패 시)
→ gemini-2.5-flash-lite
↓ (실패 시)
→ gpt-4.1-nano (OpenAI)
각 단계마다 모델별 API 키 풀이 있고, 하나가 rate limit에 걸리면 다음 단계로 넘어가는 구조였다. 논리적으로는 깔끔해 보이지만 운영에서 문제가 생겼다.
문제점
- 디버깅 어려움: 요청이 어떤 모델에서 처리되었는지 추적하기 어려움
- 품질 불일치: 같은 서비스에서 flash-preview와 flash-lite의 응답 품질 차이가 큼
- rate limit 관리 복잡: 4개 모델 × N개 키의 RPM/RPD를 각각 관리해야 함
- 불필요한 중간 단계: flash와 flash-lite의 실질적 차이가 크지 않음
현재 구조: 2단 폴백
요청 → my-llm (Gemini N개 키, 로드밸런싱)
↓ (전체 실패 시)
→ my-llm-openai (gpt-4.1-nano)
단일 진입점
모든 요청이 my-llm이라는 하나의 모델 별칭으로 들어온다. 내부적으로 N개 Gemini API 키가 simple-shuffle 전략으로 분배된다.
model_list:
- model_name: my-llm
litellm_params:
model: gemini/gemini-2.5-flash-lite
api_key: os.environ/GEMINI_API_KEY_01
rpm: 9
rpd: 19
tpm: 250000
# ... N개 키 반복
- model_name: my-llm-openai
litellm_params:
model: openai/gpt-4.1-nano
api_key: os.environ/OPENAI_API_KEY
router_settings:
routing_strategy: simple-shuffle
enable_pre_call_checks: true
enforce_model_rate_limits: true
litellm_settings:
fallbacks:
- my-llm: ["my-llm-openai"]
RPM/RPD 균형 분배
N개 키에 rate limit을 균등 분배한다.
| 설정 | 값 | 총량 (N키) |
|---|---|---|
| RPM/키 | {rpm} | N × {rpm} RPM |
| RPD/키 | {rpd} | N × {rpd} RPD |
| TPM/키 | 250,000 | N × 250K TPM |
Redis에 RPM/RPD 카운터를 저장하여 프록시 재시작 시에도 rate limit 상태가 유지된다.
동적 모델 로테이션
하루 RPD가 부족할 수 있으므로, 3개 Gemini 모델을 자동 로테이션한다.
flash-lite (RPD 상한) → flash (RPD 상한) → 3-flash-preview (RPD 상한) → gpt-4.1-nano
총 Gemini RPD: 3모델 × RPD 상한/일. 이후 OpenAI로 자동 전환.
모니터링 스크립트 (monitor-rpd.sh):
- 1분마다 cron 실행
- Docker 로그에서 200 OK 카운트
- 임계값 (95% of RPD 상한) 도달 시
rotate.sh호출 - 다음 모델 config로 교체 → 컨테이너 재시작
# 로테이션 상태 확인
cat ~/.rotation-state
# current_model=gemini-2.5-flash-lite
# rotation_count=0
# last_rotation=2026-03-20T10:30:00+09:00
일일 리셋: 17:00 KST에 Gemini RPD가 리셋되면 flash-lite로 강제 복귀.
config 자동 생성
N개 키를 수동으로 YAML에 입력하는 것은 실수의 원인이다. Python 스크립트로 자동 생성한다.
# _gen_config.py (간략화)
for model in ["gemini-2.5-flash-lite", "gemini-2.5-flash", "gemini-3-flash-preview"]:
entries = []
for i in range(1, N + 1):
entries.append({
"model_name": "my-llm",
"litellm_params": {
"model": f"gemini/{model}",
"api_key": f"os.environ/GEMINI_API_KEY_{i:02d}",
"rpm": 9, "rpd": 19, "tpm": 250000
}
})
# YAML 파일 출력
4개 config 파일이 생성되고, rotate.sh가 활성 config를 config.yaml로 복사한다.
변경 전후 비교
| 항목 | 4단 캐스케이드 | 2단 폴백 |
|---|---|---|
| 진입점 | 모델별 분리 | 단일 (my-llm) |
| 폴백 단계 | 4단 | 2단 |
| 일일 RPD | N × {rpd} (단일 모델) | 3 × N × {rpd} (3모델 로테이션) |
| 디버깅 | 어떤 모델인지 추적 어려움 | 현재 모델 명확 |
| 품질 일관성 | 불일치 | 동일 모델 내 일관성 유지 |
핵심 정리
LLM 프록시에서 폴백 단계를 늘리면 가용성은 올라가지만 복잡성도 함께 올라간다. 실제 운영에서 중요한 것은 “절대 실패하지 않는 것”이 아니라 “실패 시 빠르게 대응할 수 있는 것”이다. 단순한 2단 구조 + 자동 로테이션이 복잡한 4단 캐스케이드보다 운영하기 쉽고 디버깅도 빠르다.
참고 자료
- LiteLLM Routing — LiteLLM 공식 라우팅 전략 문서
- LiteLLM Reliability & Fallbacks — LiteLLM 공식 폴백 설정 문서