Files
hyungi_document_server/app/services/search/llm_gate.py
T
hyungi 250896cdfa feat(eid): deep 모드 = ReAct 자동검색 + 근거 카드 (ds-eid-ask-absorb P1)
- deep 분기 _eid_chat_deep: 비생성 probe → phase:searching → agentic_ask_loop
  (tool_choice=auto 가 검색 여부 자율 판단, 검색 불요는 early-exit 대화) → final_answer
  + eid_sources envelope → DONE. heartbeat {phase:ping}(~10s, 프록시 idle timeout 차단)
  · mid-stream BackendUnavailable → in-stream error envelope · disconnect 시 task.cancel()
  + await(고아화·27B 점유 방지).
- daily = call_stream 무변경(맥미니 대화). deep = 맥북 27B ReAct (tool calling 27B 전용,
  맥미니 26B token-leak 미검증). 멀티턴 = 메시지 단독 처리(agentic_ask_loop query: str,
  history 2단계 백로그).
- EidEvidenceCard.svelte 접이식 근거 카드(sources 순서번호·제목·점수) + 프론트 SSE 파서
  확장(ping/searching/error/eid_sources) + 검색 중 표시 + 이력 보존.
- 테스트: deep 4건(검색성/대화성/probe-503/mid-stream-error) + 기존 call_stream 회귀 daily
  로 이전 = 29 passed.
- 동반(이전 eid-chat 세션 미커밋): /api/eid/status endpoint + llm_gate.gate_status +
  test_eid_status (채팅 대기 UI 의 '대기 vs 고장' 구분용, 5 passed).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 14:51:00 +09:00

243 lines
9.4 KiB
Python

"""MLX single-inference 전역 gate (Phase 3.1.1 + B-1 Priority Gate).
Mac mini MLX primary(gemma-4-26b-a4b-it-8bit)는 **single-inference**다.
동시 호출이 들어오면 queue가 폭발한다(실측: 23 concurrent 요청 → 22개 15초 timeout).
이 모듈은 analyzer / evidence / classifier / synthesis(gemma-macmini backend
한정) 등 **Mac mini MLX endpoint 로 향하는 모든 호출**이 공유하는 **우선순위
기반 gate** 를 제공한다. concurrency 는 1 고정이지만 queue 의 ordering 은
`Priority.FOREGROUND` (user-facing ask) 가 `Priority.BACKGROUND` (digest/
briefing/worker) 보다 먼저 dispatch.
PR-MacBook-RAG-Backend-1 부터 `services.llm.QwenMacBookBackend` 는 별 endpoint
(MacBook mlx-vlm.server) 라 본 gate 와 무관 — 자체 Semaphore(1) 사용.
## 영구 룰
- **Mac mini MLX endpoint 호출 경로는 예외 없이 gate 획득 필수**. query_analyzer /
evidence / classifier / `synthesis (gemma-macmini backend)` 가 현재 사용자.
이후 경로가 늘어도 **같은 Mac mini endpoint** 라면 동일 gate를 import해서
사용한다. 새 Semaphore를 만들지 말 것 (같은 endpoint 에서 큐 분할 시 동시 실행
발생, [[feedback_docstring_invariant_swap_audit]] PR #20 사고 케이스).
다른 endpoint (MacBook 등) 는 그 endpoint 전용 별 gate 를 둔다 — 본 gate 와
무관.
- **`asyncio.timeout(...)`은 gate 안쪽에서만 적용**. gate 대기 자체에 timeout을
걸면 "대기만으로 timeout 발동" 버그가 재발한다(query_analyzer 초기 이슈).
- **fallback(Claude Sonnet 4 API) 경로는 gate 제외**. PR #20 이후 fallback = Claude API. 단 현재
구현상 `AIClient._call_chat` 내부에서 primary→fallback 전환이 일어나므로
fallback도 gate 점유 상태로 실행된다. 허용 가능(fallback 빈도 낮음).
- **MLX concurrency는 `MLX_CONCURRENCY = 1` 고정**. 모델이 바뀌어도 single-
inference 특성이 깨지지 않는 한 이 값을 올리지 말 것.
## 우선순위 정책 (B-1, 2026-05-17)
- `Priority.FOREGROUND = 0`: user-facing path (`/api/search/ask`, 사용자 동기
API, Hermes orchestrator 경유). 가능한 빨리 dispatch.
- `Priority.BACKGROUND = 100`: digest / briefing / classify-escalate /
study_* worker / query_analyzer prewarm. foreground 가 비어 있을 때만 dispatch.
- **DEFAULT_PRIORITY = BACKGROUND**: priority 미지정 호출은 foreground 짓밟지
않는다 (안전 default).
- **preemption 없음**: 이미 inflight 인 background 는 끊지 않는다. foreground 가
들어와도 현재 점유 background 의 남은 시간만큼은 대기. 단 background 2~5
까지 줄 서있던 큐는 foreground 가 앞으로 jump.
- **starvation aging 없음** (Phase 2 deferred). 단 BACKGROUND wait_ms > 5분이면
WARN 로그 — 원인 추적 단서.
## 사용 예
```python
from services.search.llm_gate import acquire_mlx_gate, Priority
async def user_ask_path(...):
async with acquire_mlx_gate(Priority.FOREGROUND):
async with asyncio.timeout(30):
raw = await ai_client._call_chat(ai_client.ai.primary, prompt)
async def background_worker(...):
async with acquire_mlx_gate(Priority.BACKGROUND):
...
```
## 확장 여지
- aging (background 대기 시간 → priority boost) — Phase 2
- concurrency > 1 일반화 — B-2 (Throughput)
- 별 gate 분리 (`get_analyzer_gate` / `get_ask_gate`) — single-inference 에서
throughput 개선 없으므로 의미 없음 (PriorityQueue 안의 priority 만으로 충분)
"""
from __future__ import annotations
import asyncio
import heapq
import itertools
import time
from contextlib import asynccontextmanager
from enum import IntEnum
from typing import AsyncIterator
from core.utils import setup_logger
logger = setup_logger("llm_gate")
# MLX primary는 single-inference → 1
MLX_CONCURRENCY = 1
# Background waiter wait_ms 가 이 값 초과 시 WARN (starvation 신호, aging mitigation 은 Phase 2)
STARVATION_WARN_MS = 300_000 # 5 min
class Priority(IntEnum):
"""MLX gate dispatch 우선순위. 낮을수록 먼저 dispatch."""
FOREGROUND = 0
BACKGROUND = 100
DEFAULT_PRIORITY: Priority = Priority.BACKGROUND
# ── Internal state (lazy init on first acquire) ─────────────────────────────
# Tuple format: (priority: int, seq: int, future: asyncio.Future, enqueue_ts: float)
_waiters: list[tuple[int, int, asyncio.Future, float]] = []
_seq = itertools.count()
_inflight: bool = False
_lock: asyncio.Lock | None = None
def _get_lock() -> asyncio.Lock:
"""Lazy init Lock on the current event loop."""
global _lock
if _lock is None:
_lock = asyncio.Lock()
return _lock
def _dispatch_next_locked() -> asyncio.Future | None:
"""다음 살아있는 waiter 의 Future 를 pop 후 반환. cancelled/done 인 entry skip.
caller 는 lock 보유 상태에서 호출. 반환된 Future 의 set_result 는 lock 밖에서.
"""
while _waiters:
priority, seq, fut, enqueue_ts = heapq.heappop(_waiters)
if fut.cancelled() or fut.done():
continue # timeout/cancel 후 죽은 Future 건너뜀
return fut
return None
@asynccontextmanager
async def acquire_mlx_gate(
priority: Priority = DEFAULT_PRIORITY,
) -> AsyncIterator[None]:
"""우선순위 기반 MLX primary gate.
Args:
priority: Priority.FOREGROUND (user-facing) 또는 BACKGROUND (worker).
미지정 시 BACKGROUND (안전 default).
사용 예:
async with acquire_mlx_gate(Priority.FOREGROUND):
async with asyncio.timeout(30):
raw = await ai_client._call_chat(ai_client.ai.primary, prompt)
⚠ `asyncio.timeout` 은 반드시 gate 안쪽 (Future await 후) 에 둘 것.
"""
global _inflight, _waiters
lock = _get_lock()
seq = next(_seq)
enqueue_ts = time.monotonic()
waited = False
fut: asyncio.Future | None = None
async with lock:
if not _inflight and not _waiters:
# fast path — 즉시 inflight 진입, Future 생성 안 함
_inflight = True
else:
# 대기열 진입
fut = asyncio.get_event_loop().create_future()
heapq.heappush(_waiters, (int(priority), seq, fut, enqueue_ts))
queue_len = len(_waiters)
logger.debug(
"mlx_gate enqueue priority=%s seq=%d queue_len=%d",
priority.name, seq, queue_len,
)
waited = True
if waited and fut is not None:
# lock 밖에서 await — release 가 lock 안에서 set_result 하면 reentry deadlock
await fut
# inflight 진입 — wait_ms 측정 + dispatch log + starvation WARN
wait_ms = (time.monotonic() - enqueue_ts) * 1000.0 if waited else 0.0
if waited:
async with lock:
queue_len_post = len(_waiters)
logger.info(
"mlx_gate dispatch priority=%s seq=%d wait_ms=%.0f queue_len=%d",
priority.name, seq, wait_ms, queue_len_post,
)
if priority == Priority.BACKGROUND and wait_ms > STARVATION_WARN_MS:
logger.warning(
"mlx_gate background waiter starved wait_ms=%.0f priority=%s seq=%d",
wait_ms, priority.name, seq,
)
inflight_start = time.monotonic()
try:
yield
finally:
duration_ms = (time.monotonic() - inflight_start) * 1000.0
next_fut: asyncio.Future | None = None
async with lock:
next_fut = _dispatch_next_locked()
if next_fut is None:
_inflight = False
# _inflight 는 True 유지 (다음 waiter 가 진입 예정)
logger.debug(
"mlx_gate release duration_ms=%.0f priority=%s seq=%d",
duration_ms, priority.name, seq,
)
if next_fut is not None:
# lock 밖에서 set_result — reentry deadlock 회피
loop = asyncio.get_event_loop()
loop.call_soon(next_fut.set_result, None)
# ── Backward compat: context-manager only wrapper ────────────────────────────
def get_mlx_gate():
"""Legacy wrapper — `async with get_mlx_gate():` 형태만 호환.
내부적으로 `acquire_mlx_gate(DEFAULT_PRIORITY)` (= BACKGROUND) 로 위임한다.
새 호출 site 는 `acquire_mlx_gate(Priority.FOREGROUND|BACKGROUND)` 명시 사용.
⚠ **Semaphore-like API 미지원** — `sem = get_mlx_gate(); await sem.acquire()`
같은 직접 acquire/release 패턴은 동작하지 않는다. 발견 시 호출 site 를
`async with acquire_mlx_gate(...)` 로 명시적 교체.
"""
return acquire_mlx_gate(DEFAULT_PRIORITY)
# ── Read-only status (UI 표시용) ─────────────────────────────────────────────
def gate_status() -> dict:
"""현재 gate 점유 스냅샷 (read-only, lock-free 근사치 — UI 표시용)."""
return {"inflight": _inflight, "waiters": len(_waiters)}
# ── Test helpers (conftest reset) ────────────────────────────────────────────
def _reset_for_test() -> None:
"""테스트 fixture 가 fresh loop 마다 호출. production code 에서 사용 X."""
global _waiters, _inflight, _lock, _seq
_waiters = []
_inflight = False
_lock = None
_seq = itertools.count()