응답이 중간에 끊기는 문제 해결. ModelAdapter에 max_tokens 파라미터 추가, stream/complete 양쪽 payload에 반영. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
107 lines
4.4 KiB
Python
107 lines
4.4 KiB
Python
"""BackendRegistry — 모델 어댑터 관리 + 헬스체크 루프."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import asyncio
|
|
import logging
|
|
import time
|
|
|
|
from services.model_adapter import ModelAdapter
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
CLASSIFIER_PROMPT = """\
|
|
너는 AI 라우터다. 사용자 메시지를 분석하여 JSON으로 응답하라.
|
|
반드시 아래 3가지 action 중 하나를 선택하라:
|
|
|
|
1. "direct" — 인사, 잡담, 간단한 질문, 자기소개 요청 등. 네가 직접 답변한다.
|
|
2. "route" — 복잡한 질문, 분석, 설명, 코딩 등. 추론 모델에게 넘긴다. 이때 prompt 필드에 추론 모델이 이해하기 좋게 정리된 프롬프트를 작성하라.
|
|
3. "clarify" — 질문이 모호하거나 정보가 부족할 때. 사용자에게 추가 질문한다.
|
|
|
|
반드시 아래 JSON 형식으로만 응답하라. JSON 외 텍스트는 절대 출력하지 마라:
|
|
{"action": "direct|route|clarify", "response": "direct/clarify일 때 사용자에게 보낼 텍스트", "prompt": "route일 때 추론 모델에게 보낼 프롬프트"}
|
|
|
|
너의 이름은 '이드'이고, 상냥하고 친근하게 대화한다.
|
|
너는 GPU 서버의 EXAONE 모델과 맥미니의 Gemma4 모델로 구성된 NanoClaude 파이프라인에서 돌아간다.
|
|
대화 이력이 있으면 맥락을 고려하라.\
|
|
"""
|
|
|
|
REASONER_PROMPT = (
|
|
"너는 '이드'라는 이름의 상냥하고 친근한 AI 어시스턴트야. "
|
|
"간결하고 자연스럽게 대화해. "
|
|
"질문에는 핵심만 명확하게 답해. "
|
|
"불필요한 구조화(번호 매기기, 헤더, 마크다운)는 피하고, 대화하듯 편하게 답변해."
|
|
)
|
|
|
|
|
|
class BackendRegistry:
|
|
def __init__(self) -> None:
|
|
self.classifier: ModelAdapter | None = None # EXAONE: 분류 + 직접응답
|
|
self.reasoner: ModelAdapter | None = None # Gemma4: 추론
|
|
self._health: dict[str, bool] = {"classifier": False, "reasoner": False}
|
|
self._latency: dict[str, float] = {"classifier": 0.0, "reasoner": 0.0}
|
|
self._health_task: asyncio.Task | None = None
|
|
|
|
def init_from_settings(self, settings) -> None:
|
|
self.classifier = ModelAdapter(
|
|
name="EXAONE",
|
|
base_url=settings.exaone_base_url,
|
|
model=settings.exaone_model,
|
|
system_prompt=CLASSIFIER_PROMPT,
|
|
temperature=0.3, # 분류는 낮은 temperature
|
|
timeout=settings.exaone_timeout,
|
|
)
|
|
self.reasoner = ModelAdapter(
|
|
name="Gemma4",
|
|
base_url=settings.reasoning_base_url,
|
|
model=settings.reasoning_model,
|
|
system_prompt=REASONER_PROMPT,
|
|
temperature=settings.reasoning_temperature,
|
|
timeout=settings.reasoning_timeout,
|
|
max_tokens=16000,
|
|
)
|
|
|
|
def start_health_loop(self, interval: float = 30.0) -> None:
|
|
self._health_task = asyncio.create_task(self._health_loop(interval))
|
|
|
|
def stop_health_loop(self) -> None:
|
|
if self._health_task and not self._health_task.done():
|
|
self._health_task.cancel()
|
|
|
|
async def _health_loop(self, interval: float) -> None:
|
|
while True:
|
|
await self._check_all()
|
|
await asyncio.sleep(interval)
|
|
|
|
async def _check_all(self) -> None:
|
|
for role, adapter in [("classifier", self.classifier), ("reasoner", self.reasoner)]:
|
|
if not adapter:
|
|
continue
|
|
start = time.monotonic()
|
|
healthy = await adapter.health_check()
|
|
elapsed = round((time.monotonic() - start) * 1000, 1)
|
|
prev = self._health[role]
|
|
self._health[role] = healthy
|
|
self._latency[role] = elapsed
|
|
if prev != healthy:
|
|
status = "UP" if healthy else "DOWN"
|
|
logger.warning("%s (%s) → %s (%.0fms)", adapter.name, role, status, elapsed)
|
|
|
|
def is_healthy(self, role: str) -> bool:
|
|
return self._health.get(role, False)
|
|
|
|
def health_summary(self) -> dict:
|
|
result = {}
|
|
for role, adapter in [("classifier", self.classifier), ("reasoner", self.reasoner)]:
|
|
if adapter:
|
|
result[role] = {
|
|
"name": adapter.name,
|
|
"model": adapter.model,
|
|
"healthy": self._health[role],
|
|
"latency_ms": self._latency[role],
|
|
}
|
|
return result
|
|
|
|
|
|
backend_registry = BackendRegistry()
|