Files
vidconf/backend/core/summarization/llm_client.py

156 lines
7.2 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""HTTP-клиент к OpenAI-совместимому LLM-серверу (llama.cpp server, ТЗ §3.3).
`llama.cpp server` (образ `ghcr.io/ggml-org/llama.cpp:server`) отдаёт
`/v1/chat/completions` в формате OpenAI Chat Completions API: тело запроса
`{"model": ..., "messages": [{"role": "user", "content": ...}]}`, ответ —
`choices[0].message.content` (см. `tools/server/README.md` проекта llama.cpp).
Надёжность: retry с экспоненциальным backoff на
сетевых ошибках и retryable HTTP-статусах (5xx, включая 503 — модель ещё
грузится), плюс circuit breaker поверх retry — после `breaker_threshold`
подряд неудачных вызовов `complete()` окно `breaker_cooldown_s` секунд все
вызовы падают немедленно с `LlmUnavailableError`, не делая HTTP-запросов.
"""
import logging
import time
from typing import Any
import httpx
logger = logging.getLogger(__name__)
_INITIAL_BACKOFF_S = 0.5
"""Базовая пауза перед повтором; растёт экспоненциально: `_INITIAL_BACKOFF_S * 2**attempt`."""
_RETRYABLE_STATUS_CODES = frozenset({503})
"""Дополнительные ретраибл-статусы помимо диапазона 5xx (503 — модель llama.cpp ещё грузится)."""
class LlmUnavailableError(Exception):
"""LLM-сервер недоступен: исчерпаны попытки retry либо открыт circuit breaker."""
class OpenAICompatClient:
"""Клиент чат-комплишенов OpenAI-совместимого сервера (llama.cpp, Ollama и т.п.).
`transport` — точка внедрения `httpx.MockTransport` в тестах; в проде не
передаётся (используется реальный сетевой транспорт `httpx`).
"""
def __init__(
self,
base_url: str,
model: str,
*,
temperature: float = 0.2,
max_tokens: int = 1024,
timeout_s: float = 600.0,
max_attempts: int = 3,
breaker_threshold: int = 5,
breaker_cooldown_s: float = 60.0,
transport: httpx.BaseTransport | None = None,
) -> None:
self._base_url = base_url.rstrip("/")
self._model = model
self._temperature = temperature
self._max_tokens = max_tokens
self._max_attempts = max_attempts
self._breaker_threshold = breaker_threshold
self._breaker_cooldown_s = breaker_cooldown_s
self._client = httpx.Client(timeout=timeout_s, transport=transport)
self._consecutive_failures = 0
self._breaker_open_until = 0.0
def complete(self, prompt: str, *, max_tokens: int | None = None) -> str:
"""Выполнить чат-комплишн по одному пользовательскому сообщению `prompt`.
`max_tokens` — переопределение лимита на конкретный вызов (ADR-004:
раздельные лимиты map/reduce у `QwenLocal`); по умолчанию берётся
`max_tokens`, заданный в конструкторе клиента.
Бросает `LlmUnavailableError`, если circuit breaker открыт (окно
отказа ещё не истекло) либо все попытки retry исчерпаны.
"""
now = time.monotonic()
if now < self._breaker_open_until:
raise LlmUnavailableError(
"LLM-сервер недоступен: circuit breaker открыт после серии сбоев"
)
url = f"{self._base_url}/chat/completions"
payload: dict[str, Any] = {
"model": self._model,
"messages": [{"role": "user", "content": prompt}],
"temperature": self._temperature,
"max_tokens": max_tokens if max_tokens is not None else self._max_tokens,
}
last_error: Exception | None = None
for attempt in range(self._max_attempts):
try:
response = self._client.post(url, json=payload)
except httpx.TransportError as exc:
last_error = exc
logger.warning(
"Сетевая ошибка при обращении к LLM (попытка %d): %s", attempt + 1, exc
)
else:
if response.status_code == 200:
self._consecutive_failures = 0
try:
data = response.json()
content: str = data["choices"][0]["message"]["content"]
except (ValueError, KeyError, IndexError, TypeError) as exc:
last_error = exc
logger.warning("Некорректный ответ LLM-сервера: %s", exc)
else:
return content
elif response.status_code >= 500 or response.status_code in _RETRYABLE_STATUS_CODES:
last_error = RuntimeError(
f"LLM-сервер вернул retryable статус {response.status_code}"
)
logger.warning(
"Retryable статус %d от LLM (попытка %d)", response.status_code, attempt + 1
)
else:
# Не retryable статус (например, 4xx) — не тратим оставшиеся
# попытки, но фиксируем сбой для circuit breaker.
self._register_failure()
raise LlmUnavailableError(
f"LLM-сервер вернул статус {response.status_code}: {response.text}"
) from None
if attempt < self._max_attempts - 1:
time.sleep(_INITIAL_BACKOFF_S * (2**attempt))
self._register_failure()
raise LlmUnavailableError(
f"LLM-сервер недоступен после {self._max_attempts} попыток"
) from last_error
def _register_failure(self) -> None:
"""Учесть неудачный вызов `complete()`; открыть breaker при достижении порога."""
self._consecutive_failures += 1
if self._consecutive_failures >= self._breaker_threshold:
self._breaker_open_until = time.monotonic() + self._breaker_cooldown_s
logger.warning(
"Circuit breaker открыт на %.0fс после %d подряд неудач",
self._breaker_cooldown_s,
self._consecutive_failures,
)
def close(self) -> None:
"""Закрыть базовый HTTP-клиент (освободить соединения и пул `httpx`).
Безопасно вызывать более одного раза — `httpx.Client.close()` идемпотентен.
"""
self._client.close()
def __enter__(self) -> "OpenAICompatClient":
return self
def __exit__(self, *exc_info: object) -> None:
self.close()