포스트

Orca Router 앞에 LiteLLM 한 겹 끼우기 - (게이트웨이, 비용, 폴백)

Orca Router 앞에 LiteLLM 한 겹 끼우기 - (게이트웨이, 비용, 폴백)

애플리케이션 코드는 base URL 한 줄만 바꾸고, 키 관리와 비용 집계와 폴백을 게이트웨이로 옮깁니다. LiteLLM을 실제로 띄워 무료 모델의 429, 예산 초과, 포트 충돌까지 직접 부딪히며 정리해 둡니다.


Sample Code

이 시리즈에서 다루는 전체 예제 코드는 GitHub에서 확인할 수 있습니다.

litellm-gateway-sample (게이트웨이 설정과 스크립트) spring-sse-sample (1편의 Spring 앱, 코드 변경 없이 그대로 사용)


시리즈 목차

  • SSE, LLM 시대에 다시 보기 - (토큰이 한꺼번에 도착하는 이유)
  • Orca Router 앞에 LiteLLM 한 겹 끼우기 - (게이트웨이, 비용, 폴백) ← 현재 글
  • “MCP는 결국 JSON이고 웹 통신이잖아”에 답하기 - (stdio와 Streamable HTTP) (작성 예정)
  • 게이트웨이 뒤에 MCP 서버 세우기 (작성 예정)
  • MCP 인증과 보안, 백엔드가 가장 잘 아는 자리 (작성 예정)

이 글에서 다룰 내용

  • LLM 게이트웨이가 무엇을 대신해 주는지, 왜 애플리케이션에서 빼내야 하는지
  • LiteLLM Proxy를 docker compose로 띄우고 Orca Router를 뒤에 두는 설정 (프리픽스, /v1, 환경변수 규칙)
  • 무료 모델에 “가상 단가”를 붙여 비용을 집계하는 방법과 그 계산이 맞는지 검산
  • 가상 키에 예산을 걸고 초과시키기, 스팬드 로그 읽기
  • 실패 1: api_base에 /v1을 빠뜨리면 두 번째 에러가 첫 번째와 다른 이유
  • 실패 2: 무료 모델이 429를 맞으면 다른 무료 모델로 폴백하면 되겠지, 라는 착각
  • 실패 3: 1편의 Spring 앱을 붙였더니 curl은 되는데 Java만 404를 받은 이유
  • Prometheus /metrics에 무엇이 찍히는지

“키가 앱마다 흩어져 있어요”

LLM 기능이 서비스 하나에서 두 개, 세 개로 늘어나면 이런 상태가 됩니다.

  • 업스트림 API 키가 앱마다 환경변수로 박혀 있고, 누가 어떤 키를 쓰는지 아무도 모릅니다.
  • 이번 달 비용이 얼마인지는 공급자 콘솔에 들어가야 알 수 있고, 팀별로 나눠 볼 수 없습니다.
  • 업스트림이 429를 내면 각 앱이 제각각 재시도합니다. 아니면 그냥 죽습니다.
  • 모델을 바꾸려면 앱을 전부 다시 배포합니다.

이 네 가지는 모두 “애플리케이션이 알 필요 없는 일”입니다. 그래서 중간에 한 겹을 끼웁니다. 앱은 게이트웨이 주소와 게이트웨이가 발급한 키만 알고, 업스트림 키와 비용과 라우팅은 게이트웨이가 가집니다.

flowchart LR
    subgraph APPS["애플리케이션들"]
        A1["Spring SSE 프록시<br/>(1편)"]
        A2["배치 요약 잡"]
        A3["사내 챗봇"]
    end

    subgraph GW["LiteLLM Proxy (localhost:4000)"]
        direction TB
        K["가상 키 인증<br/>키별 모델 제한, 예산"]
        R["라우터<br/>모델 그룹, 폴백, 쿨다운"]
        C["비용 계산<br/>단가 × 토큰"]
        DB[("Postgres<br/>스팬드 로그, 키")]
        M["/metrics<br/>Prometheus"]
        K --> R --> C --> DB
        C -.-> M
    end

    subgraph UP["업스트림"]
        O1["Orca Router<br/>deepseek-v4-flash-free"]
        O2["Orca Router<br/>glm-5.3-flash-free"]
        O3["(나중에) OpenAI, Anthropic, ..."]
    end

    A1 & A2 & A3 -- "게이트웨이 키" --> K
    R -- "업스트림 키 (앱은 모름)" --> O1
    R -. "폴백" .-> O2
    R -.-> O3

    style K fill:#dcfce7,stroke:#22c55e
    style R fill:#e0f2fe,stroke:#0ea5e9
    style C fill:#fef3c7,stroke:#f59e0b

이 글에서는 이 그림의 초록, 파랑, 노랑 상자를 하나씩 실제로 눌러 봅니다. 게이트웨이로는 LiteLLM을 골랐습니다. OpenAI 호환 API를 그대로 노출하기 때문에 1편의 Spring 앱을 코드 변경 없이 붙일 수 있고, 가상 키와 비용 집계가 오픈소스 버전에 들어 있습니다.


실습 환경

docker compose

LiteLLM 컨테이너와, 가상 키와 스팬드 로그를 저장할 Postgres입니다. (핵심만 남긴 것이고, container_name과 헬스체크 재시도 횟수 같은 나머지 필드는 저장소의 파일에 있습니다.)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
services:
  litellm:
    image: ghcr.io/berriai/litellm:main-latest
    ports: ["${LITELLM_PORT:-4000}:4000"]
    environment:
      LITELLM_MASTER_KEY: ${LITELLM_MASTER_KEY:-sk-local-master-1234}
      DATABASE_URL: postgresql://llmproxy:dbpassword9090@db:5432/litellm
      ORCA_ROUTER_API_KEY: ${ORCA_ROUTER_API_KEY:?ORCA_ROUTER_API_KEY 가 필요합니다}
    volumes:
      - ./config.yaml:/app/config.yaml:ro
    command: ["--config", "/app/config.yaml", "--port", "4000"]
    depends_on:
      db:
        condition: service_healthy
  db:
    image: postgres:16-alpine
    environment:
      POSTGRES_DB: litellm
      POSTGRES_USER: llmproxy
      POSTGRES_PASSWORD: dbpassword9090
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -d litellm -U llmproxy"]
      interval: 3s

LITELLM_PORT를 환경변수로 뺀 이유는 7번 절에서 나옵니다. 저는 그 이유를 실험 도중에 알게 됐습니다.

config.yaml: 규칙 세 가지

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
model_list:
  - model_name: deepseek-flash-free                   # 앱이 부르는 이름
    litellm_params:
      model: openai/deepseek/deepseek-v4-flash-free   # 규칙 1: OpenAI 호환이면 openai/ 프리픽스
      api_base: https://api.orcarouter.ai/v1          # 규칙 2: /v1 까지 적는다
      api_key: os.environ/ORCA_ROUTER_API_KEY         # 규칙 3: 키는 환경변수 참조
    model_info:
      input_cost_per_token: 0.00000022                # $0.22 / 1M (유료 쌍둥이 단가)
      output_cost_per_token: 0.00000066               # $0.66 / 1M

  - model_name: glm-flash-free                        # 폴백 대상 (역시 무료)
    litellm_params:
      model: openai/z-ai/glm-5.3-flash-free
      api_base: https://api.orcarouter.ai/v1
      api_key: os.environ/ORCA_ROUTER_API_KEY
    model_info:
      input_cost_per_token: 0.000000075
      output_cost_per_token: 0.00000025

  - model_name: broken-no-v1                          # 실패 재현용: /v1 빠뜨림
    litellm_params:
      model: openai/deepseek/deepseek-v4-flash-free
      api_base: https://api.orcarouter.ai
      api_key: os.environ/ORCA_ROUTER_API_KEY

litellm_settings:
  fallbacks: [{"deepseek-flash-free": ["glm-flash-free"]}]
  num_retries: 0          # 재시도 없이 바로 폴백
  allowed_fails: 1        # 1번 실패하면 그 배포를 쿨다운
  cooldown_time: 30
  callbacks: ["prometheus"]
  require_auth_for_metrics_endpoint: false
  # drop_params, request_timeout 과 router_settings(num_retries: 0) 는 저장소 파일 참고

general_settings:
  master_key: os.environ/LITELLM_MASTER_KEY
  database_url: os.environ/DATABASE_URL
규칙이유
model: openai/<업스트림 모델 ID>LiteLLM은 프리픽스로 어떤 프로바이더 코드로 호출할지 정합니다. Orca Router는 OpenAI 호환이므로 openai/
api_base에 /v1까지LiteLLM은 뒤에 /chat/completions만 붙입니다. 빠뜨리면 404 (3번 절)
api_key: os.environ/...설정 파일에 키를 적지 않습니다. 컨테이너 환경변수에서 읽습니다

무료 모델에 왜 단가를 붙이나

Orca Router의 deepseek/deepseek-v4-flash-free는 요청당 $0입니다. 그대로 두면 LiteLLM은 비용을 0으로 기록하고, 스팬드 로그는 아무 의미가 없어집니다.

그래서 model_info에 유료 쌍둥이 모델의 단가(deepseek/deepseek-v4-flash, 입력 $0.22 / 출력 $0.66 per 1M)를 넣었습니다. 실제 청구는 0이지만 “유료였다면 얼마였을지”가 쌓입니다. 나중에 유료로 넘어갈 때 예산을 잡는 근거가 되고, 팀별 사용량 비교도 됩니다.

이 트릭은 사내에서 구독제나 정액제 API를 쓸 때도 그대로 씁니다. 청구서에는 안 찍히지만 “누가 얼마나 썼는가”는 알아야 하기 때문입니다.

1
2
3
export ORCA_ROUTER_API_KEY=...
docker compose -f litellm-gateway-sample/docker-compose.yml up -d
curl http://localhost:4000/health/liveliness   # "I'm alive!"

1. 띄우고 첫 호출

게이트웨이의 모델 목록과 첫 chat completion 호출
/v1/models 에는 config 의 model_name 세 개가 보이고, 응답 헤더에 모델 ID와 비용이 붙어 온다

응답에서 세 가지를 확인할 수 있습니다.

  • x-litellm-model-id: 실제로 어느 배포(deployment)가 처리했는지. 같은 model_name 아래 배포가 여러 개일 때 이 값으로 구분합니다. 폴백이 일어났는지도 이 헤더로 압니다.
  • x-litellm-response-cost: 6.446e-05: 게이트웨이가 계산한 비용입니다.
  • model: deepseek-flash-free: 업스트림이 돌려준 deepseek-v4-flash-ga-260731이 아니라 config의 model_name으로 바꿔서 줍니다. 앱은 업스트림 모델 ID를 몰라도 됩니다.

비용이 맞는지 검산해 봤습니다.

1
2
3
prompt     98 × $0.22/1M = $0.00002156
completion 65 × $0.66/1M = $0.00004290
                    합계 = $0.00006446   ← x-litellm-response-cost 와 일치

completion_tokens 65개 안에 reasoning_tokens 16개가 포함되어 있습니다. 사고 과정 토큰도 출력 단가로 과금된다는 뜻이고, 이건 실제 DeepSeek 과금 방식과 같습니다.


2. 스트리밍은 그대로 통과하나

1편의 핵심이 스트리밍이었으니, 게이트웨이를 거쳐도 SSE가 그대로 흘러나오는지가 중요합니다.

LiteLLM 을 거친 스트리밍 응답 원문
reasoning_content 는 그대로 통과하고, usage 는 한 번만, 그리고 cost 필드가 추가됐다

1편에서 업스트림에 직접 받은 원문과 비교하면 이렇습니다.

항목Orca Router 직접 (1편)LiteLLM 경유
reasoning_contentdelta에 따로 옴그대로 통과 (openai/ 프로바이더가 알 수 없는 필드를 버리지 않음)
modeldeepseek-v4-flash-ga-260731deepseek-flash-free (model_name으로 치환)
usage두 번 (finish 청크, 마지막 청크)한 번, 마지막 청크에만
usage.cost없음0.00006446 추가
[DONE]있음있음

1편에서 “usage가 두 번 온다”고 프록시에서 막았던 문제가 여기서는 게이트웨이가 정리해 줍니다. 그리고 스트리밍 응답에도 cost가 붙습니다. 클라이언트가 이 값을 읽으면 요청 단위로 비용을 표시할 수 있습니다.


3. 실패 1: /v1을 빠뜨리면 두 번째 에러가 다르다

broken-no-v1은 api_base에서 /v1만 뺀 설정입니다. 일부러 두 번 연속 호출했습니다.

api_base 에v1 이 없을 때의 404 와, 바로 다음 호출의 429 cooldown
첫 호출은 404. 두 번째는 같은 설정인데 429 all_deployments_in_cooldown

첫 번째 에러는 예상대로입니다. LiteLLM이 https://api.orcarouter.ai/chat/completions를 호출했고, 그 주소에는 API가 없으니 Orca Router 웹사이트의 404 HTML 페이지가 그대로 에러 메시지에 담겨 왔습니다. OpenAIException - <!doctype html>로 시작하는 에러를 보면 api_base부터 의심하면 됩니다.

두 번째 에러가 재밌습니다. 같은 요청인데 404가 아니라 429 all_deployments_in_cooldown입니다. allowed_fails: 1로 설정했기 때문에 한 번 실패한 배포를 30초 동안 쿨다운에 넣었고, 그 그룹에 다른 배포가 없으니 “쓸 수 있는 배포가 없다”가 된 겁니다.

운영 중에 “처음엔 404였는데 지금은 429가 나요”라는 보고를 받으면, 원인은 404 쪽입니다. 429는 게이트웨이가 스스로 만든 결과입니다. 쿨다운은 진짜 장애에서 업스트림을 보호하는 장치지만, 설정 실수를 다른 에러로 덮어 버리기도 합니다.


4. 가상 키에 예산 걸기

마스터 키는 관리자용입니다. 앱에는 가상 키를 따로 발급하고, 거기에 모델 제한과 예산을 겁니다.

1
2
3
4
curl http://localhost:4000/key/generate \
  -H "Authorization: Bearer $LITELLM_MASTER_KEY" \
  -H "Content-Type: application/json" \
  -d '{"max_budget": 0.00005, "key_alias": "budget-demo"}'

예산을 일부러 5센트의 1/1000인 $0.00005로 잡았습니다. 요청 한 번(약 $0.00009)이면 넘습니다.

예산이 걸린 가상 키로 두 번 호출: 첫 번째 성공, 두 번째 budget_exceeded
1회차 성공 (cost 8.844e-05). 2회차는 Budget has been exceeded. 그런데 key/info 의 spend 는 아직 0.0

1회차 비용도 검산이 됩니다. 프롬프트 84 토큰과 출력 106 토큰(그중 사고 과정 91)이라서 84 × $0.22/1M + 106 × $0.66/1M = $0.00008844, 헤더의 8.844e-05와 같습니다. “ping” 한 마디에 사고 과정 토큰이 91개나 붙은 것도 눈여겨볼 만합니다. 짧은 질문일수록 비용의 대부분이 사고 과정입니다.

여기서 세 가지를 배웠습니다.

  • 예산 검사는 요청 전에 합니다. 첫 요청은 예산 안에서 시작했으니 통과했고 결과적으로 예산을 넘겼습니다. 두 번째 요청은 시작 전에 막혔습니다. 즉 max_budget은 “이 금액에서 정확히 멈춘다”가 아니라 “이 금액을 넘긴 뒤의 다음 요청부터 막는다”입니다.
  • 상태 코드가 422입니다. 문서에는 400이나 429로 적힌 예가 섞여 있는데, 제가 쓴 main-latest 이미지는 422를 돌려줬습니다. 클라이언트에서 type: "budget_exceeded"로 분기하는 편이 안전합니다.
  • /key/info의 spend는 바로 반영되지 않습니다. 두 번째 요청이 예산 초과로 막힌 직후에 조회했는데 spend: 0.0이었습니다. 예산 판정은 메모리 캐시에서 하고, DB 반영은 배치로 뒤따라옵니다. 몇 초 뒤 다시 조회하면 채워집니다. 모니터링 대시보드가 “예산 초과인데 사용량은 0”으로 보일 수 있다는 뜻입니다.

5. 스팬드 로그 읽기

요청 한 건마다 Postgres에 한 줄이 남습니다.

/spend/logs 로 본 요청별 비용
요청별 토큰 수와 가상 단가로 계산한 spend. 실패한 요청(broken-no-v1, 429)은 0 으로 남는다
컬럼의미
model실제 호출한 업스트림 모델 (openai/deepseek/...). 응답의 model과 달리 여기는 원본
prompt_tokens, completion_tokens업스트림 usage 그대로
spendmodel_info 단가로 계산한 금액
metadata.user_api_key_alias어느 가상 키가 썼는지. 팀별 집계의 기준

실패한 요청도 spend: 0.0으로 남습니다. 429나 404가 얼마나 났는지 이 로그로 셀 수 있습니다.


6. 실패 2: 무료 모델끼리 폴백하면 되겠지

무료 모델의 한도는 1편에서 확인한 대로 결제 이력이 없으면 분당 10회입니다. 넘기면 429가 옵니다. 그래서 config에 이렇게 적어 뒀습니다.

1
fallbacks: [{"deepseek-flash-free": ["glm-flash-free"]}]

“DeepSeek 무료가 429면 GLM 무료로 넘기자.” 그럴듯합니다. 12번 연속으로 쏴 봤습니다.

12회 연속 호출: 11번째까지 성공, 12번째 429, 폴백도 429
#12 에서 429. LiteLLM 로그: Fallback to glm-flash-free also failed: RateLimitError

11번째까지 성공하고 12번째에서 429가 났습니다. 그리고 폴백 대상인 GLM 무료 모델도 같은 429를 받았습니다. 업스트림 에러 메시지가 이유를 알려 줍니다.

1
2
Free model capacity is limited right now. Retry shortly, or add credits for higher,
more stable limits: https://www.orcarouter.ai/console/billing

무료 티어의 분당 한도는 모델별이 아니라 계정 단위였습니다. DeepSeek 무료로 10번을 쓰면 GLM 무료도 같이 막힙니다. 그러니 무료 → 무료 폴백은 설정은 맞아도 효과가 없습니다.

폴백이 의미가 있으려면 한도를 따로 세는 대상이어야 합니다. 무료 → 유료(같은 계정이라도 유료는 한도가 다름), 또는 다른 공급자입니다. 1편에서 Orca Router 문서가 “무료 모델은 폴백 체인의 대상으로 넣을 수 없다”고 한 것도 같은 맥락입니다.

sequenceDiagram
    participant App as 앱
    participant GW as LiteLLM 라우터
    participant DS as deepseek-flash-free
    participant GLM as glm-flash-free
    participant Q as Orca Router 무료 한도<br/>(계정당 10 RPM)

    App->>GW: #12 /chat/completions
    GW->>DS: 호출
    DS->>Q: 카운트 11 → 초과
    Q-->>DS: 429
    DS-->>GW: RateLimitError
    Note over GW: num_retries: 0 → 즉시 폴백
    GW->>GLM: 호출 (폴백)
    GLM->>Q: 같은 계정, 같은 카운터
    Q-->>GLM: 429
    GLM-->>GW: RateLimitError
    GW-->>App: 429 (Fallback to glm-flash-free also failed)
    Note over App,Q: 폴백은 동작했다. 대상이 같은 한도를 나눠 쓴 게 문제

LiteLLM 입장에서는 정확히 설정대로 움직였습니다. 문제는 제가 폴백 대상을 고른 기준이었습니다. 게이트웨이의 폴백 설정을 볼 때는 “이 두 배포가 같은 한도를 공유하는가”를 먼저 물어야 합니다.


7. 1편의 Spring 앱 붙이기

이제 진짜 목적입니다. 1편의 스트리밍 프록시를 게이트웨이 뒤로 보냅니다. 코드는 한 줄도 바꾸지 않고 환경변수만 바꿉니다.

1
2
3
4
5
6
7
8
9
10
# 앱 전용 가상 키 (모델 제한 + 예산 $0.01)
curl http://localhost:4000/key/generate \
  -H "Authorization: Bearer $LITELLM_MASTER_KEY" \
  -H "Content-Type: application/json" \
  -d '{"max_budget": 0.01, "key_alias": "spring-sse-sample", "models": ["deepseek-flash-free"]}'

ORCA_BASE_URL=http://localhost:4000/v1 \
ORCA_MODEL=deepseek-flash-free \
ORCA_ROUTER_API_KEY=sk-...(위에서 받은 가상 키) \
SERVER_PORT=8090 ./gradlew :spring-sse-sample:bootRun
환경변수1편2편
ORCA_BASE_URLhttps://api.orcarouter.ai/v1http://localhost:4000/v1
ORCA_MODELdeepseek/deepseek-v4-flash-freedeepseek-flash-free (config의 model_name)
ORCA_ROUTER_API_KEYOrca Router 키LiteLLM 가상 키

앱은 이제 Orca Router 키를 모릅니다. 데모 페이지에서 전송을 눌렀습니다.

실패 3: curl은 되는데 Java만 404

Spring 앱이 게이트웨이에서 404 를 받은 화면
404 Not Found from POST http://localhost:4000/v1/chat/completions. 스크립트로는 방금까지 잘 되던 주소

같은 localhost:4000인데 curl 스크립트는 되고 Spring은 404입니다. 게이트웨이 로그에는 그 요청이 아예 없었습니다. 포트를 뒤져 봤습니다.

1
2
3
4
5
6
7
8
9
$ lsof -nP -iTCP:4000 -sTCP:LISTEN
COMMAND    PID  ...  TYPE  ...  NAME
ruby      6908  ...  IPv4  ...  TCP 127.0.0.1:4000 (LISTEN)   ← jekyll serve --livereload
com.docke 9516  ...  IPv6  ...  TCP *:4000 (LISTEN)           ← LiteLLM 컨테이너 포트 매핑

$ curl -s -o /dev/null -w '%{http_code}\n' "http://[::1]:4000/v1/models" -H "Authorization: Bearer $LITELLM_MASTER_KEY"
200                                                            ← IPv6 는 LiteLLM
$ curl -s -o /dev/null -w '%{http_code} %{content_type}\n' http://127.0.0.1:4000/v1/models
404 text/html; charset=UTF-8                                   ← IPv4 는 Jekyll

블로그 미리보기용으로 띄워 둔 Jekyll 개발 서버가 127.0.0.1:4000을 먼저 잡고 있었습니다. Jekyll의 기본 포트도 4000, LiteLLM의 기본 포트도 4000입니다. Docker는 남은 IPv6 와일드카드(*:4000)에 매핑됐습니다.

flowchart LR
    C["curl http://localhost:4000"] -- "::1 (IPv6) 먼저 시도" --> D["Docker → LiteLLM ✔"]
    J["Java WebClient http://localhost:4000"] -- "127.0.0.1 (IPv4) 먼저 시도" --> R["ruby (Jekyll) → 404 HTML ✘"]

    subgraph HOST["macOS 의 localhost 는 둘이다"]
        D
        R
    end

    style D fill:#dcfce7,stroke:#22c55e
    style R fill:#fee2e2,stroke:#ef4444

localhost는 IPv4 127.0.0.1과 IPv6 ::1 둘 다를 가리키고, 어느 쪽을 먼저 시도하는지는 런타임마다 다릅니다. curl은 IPv6로 갔고 Java는 IPv4로 갔습니다. 그래서 같은 주소가 다른 프로세스에 닿았습니다. 1편에서 8080 포트에 다른 서비스가 있어 8090으로 띄웠던 것과 정확히 같은 종류의 문제인데, 이번에는 한쪽 스택만 점유되어 있어서 증상이 “간헐적”으로 보였습니다.

해결은 게이트웨이 포트를 옮기는 것입니다. Jekyll은 제 것이라 꺼도 되지만, 남의 프로세스일 수도 있으니 compose에 LITELLM_PORT를 뺐습니다.

1
2
LITELLM_PORT=4400 docker compose -f litellm-gateway-sample/docker-compose.yml up -d
ORCA_BASE_URL=http://localhost:4400/v1 ... ./gradlew :spring-sse-sample:bootRun

게이트웨이를 붙였는데 일부 클라이언트만 이상한 응답을 받으면, 같은 포트를 IPv4와 IPv6에서 서로 다른 프로세스가 듣고 있는지 확인해 보세요. lsof -nP -iTCP:<포트> -sTCP:LISTEN 한 줄이면 됩니다.

다시 전송

게이트웨이를 거쳐 정상 스트리밍되는 데모 페이지
1편과 같은 화면. 뒤에 LiteLLM 이 끼어 있다는 걸 앱도 브라우저도 모른다

서버 로그와 게이트웨이 쪽 기록을 나란히 놓으면 이렇습니다.

1
2
3
4
5
6
7
8
# Spring 로그
[stream] ttft=1653ms elapsed=2847ms chunks=192 content=130 reasoning=59 error=-

# LiteLLM 스팬드 로그 (최신 1건)
model=openai/deepseek/deepseek-v4-flash-free  prompt=98  completion=199  spend=0.0001529  key_alias=spring-sse-sample

# 앱 키 누적
{"key_alias":"spring-sse-sample","spend":0.00019382,"max_budget":0.01,"models":["deepseek-flash-free"]}

앱은 base URL만 바꿨는데, 게이트웨이에는 “spring-sse-sample 키가 이 요청에 $0.0001529를 썼고 누적 $0.00019382”가 남았습니다. 이 키의 예산 $0.01이 다 차면 앱은 4번 절의 budget_exceeded를 받게 됩니다. 1편에서 만든 event: error 처리가 그때 일합니다.


8. /metrics에는 무엇이 찍히나

callbacks: ["prometheus"]를 켜 두었습니다. /metrics는 /metrics/로 307 리다이렉트되니 curl -L이 필요합니다.

1
2
3
4
5
6
7
8
9
$ curl -sL http://localhost:4000/metrics | grep -E "^litellm_" | sed 's/{.*//' | sort | uniq -c | sort -rn
  36 litellm_request_total_latency_metric_bucket
  36 litellm_llm_api_latency_metric_bucket
  18 litellm_llm_api_time_to_first_token_metric_bucket
  15 litellm_deployment_latency_per_output_token_bucket
   3 litellm_proxy_total_requests_metric_total
   3 litellm_deployment_total_requests_total
   3 litellm_deployment_state
   ...

요청 수, 배포별 상태, 지연 히스토그램, 그리고 TTFT 히스토그램(litellm_llm_api_time_to_first_token_metric)이 있습니다. 1편에서 파이썬 스크립트로 직접 재던 값이 게이트웨이에서는 기본으로 나옵니다.

문서 일부에는 Prometheus 콜백이 엔터프라이즈 기능으로 적혀 있습니다. 제가 쓴 main-latest 오픈소스 이미지에서는 위처럼 동작했습니다. 버전에 따라 다를 수 있으니 띄운 뒤 직접 확인하는 편이 안전합니다.


9. 정리: 게이트웨이 도입 체크리스트

구간확인할 것
설정openai/ 프리픽스, api_base에 /v1, 키는 os.environ/
비용무료·정액 모델에는 유료 단가를 가상으로 붙여 집계
예산예산 검사는 요청 전. 한 번은 넘긴다. 상태 코드보다 type: budget_exceeded로 분기
예산/key/info의 spend는 몇 초 늦게 반영
폴백폴백 대상이 원본과 같은 한도를 공유하는지 먼저 확인
쿨다운allowed_fails가 설정 실수를 429로 바꿔 보여 줄 수 있음. 첫 에러를 찾을 것
포트4000은 Jekyll 등과 겹침. IPv4/IPv6 이중 스택에서 한쪽만 점유되면 클라이언트마다 증상이 다름
앱앱은 게이트웨이 주소와 가상 키만 안다. 업스트림 키는 게이트웨이 환경변수에만

참고 자료


마치며

2편에서는 1편의 앱을 한 줄도 고치지 않고 LiteLLM 뒤로 보냈습니다. 그 과정에서 세 번 넘어졌습니다.

  • /v1을 빠뜨리면 404가 나고, 그다음부터는 쿨다운 때문에 429로 보입니다.
  • 무료 모델끼리의 폴백은 같은 한도를 나눠 쓰기 때문에 소용이 없었습니다.
  • 4000 포트를 Jekyll이 IPv4 쪽만 잡고 있어서 curl과 Java가 서로 다른 프로세스에 닿았습니다.

세 가지 모두 게이트웨이 자체의 버그가 아니라, 게이트웨이가 한 겹 더 생겼기 때문에 새로 생긴 실패입니다. 층이 하나 늘면 확인할 곳도 하나 늡니다.

다음 편에서는 방향을 바꿔 MCP로 갑니다. “MCP는 결국 JSON이고 웹 통신이잖아”라는 질문에, Spring AI로 만든 MCP 서버를 stdio와 Streamable HTTP 두 전송으로 띄우고 파이프에 JSON 한 줄을 직접 넣어 가며 답하겠습니다.

게이트웨이는 문제를 없애 주는 게 아니라 문제가 생기는 자리를 한 곳으로 모아 줍니다. 그 한 곳에 로그와 비용과 한도가 같이 있다는 것이 도입의 진짜 이유입니다.

이 기사는 저작권자의 CC BY 4.0 라이센스를 따릅니다.