Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions ai/.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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(학교 키)를 물려받지 않는다.
Expand Down
29 changes: 29 additions & 0 deletions ai/CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
16 changes: 14 additions & 2 deletions ai/src/ai_server/chain/document_analysis_chain.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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,
},
)
7 changes: 7 additions & 0 deletions ai/src/ai_server/chain/feedback_generation_chain.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down
2 changes: 2 additions & 0 deletions ai/src/ai_server/chain/followup_generation_chain.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down
1 change: 1 addition & 0 deletions ai/src/ai_server/chain/pdf_vision.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
1 change: 1 addition & 0 deletions ai/src/ai_server/chain/question_generation_chain.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
18 changes: 18 additions & 0 deletions ai/src/ai_server/config/settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -117,13 +117,31 @@ 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
llm_flash_max_tokens: int = 512
# 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(멀티모달)도 쓰므로 텍스트 전용
Expand Down
114 changes: 114 additions & 0 deletions ai/tests/test_llm_retry_budget.py
Original file line number Diff line number Diff line change
@@ -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}초로 너무 촘촘하다"
3 changes: 3 additions & 0 deletions docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
3 changes: 3 additions & 0 deletions docs/environment.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
Loading