diff --git a/ai/.env.example b/ai/.env.example index 41d5d988..1b8ace65 100644 --- a/ai/.env.example +++ b/ai/.env.example @@ -23,6 +23,12 @@ LLM_FLASH_MODEL=gemini-3.5-flash-lite LLM_FLASH_TEMPERATURE=0.4 LLM_FLASH_MAX_TOKENS=512 LLM_FLASH_TIMEOUT_SEC=10.0 +# OpenAI SDK 클라이언트 재시도. 기본 2 면 LangChain 호출 1회가 HTTP 3회 → +# 위 타임아웃은 호출 상한이 아니라 (1+n)배가 실제 상한이다. +LLM_MAX_RETRIES=2 +# 문서 분석 재시도(바깥 with_retry). 간격이 촘촘하면 게이트웨이 정지 구간에서 함께 죽는다. +DOCUMENT_ANALYSIS_MAX_ATTEMPTS=3 +DOCUMENT_ANALYSIS_RETRY_INITIAL_SEC=15.0 # 티어별 엔드포인트 오버라이드(OpenAI 호환). 비우면 위 게이트웨이 공유. # 예) 꼬리질문만 로컬 Ollama: LLM_FLASH_BASE_URL=http://ollama:11434/v1 + LLM_FLASH_MODEL=exaone3.5:7.8b # 오버라이드한 티어는 LLM_API_KEY(학교 키)를 물려받지 않는다. diff --git a/ai/CLAUDE.md b/ai/CLAUDE.md index de880593..981687b5 100644 --- a/ai/CLAUDE.md +++ b/ai/CLAUDE.md @@ -141,6 +141,35 @@ async def consume_resume_analyze(message: AbstractIncomingMessage) -> None: > 정식 별칭으로 넘어가면 되고 재배포·코드 수정이 필요 없다 — 이 표와 `settings.py` 기본값은 > "지금 게이트웨이에서 검증된 이름"의 기록이지 유일한 진실이 아니다. +### 6.2-1 재시도 예산 — 타임아웃은 호출 상한이 아니었다 (2026-09-29) + +`llm_*_timeout_sec` 를 "이 호출은 최대 N초" 로 읽으면 틀린다. **OpenAI SDK 클라이언트의 +`max_retries` 기본값이 2 라 LangChain 호출 1회가 HTTP 3회**이고, 코드가 그 값을 지정한 적이 +없었다. 그래서 실제 상한은 설정값의 (1+n)배다. + +- `ai_request_logs` 는 LangChain 호출 1건에 한 행이므로 **`latency_ms` 는 숨은 시도의 합**이다. + 운영 `analyze.document` 실패 4건이 타임아웃 60초 설정에서 전부 92.1~92.6초로 찍힌 이유이고, + §6.2 주석의 "타임아웃 30초 + 재시도 26초 = 56초" 관측도 같은 현상이다. +- 이제 12개 `ChatOpenAI` 전부가 `max_retries=settings.llm_max_retries` 를 **명시**한다 + (기본 2 — 현 동작 유지). 게이트웨이 429 를 흡수하는 값이라 0 으로 내리지 않았지만, + 보이지 않는 채로 두지 않는다. 한 곳만 빠지면 그 경로만 조용히 3배로 돌아간다 — + `tests/test_llm_retry_budget.py` 가 전 빌더를 훑는다. +- **문서 분석만 `max_retries=0`** 이다. 바깥에 `with_retry` 가 있어 두 층이 곱해지면 한 메시지가 + HTTP 를 6번 치고 로그 한 행이 여러 시도의 합이 된다. + +#### 문서 분석 재시도 간격 (같은 커밋) + +`with_retry` 의 기본 대기는 1초(+지터 1초)라 **사실상 연속 시도**다. 게이트웨이가 구간적으로 +멎으면 두 시도가 같은 구간에 들어가 함께 죽는다 — 운영에서 4/4 가 그랬다. + +- 실측: `resume_id=18` 이 연속 2시도(각 92초)로 실패해 **4일간 FAILED** 로 남았고(문서 34), + 사용자가 재분석을 눌러도 또 2시도 실패(문서 35), 3분 뒤 세 번째 재분석이 **37초에 성공** + (문서 36)했다. 입력 문제가 아니라 상류 간헐 정지다(Deepgram 배치 호출과 같은 성질 — §8). +- `document_analysis_max_attempts`(3) + `document_analysis_retry_initial_sec`(15) 로 간격을 + 15초→30초로 벌린다. 소비자 `prefetch` 가 10 이라 한 문서가 오래 붙잡혀도 다른 분석을 막지 않는다. +- `retry_if_exception_type` 기본값이 `Exception` 이라 이 재시도는 파싱 실패뿐 아니라 + **타임아웃도** 잡는다 — 원래 의도(스키마 파싱 안전장치)와 달리 실제로 걸린 건 전부 타임아웃이었다. + ### 6.3 LangChain 사용 (OpenAI 호환 클라이언트) ```python from langchain_core.prompts import ChatPromptTemplate diff --git a/ai/src/ai_server/chain/document_analysis_chain.py b/ai/src/ai_server/chain/document_analysis_chain.py index 03f6c9a4..529358e4 100644 --- a/ai/src/ai_server/chain/document_analysis_chain.py +++ b/ai/src/ai_server/chain/document_analysis_chain.py @@ -67,7 +67,9 @@ async def analyze( # 프롬프트 -> LLM -> 파서 하나로 묶어서 처리함. -# 스키마가 커져 파싱 실패 가능성이 있으므로 with_retry 로 1회 재시도(저비용 안전장치). +# 스키마가 커져 파싱 실패 가능성이 있으므로 with_retry 로 재시도(저비용 안전장치). +# retry_if_exception_type 기본값이 Exception 이라 파싱 실패뿐 아니라 **타임아웃도** 여기서 +# 재시도된다 — 운영에서 실제로 걸린 건 전부 타임아웃이었다. def build_document_analysis_chain( settings: Settings, core_client: CoreClient | None = None ) -> Runnable: @@ -95,8 +97,18 @@ def build_document_analysis_chain( model=settings.llm_pro_model, temperature=settings.llm_pro_temperature, timeout=settings.llm_pro_timeout_sec, + # 여기만 0 이다 — 바깥 with_retry 가 이미 재시도를 맡는다. 두 층이 곱해지면 + # 한 메시지가 HTTP 를 최대 6번 치고, 로그 한 행이 여러 시도의 합이 되어 + # "한 번에 얼마나 걸리는가"를 알 수 없게 된다(실패 4건이 60초 설정에서 92초로 찍힌 것). + max_retries=0, api_key=settings.llm_api_key_for("pro"), base_url=settings.llm_base_url_for("pro"), callbacks=callbacks, ) - return (prompt | llm | parser).with_retry(stop_after_attempt=2) + return (prompt | llm | parser).with_retry( + stop_after_attempt=settings.document_analysis_max_attempts, + # 기본 대기 1초로는 게이트웨이 정지 구간을 못 넘는다(운영 4/4 실패). 15초→30초. + exponential_jitter_params={ + "initial": settings.document_analysis_retry_initial_sec, + }, + ) diff --git a/ai/src/ai_server/chain/feedback_generation_chain.py b/ai/src/ai_server/chain/feedback_generation_chain.py index b730fd00..b327ad97 100644 --- a/ai/src/ai_server/chain/feedback_generation_chain.py +++ b/ai/src/ai_server/chain/feedback_generation_chain.py @@ -121,6 +121,7 @@ def build_feedback_generation_chain( model=settings.llm_pro_model, temperature=settings.llm_pro_temperature, timeout=settings.llm_pro_timeout_sec, + max_retries=settings.llm_max_retries, api_key=settings.llm_api_key_for("pro"), base_url=settings.llm_base_url_for("pro"), callbacks=callbacks, @@ -392,6 +393,7 @@ def build_panel_evaluator_chain( model=settings.llm_pro_model, temperature=settings.llm_pro_temperature, timeout=settings.llm_pro_timeout_sec, + max_retries=settings.llm_max_retries, api_key=settings.llm_api_key_for("pro"), base_url=settings.llm_base_url_for("pro"), callbacks=callbacks, @@ -427,6 +429,7 @@ def build_feedback_synthesis_chain( model=settings.llm_pro_model, temperature=settings.llm_pro_temperature, timeout=settings.llm_pro_timeout_sec, + max_retries=settings.llm_max_retries, api_key=settings.llm_api_key_for("pro"), base_url=settings.llm_base_url_for("pro"), callbacks=callbacks, @@ -470,6 +473,7 @@ def build_self_intro_evaluation_chain( model=settings.llm_flash_model, temperature=settings.llm_flash_temperature, timeout=settings.llm_flash_timeout_sec, + max_retries=settings.llm_max_retries, api_key=settings.llm_api_key_for("flash"), base_url=settings.llm_base_url_for("flash"), callbacks=callbacks, @@ -509,6 +513,7 @@ def build_personality_evaluation_chain( model=settings.llm_flash_model, temperature=settings.llm_flash_temperature, timeout=settings.llm_flash_timeout_sec, + max_retries=settings.llm_max_retries, api_key=settings.llm_api_key_for("flash"), base_url=settings.llm_base_url_for("flash"), callbacks=callbacks, @@ -640,6 +645,7 @@ def build_job_fit_evaluation_chain( model=settings.llm_pro_model, temperature=settings.llm_pro_temperature, timeout=settings.llm_pro_timeout_sec, + max_retries=settings.llm_max_retries, api_key=settings.llm_api_key_for("pro"), base_url=settings.llm_base_url_for("pro"), callbacks=callbacks, @@ -732,6 +738,7 @@ def build_answer_coaching_chain( model=settings.llm_flash_model, temperature=settings.llm_flash_temperature, timeout=settings.llm_flash_timeout_sec, + max_retries=settings.llm_max_retries, api_key=settings.llm_api_key_for("flash"), base_url=settings.llm_base_url_for("flash"), callbacks=callbacks, diff --git a/ai/src/ai_server/chain/followup_generation_chain.py b/ai/src/ai_server/chain/followup_generation_chain.py index eada44c1..f7071eee 100644 --- a/ai/src/ai_server/chain/followup_generation_chain.py +++ b/ai/src/ai_server/chain/followup_generation_chain.py @@ -215,6 +215,7 @@ def build_followup_generation_chain( model=settings.llm_flash_model, temperature=settings.llm_flash_temperature, timeout=settings.llm_flash_timeout_sec, + max_retries=settings.llm_max_retries, api_key=settings.llm_api_key_for("flash"), base_url=settings.llm_base_url_for("flash"), max_tokens=settings.llm_flash_max_tokens, @@ -254,6 +255,7 @@ def build_streaming_followup_generator( model=settings.llm_flash_model, temperature=settings.llm_flash_temperature, timeout=settings.llm_flash_timeout_sec, + max_retries=settings.llm_max_retries, api_key=settings.llm_api_key_for("flash"), base_url=settings.llm_base_url_for("flash"), max_tokens=settings.llm_flash_max_tokens, diff --git a/ai/src/ai_server/chain/pdf_vision.py b/ai/src/ai_server/chain/pdf_vision.py index 79ff2fc9..90ae9385 100644 --- a/ai/src/ai_server/chain/pdf_vision.py +++ b/ai/src/ai_server/chain/pdf_vision.py @@ -74,6 +74,7 @@ def build_vision_pdf_reader( model=settings.llm_pro_model, # 멀티모달(gemini-3.1-pro) — 게이트웨이 경유 temperature=0.0, timeout=settings.llm_pro_timeout_sec, + max_retries=settings.llm_max_retries, api_key=settings.llm_api_key_for("pro"), base_url=settings.llm_base_url_for("pro"), callbacks=callbacks, diff --git a/ai/src/ai_server/chain/question_generation_chain.py b/ai/src/ai_server/chain/question_generation_chain.py index 9dfa7be5..d7bb13ce 100644 --- a/ai/src/ai_server/chain/question_generation_chain.py +++ b/ai/src/ai_server/chain/question_generation_chain.py @@ -160,6 +160,7 @@ def build_question_generation_chain( model=settings.llm_pro_model, temperature=settings.llm_pro_temperature, timeout=settings.llm_pro_timeout_sec, + max_retries=settings.llm_max_retries, api_key=settings.llm_api_key_for("pro"), base_url=settings.llm_base_url_for("pro"), callbacks=callbacks, diff --git a/ai/src/ai_server/config/settings.py b/ai/src/ai_server/config/settings.py index 0ce1b427..6c2ae9f8 100644 --- a/ai/src/ai_server/config/settings.py +++ b/ai/src/ai_server/config/settings.py @@ -117,6 +117,15 @@ class Settings(BaseSettings): # 60초면 관측 최대(31.9초)의 1.9배 — 재시도로 같은 호출을 두 번 물지 않게 한다. llm_pro_timeout_sec: float = 60.0 + # **OpenAI SDK 클라이언트의 재시도 기본값이 2 라, LangChain 호출 1회가 HTTP 3회다.** + # 코드가 max_retries 를 지정한 적이 없어 이 곱셈이 보이지 않았다 — 그래서 + # llm_*_timeout_sec 는 호출 상한이 아니고 실제 상한은 그 (1+n)배다. 위 주석이 + # "타임아웃 30초 + 재시도 26초 = 56초" 로 관측한 것도 이 현상이고, 운영 + # analyze.document 실패 4건이 타임아웃 60초 설정에서 전부 92초로 찍힌 이유다. + # ai_request_logs 는 LangChain 호출 1건에 한 행이라 latency_ms 는 숨은 시도의 합이다. + # 명시해 보이게 하고, 필요할 때 환경변수로 줄일 수 있게 둔다(기본값은 현 동작 유지). + llm_max_retries: int = 2 + # 꼬리질문용 Flash 모델 (저지연 < 3s) llm_flash_model: str = "gemini-3.5-flash-lite" llm_flash_temperature: float = 0.4 @@ -124,6 +133,15 @@ class Settings(BaseSettings): # Flash 는 저지연 요구사항이 있어 Pro 보다 짧게. llm_flash_timeout_sec: float = 10.0 + # 문서 분석 재시도(바깥 with_retry). LangChain 기본 대기는 1초(+지터 1초)라 사실상 + # 연속 시도다 — 게이트웨이가 구간적으로 멎으면 두 시도가 같은 구간에 들어가 함께 죽는다. + # 운영 실측: resume_id=18 이 연속 2시도(각 92초)로 실패해 4일간 FAILED 로 남았고, + # 같은 입력이 3분 뒤 수동 재분석에서 37초로 성공했다(그 재분석도 2시도 실패 후 3번째). + # 시도를 3회로 늘리고 간격을 15초→30초로 벌린다. 소비자 prefetch 가 10 이라 + # 한 문서가 오래 붙잡혀도 다른 문서 분석을 막지 않는다. + document_analysis_max_attempts: int = 3 + document_analysis_retry_initial_sec: float = 15.0 + # 티어별 엔드포인트 오버라이드 (OpenAI 호환). 비우면 위 llm_base_url / llm_api_key 공유. # 예: 꼬리질문(Flash)만 로컬 Ollama 로 → LLM_FLASH_BASE_URL=http://ollama:11434/v1, # LLM_FLASH_MODEL=exaone3.5:7.8b. Pro 는 pdf_vision(멀티모달)도 쓰므로 텍스트 전용 diff --git a/ai/tests/test_llm_retry_budget.py b/ai/tests/test_llm_retry_budget.py new file mode 100644 index 00000000..14e7f2b9 --- /dev/null +++ b/ai/tests/test_llm_retry_budget.py @@ -0,0 +1,114 @@ +"""LLM 호출의 실제 시간 상한과 재시도 간격. + +`llm_*_timeout_sec` 는 오래 "호출 상한"으로 읽혔지만 아니었다 — OpenAI SDK 클라이언트의 +`max_retries` 기본값이 2 라 LangChain 호출 1회가 HTTP 3회이고, 코드가 그 값을 지정한 적이 +없었다. `ai_request_logs` 는 호출 1건에 한 행이므로 `latency_ms` 는 숨은 시도의 합이다 +(운영 `analyze.document` 실패 4건이 타임아웃 60초 설정에서 전부 92초로 찍혔다). + +문서 분석은 거기에 바깥 `with_retry` 까지 겹쳐 최대 6번 HTTP 를 쳤고, 그런데도 두 시도의 +간격이 1초(+지터)라 게이트웨이 정지 구간을 넘지 못해 4/4 로 함께 실패했다. 같은 입력이 +3분 뒤 수동 재분석에서 37초로 성공했다. +""" + +from __future__ import annotations + +import pytest + +from ai_server.chain.document_analysis_chain import build_document_analysis_chain +from ai_server.chain.feedback_generation_chain import ( + build_answer_coaching_chain, + build_feedback_generation_chain, + build_feedback_synthesis_chain, + build_job_fit_evaluation_chain, + build_panel_evaluator_chain, + build_personality_evaluation_chain, + build_self_intro_evaluation_chain, +) +from ai_server.chain.followup_generation_chain import ( + build_followup_generation_chain, + build_streaming_followup_generator, +) +from ai_server.chain.pdf_vision import build_vision_pdf_reader +from ai_server.chain.question_generation_chain import build_question_generation_chain +from ai_server.config.settings import Settings + + +def _settings(**over) -> Settings: + base = dict( + rabbitmq_url="amqp://x", + s3_endpoint_url="http://x", + s3_access_key="a", + s3_secret_key="b", + s3_bucket_name="c", + # 키를 반드시 준다 — 빈 값이면 ChatOpenAI 생성 자체가 OpenAIError 로 터진다. + # 로컬에는 ai/.env 가 있어 통과하고 CI 에서만 깨지는 함정이다. + llm_api_key="test-key", + ) + base.update(over) + return Settings(**base) + + +def _llm_of(chain) -> object: + steps = getattr(chain, "steps", None) or chain.bound.steps + return next(s for s in steps if type(s).__name__ == "ChatOpenAI") + + +def test_sdk_retry_default_would_triple_the_timeout_budget(): + """이 테스트가 지키는 것은 "기본값에 맡기지 않는다" 다.""" + from langchain_openai import ChatOpenAI + + bare = ChatOpenAI(model="x", timeout=60, api_key="k", base_url="http://localhost") + # 우리가 지정하지 않으면 클라이언트가 2회 더 시도한다 → 실제 상한은 타임아웃의 3배. + assert bare.root_async_client.max_retries == 2 + + +# 한 곳만 빠져도 그 경로만 조용히 옛 동작(설정값의 3배)으로 떨어진다 — 전부 본다. +@pytest.mark.parametrize( + "build", + [ + build_question_generation_chain, + build_feedback_generation_chain, + build_panel_evaluator_chain, + build_feedback_synthesis_chain, + build_self_intro_evaluation_chain, + build_personality_evaluation_chain, + build_job_fit_evaluation_chain, + build_answer_coaching_chain, + build_followup_generation_chain, + ], +) +def test_chains_pin_max_retries_from_settings(build): + llm = _llm_of(build(_settings(llm_max_retries=1))) + + assert llm.max_retries == 1, "설정이 아니라 SDK 기본값(2)에 맡기고 있다" + + +def test_streaming_followup_generator_pins_max_retries(): + gen = build_streaming_followup_generator(_settings(llm_max_retries=1)) + + assert gen._llm.max_retries == 1 + + +def test_pdf_vision_reader_pins_max_retries(): + reader = build_vision_pdf_reader(_settings(llm_max_retries=1)) + + assert reader._llm.max_retries == 1 + + +def test_document_analysis_disables_sdk_retry_because_it_owns_one(): + # 두 층이 곱해지면 한 메시지가 HTTP 6회 + 로그 한 행이 여러 시도의 합이 된다. + chain = build_document_analysis_chain(_settings(llm_max_retries=2)) + + assert _llm_of(chain).max_retries == 0 + + +def test_document_analysis_spaces_its_retries_far_enough(): + chain = build_document_analysis_chain(_settings()) + + assert ( + chain.max_attempt_number == 3 + ), "2회로는 4일간 FAILED 로 남은 사례를 못 살린다" + assert chain.wait_exponential_jitter is True + initial = chain.exponential_jitter_params["initial"] + # 1초(기본값)로는 게이트웨이 정지 구간 안에서 재시도가 함께 죽는다. + assert initial >= 10, f"재시도 간격이 {initial}초로 너무 촘촘하다" diff --git a/docker-compose.yml b/docker-compose.yml index 9911fed5..58a4d7d2 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -126,6 +126,9 @@ services: - LLM_FLASH_BASE_URL - LLM_FLASH_API_KEY - LLM_FLASH_TIMEOUT_SEC + - LLM_MAX_RETRIES + - DOCUMENT_ANALYSIS_MAX_ATTEMPTS + - DOCUMENT_ANALYSIS_RETRY_INITIAL_SEC - QUESTIONS_RAG_TIMEOUT_SEC - STT_PROVIDER - STT_MAX_ATTEMPTS diff --git a/docs/environment.md b/docs/environment.md index bba2e4e6..86e9ba11 100644 --- a/docs/environment.md +++ b/docs/environment.md @@ -140,6 +140,9 @@ LLM_FLASH_MODEL=gemini-3.5-flash-lite # 꼬리질문(US-19) 저지연 모델 LLM_FLASH_TEMPERATURE=0.4 LLM_FLASH_MAX_TOKENS=512 LLM_FLASH_TIMEOUT_SEC=10.0 # Pro 보다 짧게 — 꼬리질문 저지연(<3s) 요구사항 +LLM_MAX_RETRIES=2 # OpenAI SDK 재시도. 기본 2라 호출 1회가 HTTP 3회 → 위 타임아웃의 (1+n)배가 실제 상한 +DOCUMENT_ANALYSIS_MAX_ATTEMPTS=3 # 문서 분석 with_retry 시도 수. 2회는 연속 실패로 4일간 FAILED 로 남은 사례가 있었다 +DOCUMENT_ANALYSIS_RETRY_INITIAL_SEC=15.0 # 그 시도 간격(지수+지터). 기본 1초로는 게이트웨이 정지 구간을 못 넘는다 LLM_PRO_BASE_URL= # 티어별 OpenAI 호환 엔드포인트 오버라이드. 비우면 LLM_BASE_URL 공유 LLM_PRO_API_KEY= # 오버라이드한 티어는 LLM_API_KEY 를 물려받지 않음(키 유출 방지) LLM_FLASH_BASE_URL= # 예: http://ollama:11434/v1 (꼬리질문만 로컬 LLM)