| """底座 ``UsageService``:LLM 调用 / Token 累计、汇总查询与阈值告警(任务 5)。 |
| |
| 对应 design.md「8. AuditService & UsageService」与需求 12.3/12.4/12.5: |
| |
| - **累计计量**(需求 12.3):``record_call`` 记录每次 LLM 调用的提供商、模型、 |
| 输入 / 输出 token 估计、时延与成功标志,落入内存用量账本(用量条目 schema 见 |
| design「Data Models」:``{ts, provider, model, in_tokens, out_tokens, latency_ms, ok}``)。 |
| - **汇总查询**(需求 12.4):``summary`` / ``by_provider`` / ``by_model`` 提供累计 |
| 调用次数与 token 消耗汇总,为管理员看板供数。 |
| - **阈值告警**(需求 12.5):可配置 token / 调用次数 / 成本(按单价估算)阈值; |
| 在**接近阈值**(达到 ``warn_ratio``,默认 80%)或超过阈值时产生告警条目。 |
| |
| 本服务为纯内存实现(无外部依赖),与 ``LLMService`` 经 ``record_call`` 接口解耦, |
| 便于在无网络环境下单元测试。持久化(SQLite)可在后续看板任务接入,本任务聚焦 |
| 计量、汇总与告警逻辑。 |
| """ |
|
|
| from __future__ import annotations |
|
|
| import logging |
| import threading |
| import time |
| from dataclasses import dataclass, field |
| from typing import Optional |
|
|
| logger = logging.getLogger(__name__) |
|
|
|
|
| |
| |
| |
|
|
| @dataclass |
| class UsageRecord: |
| """单次 LLM 调用的用量条目(design「Data Models」)。""" |
|
|
| ts: float |
| provider: str |
| model: Optional[str] |
| in_tokens: int |
| out_tokens: int |
| latency_ms: float |
| ok: bool |
|
|
| @property |
| def total_tokens(self) -> int: |
| return self.in_tokens + self.out_tokens |
|
|
|
|
| @dataclass |
| class UsageThresholds: |
| """成本 / 用量阈值配置(需求 12.5)。 |
| |
| 任一阈值为 ``None`` 表示不启用该维度的告警。``warn_ratio`` 为「接近阈值」的 |
| 比例(默认 0.8,即达到阈值 80% 时即开始告警)。 |
| """ |
|
|
| max_total_tokens: Optional[int] = None |
| max_calls: Optional[int] = None |
| max_cost: Optional[float] = None |
| warn_ratio: float = 0.8 |
|
|
| def __post_init__(self) -> None: |
| if not (0.0 < self.warn_ratio <= 1.0): |
| raise ValueError("warn_ratio 必须在 (0, 1] 区间内。") |
|
|
|
|
| @dataclass |
| class UsageAlert: |
| """单条阈值告警。 |
| |
| - ``metric``:触发维度(``"tokens"`` / ``"calls"`` / ``"cost"``)。 |
| - ``level``:``"warning"``(接近阈值)或 ``"exceeded"``(已超过阈值)。 |
| - ``current`` / ``limit``:当前值与阈值。 |
| - ``message``:面向管理员的友好提示。 |
| """ |
|
|
| metric: str |
| level: str |
| current: float |
| limit: float |
| message: str |
|
|
|
|
| @dataclass |
| class UsageSummary: |
| """累计用量汇总(需求 12.4)。""" |
|
|
| calls: int = 0 |
| successful_calls: int = 0 |
| failed_calls: int = 0 |
| in_tokens: int = 0 |
| out_tokens: int = 0 |
| total_latency_ms: float = 0.0 |
|
|
| @property |
| def total_tokens(self) -> int: |
| return self.in_tokens + self.out_tokens |
|
|
| @property |
| def avg_latency_ms(self) -> float: |
| return self.total_latency_ms / self.calls if self.calls else 0.0 |
|
|
|
|
| class UsageService: |
| """LLM 用量累计、汇总查询与阈值告警服务。""" |
|
|
| def __init__( |
| self, |
| thresholds: Optional[UsageThresholds] = None, |
| *, |
| cost_per_1k_tokens: float = 0.0, |
| time_func=time.time, |
| ) -> None: |
| """构造服务。 |
| |
| 参数: |
| - ``thresholds``:阈值配置;``None`` 时不做告警判定。 |
| - ``cost_per_1k_tokens``:每千 token 的估算单价(用于成本阈值与汇总成本估算)。 |
| - ``time_func``:时间源,便于确定性测试。 |
| """ |
| self._thresholds = thresholds |
| self._cost_per_1k = max(cost_per_1k_tokens, 0.0) |
| self._time = time_func |
| self._records: list[UsageRecord] = [] |
| self._summary = UsageSummary() |
| self._by_provider: dict[str, UsageSummary] = {} |
| self._by_model: dict[str, UsageSummary] = {} |
| self._lock = threading.Lock() |
|
|
| |
| |
| |
| def record_call( |
| self, |
| *, |
| provider: str, |
| model: Optional[str] = None, |
| in_tokens: int = 0, |
| out_tokens: int = 0, |
| latency_ms: float = 0.0, |
| ok: bool = True, |
| ) -> UsageRecord: |
| """记录单次 LLM 调用并更新累计汇总。 |
| |
| 返回写入的 :class:`UsageRecord`。线程安全,便于并发计量。 |
| """ |
| in_tokens = max(int(in_tokens), 0) |
| out_tokens = max(int(out_tokens), 0) |
| latency_ms = max(float(latency_ms), 0.0) |
| record = UsageRecord( |
| ts=self._time(), |
| provider=provider, |
| model=model, |
| in_tokens=in_tokens, |
| out_tokens=out_tokens, |
| latency_ms=latency_ms, |
| ok=bool(ok), |
| ) |
| with self._lock: |
| self._records.append(record) |
| self._apply(self._summary, record) |
| self._apply(self._by_provider.setdefault(provider, UsageSummary()), record) |
| model_key = model or "(default)" |
| self._apply(self._by_model.setdefault(model_key, UsageSummary()), record) |
| return record |
|
|
| @staticmethod |
| def _apply(summary: UsageSummary, record: UsageRecord) -> None: |
| summary.calls += 1 |
| if record.ok: |
| summary.successful_calls += 1 |
| else: |
| summary.failed_calls += 1 |
| summary.in_tokens += record.in_tokens |
| summary.out_tokens += record.out_tokens |
| summary.total_latency_ms += record.latency_ms |
|
|
| |
| |
| |
| def summary(self) -> UsageSummary: |
| """返回全局累计汇总(调用次数、token 消耗、平均时延等)。""" |
| with self._lock: |
| return UsageSummary(**vars(self._summary)) |
|
|
| def by_provider(self) -> dict[str, UsageSummary]: |
| """返回按提供商分组的累计汇总。""" |
| with self._lock: |
| return {k: UsageSummary(**vars(v)) for k, v in self._by_provider.items()} |
|
|
| def by_model(self) -> dict[str, UsageSummary]: |
| """返回按模型分组的累计汇总。""" |
| with self._lock: |
| return {k: UsageSummary(**vars(v)) for k, v in self._by_model.items()} |
|
|
| def records(self) -> list[UsageRecord]: |
| """返回全部原始用量条目(拷贝)。""" |
| with self._lock: |
| return list(self._records) |
|
|
| def estimated_cost(self) -> float: |
| """按 ``cost_per_1k_tokens`` 估算累计成本。""" |
| with self._lock: |
| return self._summary.total_tokens / 1000.0 * self._cost_per_1k |
|
|
| |
| |
| |
| def check_alerts(self) -> list[UsageAlert]: |
| """对照配置阈值返回当前告警列表(接近或超过阈值时产生)。 |
| |
| 无阈值配置时返回空列表。同一维度仅返回严重级别更高者 |
| (超过阈值优先于接近阈值)。 |
| """ |
| if self._thresholds is None: |
| return [] |
|
|
| th = self._thresholds |
| with self._lock: |
| total_tokens = float(self._summary.total_tokens) |
| calls = float(self._summary.calls) |
| cost = self._summary.total_tokens / 1000.0 * self._cost_per_1k |
|
|
| alerts: list[UsageAlert] = [] |
| self._maybe_alert(alerts, "tokens", total_tokens, th.max_total_tokens, |
| th.warn_ratio, unit="tokens") |
| self._maybe_alert(alerts, "calls", calls, th.max_calls, |
| th.warn_ratio, unit="次调用") |
| if th.max_cost is not None and self._cost_per_1k > 0: |
| self._maybe_alert(alerts, "cost", cost, th.max_cost, |
| th.warn_ratio, unit="成本单位") |
| return alerts |
|
|
| @staticmethod |
| def _maybe_alert( |
| alerts: list[UsageAlert], |
| metric: str, |
| current: float, |
| limit: Optional[float], |
| warn_ratio: float, |
| *, |
| unit: str, |
| ) -> None: |
| if limit is None or limit <= 0: |
| return |
| if current >= limit: |
| alerts.append(UsageAlert( |
| metric=metric, |
| level="exceeded", |
| current=current, |
| limit=float(limit), |
| message=( |
| f"{metric} 用量已超过阈值:" |
| f"当前 {current:g} {unit} ≥ 上限 {limit:g} {unit}。" |
| ), |
| )) |
| elif current >= limit * warn_ratio: |
| alerts.append(UsageAlert( |
| metric=metric, |
| level="warning", |
| current=current, |
| limit=float(limit), |
| message=( |
| f"{metric} 用量接近阈值:" |
| f"当前 {current:g} {unit},已达上限 {limit:g} {unit} 的 " |
| f"{current / limit * 100:.0f}%。" |
| ), |
| )) |
|
|
| def reset(self) -> None: |
| """清空所有累计用量(便于按周期重置看板统计)。""" |
| with self._lock: |
| self._records.clear() |
| self._summary = UsageSummary() |
| self._by_provider.clear() |
| self._by_model.clear() |
|
|
|
|
| __all__ = [ |
| "UsageService", |
| "UsageRecord", |
| "UsageSummary", |
| "UsageThresholds", |
| "UsageAlert", |
| ] |
|
|