[카테고리:] 미분류

  • Ollama API 문제

    원인 대부분이 **Ollama HTTP 응답(429/5xx/타임아웃/빈 응답/비JSON)**에서 생깁니다. 지금 코드에서도 재시도는 있지만,

    1. 재시도 범위·백오프가 약하고, 2) 429/5xx/네트워크 에러를 구분하지 못하며, 3) 연속 실패 시 “브레이커(circuit breaker)”가 없어 계속 두드리는 문제가 있어요.
      아래처럼 딱 5곳에 방어 로직을 넣으면 안정성이 확 올라갑니다.

    1) __init__requests.Session + 자동 재시도 어댑터

    • 위치: MultilingualDictionaryBuilder.__init__ 끝부분 (현재 self._ollama_healthcheck() 아래 아무데나)
    • 이유: TCP 커넥션 재활용, 지터 포함 재시도, 429/5xx 자동 백오프
    import random
    from requests.adapters import HTTPAdapter
    from urllib3.util.retry import Retry
    
    # __init__ 안에 추가
    self.http = requests.Session()
    retries = Retry(
        total=5,
        connect=5,
        read=5,
        backoff_factor=0.5,          # 0.5, 1.0, 2.0 ... 지터는 ask_ollama에서 추가
        status_forcelist=[429, 500, 502, 503, 504],
        allowed_methods=["POST"]
    )
    self.http.mount("http://", HTTPAdapter(max_retries=retries))
    self.http.mount("https://", HTTPAdapter(max_retries=retries))
    
    # 서킷 브레이커 상태값
    self._fail_count = 0
    self._breaker_open_until = 0.0
    self._breaker_threshold = 8      # 연속 8회 실패 시 열기
    self._breaker_cooldown = 60.0    # 60초 냉각
    

    2) ask_ollama()를 “등급별 재시도 + 지터 + 브레이커”로 교체

    • 위치: 기존 ask_ollama 전면 교체
    • 이유: 4xx(클라 오류) 즉시 실패, 429/5xx는 지수백오프, 비JSON/빈 응답 처리, 연속 실패 시 쿨다운
    def ask_ollama(self, prompt: str, timeout=45, retries=3, temperature: float = 0.2) -> str:
        if self.shutdown_requested:
            return ""
    
        # breaker open?
        now = time.time()
        if now < self._breaker_open_until:
            log(f"Ollama breaker open, skip request for {int(self._breaker_open_until - now)}s", "WARN")
            return ""
    
        self._take_token()
    
        payload = {
            "model": self.model,
            "prompt": prompt,
            "stream": False,
            "options": {"temperature": temperature, "top_p": 0.9, "repeat_penalty": 1.1},
        }
    
        base_sleep = 1.0
        for attempt in range(1, retries + 1):
            try:
                r = self.http.post(self.base_url, json=payload, timeout=timeout)
                status = r.status_code
    
                if 200 <= status < 300:
                    # 비JSON/빈 응답 방어
                    try:
                        data = r.json()
                    except Exception:
                        self._fail_count += 1
                        log("Ollama returned non-JSON body", "WARN")
                        raise requests.exceptions.RetryError("non-json")
    
                    # Ollama 에러 필드 방어
                    if isinstance(data, dict) and data.get("error"):
                        err = data.get("error")
                        self._fail_count += 1
                        log(f"Ollama error field: {err}", "WARN")
                        raise requests.exceptions.RetryError(str(err))
    
                    resp = data.get("response", "")
                    if not resp or not resp.strip():
                        self._fail_count += 1
                        log("Ollama empty response", "WARN")
                        raise requests.exceptions.RetryError("empty-response")
    
                    # 성공 경로
                    self._fail_count = 0
                    return resp
    
                # 상태코드별 처리
                if status == 429 or status in (500, 502, 503, 504):
                    # 재시도 대상: 지수백오프 + 지터
                    sleep = base_sleep * (2 ** (attempt - 1))
                    sleep += random.uniform(0, 0.5)
                    log(f"Ollama HTTP {status} attempt {attempt}/{retries}, sleep {sleep:.1f}s", "WARN")
                    time.sleep(sleep)
                    continue
                elif 400 <= status < 500:
                    # 클라이언트 오류는 즉시 중단
                    self._fail_count += 1
                    log(f"Ollama client error {status}: {r.text[:200]}", "WARN")
                    return ""
    
            except requests.exceptions.Timeout:
                self._fail_count += 1
                sleep = base_sleep * (2 ** (attempt - 1)) + random.uniform(0, 0.5)
                log(f"Ollama timeout {attempt}/{retries}, sleep {sleep:.1f}s", "WARN")
                time.sleep(sleep)
            except requests.exceptions.ConnectionError as e:
                self._fail_count += 1
                sleep = base_sleep * (2 ** (attempt - 1)) + random.uniform(0, 0.5)
                log(f"Ollama connection error {attempt}/{retries}: {e}, sleep {sleep:.1f}s", "WARN")
                time.sleep(sleep)
            except Exception as e:
                self._fail_count += 1
                log(f"Ollama unexpected error {attempt}/{retries}: {e}", "WARN")
                time.sleep(0.5)
    
        # 모든 재시도 실패 → 브레이커 open
        if self._fail_count >= self._breaker_threshold:
            self._breaker_open_until = time.time() + self._breaker_cooldown
            log(f"Opening breaker for {int(self._breaker_cooldown)}s (fail_count={self._fail_count})", "WARN")
    
        return ""
    

    3) JSON 파싱·스키마 방어 (llm_validate_and_translate/_extract_json)

    • 위치: llm_validate_and_translate 초반 파싱부
    • 이유: 모델이 코드블록/설명 텍스트를 섞어 보내는 경우가 많아 “첫 번째 {…}”만 잡으면 실패할 수 있음. 안전 가드 추가.
    def _extract_json(self, text: str) -> str:
        if not text:
            return "{}"
        # 코드블록 제거
        text = re.sub(r"^```(?:json)?\s*|```$", "", text.strip(), flags=re.MULTILINE)
        # 가장 긴 중괄호 블록 탐색
        candidates = re.findall(r"\{.*?\}", text, flags=re.S)
        if not candidates:
            return "{}"
        # 길이가 가장 긴 블록을 선택 (부분 JSON 회피)
        return max(candidates, key=len)
    

    그리고 llm_validate_and_translate에서 파싱 실패 시 이유 태그를 더 구체화:

    resp = self.ask_ollama(prompt, timeout=40, temperature=temperature)
    if not resp:
        return {"accept": False, "reasons": ["ollama_no_response"], "confidence": 0.0, "rarity": 5}
    
    try:
        j = json.loads(self._extract_json(resp))
    except Exception:
        return {"accept": False, "reasons": ["invalid_json_parse"], "confidence": 0.0, "rarity": 5}
    

    4) 번역 경로 폴백 (translate_content)

    • 위치: translate_content
    • 이유: 번역 호출도 LLM 의존이라 실패 시 빈 문자열 반환 → 후단에서 탈락 확률↑. 실패하면 원문 그대로라도 채워 저장(나중에 일괄 번역 가능).
    t = self.ask_ollama(prompt, timeout=30, temperature=0.2).strip()
    if not t:
        # 폴백: 원문 유지 + 태그
        return f"[UNTRANSLATED:{self.target_language}] {english_text}"
    return t
    

    5) 실행 루프 방어 (run_prefix / run)

    • 위치: run_prefix 단어 처리 루프 & run의 prefix 루프
    • 이유: 브레이커가 열렸거나 연속 실패가 많은 경우 즉시 저장 후 빠져나오기. 진전 없는 무한 재시도 방지.
    # run_prefix 내부, for w in cands: 바로 아래
    if time.time() < self._breaker_open_until:
        log("Breaker open during run_prefix, early exit & save", "WARN")
        break
    
    # run 내부, prefix 처리 루프 안쪽 try 블록 직후나 except 직후
    if time.time() < self._breaker_open_until:
        payload = self._make_payload(entries, mode, batch, done, failed, start)
        self.save_progress(payload)
        log("Breaker open in run(), progress saved, pausing current cycle", "WARN")
        # 현재 prefix는 failed 로 남기고 빠져나감
        break
    

    추가 팁 (코드 바깥 설정/운용)

    • 타임아웃 분리: connect=5s / read=40s같이 분리하고 싶다면 self.http.post(..., timeout=(5, timeout)) 형태 사용.
    • 모델별 rate-limit: --rate 값을 모델·GPU 여유에 맞춰 60~120/min으로 조절.
    • 프롬프트 길이 제한: 프롬프트가 길어질수록 5xx 빈도가 올라갑니다. 설명문·규칙을 짧게 유지.
    • 진행 저장 주기: --save-every를 5~10으로(이미 기본 10) 유지하면 장애 시 손실 최소화.

    요약: 어디에 무엇을 넣나?

    1. __init__: requests.Session + Retry 어댑터, 브레이커 상태값
    2. ask_ollama: 4xx/5xx/429/타임아웃 구분, 지수백오프+지터, 빈/비JSON 응답 처리, 서킷 브레이커
    3. _extract_json & llm_validate_and_translate: 코드블록 제거·가장 긴 {…} 파싱, 실패 사유 태깅
    4. translate_content: 실패 시 원문 폴백 태그
    5. run_prefix/run: 브레이커 열린 상태에서 즉시 저장·탈출

    이렇게만 넣어도 “어느 순간부터 계속 에러” 구간에서 무한 재시도→쿨다운→진행 저장의 안전 루틴으로 바뀌어서 중단 없이 다시 이어갈 수 있습니다.