Files
hkuds--lightrag/lightrag/llm_roles.py
T
2026-07-13 12:08:54 +08:00

580 lines
22 KiB
Python

"""LLM role registry, configuration types, and runtime mixin.
LightRAG can route different stages of work (entity extraction, keyword
extraction, query, vlm) to distinct LLM bindings. This module owns the
static role registry (:data:`ROLES`), the per-role configuration
(:class:`RoleLLMConfig`), and the :class:`_RoleLLMMixin` that drives the
runtime: builder registration, wrapper rebuilding, hot config updates,
queue cleanup, and queue-status reporting.
"""
from __future__ import annotations
import asyncio
import inspect
from copy import deepcopy
from dataclasses import dataclass, field
from functools import partial
from typing import Any, Callable, Mapping
from lightrag.utils import (
get_env_value,
logger,
priority_limit_async_func_call,
)
def _optional_env_int(env_key: str) -> int | None:
return get_env_value(env_key, None, int, special_none=True)
@dataclass(frozen=True)
class RoleSpec:
"""Static descriptor for a known LLM role.
Adding a new role anywhere in LightRAG is a single-line edit: append a
``RoleSpec`` to :data:`ROLES`. Every other component (env var loop in
``api/config.py``, queue observability, role config update flow) iterates
this registry rather than hard-coding role names.
"""
name: str
"""Canonical lowercase role key (used in ``role_llm_configs`` dict and CLI/log output)."""
env_prefix: str
"""Uppercase prefix used by the API env-var layer, e.g. ``"EXTRACT"`` for
``EXTRACT_LLM_BINDING`` / ``EXTRACT_MAX_ASYNC_LLM`` / ``EXTRACT_LLM_TIMEOUT``."""
queue_name: str
"""Display name passed to ``priority_limit_async_func_call`` for log lines."""
ROLES: tuple[RoleSpec, ...] = (
RoleSpec("extract", "EXTRACT", "extract LLM func"),
RoleSpec("keyword", "KEYWORD", "keyword LLM func"),
RoleSpec("query", "QUERY", "query LLM func"),
RoleSpec("vlm", "VLM", "vlm LLM func"),
)
ROLE_NAMES: frozenset[str] = frozenset(spec.name for spec in ROLES)
ROLES_BY_NAME: dict[str, RoleSpec] = {spec.name: spec for spec in ROLES}
@dataclass
class RoleLLMConfig:
"""Per-role LLM override accepted at :class:`LightRAG` init time.
Any field left as ``None`` falls back to the corresponding base LLM
setting (``llm_model_func`` / ``llm_model_kwargs`` / ``llm_model_max_async``
/ ``default_llm_timeout``). When ``max_async`` is None at init and the
user did not pass a ``role_llm_configs`` entry for the role, the value is
additionally seeded from ``{ROLE_PREFIX}_MAX_ASYNC_LLM``. ``metadata`` seeds
runtime observability and role-builder context.
"""
func: Callable[..., object] | None = None
kwargs: dict[str, Any] | None = None
max_async: int | None = None
timeout: int | None = None
metadata: dict[str, Any] | None = None
@dataclass
class _RoleLLMState:
"""Runtime state for one role. Internal — not part of the public API."""
raw_func: Callable[..., object]
kwargs: dict[str, Any] | None
max_async: int | None
timeout: int | None
metadata: dict[str, Any] = field(default_factory=dict)
wrapped: Callable[..., object] | None = None
class _RoleLLMMixin:
"""Mixin that owns the role LLM runtime on :class:`LightRAG`.
Mixed into LightRAG only. Relies on attributes that the main class
initializes in ``__post_init__`` (``_role_llm_states``, ``_role_llm_builders``,
``llm_model_func``, ``llm_model_kwargs``, ``llm_model_max_async``,
``default_llm_timeout``, ``embedding_func``, ``rerank_model_func``).
"""
_SECRET_MARKERS = (
"api_key",
"api-key",
"apikey",
"access_key",
"access-key",
"secret",
"token",
"credential",
"password",
"passphrase",
"pwd",
"auth",
"session",
)
@staticmethod
def _normalize_llm_role(role: str) -> str:
normalized = role.strip().lower()
if normalized not in ROLE_NAMES:
raise ValueError(f"Invalid LLM role: {role}")
return normalized
def register_role_llm_builder(
self,
builder: Callable[
[str, dict[str, Any]], tuple[Callable[..., object], dict[str, Any] | None]
],
) -> None:
"""Register a runtime builder used by update_llm_role_config for binding/model updates."""
self._llm_role_builder = builder
def set_role_llm_metadata(self, role: str, **metadata: Any) -> None:
"""Store role metadata used when rebuilding a role-specific LLM function."""
role = self._normalize_llm_role(role)
state = self._role_llm_states[role]
for key, value in metadata.items():
if value is None:
continue
state.metadata[key] = value
@property
def role_llm_funcs(self) -> Mapping[str, Callable[..., object]]:
"""Read-only mapping of role name → wrapped (queue-managed) LLM func."""
return {
name: state.wrapped
for name, state in self._role_llm_states.items()
if state.wrapped is not None
}
@property
def role_llm_kwargs(self) -> Mapping[str, dict[str, Any] | None]:
"""Read-only mapping of role name → effective LLM kwargs (None means inherit base)."""
return {name: state.kwargs for name, state in self._role_llm_states.items()}
def _get_effective_role_llm_kwargs(self, role: str) -> dict[str, Any]:
state = self._role_llm_states[self._normalize_llm_role(role)]
if state.kwargs is not None:
return state.kwargs
if state.metadata.get("is_cross_provider"):
return {}
return self.llm_model_kwargs
def _get_effective_role_llm_timeout(self, role: str) -> int:
state = self._role_llm_states[self._normalize_llm_role(role)]
return state.timeout if state.timeout is not None else self.default_llm_timeout
def _get_effective_role_llm_max_async(self, role: str) -> int:
state = self._role_llm_states[self._normalize_llm_role(role)]
return (
state.max_async if state.max_async is not None else self.llm_model_max_async
)
def _wrap_llm_role_func(
self,
role_name: str,
raw_func: Callable[..., object],
max_async: int,
timeout: int,
model_kwargs: dict[str, Any],
) -> Callable[..., object]:
spec = ROLES_BY_NAME[role_name]
return priority_limit_async_func_call(
max_async,
llm_timeout=timeout,
queue_name=spec.queue_name,
concurrency_group=f"llm:{role_name}",
)(
partial(
raw_func,
hashing_kv=self.llm_response_cache,
**model_kwargs,
)
)
def _rebuild_role_llm_funcs(self) -> None:
"""Wrap each role's raw_func with its own priority queue.
Base ``llm_model_func`` is intentionally NOT wrapped — concurrency
for the base function is enforced at the role layer (every code path
that calls an LLM goes through a role wrapper).
"""
for spec in ROLES:
self._rebuild_single_role_llm_func(spec.name)
def _rebuild_single_role_llm_func(self, role: str) -> None:
role = self._normalize_llm_role(role)
state = self._role_llm_states[role]
state.wrapped = self._wrap_llm_role_func(
role,
state.raw_func,
self._get_effective_role_llm_max_async(role),
self._get_effective_role_llm_timeout(role),
self._get_effective_role_llm_kwargs(role),
)
async def _shutdown_llm_wrapper(self, wrapped_func: Callable[..., object]) -> None:
shutdown = getattr(wrapped_func, "shutdown", None)
if callable(shutdown):
await shutdown(graceful=True)
def _schedule_retired_llm_queue_cleanup(
self, wrapped_func: Callable[..., object] | None
) -> None:
if wrapped_func is None or not callable(
getattr(wrapped_func, "shutdown", None)
):
return
try:
loop = asyncio.get_running_loop()
except RuntimeError:
# The retired wrapper's queue and worker tasks are tied to the
# event loop that first used them. Spinning up a fresh loop via
# asyncio.run would either hang on queue.join() or touch
# primitives bound to a closed loop. Skip cleanup with a warning
# — call aupdate_llm_role_config() from an async context for
# deterministic shutdown.
logger.warning(
"update_llm_role_config: skipping retired LLM queue cleanup "
"because no event loop is running; call aupdate_llm_role_config() "
"from an async context for deterministic shutdown"
)
return
task = loop.create_task(self._shutdown_llm_wrapper(wrapped_func))
self._retired_llm_queue_cleanup_tasks.add(task)
task.add_done_callback(self._finalize_retired_llm_queue_cleanup)
def _finalize_retired_llm_queue_cleanup(self, task: asyncio.Task) -> None:
self._retired_llm_queue_cleanup_tasks.discard(task)
try:
task.result()
except asyncio.CancelledError:
pass
except Exception as e:
logger.warning(f"Retired LLM queue cleanup failed: {e}")
async def wait_for_retired_llm_queues(self) -> None:
"""Wait until all retired role LLM queues have drained and shut down.
Cleanup failures are logged by ``_finalize_retired_llm_queue_cleanup``
and intentionally swallowed here so callers can rely on this method
always returning once every retired wrapper has finished.
"""
while self._retired_llm_queue_cleanup_tasks:
tasks = list(self._retired_llm_queue_cleanup_tasks)
await asyncio.gather(*tasks, return_exceptions=True)
def _apply_llm_role_config_update(
self,
role: str,
*,
model_func: Callable[..., object] | None = None,
model_kwargs: dict[str, Any] | None = None,
max_async: int | None = None,
timeout: int | None = None,
binding: str | None = None,
model: str | None = None,
host: str | None = None,
api_key: str | None = None,
provider_options: dict[str, Any] | None = None,
) -> Callable[..., object] | None:
role = self._normalize_llm_role(role)
state = self._role_llm_states[role]
old_wrapped = state.wrapped
snapshot = _RoleLLMState(
raw_func=state.raw_func,
kwargs=deepcopy(state.kwargs),
max_async=state.max_async,
timeout=state.timeout,
metadata=deepcopy(state.metadata),
wrapped=state.wrapped,
)
try:
if model_func is not None and not callable(model_func):
raise TypeError("model_func must be callable")
if model_kwargs is not None:
state.kwargs = model_kwargs
if max_async is not None:
state.max_async = max_async
if timeout is not None:
state.timeout = timeout
if model_func is not None:
state.raw_func = model_func
metadata_updated = any(
value is not None
for value in (binding, model, host, api_key, provider_options)
)
if binding is not None:
state.metadata["binding"] = binding
if model is not None:
state.metadata["model"] = model
if host is not None:
state.metadata["host"] = host
if api_key is not None:
state.metadata["api_key"] = api_key
if provider_options is not None:
state.metadata["provider_options"] = provider_options
if "base_binding" in state.metadata and "binding" in state.metadata:
state.metadata["is_cross_provider"] = (
state.metadata["binding"] != state.metadata["base_binding"]
)
if metadata_updated:
builder = getattr(self, "_llm_role_builder", None)
if builder is None and model_func is None:
raise ValueError(
"Runtime role builder is not configured; provide model_func or register_role_llm_builder() first"
)
if builder is not None:
built_func, built_kwargs = builder(role, state.metadata)
state.raw_func = built_func
if model_kwargs is None and built_kwargs is not None:
state.kwargs = built_kwargs
self._rebuild_single_role_llm_func(role)
except Exception:
state.raw_func = snapshot.raw_func
state.kwargs = snapshot.kwargs
state.max_async = snapshot.max_async
state.timeout = snapshot.timeout
state.metadata = snapshot.metadata
state.wrapped = snapshot.wrapped
raise
self._log_llm_role_config("updated", role=role)
return old_wrapped
def update_llm_role_config(
self,
role: str,
*,
model_func: Callable[..., object] | None = None,
model_kwargs: dict[str, Any] | None = None,
max_async: int | None = None,
timeout: int | None = None,
binding: str | None = None,
model: str | None = None,
host: str | None = None,
api_key: str | None = None,
provider_options: dict[str, Any] | None = None,
) -> None:
"""
Update a role-specific LLM configuration at runtime.
Supports lightweight updates (kwargs/max_async/timeout/model_func) directly.
For binding/model/host/api_key/provider_options updates, a role builder must
be registered via register_role_llm_builder().
"""
old_wrapped = self._apply_llm_role_config_update(
role,
model_func=model_func,
model_kwargs=model_kwargs,
max_async=max_async,
timeout=timeout,
binding=binding,
model=model,
host=host,
api_key=api_key,
provider_options=provider_options,
)
self._schedule_retired_llm_queue_cleanup(old_wrapped)
async def aupdate_llm_role_config(
self,
role: str,
*,
model_func: Callable[..., object] | None = None,
model_kwargs: dict[str, Any] | None = None,
max_async: int | None = None,
timeout: int | None = None,
binding: str | None = None,
model: str | None = None,
host: str | None = None,
api_key: str | None = None,
provider_options: dict[str, Any] | None = None,
) -> None:
"""Async variant of update_llm_role_config that waits for queue cleanup.
Blocking behavior:
This coroutine awaits a graceful shutdown of the retired role
wrapper's priority queue. The shutdown blocks on
``queue.join()`` until every already-queued LLM call has been
executed (workers always call ``task_done()`` in ``finally``,
so in-flight requests are not cut off).
The wait is bounded by ``max_task_duration`` of the retired
queue, which is computed as ``llm_timeout * 2 + 15`` seconds
(default ``180 * 2 + 15 = 375`` seconds, ~6 min 15 s). When
this bound is reached, the drain times out and the shutdown
falls through to forced cancellation: pending futures are
cancelled, the queue is cleared, workers are stopped. So this
method **never blocks indefinitely**, but with a deep backlog
of slow LLM calls it can take up to that bound to return, and
in-flight calls past the bound will be cancelled.
If you need a non-blocking switch, use the sync
``update_llm_role_config()`` (which schedules cleanup as a
background task) and await ``wait_for_retired_llm_queues()``
separately when you want to confirm the old queue is gone.
"""
old_wrapped = self._apply_llm_role_config_update(
role,
model_func=model_func,
model_kwargs=model_kwargs,
max_async=max_async,
timeout=timeout,
binding=binding,
model=model,
host=host,
api_key=api_key,
provider_options=provider_options,
)
if old_wrapped is not None:
await self._shutdown_llm_wrapper(old_wrapped)
@classmethod
def _is_secret_key(cls, key: str) -> bool:
lowered = key.lower()
return any(marker in lowered for marker in cls._SECRET_MARKERS)
def _scrubbed_llm_metadata(self, metadata: dict[str, Any]) -> dict[str, Any]:
"""Return a deep copy of ``metadata`` with auth-bearing fields removed.
Auth-bearing fields are stripped entirely — not masked — because a
masked ``"***"`` carries no information for an external consumer
(operators already see ``binding`` / ``host`` to confirm a role is
configured). Stripping makes the invariant simple: anything that
appears in this output is safe to log, cache, ship over the wire.
Components that legitimately need the raw secret (the role builder,
provider clients) read it directly off the private
``_role_llm_states[role].metadata`` dict.
"""
def scrub_value(value: Any) -> Any:
if isinstance(value, Mapping):
return {
key: scrub_value(inner_value)
for key, inner_value in value.items()
if not self._is_secret_key(str(key))
}
if isinstance(value, list):
return [scrub_value(item) for item in value]
if isinstance(value, tuple):
return tuple(scrub_value(item) for item in value)
return deepcopy(value)
return scrub_value(metadata)
def get_llm_role_config(self, role: str | None = None) -> dict[str, Any]:
"""Return effective role LLM runtime configuration (observability snapshot).
Each role entry exposes ``binding`` / ``model`` / ``host`` at the top
level for convenience and again inside ``metadata`` as part of the
full runtime snapshot (which may contain extra builder-specific
keys). Auth-bearing fields (``api_key``, ``aws_secret_access_key``,
``password``, …) are **stripped entirely** from ``metadata`` — this
method is intended for ``/health`` / WebUI / audit output and must
never leak credentials. There is no escape hatch; runtime components
that legitimately need the raw value read it from
``_role_llm_states[role].metadata`` directly.
"""
def role_config(role_name: str) -> dict[str, Any]:
state = self._role_llm_states[role_name]
metadata = self._scrubbed_llm_metadata(state.metadata)
return {
"binding": metadata.get("binding"),
"model": metadata.get("model"),
"host": metadata.get("host"),
"is_cross_provider": metadata.get("is_cross_provider", False),
"max_async": self._get_effective_role_llm_max_async(role_name),
"timeout": self._get_effective_role_llm_timeout(role_name),
"has_model_kwargs": state.kwargs is not None,
"metadata": metadata,
}
if role is not None:
return role_config(self._normalize_llm_role(role))
return {spec.name: role_config(spec.name) for spec in ROLES}
def _log_llm_role_config(self, reason: str, role: str | None = None) -> None:
"""Log the sanitized role LLM runtime configuration."""
if role is None:
configs = self.get_llm_role_config()
role_names = [spec.name for spec in ROLES]
logger.info(f"Role LLM Configuration ({reason}):")
else:
normalized_role = self._normalize_llm_role(role)
configs = {normalized_role: self.get_llm_role_config(normalized_role)}
role_names = [normalized_role]
logger.info(f"Role LLM Configuration ({reason}: {normalized_role}):")
for role_name in role_names:
cfg = configs[role_name]
logger.info(
" - %s: %s/%s, host=%s, max_async=%s, timeout=%s",
role_name,
cfg["binding"],
cfg["model"],
cfg["host"],
cfg["max_async"],
cfg["timeout"],
)
async def _queue_status_for_func(
self, func: Callable[..., object] | None
) -> dict[str, Any]:
if func is None:
return {"available": False}
# Prefer the cross-worker aggregated view (sums every gunicorn
# worker's published snapshot; falls back to the local snapshot
# internally on any shared-storage failure, so "available" keeps
# meaning "this wrapper exists", never "aggregation succeeded").
get_stats = getattr(func, "get_aggregated_queue_stats", None)
if not callable(get_stats):
get_stats = getattr(func, "get_queue_stats", None)
if not callable(get_stats):
return {"available": False}
stats = get_stats()
if inspect.isawaitable(stats):
stats = await stats
stats["available"] = True
return stats
async def get_llm_queue_status(self, include_base: bool = True) -> dict[str, Any]:
"""Return queue status for each role's wrapped LLM func.
The base ``llm_model_func`` is no longer queue-wrapped, so it is not
reported here. ``include_base`` is kept for signature compatibility
but has no effect.
"""
del include_base # base is unwrapped — see docstring
result: dict[str, Any] = {}
for spec in ROLES:
state = self._role_llm_states.get(spec.name)
result[spec.name] = await self._queue_status_for_func(
state.wrapped if state else None
)
return result
async def get_embedding_queue_status(self) -> dict[str, Any]:
"""Return queue status for the wrapped embedding function."""
return await self._queue_status_for_func(
self.embedding_func.func if self.embedding_func is not None else None
)
async def get_rerank_queue_status(self) -> dict[str, Any]:
"""Return queue status for the wrapped rerank function."""
return await self._queue_status_for_func(self.rerank_model_func)