feat(server): 新增 LLM 成本追踪与熔断降级机制
This commit is contained in:
@@ -0,0 +1,555 @@
|
||||
"""LLM 成本追踪器 + 增强降级策略 (MVP-3 / D34)
|
||||
|
||||
职责
|
||||
----
|
||||
|
||||
1. 记录每次 LLM 调用的 token / 费用 / 延迟,提供按时段聚合的统计
|
||||
2. 实现"硬熔断"降级策略: 当达到日预算 / 错误率阈值时自动禁用 LLM
|
||||
3. 提供成本仪表盘所需的导出接口(供 ``api/llm.py`` 暴露给前端)
|
||||
|
||||
设计要点
|
||||
--------
|
||||
|
||||
* **职责分离**:成本追踪本身只负责"记账 + 状态机",不直接决定能否调用 LLM;
|
||||
调用方 (例如检测管道) 在每次调用前调用 ``can_call()``,根据结果决定降级
|
||||
* **滑动窗口**:使用按 UTC 日历日的桶 (``deque``) 保存最近 N 天的统计,
|
||||
避免长时间运行内存无限增长
|
||||
* **熔断**:达到错误率阈值 (默认连续 ``error_threshold`` 次调用失败率 >50%)
|
||||
自动进入 ``CIRCUIT_OPEN`` 状态,``cooldown_seconds`` 后自动半开尝试恢复
|
||||
* **Token 估算**:未拿到真实 usage 时按"图像 + prompt 长度"做粗估,
|
||||
保证账单不会因为 provider 不返回 usage 而归零
|
||||
|
||||
接入方式
|
||||
--------
|
||||
|
||||
::
|
||||
|
||||
tracker = LLMCostTracker(daily_budget_usd=5.0)
|
||||
if not tracker.can_call():
|
||||
return None # 降级处理
|
||||
result = await llm_service.analyze_with_fallback(req)
|
||||
tracker.record(result, request=req)
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
import time
|
||||
from collections import deque
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any, Deque, Dict, List, Optional
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 常量 / 数据结构
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
# 不同 provider 的近似定价 (USD per 1M tokens),仅供估算
|
||||
DEFAULT_PRICING: Dict[str, Dict[str, float]] = {
|
||||
# OpenAI GPT-4o
|
||||
"openai": {"prompt": 5.0, "completion": 15.0, "image": 0.005},
|
||||
# 豆包 / Qwen / GLM 国内 VLM 大致价位(仅估算)
|
||||
"qwen": {"prompt": 0.8, "completion": 2.0, "image": 0.001},
|
||||
"glm": {"prompt": 1.0, "completion": 3.0, "image": 0.001},
|
||||
"doubao": {"prompt": 0.8, "completion": 2.0, "image": 0.001},
|
||||
# mock provider 不计费
|
||||
"mock": {"prompt": 0.0, "completion": 0.0, "image": 0.0},
|
||||
# 兜底
|
||||
"default": {"prompt": 1.0, "completion": 3.0, "image": 0.002},
|
||||
}
|
||||
|
||||
|
||||
class CircuitState:
|
||||
CLOSED = "closed" # 正常
|
||||
OPEN = "open" # 熔断
|
||||
HALF_OPEN = "half_open" # 试探恢复
|
||||
|
||||
|
||||
@dataclass
|
||||
class CallRecord:
|
||||
"""单次 LLM 调用记录。"""
|
||||
|
||||
timestamp: float
|
||||
provider: str
|
||||
model: str
|
||||
success: bool
|
||||
confirmed: Optional[bool]
|
||||
latency_ms: float
|
||||
prompt_tokens: int
|
||||
completion_tokens: int
|
||||
image_count: int
|
||||
cost_usd: float
|
||||
error: Optional[str] = None
|
||||
|
||||
|
||||
@dataclass
|
||||
class _DailyBucket:
|
||||
"""按 UTC 日历日聚合的桶。"""
|
||||
|
||||
date: str
|
||||
call_count: int = 0
|
||||
success_count: int = 0
|
||||
failure_count: int = 0
|
||||
total_prompt_tokens: int = 0
|
||||
total_completion_tokens: int = 0
|
||||
total_image_count: int = 0
|
||||
total_cost_usd: float = 0.0
|
||||
total_latency_ms: float = 0.0
|
||||
|
||||
def add(self, record: CallRecord) -> None:
|
||||
self.call_count += 1
|
||||
if record.success:
|
||||
self.success_count += 1
|
||||
else:
|
||||
self.failure_count += 1
|
||||
self.total_prompt_tokens += record.prompt_tokens
|
||||
self.total_completion_tokens += record.completion_tokens
|
||||
self.total_image_count += record.image_count
|
||||
self.total_cost_usd += record.cost_usd
|
||||
self.total_latency_ms += record.latency_ms
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
return {
|
||||
"date": self.date,
|
||||
"call_count": self.call_count,
|
||||
"success_count": self.success_count,
|
||||
"failure_count": self.failure_count,
|
||||
"prompt_tokens": self.total_prompt_tokens,
|
||||
"completion_tokens": self.total_completion_tokens,
|
||||
"image_count": self.total_image_count,
|
||||
"cost_usd": round(self.total_cost_usd, 6),
|
||||
"avg_latency_ms": round(
|
||||
self.total_latency_ms / self.call_count, 2
|
||||
)
|
||||
if self.call_count
|
||||
else 0.0,
|
||||
}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# LLMCostTracker
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class LLMCostTracker:
|
||||
"""LLM 成本追踪器 + 熔断降级。
|
||||
|
||||
Args:
|
||||
daily_budget_usd: 日预算,0 表示不限
|
||||
history_days: 保留多少天的日级统计
|
||||
recent_window: 用于错误率计算的最近调用窗口
|
||||
error_rate_threshold: 触发熔断的错误率阈值 [0, 1]
|
||||
cooldown_seconds: 熔断进入半开状态的冷却时间
|
||||
pricing_overrides: 自定义价格表 (覆盖默认)
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
daily_budget_usd: float = 0.0,
|
||||
history_days: int = 7,
|
||||
recent_window: int = 20,
|
||||
error_rate_threshold: float = 0.5,
|
||||
cooldown_seconds: float = 60.0,
|
||||
pricing_overrides: Optional[Dict[str, Dict[str, float]]] = None,
|
||||
) -> None:
|
||||
if daily_budget_usd < 0:
|
||||
raise ValueError("daily_budget_usd 必须 >= 0")
|
||||
if history_days < 1:
|
||||
raise ValueError("history_days 必须 >= 1")
|
||||
if recent_window < 1:
|
||||
raise ValueError("recent_window 必须 >= 1")
|
||||
if not 0.0 < error_rate_threshold <= 1.0:
|
||||
raise ValueError("error_rate_threshold 必须在 (0, 1]")
|
||||
if cooldown_seconds < 0:
|
||||
raise ValueError("cooldown_seconds 必须 >= 0")
|
||||
|
||||
self.daily_budget_usd = daily_budget_usd
|
||||
self.history_days = history_days
|
||||
self.recent_window = recent_window
|
||||
self.error_rate_threshold = error_rate_threshold
|
||||
self.cooldown_seconds = cooldown_seconds
|
||||
|
||||
# 合并定价
|
||||
self._pricing: Dict[str, Dict[str, float]] = {
|
||||
k: dict(v) for k, v in DEFAULT_PRICING.items()
|
||||
}
|
||||
if pricing_overrides:
|
||||
for provider, table in pricing_overrides.items():
|
||||
self._pricing.setdefault(provider, {})
|
||||
self._pricing[provider].update(table)
|
||||
|
||||
# 状态
|
||||
self._buckets: Deque[_DailyBucket] = deque(maxlen=history_days)
|
||||
self._recent: Deque[bool] = deque(maxlen=recent_window)
|
||||
self._records: Deque[CallRecord] = deque(maxlen=200)
|
||||
self._circuit_state = CircuitState.CLOSED
|
||||
self._circuit_opened_at: Optional[float] = None
|
||||
self._manually_disabled = False
|
||||
self._lock = asyncio.Lock()
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# 准入检查
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def can_call(self, now: Optional[float] = None) -> bool:
|
||||
"""同步判断当前是否允许调用 LLM。
|
||||
|
||||
触发拒绝的条件按优先级:
|
||||
1. 手动禁用
|
||||
2. 日预算超限
|
||||
3. 熔断器处于 OPEN 且未到冷却结束
|
||||
"""
|
||||
|
||||
if self._manually_disabled:
|
||||
return False
|
||||
if self._budget_exhausted():
|
||||
return False
|
||||
|
||||
ts = now if now is not None else time.time()
|
||||
if self._circuit_state == CircuitState.OPEN:
|
||||
if (
|
||||
self._circuit_opened_at is not None
|
||||
and (ts - self._circuit_opened_at) >= self.cooldown_seconds
|
||||
):
|
||||
# 进入半开,允许一次试探
|
||||
self._circuit_state = CircuitState.HALF_OPEN
|
||||
logger.info("LLMCostTracker 熔断器进入 HALF_OPEN")
|
||||
return True
|
||||
return False
|
||||
return True
|
||||
|
||||
def reason_for_block(self, now: Optional[float] = None) -> Optional[str]:
|
||||
"""返回当前不可调用的原因 (调试用),None 表示可以调用。"""
|
||||
|
||||
if self._manually_disabled:
|
||||
return "manually_disabled"
|
||||
if self._budget_exhausted():
|
||||
return f"daily_budget_exceeded({self._today_cost():.4f}/{self.daily_budget_usd})"
|
||||
ts = now if now is not None else time.time()
|
||||
if self._circuit_state == CircuitState.OPEN:
|
||||
if (
|
||||
self._circuit_opened_at is not None
|
||||
and (ts - self._circuit_opened_at) < self.cooldown_seconds
|
||||
):
|
||||
return "circuit_open"
|
||||
return None
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# 记录
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def record(
|
||||
self,
|
||||
result: Any,
|
||||
prompt_tokens: Optional[int] = None,
|
||||
completion_tokens: Optional[int] = None,
|
||||
image_count: int = 1,
|
||||
provider_override: Optional[str] = None,
|
||||
) -> CallRecord:
|
||||
"""记录一次 LLM 调用。
|
||||
|
||||
``result`` 可以是 ``LLMAnalysisResult`` 实例或鸭子类型 (含 confirmed
|
||||
/ provider / model / latency_ms / error / metadata)。
|
||||
"""
|
||||
|
||||
provider = provider_override or getattr(result, "provider", "default")
|
||||
model = getattr(result, "model", "")
|
||||
success = getattr(result, "error", None) is None and getattr(
|
||||
result, "confirmed", None
|
||||
) is not None
|
||||
latency_ms = float(getattr(result, "latency_ms", 0.0) or 0.0)
|
||||
|
||||
usage = self._extract_usage(result)
|
||||
prompt_tokens = (
|
||||
int(prompt_tokens)
|
||||
if prompt_tokens is not None
|
||||
else int(usage.get("prompt_tokens") or 0)
|
||||
)
|
||||
completion_tokens = (
|
||||
int(completion_tokens)
|
||||
if completion_tokens is not None
|
||||
else int(usage.get("completion_tokens") or 0)
|
||||
)
|
||||
|
||||
# token 估算兜底
|
||||
if prompt_tokens == 0 and completion_tokens == 0 and provider != "mock":
|
||||
prompt_tokens = self._estimate_prompt_tokens(image_count)
|
||||
completion_tokens = self._estimate_completion_tokens(result)
|
||||
|
||||
cost = self._calc_cost(provider, prompt_tokens, completion_tokens, image_count)
|
||||
|
||||
record = CallRecord(
|
||||
timestamp=time.time(),
|
||||
provider=provider,
|
||||
model=model,
|
||||
success=success,
|
||||
confirmed=getattr(result, "confirmed", None),
|
||||
latency_ms=latency_ms,
|
||||
prompt_tokens=prompt_tokens,
|
||||
completion_tokens=completion_tokens,
|
||||
image_count=image_count,
|
||||
cost_usd=cost,
|
||||
error=getattr(result, "error", None),
|
||||
)
|
||||
|
||||
self._append_record(record)
|
||||
self._update_circuit(record)
|
||||
return record
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# 控制
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def disable(self) -> None:
|
||||
"""手动禁用 LLM 调用 (运维场景)。"""
|
||||
|
||||
self._manually_disabled = True
|
||||
logger.warning("LLMCostTracker 已被手动禁用")
|
||||
|
||||
def enable(self) -> None:
|
||||
"""手动重新启用。"""
|
||||
|
||||
self._manually_disabled = False
|
||||
self._circuit_state = CircuitState.CLOSED
|
||||
self._circuit_opened_at = None
|
||||
logger.info("LLMCostTracker 已重新启用")
|
||||
|
||||
def reset(self) -> None:
|
||||
"""清空全部统计 (谨慎使用)。"""
|
||||
|
||||
self._buckets.clear()
|
||||
self._recent.clear()
|
||||
self._records.clear()
|
||||
self._circuit_state = CircuitState.CLOSED
|
||||
self._circuit_opened_at = None
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# 导出
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
@property
|
||||
def is_enabled(self) -> bool:
|
||||
return not self._manually_disabled
|
||||
|
||||
@property
|
||||
def circuit_state(self) -> str:
|
||||
return self._circuit_state
|
||||
|
||||
def daily_summary(self) -> List[Dict[str, Any]]:
|
||||
return [bucket.to_dict() for bucket in self._buckets]
|
||||
|
||||
def today_summary(self) -> Dict[str, Any]:
|
||||
bucket = self._today_bucket(create=False)
|
||||
if bucket is None:
|
||||
return {
|
||||
"date": _today_str(),
|
||||
"call_count": 0,
|
||||
"cost_usd": 0.0,
|
||||
"budget_usd": self.daily_budget_usd,
|
||||
"budget_used_ratio": 0.0,
|
||||
}
|
||||
data = bucket.to_dict()
|
||||
data["budget_usd"] = self.daily_budget_usd
|
||||
data["budget_used_ratio"] = (
|
||||
data["cost_usd"] / self.daily_budget_usd
|
||||
if self.daily_budget_usd > 0
|
||||
else 0.0
|
||||
)
|
||||
return data
|
||||
|
||||
def recent_records(self, limit: int = 20) -> List[Dict[str, Any]]:
|
||||
records = list(self._records)[-limit:]
|
||||
return [
|
||||
{
|
||||
"timestamp": r.timestamp,
|
||||
"provider": r.provider,
|
||||
"model": r.model,
|
||||
"success": r.success,
|
||||
"confirmed": r.confirmed,
|
||||
"latency_ms": round(r.latency_ms, 2),
|
||||
"prompt_tokens": r.prompt_tokens,
|
||||
"completion_tokens": r.completion_tokens,
|
||||
"image_count": r.image_count,
|
||||
"cost_usd": round(r.cost_usd, 6),
|
||||
"error": r.error,
|
||||
}
|
||||
for r in reversed(records)
|
||||
]
|
||||
|
||||
def stats(self) -> Dict[str, Any]:
|
||||
recent_total = len(self._recent)
|
||||
recent_failed = sum(1 for ok in self._recent if not ok)
|
||||
recent_error_rate = (
|
||||
recent_failed / recent_total if recent_total > 0 else 0.0
|
||||
)
|
||||
|
||||
return {
|
||||
"enabled": self.is_enabled,
|
||||
"circuit_state": self._circuit_state,
|
||||
"circuit_opened_at": self._circuit_opened_at,
|
||||
"block_reason": self.reason_for_block(),
|
||||
"today": self.today_summary(),
|
||||
"history_days": self.history_days,
|
||||
"recent_window": self.recent_window,
|
||||
"recent_error_rate": round(recent_error_rate, 4),
|
||||
"error_rate_threshold": self.error_rate_threshold,
|
||||
}
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# 内部
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _append_record(self, record: CallRecord) -> None:
|
||||
bucket = self._today_bucket(create=True)
|
||||
assert bucket is not None
|
||||
bucket.add(record)
|
||||
self._recent.append(record.success)
|
||||
self._records.append(record)
|
||||
|
||||
def _today_bucket(self, *, create: bool) -> Optional[_DailyBucket]:
|
||||
today = _today_str()
|
||||
if self._buckets and self._buckets[-1].date == today:
|
||||
return self._buckets[-1]
|
||||
if not create:
|
||||
for b in self._buckets:
|
||||
if b.date == today:
|
||||
return b
|
||||
return None
|
||||
bucket = _DailyBucket(date=today)
|
||||
self._buckets.append(bucket)
|
||||
return bucket
|
||||
|
||||
def _today_cost(self) -> float:
|
||||
bucket = self._today_bucket(create=False)
|
||||
return bucket.total_cost_usd if bucket else 0.0
|
||||
|
||||
def _budget_exhausted(self) -> bool:
|
||||
if self.daily_budget_usd <= 0:
|
||||
return False
|
||||
return self._today_cost() >= self.daily_budget_usd
|
||||
|
||||
def _update_circuit(self, record: CallRecord) -> None:
|
||||
# HALF_OPEN: 一次结果决定回到 CLOSED 还是 OPEN
|
||||
if self._circuit_state == CircuitState.HALF_OPEN:
|
||||
if record.success:
|
||||
self._circuit_state = CircuitState.CLOSED
|
||||
self._circuit_opened_at = None
|
||||
logger.info("LLMCostTracker 熔断器恢复 CLOSED")
|
||||
else:
|
||||
self._circuit_state = CircuitState.OPEN
|
||||
self._circuit_opened_at = time.time()
|
||||
logger.warning("LLMCostTracker 半开试探失败,回到 OPEN")
|
||||
return
|
||||
|
||||
# CLOSED: 检查最近窗口的错误率
|
||||
if (
|
||||
self._circuit_state == CircuitState.CLOSED
|
||||
and len(self._recent) >= max(3, self.recent_window // 2)
|
||||
):
|
||||
failures = sum(1 for ok in self._recent if not ok)
|
||||
error_rate = failures / len(self._recent)
|
||||
if error_rate >= self.error_rate_threshold:
|
||||
self._circuit_state = CircuitState.OPEN
|
||||
self._circuit_opened_at = time.time()
|
||||
logger.warning(
|
||||
"LLMCostTracker 触发熔断: 最近 %d 次调用错误率 %.2f >= %.2f",
|
||||
len(self._recent),
|
||||
error_rate,
|
||||
self.error_rate_threshold,
|
||||
)
|
||||
|
||||
def _calc_cost(
|
||||
self,
|
||||
provider: str,
|
||||
prompt_tokens: int,
|
||||
completion_tokens: int,
|
||||
image_count: int,
|
||||
) -> float:
|
||||
rates = self._pricing.get(provider) or self._pricing["default"]
|
||||
cost = (
|
||||
prompt_tokens / 1_000_000.0 * rates.get("prompt", 0.0)
|
||||
+ completion_tokens / 1_000_000.0 * rates.get("completion", 0.0)
|
||||
+ image_count * rates.get("image", 0.0)
|
||||
)
|
||||
return max(0.0, cost)
|
||||
|
||||
@staticmethod
|
||||
def _estimate_prompt_tokens(image_count: int) -> int:
|
||||
# 经验值:每张图 ~ 200 token,prompt 模板 ~ 120 token
|
||||
return 120 + 200 * max(0, image_count)
|
||||
|
||||
@staticmethod
|
||||
def _estimate_completion_tokens(result: Any) -> int:
|
||||
reasoning = getattr(result, "reasoning", "") or ""
|
||||
# 粗略 1.3 字符 = 1 token
|
||||
return max(16, int(len(reasoning) / 1.3))
|
||||
|
||||
@staticmethod
|
||||
def _extract_usage(result: Any) -> Dict[str, Any]:
|
||||
"""从结果对象上尽力获取 usage (provider 可能放 metadata 里)。"""
|
||||
|
||||
usage = getattr(result, "usage", None)
|
||||
if isinstance(usage, dict):
|
||||
return usage
|
||||
metadata = getattr(result, "metadata", None)
|
||||
if isinstance(metadata, dict):
|
||||
md_usage = metadata.get("usage")
|
||||
if isinstance(md_usage, dict):
|
||||
return md_usage
|
||||
return {}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 全局单例
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
_global_tracker: Optional[LLMCostTracker] = None
|
||||
|
||||
|
||||
def init_global_tracker(
|
||||
daily_budget_usd: float = 0.0,
|
||||
history_days: int = 7,
|
||||
recent_window: int = 20,
|
||||
error_rate_threshold: float = 0.5,
|
||||
cooldown_seconds: float = 60.0,
|
||||
) -> LLMCostTracker:
|
||||
"""初始化并返回全局成本追踪器。"""
|
||||
|
||||
global _global_tracker
|
||||
_global_tracker = LLMCostTracker(
|
||||
daily_budget_usd=daily_budget_usd,
|
||||
history_days=history_days,
|
||||
recent_window=recent_window,
|
||||
error_rate_threshold=error_rate_threshold,
|
||||
cooldown_seconds=cooldown_seconds,
|
||||
)
|
||||
return _global_tracker
|
||||
|
||||
|
||||
def get_global_tracker() -> Optional[LLMCostTracker]:
|
||||
return _global_tracker
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 工具
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _today_str() -> str:
|
||||
return datetime.now(tz=timezone.utc).strftime("%Y-%m-%d")
|
||||
|
||||
|
||||
__all__ = [
|
||||
"LLMCostTracker",
|
||||
"CallRecord",
|
||||
"CircuitState",
|
||||
"DEFAULT_PRICING",
|
||||
"init_global_tracker",
|
||||
"get_global_tracker",
|
||||
]
|
||||
Reference in New Issue
Block a user