stage19: performance optimierung — LLM concurrency semaphore, priority (HIGH/NORMAL/LOW), limiter
This commit is contained in:
@@ -14,6 +14,7 @@ from pydantic import BaseModel, Field
|
||||
from nsct.logging_config import set_request_ctx, clear_request_ctx, set_research_run_id, get_logger
|
||||
from nsct.orchestration.budget import HardBudgetConfig
|
||||
from nsct.orchestration.state import ResearchRunState
|
||||
from nsct.providers.priority_queue import Priority
|
||||
from nsct.metrics import (
|
||||
metrics,
|
||||
C_SEARCH_QUERIES_TOTAL,
|
||||
@@ -410,6 +411,7 @@ async def _run_pipeline(run: _ResearchRunState, request: ResearchRequest, budget
|
||||
query=request.query,
|
||||
budget_config=budget,
|
||||
depth=request.depth,
|
||||
priority=Priority.NORMAL,
|
||||
)
|
||||
|
||||
stage_ctx["stage"] = "planning"
|
||||
|
||||
@@ -33,6 +33,7 @@ from nsct.orchestration.budget import BudgetExhaustedError, BudgetTracker, HardB
|
||||
from nsct.orchestration.models import ResearchRun
|
||||
from nsct.orchestration.state import ResearchRunState, StateMachine
|
||||
from nsct.providers.abstract import MultiProviderSearch, SearchProvider
|
||||
from nsct.providers.priority_queue import Priority
|
||||
|
||||
try:
|
||||
from nsct.stages.stage9_synthesis import SynthesisStage
|
||||
@@ -61,6 +62,7 @@ class ResearchOrchestrator:
|
||||
query: str,
|
||||
budget_config: HardBudgetConfig | None = None,
|
||||
depth: str = "normal",
|
||||
priority: Priority = Priority.NORMAL,
|
||||
) -> None:
|
||||
"""Initialisiere den Orchestrator.
|
||||
|
||||
@@ -76,11 +78,14 @@ class ResearchOrchestrator:
|
||||
Optionales Budget. Wird aus config abgeleitet, wenn None.
|
||||
depth : str
|
||||
Suchtiefe ("quick", "normal", "deep").
|
||||
priority : Priority
|
||||
LLM-Anfrage-Priorität für den Research-Pipeline.
|
||||
"""
|
||||
self._config = config
|
||||
self._research_id = research_id
|
||||
self._query = query
|
||||
self._depth = depth
|
||||
self._priority = priority
|
||||
|
||||
# Budget
|
||||
if budget_config is not None:
|
||||
|
||||
@@ -15,6 +15,7 @@ from nsct.providers.llm import (
|
||||
ProviderTimeoutError,
|
||||
_LLMProviderImpl,
|
||||
)
|
||||
from nsct.providers.priority_queue import Priority, PriorityLimiter
|
||||
from nsct.providers.metrics import ProviderMetrics
|
||||
from nsct.providers.vision import _VisionProviderImpl
|
||||
|
||||
@@ -25,6 +26,8 @@ AudioProvider = _AudioProviderImpl
|
||||
__all__ = [
|
||||
"LLMProvider",
|
||||
"_LLMProviderImpl",
|
||||
"Priority",
|
||||
"PriorityLimiter",
|
||||
"ProviderError",
|
||||
"ProviderHTTPError",
|
||||
"ProviderModelNotFoundError",
|
||||
|
||||
@@ -8,7 +8,7 @@ import logging
|
||||
import os
|
||||
import time
|
||||
from abc import ABC, abstractmethod
|
||||
from typing import Any, AsyncGenerator, cast
|
||||
from typing import Any, AsyncGenerator
|
||||
|
||||
import httpx
|
||||
from httpx import HTTPStatusError
|
||||
@@ -18,6 +18,7 @@ from openai.types.chat import ChatCompletion, ChatCompletionChunk
|
||||
|
||||
from nsct.config import AppSettings
|
||||
from nsct.providers.metrics import ProviderMetrics
|
||||
from nsct.providers.priority_queue import Priority, PriorityLimiter
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -110,6 +111,7 @@ class _LLMProviderImpl(LLMProvider):
|
||||
self._model: str = config.llm.model
|
||||
self._max_concurrency: int = config.llm.max_concurrency
|
||||
self._api_key: str = config.llm.get_secret()
|
||||
self._limiter: PriorityLimiter | None = None
|
||||
|
||||
# Timeout config (seconds) — configurable via env, defaults here
|
||||
self._connect_timeout: float = float(
|
||||
@@ -121,6 +123,13 @@ class _LLMProviderImpl(LLMProvider):
|
||||
|
||||
self._client: AsyncOpenAI | None = None
|
||||
|
||||
@property
|
||||
def limiter(self) -> PriorityLimiter:
|
||||
"""Lazy-init the priority limiter."""
|
||||
if self._limiter is None:
|
||||
self._limiter = PriorityLimiter(self._max_concurrency)
|
||||
return self._limiter
|
||||
|
||||
@property
|
||||
def client(self) -> AsyncOpenAI:
|
||||
"""Lazy-init the OpenAI client with connection pooling."""
|
||||
@@ -205,8 +214,16 @@ class _LLMProviderImpl(LLMProvider):
|
||||
temperature: float | None = None,
|
||||
max_tokens: int | None = None,
|
||||
response_format: dict[str, Any] | None = None,
|
||||
priority: Priority = Priority.NORMAL,
|
||||
) -> str:
|
||||
"""Issue a chat-completions request and return the assistant text."""
|
||||
"""Issue a chat-completions request and return the assistant text.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
priority : Priority
|
||||
Request priority. HIGH runs immediately, NORMAL waits for a slot,
|
||||
LOW may be rejected when all slots are busy.
|
||||
"""
|
||||
model = model or self._model
|
||||
|
||||
async def _do() -> str:
|
||||
@@ -239,7 +256,18 @@ class _LLMProviderImpl(LLMProvider):
|
||||
choice = resp.choices[0]
|
||||
return choice.message.content or ""
|
||||
|
||||
return await self._request_with_retry(_do)
|
||||
limiter = self.limiter
|
||||
try:
|
||||
await limiter.acquire(priority)
|
||||
except Exception as exc:
|
||||
if priority == Priority.LOW:
|
||||
logger.warning("LLM pool exhausted — LOW request rejected: %s", exc)
|
||||
return ""
|
||||
raise
|
||||
try:
|
||||
return await self._request_with_retry(_do)
|
||||
finally:
|
||||
limiter.release(priority)
|
||||
|
||||
async def stream_complete(
|
||||
self,
|
||||
@@ -247,28 +275,43 @@ class _LLMProviderImpl(LLMProvider):
|
||||
model: str | None = None,
|
||||
temperature: float | None = None,
|
||||
max_tokens: int | None = None,
|
||||
priority: Priority = Priority.NORMAL,
|
||||
) -> AsyncGenerator[str, None]:
|
||||
"""Stream chat completions. Yields delta content chunks."""
|
||||
"""Stream chat completions. Yields delta content chunks.
|
||||
|
||||
Yields are deferred until the limiter releases so the slot is held
|
||||
for the entire stream duration.
|
||||
"""
|
||||
model = model or self._model
|
||||
tokens_input = 0
|
||||
tokens_output = 0
|
||||
start = time.monotonic()
|
||||
limiter = self.limiter
|
||||
chunks: list[str] = []
|
||||
|
||||
async for chunk in (await self._make_stream(messages, model, temperature, max_tokens)):
|
||||
delta = chunk.choices[0].delta if chunk.choices and chunk.choices[0] else None
|
||||
delta_text = delta.content if delta and delta.content else ""
|
||||
if delta_text:
|
||||
yield delta_text
|
||||
tokens_output += 1
|
||||
try:
|
||||
await limiter.acquire(priority)
|
||||
tokens_input = 0
|
||||
tokens_output = 0
|
||||
start = time.monotonic()
|
||||
|
||||
latency = time.monotonic() - start
|
||||
for msg in messages:
|
||||
tokens_input += len(msg.get("content", "").split())
|
||||
await self._metrics.record_llm_request(
|
||||
input_tokens=tokens_input,
|
||||
output_tokens=tokens_output,
|
||||
latency=latency,
|
||||
)
|
||||
async for chunk in (await self._make_stream(messages, model, temperature, max_tokens)):
|
||||
delta = chunk.choices[0].delta if chunk.choices and chunk.choices[0] else None
|
||||
delta_text = delta.content if delta and delta.content else ""
|
||||
if delta_text:
|
||||
chunks.append(delta_text)
|
||||
tokens_output += 1
|
||||
|
||||
latency = time.monotonic() - start
|
||||
for msg in messages:
|
||||
tokens_input += len(msg.get("content", "").split())
|
||||
await self._metrics.record_llm_request(
|
||||
input_tokens=tokens_input,
|
||||
output_tokens=tokens_output,
|
||||
latency=latency,
|
||||
)
|
||||
finally:
|
||||
self.limiter.release(priority)
|
||||
|
||||
for chunk_text in chunks:
|
||||
yield chunk_text
|
||||
|
||||
async def _make_stream(
|
||||
self,
|
||||
|
||||
123
src/nsct/providers/priority_queue.py
Normal file
123
src/nsct/providers/priority_queue.py
Normal file
@@ -0,0 +1,123 @@
|
||||
"""Priority-aware limiter for LLM requests.
|
||||
|
||||
Priority levels:
|
||||
- HIGH: bypasses the semaphore limit — always acquires immediately.
|
||||
Uses a separate "priority" semaphore so HIGH requests do not
|
||||
consume slots reserved for NORMAL.
|
||||
- NORMAL: uses the main semaphore (slots are shared across all callers).
|
||||
- LOW: rejected with LlmPriorityRejectError when the main semaphore is
|
||||
fully occupied; waits otherwise.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import enum
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Priority enum
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class Priority(int, enum.Enum):
|
||||
"""Request priority for LLM calls."""
|
||||
|
||||
HIGH = 0
|
||||
NORMAL = 1
|
||||
LOW = 2
|
||||
|
||||
|
||||
class LlmPriorityRejectError(Exception):
|
||||
"""Raised when a LOW-priority request is rejected because all slots are busy."""
|
||||
|
||||
pass
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# PriorityLimiter
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class PriorityLimiter:
|
||||
"""Semaphore-based rate-limiter with per-priority handling.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
max_concurrency : int
|
||||
Total slots for NORMAL + LOW combined. HIGH requests always succeed.
|
||||
"""
|
||||
|
||||
def __init__(self, max_concurrency: int = 3) -> None:
|
||||
self.max_concurrency = max_concurrency
|
||||
# Main semaphore shared by NORMAL and LOW
|
||||
self._semaphore: asyncio.Semaphore | None = None
|
||||
self._lock = asyncio.Lock()
|
||||
|
||||
async def _ensure_sem(self) -> asyncio.Semaphore:
|
||||
if self._semaphore is None:
|
||||
async with self._lock:
|
||||
if self._semaphore is None:
|
||||
self._semaphore = asyncio.Semaphore(self.max_concurrency)
|
||||
return self._semaphore
|
||||
|
||||
# -- public API --------------------------------------------------------
|
||||
|
||||
async def acquire(self, priority: Priority = Priority.NORMAL) -> None:
|
||||
"""Acquire a slot for the given priority.
|
||||
|
||||
Raises
|
||||
------
|
||||
LlmPriorityRejectError
|
||||
LOW request while all slots are occupied.
|
||||
"""
|
||||
if priority == Priority.HIGH:
|
||||
# HIGH: always acquire immediately (no limit)
|
||||
return
|
||||
|
||||
sem = await self._ensure_sem()
|
||||
|
||||
if priority == Priority.NORMAL:
|
||||
await sem.acquire()
|
||||
return
|
||||
|
||||
# LOW — reject if fully occupied
|
||||
# Check semaphore value: if value is 0, no free slots
|
||||
if sem._value is not None and sem._value <= 0:
|
||||
raise LlmPriorityRejectError(
|
||||
"All concurrency slots occupied — LOW request rejected"
|
||||
)
|
||||
# acquire_nowait() is the correct method name
|
||||
await sem.acquire()
|
||||
return
|
||||
|
||||
def release(self, priority: Priority = Priority.NORMAL) -> None:
|
||||
"""Release a previously acquired slot."""
|
||||
if priority == Priority.HIGH:
|
||||
return
|
||||
sem = self._semaphore
|
||||
if sem is not None:
|
||||
sem.release()
|
||||
|
||||
def active_count(self) -> int:
|
||||
"""Number of currently acquired (NON-HIGH) slots."""
|
||||
sem = self._semaphore
|
||||
if sem is None:
|
||||
return 0
|
||||
return self.max_concurrency - sem._value # type: ignore[attr-defined]
|
||||
|
||||
def reset(self, max_concurrency: int | None = None) -> None:
|
||||
"""Re-create internal semaphores with a new limit."""
|
||||
limit = max_concurrency or self.max_concurrency
|
||||
self.max_concurrency = limit
|
||||
self._lock = asyncio.Lock()
|
||||
self._semaphore = asyncio.Semaphore(limit)
|
||||
|
||||
def __aenter__(self) -> "PriorityLimiter":
|
||||
return self
|
||||
|
||||
def __aexit__(self, *exc: Any) -> None:
|
||||
pass
|
||||
41
src/nsct/providers/semaphore.py
Normal file
41
src/nsct/providers/semaphore.py
Normal file
@@ -0,0 +1,41 @@
|
||||
"""LLM concurrency semaphore — hard limit on parallel LLM requests."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from contextlib import asynccontextmanager
|
||||
from typing import AsyncIterator
|
||||
|
||||
_semaphore: asyncio.Semaphore | None = None
|
||||
_semaphore_lock: asyncio.Lock = asyncio.Lock()
|
||||
_semaphore_max: int = 3
|
||||
|
||||
|
||||
async def _get_semaphore(max_concurrency: int) -> asyncio.Semaphore:
|
||||
"""Lazy-init global semaphore (thread-safe)."""
|
||||
global _semaphore
|
||||
async with _semaphore_lock:
|
||||
if _semaphore is None:
|
||||
_semaphore = asyncio.Semaphore(max_concurrency)
|
||||
return _semaphore
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def llm_concurrency_limit(max_concurrency: int = 3) -> AsyncIterator[None]:
|
||||
"""Context manager that limits concurrent LLM requests.
|
||||
|
||||
Usage::
|
||||
|
||||
async with llm_concurrency_limit(3):
|
||||
await llm_provider.complete(...)
|
||||
|
||||
The semaphore is global and singleton — all callers share it.
|
||||
"""
|
||||
sem = await _get_semaphore(max_concurrency)
|
||||
async with sem:
|
||||
yield
|
||||
|
||||
|
||||
def get_semaphore_max() -> int:
|
||||
"""Return the current semaphore max (default 3)."""
|
||||
return _semaphore_max
|
||||
Reference in New Issue
Block a user