From 03be38532d22a0a6b43bf3f71acc741d0e33f07f Mon Sep 17 00:00:00 2001 From: jmj Date: Thu, 17 Sep 2026 16:09:15 +0900 Subject: [PATCH] =?UTF-8?q?feat(ai):=20LLM=20=ED=8B=B0=EC=96=B4=EB=B3=84(P?= =?UTF-8?q?ro/Flash)=20=EC=97=94=EB=93=9C=ED=8F=AC=EC=9D=B8=ED=8A=B8=20?= =?UTF-8?q?=EC=98=A4=EB=B2=84=EB=9D=BC=EC=9D=B4=EB=93=9C=20+=20=EB=AA=A8?= =?UTF-8?q?=EB=8D=B8=20=EB=B9=84=EA=B5=90=20=EB=B2=A4=EC=B9=98=20=EC=8A=A4?= =?UTF-8?q?=ED=81=AC=EB=A6=BD=ED=8A=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 로컬 LLM(Ollama 등 OpenAI 호환 서버) 검토를 위해, Pro/Flash 가 각자 다른 서버를 쓸 수 있게 한다. 기본값은 비어 있어 기존처럼 두 티어 모두 게이트웨이를 공유 — 동작 변화 없음. - Settings: LLM_PRO_BASE_URL / LLM_PRO_API_KEY / LLM_FLASH_BASE_URL / LLM_FLASH_API_KEY 추가, llm_base_url_for(tier) / llm_api_key_for(tier) 로 11개 ChatOpenAI 생성 지점을 일원화 - base_url 을 오버라이드한 티어는 공용 LLM_API_KEY(학교 게이트웨이 키)를 물려받지 않는다 (다른 서버로 키 유출 방지). 키 없는 로컬 서버는 placeholder 키로 SDK 검증만 통과 - docker-compose / .env.example / docs/environment.md 에 신규 변수 반영 - scripts/llm_bench.py: 운영 프롬프트·파서(질문 풀 생성, 스트리밍 꼬리질문)를 그대로 태워 후보 모델의 성공률·지연·출력 샘플을 JSON 으로 남기는 벤치마크 --- .env.example | 7 + ai/.env.example | 7 + ai/scripts/llm_bench.py | 291 ++++++++++++++++++ .../chain/document_analysis_chain.py | 4 +- .../chain/feedback_generation_chain.py | 28 +- .../chain/followup_generation_chain.py | 8 +- ai/src/ai_server/chain/pdf_vision.py | 4 +- .../chain/question_generation_chain.py | 4 +- ai/src/ai_server/config/settings.py | 25 ++ ai/tests/test_llm_endpoint_settings.py | 53 ++++ docker-compose.yml | 4 + docs/environment.md | 4 + 12 files changed, 415 insertions(+), 24 deletions(-) create mode 100644 ai/scripts/llm_bench.py create mode 100644 ai/tests/test_llm_endpoint_settings.py diff --git a/.env.example b/.env.example index 2aa3cab..6537dda 100644 --- a/.env.example +++ b/.env.example @@ -32,6 +32,13 @@ 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 호환). 비우면 위 게이트웨이 공유. +# 예) 꼬리질문만 로컬 Ollama: LLM_FLASH_BASE_URL=http://ollama:11434/v1 + LLM_FLASH_MODEL=exaone3.5:7.8b +# 오버라이드한 티어는 LLM_API_KEY(학교 키)를 물려받지 않는다. +LLM_PRO_BASE_URL= +LLM_PRO_API_KEY= +LLM_FLASH_BASE_URL= +LLM_FLASH_API_KEY= QUESTIONS_RAG_TIMEOUT_SEC=1.5 # 음성 STT (Phase 2). auto=DEEPGRAM_API_KEY 있으면 deepgram, 없으면 mock. diff --git a/ai/.env.example b/ai/.env.example index 6e4e73e..411cff5 100644 --- a/ai/.env.example +++ b/ai/.env.example @@ -23,6 +23,13 @@ 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 호환). 비우면 위 게이트웨이 공유. +# 예) 꼬리질문만 로컬 Ollama: LLM_FLASH_BASE_URL=http://ollama:11434/v1 + LLM_FLASH_MODEL=exaone3.5:7.8b +# 오버라이드한 티어는 LLM_API_KEY(학교 키)를 물려받지 않는다. +LLM_PRO_BASE_URL= +LLM_PRO_API_KEY= +LLM_FLASH_BASE_URL= +LLM_FLASH_API_KEY= # 질문 풀 생성 시 다문서 RAG 검색 상한(초). followup 과 대칭. QUESTIONS_RAG_TIMEOUT_SEC=1.5 diff --git a/ai/scripts/llm_bench.py b/ai/scripts/llm_bench.py new file mode 100644 index 0000000..1fbd9be --- /dev/null +++ b/ai/scripts/llm_bench.py @@ -0,0 +1,291 @@ +"""LLM 후보(게이트웨이 vs 로컬 Ollama 등) 를 실제 체인으로 비교하는 벤치마크. + +실제 프롬프트·파서(question_generation / followup_generation) 를 그대로 태우므로 +"이 모델이 우리 JSON 스키마를 지키는가, 한국어 품질은 어떤가, 지연은 얼마인가" 를 +운영 코드와 같은 조건에서 본다. + +사용 예 (stackup-ai 컨테이너 안에서): + python scripts/llm_bench.py --base-url http://ollama:11434/v1 --api-key ollama \ + --model qwen3:4b --runs 2 --out /tmp/bench-qwen3-4b.json + python scripts/llm_bench.py --model gemini-3.5-flash-lite --runs 2 # 게이트웨이 기본값 + +--extra-body 로 공급자 전용 옵션을 넘길 수 있다 (예: Ollama qwen3 thinking 끄기 + '{"think": false}'). +""" + +from __future__ import annotations + +import argparse +import asyncio +import json +import statistics +import sys +import time +from typing import Any + +from ai_server.chain.followup_generation_chain import ( + build_streaming_followup_generator, +) +from ai_server.chain.question_generation_chain import ( + LlmQuestionGenerator, + build_question_generation_chain, +) +from ai_server.config.settings import Settings + +SAMPLE_RESUME_MD = """# 이력서 — 김도현 (백엔드 개발자, 3년차) + +## 요약 +- Spring Boot / Kotlin 기반 커머스 주문·정산 도메인 3년 +- MSA 전환 프로젝트에서 주문 서비스 분리 및 Kafka 이벤트 파이프라인 설계 +- 장애 대응: 결제 승인 지연으로 인한 중복 주문 이슈를 멱등 키 + Outbox 패턴으로 해결 + +## 경력 +### (주)마켓온 — 백엔드 개발자 (2023.03 ~ 현재) +- 주문/결제 도메인 담당. 일 평균 주문 12만 건 처리 +- 모놀리식 → MSA 전환: 주문 서비스를 별도 서비스로 분리, DB 분리(PostgreSQL) 및 + Kafka 기반 이벤트 발행/구독 구조 도입. 배포 단위 축소로 배포 주기 2주 → 2일 +- 정산 배치 성능 개선: 5시간 → 40분 (QueryDSL 튜닝, 청크 단위 처리, 인덱스 재설계) +- 결제 승인 콜백 지연 시 중복 주문 발생 문제: 멱등 키 테이블 + Transactional Outbox 로 + 해결, 중복 주문 0건 달성 + +### 스타트업 인턴 — 서버 개발 (2022.07 ~ 2022.12) +- Node.js/Express 기반 사내 예약 시스템 API 개발, Jest 테스트 커버리지 70% 달성 + +## 프로젝트 +### 실시간 재고 동기화 (2024) +- Redis 기반 재고 캐시 + DB write-behind, 동시성 제어(낙관적 락 → 분산 락 전환) +- 재고 불일치 건수 월 200건 → 3건 + +## 기술 스택 +Kotlin, Java 17, Spring Boot 3, JPA/QueryDSL, PostgreSQL, Kafka, Redis, Docker, GitHub Actions +""" + +SAMPLE_FOLLOWUP = { + "job_category": "BACKEND", + "mode": "TECHNICAL", + "previous_question": ( + "결제 승인 콜백 지연으로 중복 주문이 발생했던 문제를 멱등 키와 Outbox 패턴으로 " + "해결하셨다고 했는데, 두 가지를 함께 쓴 이유와 각각이 어떤 실패 케이스를 막아주는지 설명해 주세요." + ), + "answer_text": ( + "네, 먼저 멱등 키는 같은 결제 승인 콜백이 두 번 들어와도 주문이 한 번만 생성되게 하려고 " + "썼습니다. PG사에서 타임아웃 후 재전송을 하다 보니 같은 승인 건이 두 번 오는 경우가 있었고, " + "결제 키를 유니크 제약으로 걸어서 두 번째 요청은 기존 주문을 그대로 돌려주도록 했습니다. " + "Outbox 는 주문 저장이랑 Kafka 이벤트 발행이 하나의 트랜잭션이 아니어서, 주문은 저장됐는데 " + "이벤트가 안 나가는 경우가 있었어요. 그래서 이벤트를 같은 DB 트랜잭션 안에서 outbox 테이블에 " + "먼저 쓰고, 별도 릴레이가 폴링해서 발행하도록 바꿨습니다." + ), + "context": "(none)", + "parent_category": "PROJECT", + "expected_signal": "멱등성과 트랜잭션 경계에 대한 이해, 실패 케이스를 구체적으로 구분하는지", + "history": "(none)", +} + + +def _settings(args: argparse.Namespace) -> Settings: + over: dict[str, Any] = {} + if args.base_url: + over["llm_base_url"] = args.base_url + if args.api_key is not None: + over["llm_api_key"] = args.api_key + if args.model: + over["llm_pro_model"] = args.model + over["llm_flash_model"] = args.model + over["llm_pro_timeout_sec"] = args.timeout + over["llm_flash_timeout_sec"] = args.timeout + if args.max_tokens: + over["llm_flash_max_tokens"] = args.max_tokens + return Settings(**over) + + +def _patch_extra_body(chain: Any, extra_body: dict[str, Any] | None) -> None: + """체인 안의 ChatOpenAI 에 extra_body 를 주입 (공급자 전용 옵션 실험용).""" + if not extra_body: + return + from langchain_openai import ChatOpenAI + + def visit(node: Any) -> None: + if isinstance(node, ChatOpenAI): + node.extra_body = {**(node.extra_body or {}), **extra_body} + return + for attr in ("steps", "first", "middle", "last", "bound"): + child = getattr(node, attr, None) + if child is None: + continue + if isinstance(child, (list, tuple)): + for c in child: + visit(c) + else: + visit(child) + + visit(chain) + + +async def bench_questions( + settings: Settings, runs: int, extra_body: dict | None +) -> dict: + chain = build_question_generation_chain(settings) + _patch_extra_body(chain, extra_body) + gen = LlmQuestionGenerator(chain) + samples: list[dict[str, Any]] = [] + for i in range(runs): + t0 = time.perf_counter() + try: + pool = await gen.generate( + job_categories=["BACKEND"], + mode="TECHNICAL", + max_questions=3, + context=SAMPLE_RESUME_MD, + ) + elapsed = time.perf_counter() - t0 + samples.append( + { + "ok": True, + "latency_sec": round(elapsed, 2), + "questions": [q.model_dump(by_alias=True) for q in pool.questions], + } + ) + except Exception as exc: # noqa: BLE001 — 벤치는 실패 사유를 기록만 한다 + elapsed = time.perf_counter() - t0 + samples.append( + { + "ok": False, + "latency_sec": round(elapsed, 2), + "error": f"{type(exc).__name__}: {str(exc)[:400]}", + } + ) + print( + f" questions run {i + 1}/{runs}: ok={samples[-1]['ok']} {samples[-1]['latency_sec']}s", + file=sys.stderr, + ) + return _summarize("questions", samples) + + +async def bench_followup( + settings: Settings, runs: int, extra_body: dict | None +) -> dict: + # 운영 경로(followup_consumer → StreamingFollowupGenerator.stream, // 태그) + # 를 그대로 태운다. build_followup_generation_chain(JSON 파서) 는 운영에서 쓰이지 않는다. + gen = build_streaming_followup_generator(settings) + _patch_extra_body(gen._llm, extra_body) # noqa: SLF001 — 벤치 전용 주입 + samples: list[dict[str, Any]] = [] + for i in range(runs): + t0 = time.perf_counter() + first_token_at: list[float] = [] + + def on_token(_delta: str) -> None: + if not first_token_at: + first_token_at.append(time.perf_counter() - t0) + + try: + res = await gen.stream(on_question_token=on_token, **SAMPLE_FOLLOWUP) + elapsed = time.perf_counter() - t0 + samples.append( + { + "ok": True, + "latency_sec": round(elapsed, 2), + "first_question_token_sec": ( + round(first_token_at[0], 2) if first_token_at else None + ), + "followup_question": res.followup_question, + "answer_intent": res.answer_intent, + "answer_evaluation": ( + res.answer_evaluation.model_dump(by_alias=True) + if res.answer_evaluation + else None + ), + } + ) + except Exception as exc: # noqa: BLE001 + elapsed = time.perf_counter() - t0 + samples.append( + { + "ok": False, + "latency_sec": round(elapsed, 2), + "error": f"{type(exc).__name__}: {str(exc)[:400]}", + } + ) + print( + f" followup run {i + 1}/{runs}: ok={samples[-1]['ok']} {samples[-1]['latency_sec']}s", + file=sys.stderr, + ) + return _summarize("followup", samples) + + +def _summarize(name: str, samples: list[dict[str, Any]]) -> dict: + oks = [s for s in samples if s["ok"]] + lat = [s["latency_sec"] for s in oks] + return { + "chain": name, + "runs": len(samples), + "success": len(oks), + "latency_median_sec": round(statistics.median(lat), 2) if lat else None, + "latency_max_sec": max(lat) if lat else None, + "samples": samples, + } + + +async def main() -> int: + ap = argparse.ArgumentParser( + description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter + ) + ap.add_argument( + "--base-url", + default="", + help="OpenAI 호환 base URL (기본: settings.llm_base_url)", + ) + ap.add_argument( + "--api-key", default=None, help="API key (Ollama 는 아무 값이나, 예: ollama)" + ) + ap.add_argument( + "--model", default="", help="pro/flash 둘 다 이 모델로 (기본: settings 값)" + ) + ap.add_argument("--runs", type=int, default=2) + ap.add_argument("--timeout", type=float, default=180.0) + ap.add_argument( + "--max-tokens", type=int, default=0, help="flash max_tokens 덮어쓰기 (0=기본)" + ) + ap.add_argument("--extra-body", default="", help="JSON. 예: '{\"think\": false}'") + ap.add_argument("--only", choices=["questions", "followup"], default=None) + ap.add_argument("--out", default="", help="결과 JSON 저장 경로") + args = ap.parse_args() + + settings = _settings(args) + extra_body = json.loads(args.extra_body) if args.extra_body else None + label = args.model or f"{settings.llm_pro_model}/{settings.llm_flash_model}" + print( + f"== bench model={label} base_url={settings.llm_base_url} runs={args.runs}", + file=sys.stderr, + ) + + # 1회 워밍업 호출 지연(모델 로드)을 분리해 보기 위해 첫 run 도 그대로 기록한다. + results: list[dict] = [] + if args.only in (None, "questions"): + results.append(await bench_questions(settings, args.runs, extra_body)) + if args.only in (None, "followup"): + results.append(await bench_followup(settings, args.runs, extra_body)) + + report = { + "model": label, + "base_url": settings.llm_base_url, + "extra_body": extra_body, + "results": results, + } + text = json.dumps(report, ensure_ascii=False, indent=2) + if args.out: + with open(args.out, "w", encoding="utf-8") as f: + f.write(text) + print(f"saved {args.out}", file=sys.stderr) + else: + print(text) + for r in results: + print( + f"{r['chain']:<10} success={r['success']}/{r['runs']} " + f"median={r['latency_median_sec']}s max={r['latency_max_sec']}s", + file=sys.stderr, + ) + return 0 + + +if __name__ == "__main__": + raise SystemExit(asyncio.run(main())) diff --git a/ai/src/ai_server/chain/document_analysis_chain.py b/ai/src/ai_server/chain/document_analysis_chain.py index f759152..03f6c9a 100644 --- a/ai/src/ai_server/chain/document_analysis_chain.py +++ b/ai/src/ai_server/chain/document_analysis_chain.py @@ -95,8 +95,8 @@ def build_document_analysis_chain( model=settings.llm_pro_model, temperature=settings.llm_pro_temperature, timeout=settings.llm_pro_timeout_sec, - api_key=settings.llm_api_key or None, - base_url=settings.llm_base_url, + 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) diff --git a/ai/src/ai_server/chain/feedback_generation_chain.py b/ai/src/ai_server/chain/feedback_generation_chain.py index 05850fe..b074ec6 100644 --- a/ai/src/ai_server/chain/feedback_generation_chain.py +++ b/ai/src/ai_server/chain/feedback_generation_chain.py @@ -121,8 +121,8 @@ def build_feedback_generation_chain( model=settings.llm_pro_model, temperature=settings.llm_pro_temperature, timeout=settings.llm_pro_timeout_sec, - api_key=settings.llm_api_key or None, - base_url=settings.llm_base_url, + api_key=settings.llm_api_key_for("pro"), + base_url=settings.llm_base_url_for("pro"), callbacks=callbacks, ) return prompt | llm | parser @@ -301,8 +301,8 @@ def build_panel_evaluator_chain( model=settings.llm_pro_model, temperature=settings.llm_pro_temperature, timeout=settings.llm_pro_timeout_sec, - api_key=settings.llm_api_key or None, - base_url=settings.llm_base_url, + api_key=settings.llm_api_key_for("pro"), + base_url=settings.llm_base_url_for("pro"), callbacks=callbacks, ) return prompt | llm | parser @@ -336,8 +336,8 @@ def build_feedback_synthesis_chain( model=settings.llm_pro_model, temperature=settings.llm_pro_temperature, timeout=settings.llm_pro_timeout_sec, - api_key=settings.llm_api_key or None, - base_url=settings.llm_base_url, + api_key=settings.llm_api_key_for("pro"), + base_url=settings.llm_base_url_for("pro"), callbacks=callbacks, ) return prompt | llm | parser @@ -379,8 +379,8 @@ def build_self_intro_evaluation_chain( model=settings.llm_flash_model, temperature=settings.llm_flash_temperature, timeout=settings.llm_flash_timeout_sec, - api_key=settings.llm_api_key or None, - base_url=settings.llm_base_url, + api_key=settings.llm_api_key_for("flash"), + base_url=settings.llm_base_url_for("flash"), callbacks=callbacks, ) return prompt | llm | parser @@ -418,8 +418,8 @@ def build_personality_evaluation_chain( model=settings.llm_flash_model, temperature=settings.llm_flash_temperature, timeout=settings.llm_flash_timeout_sec, - api_key=settings.llm_api_key or None, - base_url=settings.llm_base_url, + api_key=settings.llm_api_key_for("flash"), + base_url=settings.llm_base_url_for("flash"), callbacks=callbacks, ) return prompt | llm | parser @@ -549,8 +549,8 @@ def build_job_fit_evaluation_chain( model=settings.llm_pro_model, temperature=settings.llm_pro_temperature, timeout=settings.llm_pro_timeout_sec, - api_key=settings.llm_api_key or None, - base_url=settings.llm_base_url, + api_key=settings.llm_api_key_for("pro"), + base_url=settings.llm_base_url_for("pro"), callbacks=callbacks, ) return prompt | llm | parser @@ -641,8 +641,8 @@ def build_answer_coaching_chain( model=settings.llm_flash_model, temperature=settings.llm_flash_temperature, timeout=settings.llm_flash_timeout_sec, - api_key=settings.llm_api_key or None, - base_url=settings.llm_base_url, + api_key=settings.llm_api_key_for("flash"), + base_url=settings.llm_base_url_for("flash"), callbacks=callbacks, ) return prompt | llm | parser diff --git a/ai/src/ai_server/chain/followup_generation_chain.py b/ai/src/ai_server/chain/followup_generation_chain.py index 7389a26..eada44c 100644 --- a/ai/src/ai_server/chain/followup_generation_chain.py +++ b/ai/src/ai_server/chain/followup_generation_chain.py @@ -215,8 +215,8 @@ def build_followup_generation_chain( model=settings.llm_flash_model, temperature=settings.llm_flash_temperature, timeout=settings.llm_flash_timeout_sec, - api_key=settings.llm_api_key or None, - base_url=settings.llm_base_url, + api_key=settings.llm_api_key_for("flash"), + base_url=settings.llm_base_url_for("flash"), max_tokens=settings.llm_flash_max_tokens, callbacks=callbacks, ) @@ -254,8 +254,8 @@ def build_streaming_followup_generator( model=settings.llm_flash_model, temperature=settings.llm_flash_temperature, timeout=settings.llm_flash_timeout_sec, - api_key=settings.llm_api_key or None, - base_url=settings.llm_base_url, + api_key=settings.llm_api_key_for("flash"), + base_url=settings.llm_base_url_for("flash"), max_tokens=settings.llm_flash_max_tokens, callbacks=callbacks, ) diff --git a/ai/src/ai_server/chain/pdf_vision.py b/ai/src/ai_server/chain/pdf_vision.py index c35de93..79ff2fc 100644 --- a/ai/src/ai_server/chain/pdf_vision.py +++ b/ai/src/ai_server/chain/pdf_vision.py @@ -74,8 +74,8 @@ def build_vision_pdf_reader( model=settings.llm_pro_model, # 멀티모달(gemini-3.1-pro) — 게이트웨이 경유 temperature=0.0, timeout=settings.llm_pro_timeout_sec, - api_key=settings.llm_api_key or None, - base_url=settings.llm_base_url, + api_key=settings.llm_api_key_for("pro"), + base_url=settings.llm_base_url_for("pro"), callbacks=callbacks, ) return LlmVisionPdfReader( diff --git a/ai/src/ai_server/chain/question_generation_chain.py b/ai/src/ai_server/chain/question_generation_chain.py index bb08810..920096f 100644 --- a/ai/src/ai_server/chain/question_generation_chain.py +++ b/ai/src/ai_server/chain/question_generation_chain.py @@ -156,8 +156,8 @@ def build_question_generation_chain( model=settings.llm_pro_model, temperature=settings.llm_pro_temperature, timeout=settings.llm_pro_timeout_sec, - api_key=settings.llm_api_key or None, - base_url=settings.llm_base_url, + api_key=settings.llm_api_key_for("pro"), + base_url=settings.llm_base_url_for("pro"), callbacks=callbacks, ) return prompt | llm | parser diff --git a/ai/src/ai_server/config/settings.py b/ai/src/ai_server/config/settings.py index 9883c15..eb6f81b 100644 --- a/ai/src/ai_server/config/settings.py +++ b/ai/src/ai_server/config/settings.py @@ -108,6 +108,17 @@ class Settings(BaseSettings): # Flash 는 저지연 요구사항이 있어 Pro 보다 짧게. llm_flash_timeout_sec: float = 10.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(멀티모달)도 쓰므로 텍스트 전용 + # 로컬 모델로 돌리면 PDF 이미지 판독이 깨진다. + # base_url 을 오버라이드한 티어는 공용 llm_api_key(학교 게이트웨이 키)를 절대 물려받지 않는다 + # — 다른 서버로 키가 새지 않게. 키가 필요 없는 로컬 서버면 비워 둬도 된다. + llm_pro_base_url: str = "" + llm_pro_api_key: str = "" + llm_flash_base_url: str = "" + llm_flash_api_key: str = "" + analyzed_resume_md_key_template: str = "analyzed/resume/{resume_id}/summary.md" analyzed_repository_md_key_template: str = ( "analyzed/repository/{repository_id}/summary.md" @@ -158,6 +169,20 @@ class Settings(BaseSettings): gemini_api_key: str = "" + def llm_base_url_for(self, tier: Literal["pro", "flash"]) -> str: + override = self.llm_pro_base_url if tier == "pro" else self.llm_flash_base_url + return override or self.llm_base_url + + def llm_api_key_for(self, tier: Literal["pro", "flash"]) -> str | None: + override_url = ( + self.llm_pro_base_url if tier == "pro" else self.llm_flash_base_url + ) + tier_key = self.llm_pro_api_key if tier == "pro" else self.llm_flash_api_key + if override_url: + # 로컬 OpenAI 호환 서버(Ollama/vLLM)는 키를 검사하지 않지만 SDK 는 빈 키를 거부한다. + return tier_key or "not-needed" + return tier_key or self.llm_api_key or None + def get_settings() -> Settings: return Settings() diff --git a/ai/tests/test_llm_endpoint_settings.py b/ai/tests/test_llm_endpoint_settings.py new file mode 100644 index 0000000..30dc96d --- /dev/null +++ b/ai/tests/test_llm_endpoint_settings.py @@ -0,0 +1,53 @@ +from ai_server.config.settings import Settings + + +def _settings(**over): + base = dict( + rabbitmq_url="amqp://x", + s3_endpoint_url="http://x", + s3_access_key="x", + s3_secret_key="x", + s3_bucket_name="b", + llm_api_key="school-gw-key", + llm_base_url="https://gateway.example/v1", + llm_pro_base_url="", + llm_pro_api_key="", + llm_flash_base_url="", + llm_flash_api_key="", + ) + base.update(over) + return Settings(**base) + + +def test_tiers_share_gateway_when_not_overridden(): + s = _settings() + for tier in ("pro", "flash"): + assert s.llm_base_url_for(tier) == "https://gateway.example/v1" + assert s.llm_api_key_for(tier) == "school-gw-key" + + +def test_flash_override_routes_only_flash_to_local(): + s = _settings(llm_flash_base_url="http://ollama:11434/v1") + assert s.llm_base_url_for("flash") == "http://ollama:11434/v1" + assert s.llm_base_url_for("pro") == "https://gateway.example/v1" + assert s.llm_api_key_for("pro") == "school-gw-key" + + +def test_overridden_tier_never_inherits_shared_gateway_key(): + # 학교 게이트웨이 키가 다른 서버(로컬/외부)로 새면 안 된다. + s = _settings(llm_flash_base_url="http://ollama:11434/v1") + key = s.llm_api_key_for("flash") + assert key != "school-gw-key" + assert key # OpenAI SDK 는 빈 키를 거부하므로 placeholder + + +def test_overridden_tier_uses_its_own_key(): + s = _settings( + llm_pro_base_url="https://other.example/v1", llm_pro_api_key="pro-key" + ) + assert s.llm_api_key_for("pro") == "pro-key" + + +def test_no_key_anywhere_returns_none_for_gateway_path(): + s = _settings(llm_api_key="") + assert s.llm_api_key_for("pro") is None diff --git a/docker-compose.yml b/docker-compose.yml index ae149bb..a1b1975 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -91,6 +91,10 @@ services: LLM_FLASH_TEMPERATURE: ${LLM_FLASH_TEMPERATURE:-0.4} LLM_FLASH_MAX_TOKENS: ${LLM_FLASH_MAX_TOKENS:-512} LLM_PRO_TIMEOUT_SEC: ${LLM_PRO_TIMEOUT_SEC:-30.0} + LLM_PRO_BASE_URL: ${LLM_PRO_BASE_URL:-} + LLM_PRO_API_KEY: ${LLM_PRO_API_KEY:-} + LLM_FLASH_BASE_URL: ${LLM_FLASH_BASE_URL:-} + LLM_FLASH_API_KEY: ${LLM_FLASH_API_KEY:-} LLM_FLASH_TIMEOUT_SEC: ${LLM_FLASH_TIMEOUT_SEC:-10.0} QUESTIONS_RAG_TIMEOUT_SEC: ${QUESTIONS_RAG_TIMEOUT_SEC:-1.5} STT_PROVIDER: ${STT_PROVIDER:-auto} diff --git a/docs/environment.md b/docs/environment.md index de2cf8a..579d99a 100644 --- a/docs/environment.md +++ b/docs/environment.md @@ -107,6 +107,10 @@ 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_PRO_BASE_URL= # 티어별 OpenAI 호환 엔드포인트 오버라이드. 비우면 LLM_BASE_URL 공유 +LLM_PRO_API_KEY= # 오버라이드한 티어는 LLM_API_KEY 를 물려받지 않음(키 유출 방지) +LLM_FLASH_BASE_URL= # 예: http://ollama:11434/v1 (꼬리질문만 로컬 LLM) +LLM_FLASH_API_KEY= # 로컬 서버면 비워도 됨 QUESTIONS_RAG_TIMEOUT_SEC=1.5 # 질문 풀 생성 시 다문서 RAG 검색 상한. followup 과 대칭 # (외부 직접 호출용, fallback — 임베딩·TTS 폴백)