Issue 01중국 AI
AC POST
중국 AI 목록
掘金2026년 10월 30일 18:45중국어 → 한국어

대형 모델 호출에 60줄 회로차단기…세 가지 임계값 적용

한 개발자가 응답 지연 장애를 겪은 뒤 대형 모델 호출에 세 가지 임계값을 두는 60줄짜리 회로차단기를 적용한 사례를 공유했다.

중국어 원문을 AI로 번역했습니다. 고유명사와 수치는 원문 표기를 우선하며, 중요한 판단에는 아래 출처 원문을 함께 확인하세요.

작년 겨울 어느 날 밤, 우리의 글쓰기 어시스턴트가 62초 동안 멈췄다.

서버가 다운된 게 아니라, "계속 빙글빙글 도는" 상태였다. 사용자가 전송을 누르면 화면의 커서가 1분 동안 깜빡였고, 그다음 "생성 실패, 다시 시도해 주세요"라는 문구가 떴다.

그날 밤 서버 측 5xx 피크는 11분 동안 지속됐다. 우리는 4,200번의 요청을 보냈고, 그중 2,100번이 타임아웃으로 끝났으며, 사용자 평균 대기 시간은 62초였다. 사후에 로그를 보니, 처음 300번이 타임아웃됐을 때 이미 멈췄어야 했다——하지만 우리는 멈추지 않았는데, 코드에 max_retries=3 이라고 적혀 있어서 매번 얌전하게 60초 타임아웃을 꽉 채워 기다렸기 때문이다.

재시도 자체가 문제가 아니다. "상대가 이미 죽은 걸 아는 상황에서도 재시도하는 것"이 문제다.

이 글은 내가 추가한 그 서킷 브레이커에 관한 이야기다: 세 개의 임계값, 60줄의 코드, 출시 후 반년 동안 두 번의 사고를 막아냈다.

1. 대형 모델 호출에 특히 서킷 브레이커가 필요한 이유

전통적인 HTTP 서비스에 서킷 브레이커를 추가하는 것은 보통 눈사태(연쇄 장애)를 막기 위해서다. 대형 모델 호출에는 세 가지 다른 점이 있다:

첫째, 단일 요청이 특히 느리다. 첫 토큰 지연은 정상적으로 800ms이지만, 서버 측에 대기가 쌓이면 40초 이상까지 늘어날 수 있다. 타임아웃을 60초로 설정하는 것은 일반적인 관행이며, 이는 한 번의 실패가 하나의 연결을 60초 동안 점유한다는 뜻이다.

둘째, 재시도 비용이 특히 비싸다. 한 번의 호출에 수천 개의 토큰이 소모될 수 있고, 세 번 재시도하면 곧 세 배의 비용이다. 서버가 죽은 상태에서는 결과도 못 받을 뿐더러 돈까지 쓰게 된다.

셋째, 실패가 무리 지어 나타난다. 서버 측의 흔들림은 무작위가 아니라 일정 구간씩 나타난다. "이 구간은 망가졌다"는 것을 식별하고 이 구간을 건너뛸 수 있다면 수익이 매우 크다.

그 사고의 숫자는 직관적이다:

서킷 브레이커 없음 | 서킷 브레이커 적용(사후 복기 시뮬레이션) | 내보낸 요청 | 4,200 | 4,200 | 실제로 서버에 도달한 요청 | 4,200 | 2,140 | 사용자 평균 대기 | 62초 | 1.2초 | 실패 전 낭비한 호출 비용 | 약 210위안 | 약 96위안 |

추가로 발생한 2,060번은 전부 서버가 이미 명확히 고장 난 뒤에 내보낸 것이다.

2. 세 가지 임계값, 하나도 빠질 수 없다

내 구현은 클래스 하나, 세 개의 매개변수뿐이다:

class Breaker: def __init__(self, window = 20 , fail_ratio= 0.5 , cooldown= 30.0 ): self.window = window # 최근 20번의 결과만 본다 self.fail_ratio = fail_ratio # 실패율이 50%를 넘으면 열린다 self.cooldown = cooldown # 열린 후 30초 뒤 반열림으로 진입

임계값 1: 윈도 = 20번. "연속 실패 N번"은 권장하지 않는데, 대형 모델 호출에는 사용자 취소, 콘텐츠 심사 차단 같은 비(非)서버 측 실패가 섞여 있어 연속 카운트가 개별 요청에 의해 쉽게 왜곡되기 때문이다. 슬라이딩 윈도로 비율을 보면 노이즈에 강하다.

윈도가 너무 작으면(예: 5) 오판한다——한 번의 배치 작업은 동시에 발송되므로 5개 중 3개가 타임아웃되는 것은 지극히 정상이다; 윈도가 너무 크면(예: 200) 반응이 너무 느려서, 200번 × 60초의 손실은 이미 발생한 뒤다. 20은 내가 시행착오로 찾은 균형점이다.

임계값 2: 실패율 = 50%. 이것은 너무 높게 설정하면 안 된다. 평소 우리의 실패율은 0.3%–0.8%이고, 50%는 이미 명확한 이상이다. 30%로 설정하면 출퇴근 피크 시간에 오작동하고, 80%로 설정하면 설정하지 않은 것과 같다.

임계값 3: 쿨다운 = 30초. 열린 후에는 더 이상 통과시키지 않고, 30초 뒤 "반열림"으로 진입하여 3개의 탐색 요청을 통과시킨다. 3개 중 2개가 성공하면 닫히고, 그렇지 않으면 다시 열린다. 30초의 근거는 이렇다: 서버 측 흔들림은 보통 분 단위의 덩어리 형태로 나타나며, 탐색이 너무 잦으면 이미 죽은 서버에 혼란만 더할 뿐이다.

3. 상태 기계는 세 가지 상태뿐이다

import time from collections import deque class Breaker : def __init__ ( self, window= 20 , fail_ratio= 0.5 , cooldown= 30.0 , probes= 3 ): self.window, self.fail_ratio = window, fail_ratio self.cooldown, self.probes = cooldown, probes self.results = deque(maxlen=window) self.state = "closed" self.opened_at = 0.0 self.probe_ok = 0 self.probe_total = 0 def allow ( self ) -> bool : if self.state == "closed" : return True if self.state == "open" : if time.monotonic() - self.opened_at >= self.cooldown: self.state, self.probe_ok, self.probe_total = "half" , 0 , 0 return True return False # half-open:제한된 수의 탐색만 통과 return self.probe_total < self.probes def record ( self, ok: bool ): if self.state == "half" : self.probe_total += 1 self.probe_ok += int (ok) if self.probe_total >= self.probes: if self.probe_ok >= 2 : self.state, self.results = "closed" , deque(maxlen=self.window) else : self.state, self.opened_at = "open" , time.monotonic() return self.results.append(ok) if len (self.results) == self.window: ratio = 1 - sum (self.results) / len (self.results) if ratio >= self.fail_ratio: self.state, self.opened_at = "open" , time.monotonic()

호출부의 변경은 매우 작다:

from openai import OpenAI import os client = OpenAI( api_key =os.environ[ "API_KEY" ], base_url = "https://api.tokenfreeai.com/v1" , # OpenAI 호환 timeout = 60 , max_retries= 0 , # 재시도는 서킷 브레이커가 판단하도록 ) breaker = Breaker() def ask(prompt: str) -> str | None: if not breaker.allow(): return None # 빠르게 실패해서, 사용자가 헛되이 기다리지 않게 try: r = client.chat.completions.create( model = "glm-5" , messages =[{ "role" : "user" , "content" : prompt}], ) except Exception: breaker.record(False) raise breaker.record(True) return r.choices [0] .message.content

두 가지를 주의해야 한다: max_retries=0 은 반드시 써야 한다. 그렇지 않으면 SDK에 내장된 백오프 재시도가 서킷 브레이커를 우회한다. 그리고 None을 반환할 때 상위 계층이 그것을 받아낼 수 있어야 하는데, 이 단계가 서킷 브레이커 로직 자체보다 더 자주 빠뜨리기 쉽다.

4. None을 반환한 뒤에 무엇을 할 것인가

이것이 많은 사람이 빠뜨리는 절반이다. 서킷 브레이커의 가치는 "막는 것"에 있는 게 아니라 "막은 뒤에 무엇을 주는가"에 있다.

우리의 3단계 폴백:

- 모델 교체. 주력 모델이 서킷 브레이커로 차단되면, 같은 회사의 경량 버전으로 전환한다. 출력 품질은 한 단계 떨어지지만 쓸 수 있다. 실측 결과 전환 후 사용자 불만량이 뚜렷이 늘지 않았는데, 대부분의 요청이 윤문이나 제목 짓기 같은 작업이기 때문이다.

- 대기열. 비실시간 작업(야간 배치 처리, 보고서 생성)은 지연 큐에 넣고 5분 후에 재시도하며, 사용자 쪽에는 "처리 중, 완료되면 알림"이라고 표시한다.

- 명확한 고지. 실시간 대화류에서 두 가지 모두 안 되면, 사용자가 1분을 기다렸다가 실패를 보게 하는 대신 "현재 서비스가 혼잡합니다"라고 곧바로 알린다.

세 번째가 가장 하기 어렵지만, 가장 중요하다. 그 62초의 밤, 사용자가 진짜로 분노한 것은 실패가 아니라 "성공할지 안 할지 당신도 알 수 없다"는 점이었다.

5. 서비스 오픈 반년 동안의 두 차례 차단

첫 번째는 4월, 서버 측 지역별 장애였고 서킷 브레이커는 38초 후에 열려 1,800여 회의 요청을 막아냈다. 사용자 평균 대기 시간이 예상치 50초에서 1.1초로 내려갔다.

두 번째는 더 은밀했다. 우리 자체 스크립트 하나가 무한 루프로 재시도하면서 한 시간에 9,000회 호출을 날렸다. 서킷 브레이커 자체는 막지 못했지만(서버 측은 정상이었으므로), 같은 상태 머신에 "한 시간 호출량이 임계값을 넘으면 열린다"는 우회로를 추가해 그것을 막았다. 그때 약 380위안을 절약했다.

6. 한 가지 함정: 서킷 브레이커를 파티션과 무관하게 만들지 말 것

처음에는 전역 서킷 브레이커가 하나뿐이었다. 그런데 문제를 발견했다. 주력 모델이 차단되면 모든 요청이 밖으로 막혀버렸고, 그중에는 본래 백업 모델로 보낼 수 있었던 부분도 포함되어 있었다.

이후 "모델 + 지역" 기준으로 버킷을 나누는 방식으로 바꿨다. 버킷마다 서킷 브레이커가 하나씩이고, 버킷끼리는 서로 영향을 주지 않는다. 이 변경은 세 줄만 추가했지만(dict 키로 인스턴스를 저장), 가용성 향상은 서킷 브레이커 자체보다도 컸다.

호출이 OpenAI 호환 프로토콜을 경유한다면 이 일은 아주 수월하다. 같은 클라이언트 코드에서 model 문자열만 바꾸면 새로운 버킷이 되고, 벤더마다 어댑터를 한 세트씩 작성할 필요가 없다. 우리 쪽은 词元云를 쓰는데, 하나의 키로 몇 개의 모델을 관리하고 횡평과 버킷 전환을 모두 같은 클라이언트 안에서 한다. 다만 각 회사의 공식 인터페이스를 직접 써도 완전히 돌아가며, 서킷 브레이커 로직은 동일하다.

7. 기성품을 써야 할까

기성 라이브러리가 있다. pybreaker, tenacity 모두 된다. 내가 결국 직접 작성한 이유는 세 임계값의 의미를 대형 모델 호출의 특징에 맞게 조정해야 했기 때문이다(창은 시간이 아니라 횟수로, 실패 판정 기준은 콘텐츠 검수 차단을 배제할 수 있어야 함). 기성 라이브러리는 이 계층을 비교적 완고하게 감싸고 있다.

그저 돌아가는 것만 원한다면, 기성품을 쓰는 것이 전혀 부끄러운 일이 아니다. 이 60줄에서 진짜 값어치 있는 것은 코드가 아니라, 그 세 숫자 배후에 있는 판단이다.